Metadata-Version: 2.4
Name: ndm-client
Version: 2026.4
Summary: Python client and typed command-line tool for the NX-DEVICE REST API server
Author-email: "NATIXON Sp. z o.o." <contact@natixon.com>
Maintainer-email: "NATIXON Sp. z o.o." <contact@natixon.com>
License-Expression: LicenseRef-Proprietary
Project-URL: Homepage, http://service-machine.gen/software/python-api-client
Project-URL: Repository, http://service-machine.gen/software/python-api-client
Project-URL: Changelog, http://service-machine.gen/software/python-api-client/-/blob/master/CHANGELOG.md
Project-URL: Issues, http://service-machine.gen/software/python-api-client/-/issues
Keywords: rest,nx-device,device-management,routing,bgp,isis,ldp,mpls,automation
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Telecommunications Industry
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: System :: Networking
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: test
Requires-Dist: pytest>=7; extra == "test"
Requires-Dist: pyyaml>=6; extra == "test"
Dynamic: license-file

# NDM Client (`ndm-client`)

Python client and command-line tool for the **NX-DEVICE REST API server**.
The library (`NxClient`) has a method for every operation of REST API 5.3: users,
workspaces, devices, port configuration, generator/analyzer properties and resource
bindings, port protocol tools (ARP/IPv6, IGMP, MLD, DHCPv4/v6, LLDP, PPPoE) and routing
nodes with their protocols and control-plane scenarios. The command-line tool
(`ndm-client`) wraps the typed device-management layer and the routing operations.

* **Zero dependencies**: pure standard library, Python 3.9 to 3.12.
* **Multi-server**: the server address is a parameter of every call, so one client can
  drive any number of servers.
* **Typed**: typed wrappers and results, a clear exception hierarchy, `py.typed`.

## Installation

The package is published in the NATIXON package registry (GitLab), not on PyPI:

```bash
python -m pip install ndm-client \
  --index-url https://<deploy-user>:<deploy-token>@gitlab.example/api/v4/projects/<project-id>/packages/pypi/simple
```

Ask your NATIXON contact for the registry URL and a read-only token (or put the index
in `pip.conf`, or use `~/.netrc`, so that the token does not end up in your shell
history). Install the release that matches your server, see
[Compatibility with the server](#compatibility-with-the-server):

```bash
python -m pip install "ndm-client==2026.4" --index-url ...
```

## Quick start

```python
from nx_device_client import NxClient

SERVER = "http://192.0.2.10:18080"            # a device or the server of your station
client = NxClient("alice", "s3cret")          # HTTP Basic credentials

print(client.get_own_user(SERVER))            # login check: who am I?
for device in client.list_devices(SERVER):    # registered devices (IP and TAG)
    print(device)
```

A port tool, here PPPoE (the other tools have the same shape: `list_*_tools`,
`get_*_config`, `put_*_config`, `delete_*_config`, `get_*_view`):

```python
PORT = "100G_0_3"        # a port you have reserved
client.put_pppoe_config(SERVER, port=PORT, config={
    "enabled": True,
    "client_pools": [{"id": "pool1", "count": 2}],
    "access_concentrators": [{"id": "ac1"}],
})
print(client.get_pppoe_view(SERVER, "sessions", port=PORT, offset=0, limit=50))
client.run_pppoe_action(SERVER, port=PORT, operation="connect", target="pool1")
```

A routing node (the port must be reserved and assigned to a user device of a workspace,
see [docs/routing.md](docs/routing.md)):

```python
node = client.bind(SERVER, port=PORT)         # pre-fills server and port
node.create_node(node={"container_name": "my-node", "loopback": {"prefixes": ["192.0.2.1/32"]},
                      "interfaces": [{}], "cpu_cores": 1, "memory_mi_b": 1024})   # its CPU and memory (API 5.4.0)
print(node.get_node_status("my-node"))
```

`client.bind(...)` returns a proxy with the same methods minus the `server` argument.

## Passwords

The server compares the hex SHA-256 of a password, the form the NATIXON Device Manager sends; only the
bootstrap password `admin` goes unhashed. Give the client the password you type and let it hash:

```python
from nx_device_client import NxClient, Users, hash_password

client = NxClient.from_password("admin", "the password you type")    # sends hash_password(...)
users = Users(client, "http://192.0.2.10:18080")
users.create_or_update("alice", hash_password("alice's password"))   # account passwords too
```

On the command line add `--hash-password`; `NxClient(user, password)` and the CLI without the flag send
the password unchanged (for a caller that already holds the hash).

## Port tools

ARP/IPv6, IGMP, MLD, DHCPv4/v6, LLDP and PPPoE share one set of methods
(`list_<tool>_tools`, `get_/put_/delete_<tool>_config`, `get_<tool>_view`,
`run_<tool>_action`); see the PPPoE example in the quick start above.

* `offset=`/`limit=` are sent only to the views the contract pages (the lists that return
  `total`, for PPPoE `sessions`, `clients`, `events`, `history`); for another view they
  are dropped with a warning.
* Actions need their selector: `session_id` for DHCPv4/v6, `host_id` for LLDP, `target`
  for PPPoE (`connect`, `disconnect`, `reconnect`, `terminate`). A missing one raises
  `ValueError` before the request.
* Details and the workspace form (`port-tool-set`) are in
  [docs/typed-wrappers.md](docs/typed-wrappers.md).

## Routing nodes

```python
for info in client.list_nodes(SERVER):          # GET /rp_nodes
    print(info.node_name, info.state)
    print(info.port)             # primary port: speed, first_lane, last_lane (None before API 3.9)
    print(info.attachments)      # every attached port: first_lane and interface_index
    print(info.can_edit)         # the caller has the configurator role on every attachment

client.remove_node(SERVER, "my-node", port=PORT)                # normal removal
client.remove_node(SERVER, "my-node", port=PORT, force=True)    # administrators: even when that fails
```

`RpNodeInfo.first_lane` is the first lane of the primary port, the value the `port=`
argument of the node methods takes. A node that only the database holds is listed with
`state="needs_repair"` and a `reason`; see [docs/routing.md](docs/routing.md).

A node names the CPU cores and the memory its container takes (`cpu_cores`, `memory_mi_b`,
required since REST API 5.4); the routing nodes of a device share a budget
(`client.get_node_resource_budget(SERVER)`, 409 `rp_resources_exhausted` beyond it) and
`set_node_resources(SERVER, "my-node", port=PORT, cpu_cores=2, memory_mi_b=2048)` changes a
running node live; see [docs/routing.md](docs/routing.md).

A node's configuration is organised in groups (REST API 5.3): `GET .../config` returns
them, the `applyGroups` batch operation (last in its batch) and `create_node(groups=...)`
write them, and `nx_device_client.routing.groups` describes a node as groups
(`GroupedNode`, `Group`) and builds the node, the batches and the groups document from
that one description; see [docs/routing.md](docs/routing.md).

## Workspace script

Export a workspace as a Python script that builds it again -- ports, user devices and links with their
positions, tools, routing nodes with every protocol and the groups of their configuration, generators,
analyzers, scenarios -- and run that script
wherever the package is installed (the NATIXON Device Manager runs it from its Repository page):

```bash
ndm-client -i 192.0.2.10 workspace-export-script 3f1a0b2c4d5e6f70 --out lab.py
python3 lab.py --dry-run        # the port plan: reuse, reserve, or refuse (then nothing changes)
python3 lab.py --start          # build it and start the traffic
python3 lab.py --teardown       # remove the workspace and the ports the script reserved itself
```

```python
from nx_device_client.workspace_script import Lab, Runner, read_workspace, to_script

lab = read_workspace(client, SERVER, "3f1a0b2c4d5e6f70")    # or declare one: Lab(...), lab.port(...), ...
print(to_script(lab))                                        # the script's source
report = Runner(client, lab).run(dry_run=True)               # report.plan, report.ok
```

Passwords and keys the device masks come back as `Secret` placeholders the script lists in `SECRETS`;
`--lane-map 0:4`, `--device NAME=ADDRESS` and `--new-id` move or copy a lab. A routing node is exported
with its `cpu_cores` and `memory_mi_b` (a node stored before REST API 5.4 without them gets the device's
maxima, under a comment) and rebuilt with them. The `ndm-workspace` skill describes the rules (Recipe 7).

## Scripts on the devices

Every device of a group keeps every script with its whole history; one user edits a file at a time, with a lock
taken on more than half of the devices (others see who edits it):

```python
from nx_device_client import ScriptStore

store = ScriptStore(client, SERVER)
file = store.create("users/tester/pe.py", source, "first version")
with store.checkout(file.id, auto_renew=True) as edit:
    edit.submit(edit.content + "\n# tuned\n", "tuned")
print([(e.file.path, e.file.revision, e.file.lock) for e in store.list("users/tester")])
```

`ndm-client script-list | script-put | script-history | script-diff ...` do the same from a shell; the
`ndm-scripts` skill describes folders, roles, locks, history and space.

## Command line

Installing the package adds the `ndm-client` command:

```bash
ndm-client --help                    # command groups
ndm-client --version                 # client version and the REST API version it declares
ndm-client -s http://192.0.2.10:18080 server-version   # server's API version as JSON, exit 4 if incompatible
ndm-client COMMAND --help            # parameters of one command

export NX_DEVICE_IP=192.0.2.10 NX_DEVICE_USERNAME=alice
ndm-client device-list               # prompts for the password
ndm-client rp-call get_node_state --node my-node --port 100G_0_3

ndm-client --server https://nx-1.lab:18443 --insecure --password-file ~/.ndm-password \
  port-tool-get --tool pppoe --first-lane 3 --view sessions --offset 0 --limit 50
ndm-client port-tool-action --tool pppoe --first-lane 3 --operation connect --target pool1
```

`port-tool-get` reads the configuration or a view of a port tool (`--tool`, `--view`,
`--offset`, `--limit`), `port-tool-action` queues a PPPoE action, and `device-expander-set`
needs `--card-type sfp|qsfp`. The typed device-management commands (users, devices, ports,
workspaces, generator, analyzer, QSFP-DD, `run-flow`) are described in
[docs/typed-wrappers.md](docs/typed-wrappers.md); the routing commands `rp-call`,
`rp-plan` and `rp-scenario` in [docs/routing.md](docs/routing.md).

| Option | Environment variable | Meaning |
|---|---|---|
| `-i/--ip` | `NX_DEVICE_IP` | Device IP; the URL is `http://IP:18080`. |
| `-s/--server` | `NX_DEVICE_SERVER` | Full base URL (`http://` or `https://`, optional port), used instead of `--ip`; routing commands need it. |
| `-u/--username` | `NX_DEVICE_USERNAME` | Basic-auth user. |
| `--password-file` | `NX_DEVICE_PASSWORD` | Basic-auth password: first line of a file, or the variable; the prompt is used when neither is set (a prompted password is always sent hashed, as `--hash-password` does). `-p/--password` still works but is deprecated (visible in the process list). |
| `--timeout` | | HTTP timeout in seconds (default 30). |
| `--insecure` | | Skip TLS certificate verification (self-signed certificates). |

Routing scenarios read keys from variables named in the scenario file (for example
`NX_ISIS_KEY`), never from the command line.

Exit codes: `0` success, `1` API or flow error, `2` usage error, `3` transport error
(server unreachable), `4` `server-version` found the server's API version incompatible with the client
(every other command then fails with `1` and the reason on stderr).

## Timeouts and TLS

```python
client = NxClient("alice", "s3cret", timeout=60, routing_timeout=240, verify_tls=False)
```

* `timeout`: socket timeout in seconds for every request (default 30).
* `routing_timeout`: independent timeout of routing requests (default 180 s,
  `DEFAULT_ROUTING_TIMEOUT`, because applying a configuration can take long; the CLI
  uses the same for `rp-*` commands). A timeout of a mutation means the result is unknown, not that nothing
  changed: read the state before repeating it. The client never retries mutations.
* `verify_tls=False` accepts self-signed certificates (`--insecure` in the CLI). Certificates are verified by
  default, and so is the host name; use HTTPS whenever the server offers it, because
  Basic authentication sends the password with every request.

## Resilience

```python
from nx_device_client import NxClient, RetryPolicy, RpVersionMismatchError

client = NxClient("alice", "s3cret")                                  # defaults below
client = NxClient("alice", "s3cret", retry=RetryPolicy(attempts=5, backoff=1.0))
client = NxClient("alice", "s3cret", retry=0)                         # never retry
client = NxClient("alice", "s3cret", check_version=False)             # skip the version check (diagnostic tools)
```

* **Retry.** By default a `GET` is sent up to 3 times (backoff 0.5 s, doubling, at most
  5 s) after a network failure or HTTP 503 ("busy, try later"). Mutations are never
  repeated unless you extend `RetryPolicy(methods=...)` for a flow that knows its
  requests are idempotent. `retry=` takes a `RetryPolicy`, a number of attempts, or
  `0`/`False` to turn retrying off.
* **No redirects.** A 3xx answer is an error of its own (`RpApiError` with its `status`);
  the client never follows it, so the credentials go to no address but the server's. An
  answer that is not HTTP (a broken status line, a body cut short) or a host name that
  cannot be encoded is an `RpTransportError`, like a refused connection.
* **Version check.** Before the first request to a server the client reads `GET /version`
  and compares the server's API version with the one this release was built for. The rule:
  the API **major** must be equal and the server's **minor** must be at least the client's
  (the patch never matters; `api_compatible(server_api, client_api)` implements it). If the
  versions do not match, or the server does not report its version at all, **every**
  request of the client to that server raises `RpVersionMismatchError` (`server`,
  `server_version`, `client_version`, `server_release`, `rule`); the verdict is cached, so
  later requests fail at once without network traffic. The message names both API versions,
  the server release, the client version and what to do: update the client or the server.
  The `X-NX-API-Version` header of every response is compared with the remembered version,
  so a server upgraded while the client runs is caught too: that response is replaced by
  the error (for a mutation `outcome_unknown=True`: it may have been applied; read the
  state back). "Does not report" means a 404 or a 200 without `api_version`. If the check
  itself fails on the network, or `GET /version` answers anything else (5xx, a redirect, a
  proxy's 401/407, 429), nothing is cached, the request goes on and reports its own error,
  and the next request checks again.
  `check_version=False` turns the whole check off (for diagnostic tools); an injected
  `transport=` has it off by default. `strict=` is kept for compatibility: `strict=False`
  (deprecated, `DeprecationWarning`) only logs a warning instead of raising.
* **Showing the version.** `client.server_version(server)` never raises for a mismatch and
  returns a `ServerVersion` (`api_version`, `version`, `api_major`, `api_minor`,
  `api_patch`, `release`, `compatible`, `rule`); the CLI equivalent is `ndm-client
  server-version`.

## Logging

The client logs through the standard `logging` module and is silent by default. Each
request and response is a `DEBUG` record on the logger `nx_device_client.http` (method,
URL, status, body size; never headers or bodies, which carry passwords and keys); retries
and a version mismatch are warnings.

```python
import logging

logging.basicConfig(level=logging.WARNING)
logging.getLogger("nx_device_client.http").setLevel(logging.DEBUG)
```

## Error handling

All errors derive from `RpClientError`:

| Exception | Meaning |
|---|---|
| `RpTransportError` | No response (refused, DNS, timeout). |
| `RpVersionMismatchError` | The server's API version is not compatible with the client's (or unreported). |
| `RpApiError` | The server answered with an error status; see the subclasses below. |

Subclasses of `RpApiError`:

| Exception | Status |
|---|---|
| `RpValidationError` | 400 |
| `RpAuthenticationError` | 401 |
| `RpAuthorizationError` | 403 |
| `RpNotFoundError` | 404 |
| `RpConflictError` | 409 |
| `RpExpiredError` | 410, the reservation or workspace expired |
| `RpPayloadTooLargeError` | 413 |
| `RpUnprocessableError` | 422, for example a license the device cannot accept |
| `RpThrottledError` | 429, too many failed logins |
| `RpServerError` | 5xx (also 503 busy) |
| `RpNotImplementedError` | 501, a subclass of `RpServerError`: the operation is not on this server |

Catch the more specific class first. A parameter the contract requires but that has no
value (for example `session_id` of `run_dhcp4_action`) raises `ValueError` before any
request.

`RpApiError` carries the status and the server's error envelope:

```python
from nx_device_client import RpApiError, RpClientError

try:
    client.get_node_status(SERVER, "tg9", port=PORT)
except RpApiError as error:
    print(error.status, error.code, error.message, error.details)
except RpClientError:
    raise
```

`code` is the stable machine-readable error code (for example `port_expired`), `message`
the human-readable cause and `details` an optional explanation (sent only at high server
verbosity). `code` and `details` are `None` when the body was not an error envelope.

## Compatibility with the server

Each release is synchronised with one version of the REST API (`info.version` of the
server's OpenAPI contract, also reported by `GET /version`). Use the release that
matches the server; the contract has 353 operations, of which the routing snapshot
(`src/nx_device_client/routing/_contract.py`) covers 199.

| `ndm-client` | REST API | Notable contract change |
|---|---|---|
| 1.1.0 | 3.22.0 | PIM `join` and IGMP views, licensing methods |
| 1.2.0 | 3.23.0 | one model of read sections (removed views, paged lists), PCEP `session`, `refresh` flag |
| 1.3.0 | 3.24.0 | PPPoE as a port tool (node-level PPPoE methods removed), lease-loss policy of bindings, DHCP relay options |
| 1.4.0 | 4.0.0 | Same contract as 3.24.0, published as a major version because of the removed operations |
| 2026.4 | 6.0.0 | In preparation: corrections found by the audits, the version check, resource versions (ETag), the script store, the groups of a routing node's configuration, the CPU and memory of a routing node, no mock in the API (the server runs only on the hardware and in the VMware image); needs a 6.x server |

From 2026.4 on, client versions are `<year>.<quarter>` for the quarterly release and
`<year>.<quarter>.<fix>` for its fixes (`2026.3`, then `2026.3.1`, `2026.3.2`, ...; the same numbers as the
NATIXON app and REST API server of that release); 1.1.0–1.4.0 were the earlier semantic versions.

The Python methods of an older release fail with `RpNotFoundError` or `RpClientError` on
operations the newer server removed; see `CHANGELOG.md` for the exact list per release.
Check the server before you rely on a release:

```python
info = client.server_version(SERVER)           # GET /version, no authentication
print(info.api_version, info.release)          # for example "4.1.0" "2026.03"
assert info.compatible
```

## Offline reference and AI agent skills

The package carries the whole API contract of the server it was built for, so a command, method,
schema or REST operation can be looked up without a server or the repository:

```bash
ndm-client describe generator-stream-add        # usage, the methods it calls, its JSON schema
ndm-client describe set_generator_properties    # signature, docstring, REST call, body, example
ndm-client describe StreamConfig                # fields, ranges, enums, a minimal valid value
ndm-client describe --list schemas
```

It also carries skills for AI agents (Claude Code, Claude Agent SDK) that drive the generator with
this client. Install the skills of the installed client version:

```bash
ndm-client skills list
ndm-client skills install                       # into ~/.claude/skills
ndm-client skills install --dest .claude/skills # into one project
```

Step by step for a clean workspace: [docs/ai-skills-quickstart.md](docs/ai-skills-quickstart.md).
The sources are in `ai_skills/`; [docs/plan-ai-skills.md](docs/plan-ai-skills.md) describes them.

## More documentation

* [docs/typed-wrappers.md](docs/typed-wrappers.md): typed wrappers and the device CLI.
* [docs/routing.md](docs/routing.md): routing nodes, `rp-*` commands, scenarios.
* [docs/routing-routes.md](docs/routing-routes.md), [docs/routing-telemetry.md](docs/routing-telemetry.md),
  [docs/isis-authentication.md](docs/isis-authentication.md): route reads, telemetry,
  IS-IS authentication.
* [docs/release.md](docs/release.md): how a release is built and published.
* [CHANGELOG.md](CHANGELOG.md).
* `examples/`: runnable samples.

## License

Free to use. Modification of your own copy for internal use is allowed;
redistribution, resale and distributing modified copies are not. See the
`LICENSE` file for the full terms.
