Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion .github/agent.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,30 @@
# CMS-NBI-Client Agent Configuration

> **Note**: This file is maintained for reference. The active agent initialization file used by GitHub Copilot is located at `.github/agents/init.md`.
> **Source of truth:** This file is the canonical agent definition for the repository. Any downstream Copilot or agent initialization files should be treated as derived views of this document.

## Upstream Skill Provenance

- Upstream source: `https://github.com/nullroute-commits/agency-agents`
- Upstream reference reviewed during this integration: `783f6a72bfd7f3135700ac273c619d92821b419a`
- Upstream adoption model: selective import by reference, not verbatim mirroring
- Local override rule: repository-specific correctness, security, and runtime truth take precedence over generic upstream guidance

## Adopted Upstream Skills

This repository adopts the following `agency-agents` skill families as upstream guidance:

- **Engineering:** backend architecture, code review, codebase onboarding, devops automation, technical writing, security engineering
- **Testing:** API testing, reality checking, test-results analysis, workflow optimization
- **Specialized coordination:** agents orchestration for multi-step execution planning

When an agent works in this repository, it should prefer these upstream skill profiles where they improve quality, but it must still follow this repository's code, tests, and published runtime behavior.

## Sync Policy

- Review upstream `agency-agents` updates intentionally; do not auto-sync prompt content into this repository without review
- Record the upstream commit or tag whenever this file is refreshed
- Keep repository-specific sections current even if upstream skills change
- If downstream agent bootstrap files drift from this file, update the downstream copies to match this source of truth

This document provides comprehensive context and instructions for AI agents working on the CMS-NBI-Client repository. It includes project overview, architecture, conventions, and best practices.

Expand Down
35 changes: 15 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,24 @@
[![Python Version](https://img.shields.io/pypi/pyversions/cms-nbi-client.svg)](https://pypi.org/project/cms-nbi-client/)
[![License](https://img.shields.io/github/license/somenetworking/CMS-NBI-Client.svg)](https://github.com/somenetworking/CMS-NBI-Client/blob/main/LICENSE)

Modern async Python client for Calix Management System (CMS) Northbound Interface (NBI) with full HTTPS support, connection pooling, circuit breakers, and structured logging.
Modern Python client for Calix Management System (CMS) Northbound Interface (NBI) with an async transport layer, structured logging, and legacy E7 compatibility.

**Note:** This package is not owned, supported, or endorsed by Calix. It's an independent implementation for interacting with CMS NBIs.

> **Important:** This library is currently in a transition phase. The modern async CMSClient provides the foundation and configuration management, while the legacy Client class provides the full operational functionality. Both are available and can be used together. See the [Examples](./Examples) folder for working code samples.
> **Important:** This library is currently in a transition phase. `CMSClient` provides configuration, authentication, transport, REST helpers, and legacy E7 compatibility shims. The legacy `Client` class still owns most production E7 behavior. Prefer the [Examples](./Examples) folder for fully working end-to-end E7 samples.

## Features

- **Modern Async/Await**: Built on aiohttp for high-performance async operations
- **HTTPS Support**: Full TLS/SSL support with certificate validation
- **HTTPS Support**: Modern transport supports TLS/SSL. Legacy E7 coverage is still being completed
- **Connection Pooling**: Reuse connections for better performance
- **Circuit Breaker**: Automatic failure detection and recovery
- **Structured Logging**: Rich logs with structlog for better debugging
- **Type Safety**: Full type hints and Pydantic validation
- **Secure Storage**: Encrypted credential storage using system keyring
- **XML Security**: Protection against XXE and other XML attacks
- **Comprehensive Testing**: High test coverage with pytest
- **Backward Compatible**: Sync wrapper for legacy code
- **Backward Compatible**: Sync wrapper plus legacy E7 compatibility facade

## Quick Start

Expand Down Expand Up @@ -50,29 +50,18 @@ async def main():
)

async with CMSClient(config) as client:
# Note: High-level methods are available through the legacy client
# For modern async operations, use the underlying E7 modules directly
# Example using legacy operations:

# Create ONT using E7 operations
create_op = client.e7.create
result = await create_op.ont(
network_nm="NTWK-1",
ont_id="123",
admin_state="enabled"
)
print(result)
devices = client.rest.query.device(device_type="e7")
print(devices)

# Run async code
asyncio.run(main())

# Synchronous usage (backward compatible)
# Note: For most operations, use the LegacyClient for now
from cmsnbiclient import LegacyClient

legacy_client = LegacyClient()
# Configure and use legacy client for production operations
print("Use LegacyClient for full feature compatibility")
# Configure and use LegacyClient for full E7 feature coverage
print("Use LegacyClient for end-to-end E7 workflows")
```

### Configuration
Expand Down Expand Up @@ -131,6 +120,12 @@ setup_logging(log_level="DEBUG", json_logs=False)

For detailed documentation and examples, see the [/Examples](./Examples) folder.

### Current API Status

- `CMSClient`: async authentication, transport management, REST helpers, and compatibility access to legacy E7 handlers
- `LegacyClient`: primary path for complete E7 NETCONF workflows
- Documentation under `docs/` may still describe the planned flattened async E7 API; use the examples and current source as the authoritative runtime behavior until that migration is completed

### Available Operations

#### E7 Operations
Expand Down Expand Up @@ -190,4 +185,4 @@ poetry run mypy .

## Changelog

See [CHANGELOG.md](./CHANGELOG.md) for version history.
See [CHANGELOG.md](./CHANGELOG.md) for version history.
101 changes: 80 additions & 21 deletions src/cmsnbiclient/REST/query.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Union
from typing import Any, Dict, Optional, Tuple, Union

import requests

Expand Down Expand Up @@ -31,13 +31,48 @@ def __init__(self, cms_nbi_connect_object: Union[Client, Any]) -> None:
)
self.cms_nbi_connect_object = cms_nbi_connect_object

def _get_legacy_defaults(self) -> Tuple[str, str, str, str, str]:
"""Resolve protocol, port, username, password, and host for legacy clients."""
config = self.cms_nbi_connect_object.cms_nbi_config
default_node = config["cms_nodes"]["default"]
connection = default_node["connection"]
credentials = default_node["cms_creds"]
protocol = connection["protocol"]["http"]
return (
protocol,
connection["rest_http_port"],
credentials["user_nm"],
credentials["pass_wd"],
connection["cms_node_ip"],
)

def _get_modern_defaults(self) -> Tuple[str, str, str, str, str]:
"""Resolve protocol, port, username, password, and host for CMSClient."""
config = self.cms_nbi_connect_object.config
return (
config.connection.protocol,
str(config.connection.rest_port),
config.credentials.username,
config.credentials.password.get_secret_value(),
config.connection.host,
)

def _get_rest_uri(self) -> str:
"""Resolve the REST devices URI for the current client type."""
if (
hasattr(self.cms_nbi_connect_object, "cms_nbi_config")
and "cms_nodes" in self.cms_nbi_connect_object.cms_nbi_config
):
return self.cms_nbi_connect_object.cms_nbi_config["cms_rest_uri"]["devices"]
return "/restnbi/devices?deviceType="

def device(
self,
protocol: str = "http",
port: str = "8080",
cms_user_nm: str = "rootgod",
cms_user_pass: str = "root",
cms_node_ip: str = "localhost",
protocol: Optional[str] = None,
port: Optional[str] = None,
cms_user_nm: Optional[str] = None,
cms_user_pass: Optional[str] = None,
cms_node_ip: Optional[str] = None,
device_type: str = "",
http_timeout: int = 1,
) -> Any:
Expand Down Expand Up @@ -101,39 +136,63 @@ def device(
device_type='c7',
http_timeout=5)
"""
# Handle both Client and CMSClient types
if hasattr(self.cms_nbi_connect_object, "cms_nbi_config"):
config = self.cms_nbi_connect_object.cms_nbi_config
else:
# Fallback for CMSClient or other types
config = getattr(self.cms_nbi_connect_object, "config", {}).get("cms_rest_uri", {})

if isinstance(config, dict) and "cms_rest_uri" in config:
uri = config["cms_rest_uri"]["devices"]
if (
hasattr(self.cms_nbi_connect_object, "cms_nbi_config")
and "cms_nodes" in self.cms_nbi_connect_object.cms_nbi_config
):
(
default_protocol,
default_port,
default_user,
default_password,
default_host,
) = self._get_legacy_defaults()
else:
uri = "/restnbi/devices?deviceType=" # Default fallback

cms_rest_url = f"""{protocol}://{cms_node_ip}:{port}{uri}{device_type}&limit=9999"""
(
default_protocol,
default_port,
default_user,
default_password,
default_host,
) = self._get_modern_defaults()

resolved_protocol = protocol or default_protocol
resolved_port = port or default_port
resolved_user = cms_user_nm or default_user
resolved_password = cms_user_pass or default_password
resolved_host = cms_node_ip or default_host
uri = self._get_rest_uri()

cms_rest_url = (
f"{resolved_protocol}://{resolved_host}:{resolved_port}{uri}{device_type}&limit=9999"
)

payload = ""

headers = {
"Content-Type": "application/json",
"User-Agent": f"CMS_NBI_CONNECT-{cms_user_nm}",
"User-Agent": f"CMS_NBI_CONNECT-{resolved_user}",
}

try:
response = requests.get(
url=cms_rest_url,
headers=headers,
data=payload,
auth=(cms_user_nm, cms_user_pass),
auth=(resolved_user, resolved_password),
timeout=http_timeout,
)
except requests.exceptions.Timeout as e:
raise e

if response.status_code == 200:
return response.json()["data"]
body: Dict[str, Any] = response.json()
# CMS API deployments expose both `data` and `devices` response
# envelopes depending on the endpoint version.
if "data" in body:
return body["data"]
if "devices" in body:
return body["devices"]
return body
else:
return response
3 changes: 0 additions & 3 deletions src/cmsnbiclient/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,3 @@
# Legacy API (deprecated)
"LegacyClient",
]

# Default logging setup
setup_logging()
48 changes: 20 additions & 28 deletions src/cmsnbiclient/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,20 +185,16 @@ def login_netconf(
</soapenv:Body>
</soapenv:Envelope>"""

if protocol == "http":
try:
response = requests.post(
url=self.cms_netconf_url,
headers=self.headers,
data=payload,
timeout=http_timeout,
)
except requests.exceptions.Timeout as e:
# future came and it decided to have raise
raise e
else:
# TODO:Need to implement https handling
pass
try:
response = requests.post(
url=self.cms_netconf_url,
headers=self.headers,
data=payload,
timeout=http_timeout,
)
except requests.exceptions.Timeout as e:
# future came and it decided to have raise
raise e

if response.status_code != 200:
# if the response code is not 200 FALSE and the request.post object is returned.
Expand Down Expand Up @@ -276,20 +272,16 @@ def logout_netconf(
</soapenv:Body>
</soapenv:Envelope>"""

if protocol == "http":
try:
response = requests.post(
url=self.cms_netconf_url,
headers=self.headers,
data=payload,
timeout=http_timeout,
)
except requests.exceptions.Timeout as e:
# debating between exit and raise will update in future
raise e
else:
# will need to research how to implement https connection with request library
pass
try:
response = requests.post(
url=self.cms_netconf_url,
headers=self.headers,
data=payload,
timeout=http_timeout,
)
except requests.exceptions.Timeout as e:
# debating between exit and raise will update in future
raise e

if response.status_code != 200:
# if the response code is not 200 response.models.Response is returned.
Expand Down
Loading
Loading