From a2df70609a4711b4eb673f28cee3e6f72414148a Mon Sep 17 00:00:00 2001 From: Roberto Bertó <463349+robertoberto@users.noreply.github.com> Date: Tue, 19 May 2026 02:54:59 +0000 Subject: chore: trim root, move examples, drop committed uv.lock - delete CONTRIB.md (superseded by CONTRIBUTING.md) - delete requirements.txt (duplicated [project].dependencies) - delete test_quick.py (ad-hoc smoke covered by tests/test_shims.py) - rewrite example.py as examples/basic.py using the public API (from pyvyos import VyDevice) and drop the RuntimeWarning suppression - move vagrant/ to examples/vagrant/ - stop tracking uv.lock and add it to .gitignore (library, not app) - README: link the examples/ directory - CHANGELOG: document the moves and removals --- examples/basic.py | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 examples/basic.py (limited to 'examples/basic.py') diff --git a/examples/basic.py b/examples/basic.py new file mode 100644 index 0000000..3a275f4 --- /dev/null +++ b/examples/basic.py @@ -0,0 +1,87 @@ +"""Basic pyvyos usage example. + +Run with environment variables loaded from a .env file (see ../.env.example) +or exported in your shell: + + VYDEVICE_HOSTNAME=192.0.2.1 \\ + VYDEVICE_APIKEY=secret \\ + python examples/basic.py +""" + +import os +import pprint +import random +import string + +from dotenv import load_dotenv + +from pyvyos import VyDevice + +load_dotenv() + + +def make_device() -> VyDevice: + return VyDevice( + hostname=os.environ["VYDEVICE_HOSTNAME"], + apikey=os.environ["VYDEVICE_APIKEY"], + port=int(os.environ.get("VYDEVICE_PORT", "443")), + protocol=os.environ.get("VYDEVICE_PROTOCOL", "https"), + verify=os.environ.get("VYDEVICE_VERIFY_SSL", "true").lower() == "true", + timeout=60, + ) + + +def main() -> None: + device = make_device() + + # Retrieve the running configuration for the system tree. + pprint.pprint(device.retrieve_show_config(path=["system"])) + + # Configure a dummy interface, read it back, then delete it. + pprint.pprint( + device.configure_set( + path=["interfaces", "dummy", "dum1", "address", "192.168.56.100/24"] + ) + ) + pprint.pprint( + device.retrieve_return_values( + path=["interfaces", "dummy", "dum1", "address"] + ) + ) + pprint.pprint(device.configure_delete(path=["interfaces", "dummy", "dum1"])) + + # Generate a one-shot SSH client key. + randstring = "".join( + random.choice(string.ascii_letters + string.digits) for _ in range(20) + ) + pprint.pprint( + device.generate(path=["ssh", "client-key", f"/tmp/key_{randstring}"]) + ) + + # Operational commands. + pprint.pprint(device.show(path=["system", "image"])) + pprint.pprint(device.reset(path=["conntrack-sync", "internal-cache"])) + + # Save and reload the running configuration to/from a file on the device. + pprint.pprint(device.config_file_save(file="/config/test300.config")) + pprint.pprint(device.config_file_load(file="/config/test300.config")) + + # Batch multiple configuration operations in a single request. + pprint.pprint( + device.configure_multiple_op( + path=[ + { + "op": "set", + "path": [ + "interfaces", "dummy", "dum1", "address", + "192.168.56.100/24", + ], + }, + {"op": "delete", "path": ["interfaces", "dummy", "dum1"]}, + ] + ) + ) + + +if __name__ == "__main__": + main() -- cgit v1.2.3 From 4c19d27c2cf87176dfb983ccc65f2c817ff1f650 Mon Sep 17 00:00:00 2001 From: Roberto Bertó <463349+robertoberto@users.noreply.github.com> Date: Tue, 19 May 2026 02:57:11 +0000 Subject: docs: correct README and example to match actual API - configure_multiple_op uses op_path=, not path= - ApiResponse.result is dict | list | str | None (varies per endpoint), not just dict | list - VYDEVICE_VERIFY_SSL parsing accepts 1/true/yes - describe logging accurately: log records only carry structural fields (no payload, no key); sanitization applies to ApiResponse.request, not to logs; do not claim a NullHandler is attached (none is) - shims at 1.0.0: phrase as a decision deferred to release time --- README.md | 30 ++++++++++++++++++------------ examples/basic.py | 2 +- 2 files changed, 19 insertions(+), 13 deletions(-) (limited to 'examples/basic.py') diff --git a/README.md b/README.md index 9f9f727..be28fa0 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ device = VyDevice( apikey=os.environ["VYDEVICE_APIKEY"], port=int(os.environ.get("VYDEVICE_PORT", "443")), protocol=os.environ.get("VYDEVICE_PROTOCOL", "https"), - verify=os.environ.get("VYDEVICE_VERIFY_SSL", "true").lower() == "true", + verify=os.environ.get("VYDEVICE_VERIFY_SSL", "true").lower() in ("1", "true", "yes"), ) response = device.show(path=["system", "image"]) @@ -80,12 +80,16 @@ All methods return an `ApiResponse` dataclass with four fields: ```python @dataclass class ApiResponse: - status: int # HTTP status code - request: dict # the request payload (API key removed) - result: dict | list # parsed `data` field from the response - error: str | bool # error message, or False on success + status: int # HTTP status code + request: dict # the request payload (API key redacted) + result: dict | list | str | None # parsed `data` field from the response + error: str | bool # error message, or False on success ``` +`result` varies per endpoint: configuration retrieval returns a `dict` or +`list`, operational commands like `show`/`generate` often return a `str`, +and some endpoints return `None`. + The recommended usage pattern is: ```python @@ -100,7 +104,7 @@ do_something_with(response.result) ```python device.configure_set(path=["interfaces", "ethernet", "eth0", "address", "192.0.2.1/24"]) device.configure_delete(path=["interfaces", "dummy", "dum1"]) -device.configure_multiple_op(path=[ +device.configure_multiple_op(op_path=[ {"op": "set", "path": ["interfaces", "dummy", "dum2", "address", "203.0.113.1/24"]}, {"op": "delete", "path": ["interfaces", "dummy", "dum1"]}, ]) @@ -169,7 +173,7 @@ The deprecation timeline is: | `0.4.x` | Compatibility shims work without warnings. | | `0.5.x` | Internal solidity work; shims still silent. | | `0.6.x` | Compatibility shims emit a `DeprecationWarning`. | -| `1.0.0` | Shims are removed or kept, depending on observed usage. | +| `1.0.0` | Final shim behaviour decided before release, based on observed usage and maintenance cost. | ## Examples @@ -178,9 +182,7 @@ and a Vagrant-based lab setup under [`examples/vagrant/`](examples/vagrant/). ## Logging -`pyvyos` uses the standard `logging` module under the `pyvyos` namespace and -attaches a `NullHandler` so a default install does not print anything. - +`pyvyos` uses the standard `logging` module under the `pyvyos` namespace. To see request/response activity, configure the logger in your application: ```python @@ -189,8 +191,12 @@ logging.basicConfig(level=logging.INFO) logging.getLogger("pyvyos").setLevel(logging.DEBUG) ``` -Request payloads are sanitised before logging — the `key` field is replaced -with `***REDACTED***`. +Log records contain structural fields only (`command`, `op`, `status`, +`elapsed_ms`) and never include the request payload or the API key. + +The request payload returned via `ApiResponse.request` is sanitised — the +`key` field is replaced with `***REDACTED***` before the response is +handed back to the caller. ## VyOS compatibility diff --git a/examples/basic.py b/examples/basic.py index 3a275f4..c133d41 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -69,7 +69,7 @@ def main() -> None: # Batch multiple configuration operations in a single request. pprint.pprint( device.configure_multiple_op( - path=[ + op_path=[ { "op": "set", "path": [ -- cgit v1.2.3 From b2f1765d286502307ea07a62648718d0414e4f39 Mon Sep 17 00:00:00 2001 From: Roberto Bertó <463349+robertoberto@users.noreply.github.com> Date: Tue, 19 May 2026 03:02:52 +0000 Subject: docs: split examples into read-only basic and guarded smoke The previous examples/basic.py ran destructive operations (configure_set, configure_delete, generate, reset, config_file_load) by default. That is not what a public 'basic' example should do. - examples/basic.py: rewritten as a read-only example (retrieve_show_config, show, retrieve_return_values), uses a robust env-bool parser, prints response.result via a small helper, and uses the supported public import 'from pyvyos import ApiResponse, VyDevice'. - examples/integration_smoke.py: renamed from the old basic.py, keeps the mutating operations, and refuses to run unless PYVYOS_ALLOW_MUTATING_EXAMPLE=1 is set. - README: link both examples and describe what each one does. Note: the dev/ note about a future Docker-based e2e harness is tracked in the unreleased issue drafts; it is not part of this release. --- README.md | 10 +++- examples/basic.py | 80 ++++++++++++-------------------- examples/integration_smoke.py | 105 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 52 deletions(-) create mode 100644 examples/integration_smoke.py (limited to 'examples/basic.py') diff --git a/README.md b/README.md index be28fa0..96c1ec8 100644 --- a/README.md +++ b/README.md @@ -177,8 +177,14 @@ The deprecation timeline is: ## Examples -A runnable end-to-end example lives in [`examples/basic.py`](examples/basic.py), -and a Vagrant-based lab setup under [`examples/vagrant/`](examples/vagrant/). +- [`examples/basic.py`](examples/basic.py) — read-only end-to-end usage + example. Safe to run against any reachable device. +- [`examples/integration_smoke.py`](examples/integration_smoke.py) — + exercises mutating operations (`configure_set/delete`, `generate`, + `config_file_save/load`). Intended for a disposable lab; guarded by the + `PYVYOS_ALLOW_MUTATING_EXAMPLE=1` environment variable. +- [`examples/vagrant/`](examples/vagrant/) — Vagrant-based VyOS lab for + local development and integration testing. ## Logging diff --git a/examples/basic.py b/examples/basic.py index c133d41..1e1b552 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -1,85 +1,65 @@ -"""Basic pyvyos usage example. +"""Basic read-only pyvyos usage example. -Run with environment variables loaded from a .env file (see ../.env.example) -or exported in your shell: +Run with environment variables loaded from a .env file or exported in your +shell: VYDEVICE_HOSTNAME=192.0.2.1 \\ VYDEVICE_APIKEY=secret \\ python examples/basic.py + +This example only runs read-only commands. """ import os import pprint -import random -import string from dotenv import load_dotenv -from pyvyos import VyDevice +from pyvyos import ApiResponse, VyDevice load_dotenv() +def env_bool(name: str, default: str = "true") -> bool: + return os.environ.get(name, default).lower() in ("1", "true", "yes") + + def make_device() -> VyDevice: return VyDevice( hostname=os.environ["VYDEVICE_HOSTNAME"], apikey=os.environ["VYDEVICE_APIKEY"], port=int(os.environ.get("VYDEVICE_PORT", "443")), protocol=os.environ.get("VYDEVICE_PROTOCOL", "https"), - verify=os.environ.get("VYDEVICE_VERIFY_SSL", "true").lower() == "true", - timeout=60, + verify=env_bool("VYDEVICE_VERIFY_SSL"), + timeout=int(os.environ.get("VYDEVICE_TIMEOUT", "60")), ) +def print_response(label: str, response: ApiResponse) -> None: + print(f"\n== {label} ==") + if response.error: + print(f"Error {response.status}: {response.error}") + return + + pprint.pprint(response.result) + + def main() -> None: device = make_device() - # Retrieve the running configuration for the system tree. - pprint.pprint(device.retrieve_show_config(path=["system"])) - - # Configure a dummy interface, read it back, then delete it. - pprint.pprint( - device.configure_set( - path=["interfaces", "dummy", "dum1", "address", "192.168.56.100/24"] - ) + print_response( + "Running system configuration", + device.retrieve_show_config(path=["system"]), ) - pprint.pprint( - device.retrieve_return_values( - path=["interfaces", "dummy", "dum1", "address"] - ) - ) - pprint.pprint(device.configure_delete(path=["interfaces", "dummy", "dum1"])) - # Generate a one-shot SSH client key. - randstring = "".join( - random.choice(string.ascii_letters + string.digits) for _ in range(20) - ) - pprint.pprint( - device.generate(path=["ssh", "client-key", f"/tmp/key_{randstring}"]) + print_response( + "System images", + device.show(path=["system", "image"]), ) - # Operational commands. - pprint.pprint(device.show(path=["system", "image"])) - pprint.pprint(device.reset(path=["conntrack-sync", "internal-cache"])) - - # Save and reload the running configuration to/from a file on the device. - pprint.pprint(device.config_file_save(file="/config/test300.config")) - pprint.pprint(device.config_file_load(file="/config/test300.config")) - - # Batch multiple configuration operations in a single request. - pprint.pprint( - device.configure_multiple_op( - op_path=[ - { - "op": "set", - "path": [ - "interfaces", "dummy", "dum1", "address", - "192.168.56.100/24", - ], - }, - {"op": "delete", "path": ["interfaces", "dummy", "dum1"]}, - ] - ) + print_response( + "Interface address values", + device.retrieve_return_values(path=["interfaces"]), ) diff --git a/examples/integration_smoke.py b/examples/integration_smoke.py new file mode 100644 index 0000000..137ca9b --- /dev/null +++ b/examples/integration_smoke.py @@ -0,0 +1,105 @@ +"""Integration smoke test for pyvyos. + +This script changes configuration on the target VyOS device and should only +be used against a disposable lab device, such as the one provided by +``examples/vagrant/``. + +It may create and delete dummy interfaces, generate temporary files on the +device, and save/load configuration files. + +Run only against a lab device:: + + PYVYOS_ALLOW_MUTATING_EXAMPLE=1 \\ + VYDEVICE_HOSTNAME=127.0.0.1 \\ + VYDEVICE_APIKEY=secret \\ + python examples/integration_smoke.py +""" + +import os +import pprint +import random +import string +import sys + +from dotenv import load_dotenv + +from pyvyos import VyDevice + +load_dotenv() + + +def env_bool(name: str, default: str = "true") -> bool: + return os.environ.get(name, default).lower() in ("1", "true", "yes") + + +def make_device() -> VyDevice: + return VyDevice( + hostname=os.environ["VYDEVICE_HOSTNAME"], + apikey=os.environ["VYDEVICE_APIKEY"], + port=int(os.environ.get("VYDEVICE_PORT", "443")), + protocol=os.environ.get("VYDEVICE_PROTOCOL", "https"), + verify=env_bool("VYDEVICE_VERIFY_SSL"), + timeout=int(os.environ.get("VYDEVICE_TIMEOUT", "60")), + ) + + +def main() -> None: + if os.environ.get("PYVYOS_ALLOW_MUTATING_EXAMPLE") != "1": + sys.exit( + "This example mutates the target device. " + "Set PYVYOS_ALLOW_MUTATING_EXAMPLE=1 to run it." + ) + + device = make_device() + + # Retrieve the running configuration for the system tree. + pprint.pprint(device.retrieve_show_config(path=["system"])) + + # Configure a dummy interface, read it back, then delete it. + pprint.pprint( + device.configure_set( + path=["interfaces", "dummy", "dum1", "address", "192.168.56.100/24"] + ) + ) + pprint.pprint( + device.retrieve_return_values( + path=["interfaces", "dummy", "dum1", "address"] + ) + ) + pprint.pprint(device.configure_delete(path=["interfaces", "dummy", "dum1"])) + + # Generate a one-shot SSH client key. + randstring = "".join( + random.choice(string.ascii_letters + string.digits) for _ in range(20) + ) + pprint.pprint( + device.generate(path=["ssh", "client-key", f"/tmp/key_{randstring}"]) + ) + + # Operational commands. + pprint.pprint(device.show(path=["system", "image"])) + pprint.pprint(device.reset(path=["conntrack-sync", "internal-cache"])) + + # Save and reload the running configuration to/from a file on the device. + pprint.pprint(device.config_file_save(file="/config/test300.config")) + pprint.pprint(device.config_file_load(file="/config/test300.config")) + + # Batch multiple configuration operations in a single request. + pprint.pprint( + device.configure_multiple_op( + op_path=[ + { + "op": "set", + "path": [ + "interfaces", "dummy", "dum1", "address", + "192.168.56.100/24", + ], + }, + {"op": "delete", "path": ["interfaces", "dummy", "dum1"]}, + ] + ) + ) + + +if __name__ == "__main__": + main() -- cgit v1.2.3