summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRoberto Bertó <463349+robertoberto@users.noreply.github.com>2026-05-19 03:15:55 -0300
committerGitHub <noreply@github.com>2026-05-19 03:15:55 -0300
commit294d060ac1557ed70cab7a12f97d930f9dc4baf9 (patch)
tree25e03a160fb1dc05a27a9118a7a6f573f0ffd123
parentffd5ba16eb1ada42a582db4ac8bdaf29f66a868f (diff)
parent6071528289e4a8b11a772433c33851136d30f133 (diff)
downloadpyvyos-294d060ac1557ed70cab7a12f97d930f9dc4baf9.tar.gz
pyvyos-294d060ac1557ed70cab7a12f97d930f9dc4baf9.zip
Merge pull request #31 from vyos-contrib/release/v0.4.0-cleanupv0.4.0
Release v0.4.0 — cleanup and consolidation
-rw-r--r--.env.example16
-rw-r--r--.github/dependabot.yml20
-rw-r--r--.github/workflows/python-app.yml39
-rw-r--r--.github/workflows/python-pr-validation.yml41
-rw-r--r--.github/workflows/python-publish.yml6
-rw-r--r--.gitignore4
-rw-r--r--.pre-commit-config.yaml17
-rw-r--r--.python-version2
-rw-r--r--.readthedocs.yaml33
-rw-r--r--CHANGELOG.md140
-rw-r--r--CONTRIB.md79
-rw-r--r--CONTRIBUTING.md71
-rw-r--r--LICENSE2
-rw-r--r--Makefile14
-rw-r--r--README.md309
-rw-r--r--docs/development/architecture.md77
-rw-r--r--docs/development/quality-and-utils.md72
-rw-r--r--docs/development/refactor-roadmap.md56
-rw-r--r--docs/getting-started.md4
-rw-r--r--docs/index.md4
-rw-r--r--example.py113
-rw-r--r--examples/basic.py67
-rw-r--r--examples/integration_smoke.py105
-rw-r--r--examples/vagrant/.env.example (renamed from vagrant/.env.example)0
-rw-r--r--examples/vagrant/VAGRANT.md (renamed from vagrant/VAGRANT.md)0
-rw-r--r--examples/vagrant/Vagrantfile (renamed from vagrant/Vagrantfile)0
-rw-r--r--examples/vagrant/virtualbox-wsl2.sh (renamed from vagrant/virtualbox-wsl2.sh)0
-rw-r--r--pyproject.toml62
-rw-r--r--pyvyos/core/rest_client.py9
-rw-r--r--pyvyos/exceptions.py33
-rw-r--r--pyvyos/py.typed0
-rw-r--r--pyvyos/specs/__init__.py12
-rw-r--r--pyvyos/specs/commands/__init__.py13
-rw-r--r--pyvyos/specs/commands/config_file.py36
-rw-r--r--pyvyos/specs/commands/configure.py38
-rw-r--r--pyvyos/specs/commands/generate.py20
-rw-r--r--pyvyos/specs/commands/image.py33
-rw-r--r--pyvyos/specs/commands/reset.py20
-rw-r--r--pyvyos/specs/commands/retrieve.py27
-rw-r--r--pyvyos/specs/commands/show.py20
-rw-r--r--pyvyos/specs/commands/system.py27
-rw-r--r--pyvyos/specs/models.py31
-rw-r--r--pyvyos/utils/__init__.py3
-rw-r--r--pyvyos/utils/ids.py14
-rw-r--r--requirements.txt3
-rw-r--r--run_tests.py64
-rw-r--r--run_tests.sh36
-rw-r--r--sphinx/Makefile20
-rw-r--r--sphinx/README.md27
-rw-r--r--sphinx/make.bat35
-rw-r--r--sphinx/requirements.txt2
-rw-r--r--sphinx/source/conf.py36
-rw-r--r--sphinx/source/index.rst214
-rw-r--r--sphinx/source/pyvyos.rst21
-rw-r--r--test_quick.py40
-rw-r--r--tests/e2e/__init__.py0
-rw-r--r--tests/e2e/conftest.py96
-rw-r--r--tests/e2e/test_vydevice_live.py71
-rw-r--r--tests/pve/.env.example70
-rw-r--r--tests/pve/README.md162
-rwxr-xr-xtests/pve/_lib.sh128
-rw-r--r--tests/pve/cloud-init/meta-data.template2
-rw-r--r--tests/pve/cloud-init/user-data.template14
-rwxr-xr-xtests/pve/create-vm.sh42
-rwxr-xr-xtests/pve/destroy-vm.sh12
-rwxr-xr-xtests/pve/ensure-template.sh175
-rwxr-xr-xtests/pve/preflight.sh45
-rwxr-xr-xtests/pve/run-e2e.sh26
-rwxr-xr-xtests/pve/start-vm.sh10
-rwxr-xr-xtests/pve/stop-vm.sh10
-rw-r--r--tests/test_exceptions.py51
-rw-r--r--tests/utils/test_ids.py25
-rw-r--r--uv.lock361
73 files changed, 1540 insertions, 1947 deletions
diff --git a/.env.example b/.env.example
index 328f4c0..d3a2448 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,19 @@
+# pyvyos device configuration.
+# Copy this file to .env and fill in the values.
+
+# Hostname or IP address of the VyOS device.
VYDEVICE_HOSTNAME=
+
+# API key configured on the device with:
+# set service https api keys id <name> key <secret>
VYDEVICE_APIKEY=
+
+# HTTPS port of the VyOS API (default: 443).
VYDEVICE_PORT=443
+
+# Protocol: "https" (recommended) or "http".
VYDEVICE_PROTOCOL=https
-VYDEVICE_VERIFY_SSL=False \ No newline at end of file
+
+# Verify the TLS certificate of the device.
+# Set to "false" ONLY in lab environments using self-signed certificates.
+VYDEVICE_VERIFY_SSL=true
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 9d866e3..973270b 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,11 +1,21 @@
-# To get started with Dependabot version updates, you'll need to specify which
-# package ecosystems to update and where the package manifests are located.
-# Please see the documentation for all configuration options:
+# Dependabot version updates
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- - package-ecosystem: "pip" # See documentation for possible values
- directory: "/" # Location of package manifests
+ - package-ecosystem: "pip"
+ directory: "/"
schedule:
interval: "weekly"
+ open-pull-requests-limit: 5
+ labels:
+ - "dependencies"
+
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "monthly"
+ open-pull-requests-limit: 5
+ labels:
+ - "dependencies"
+ - "ci"
diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
deleted file mode 100644
index 96d39fa..0000000
--- a/.github/workflows/python-app.yml
+++ /dev/null
@@ -1,39 +0,0 @@
-# This workflow will install Python dependencies, run tests and lint with a single version of Python
-# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
-
-name: Python application
-
-on:
- push:
- branches: [ "main" ]
- pull_request:
- branches: [ "main" ]
-
-permissions:
- contents: read
-
-jobs:
- build:
-
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v4.1.1
- - name: Set up Python 3.12.2
- uses: actions/setup-python@v5.0.0
- with:
- python-version: "3.12.2"
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install flake8 pytest
- if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- - name: Lint with flake8
- run: |
- # stop the build if there are Python syntax errors or undefined names
- flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
- # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
- flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- #- name: Test with pytest
- # run: |
- # pytest
diff --git a/.github/workflows/python-pr-validation.yml b/.github/workflows/python-pr-validation.yml
index 3aae387..5defe21 100644
--- a/.github/workflows/python-pr-validation.yml
+++ b/.github/workflows/python-pr-validation.yml
@@ -1,29 +1,36 @@
-name: Python Pull Request Validation Workflow
+name: Pull Request Validation
on:
- pull_request:
- branches: [ "main", "master" ]
+ pull_request:
+ branches: ["main"]
+
+permissions:
+ contents: read
jobs:
- Validation:
+ validate:
runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.11", "3.12", "3.13"]
steps:
- name: Checkout
- uses: actions/checkout@v2
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ with:
+ python-version: ${{ matrix.python-version }}
- - name: Set up Python 3.12
- uses: actions/setup-python@v2
+ - name: Install uv
+ uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
- python-version: "3.13"
- architecture: "x64"
+ enable-cache: true
+ cache-dependency-glob: "pyproject.toml"
- name: Install dependencies
- run: |
- python3 -m pip install --upgrade pip
- pip install uv
- uv venv
- uv pip install -e .[dev]
+ run: uv sync --extra dev --python ${{ matrix.python-version }}
- - name: Python Run Tests
- run: |
- uv run pytest -v
+ - name: Run tests
+ run: uv run --python ${{ matrix.python-version }} pytest -v
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
index 57189af..9ab4867 100644
--- a/.github/workflows/python-publish.yml
+++ b/.github/workflows/python-publish.yml
@@ -19,10 +19,10 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4.1.1
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python 3.13
- uses: actions/setup-python@v5.0.0
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.13"
@@ -35,7 +35,7 @@ jobs:
run: uv build
- name: Publish package
- uses: pypa/gh-action-pypi-publish@v1.13.0
+ uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 4e3c616..5839e48 100644
--- a/.gitignore
+++ b/.gitignore
@@ -160,11 +160,13 @@ cython_debug/
#.idea/
-vagrant/.vagrant
+examples/vagrant/.vagrant
+tests/pve/.env
packer/
*.code-workspace
.env
dev/
.idea
+uv.lock
docs/vyos-documentation/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..4d771e1
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,17 @@
+# Pre-commit hooks for pyvyos
+# Install: pip install pre-commit && pre-commit install
+# Run manually: pre-commit run --all-files
+
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v6.0.0
+ hooks:
+ - id: trailing-whitespace
+ - id: end-of-file-fixer
+ - id: check-yaml
+ - id: check-toml
+ - id: check-merge-conflict
+ - id: check-added-large-files
+ args: ["--maxkb=500"]
+ - id: mixed-line-ending
+ args: ["--fix=lf"]
diff --git a/.python-version b/.python-version
index 4eba2a6..24ee5b1 100644
--- a/.python-version
+++ b/.python-version
@@ -1 +1 @@
-3.13.0
+3.13
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
deleted file mode 100644
index 1ca8544..0000000
--- a/.readthedocs.yaml
+++ /dev/null
@@ -1,33 +0,0 @@
-# .readthedocs.yaml
-# Read the Docs configuration file
-# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
-
-# Required
-version: 2
-
-# Set the OS, Python version and other tools you might need
-build:
- os: ubuntu-22.04
- tools:
- python: "3.12"
- # You can also specify other tool versions:
- # nodejs: "19"
- # rust: "1.64"
- # golang: "1.19"
-
-# Build documentation in the "docs/" directory with Sphinx
-sphinx:
- configuration: docs/source/conf.py
-
-# Optionally build your docs in additional formats such as PDF and ePub
-# formats:
-# - pdf
-# - epub
-
-# Optional but recommended, declare the Python requirements required
-# to build your documentation
-# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
-
-python:
- install:
- - requirements: docs/requirements.txt
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 15b9314..28c5e82 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,50 +5,128 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [0.4.0] - 2025-01-XX
+## [0.4.0] - 2025-11-20
+
+`0.4.0` is a cleanup and consolidation release. It is the first version
+since `0.2.2` published to PyPI. The git tag `v0.4.0` previously pointed at
+the intermediate refactor commit `6b4e901`; it has been moved to the
+release commit that includes the cleanup described below.
+
+This release **does not change HTTP payload generation or request/response
+behavior**.
### Added
-- Exception hierarchy (`SDKError`, `HttpError`, `ApiError`, `ValidationError`) in `pyvyos.exceptions`
-- Utility functions in `pyvyos.utils`:
- - `json.redact_key()` and `json.safe_dumps()` for secure JSON handling
- - `ids.request_id()` for request tracing
- - `paths.build_path()` for building configuration paths
-- Structured logging in `RestClient` with request ID tracking and elapsed time
-- Optional Pydantic validation models in `pyvyos.specs.commands.*` for request/response validation
-- Development documentation:
- - Architecture guide (`docs/development/architecture.md`)
- - Refactor roadmap (`docs/development/refactor-roadmap.md`)
- - Quality and utils guidelines (`docs/development/quality-and-utils.md`)
-- Comprehensive test suite for backward compatibility (19 tests for shims, 16 tests for utils, 6 tests for exceptions)
-- `[tool.uv] package = true` in `pyproject.toml` for editable installation via `uv sync`
+- `examples/basic.py` — runnable end-to-end usage example using the
+ supported public API (`from pyvyos import VyDevice`).
+- `pyvyos/py.typed` PEP 561 marker, advertising the package as typed.
+- `.pre-commit-config.yaml` with neutral hooks (trailing whitespace,
+ end-of-file fixer, YAML/TOML syntax check, large-file guard).
+- GitHub Actions ecosystem entry in `.github/dependabot.yml` so workflow
+ versions stay current.
+- Documented public API stability and deprecation timeline in `README.md`.
+- Opt-in live end-to-end harness under `tests/pve/` and `tests/e2e/`.
+ Maintainers can ssh to a Proxmox host, build a VyOS template once,
+ clone a disposable VM, and run a small live test suite against the
+ real HTTPS API. Auto-skipped unless `PYVYOS_E2E=1`. Not part of CI.
### Changed
-- Moved JSON API specifications from `pyvyos/vyos-api/` to `docs/development/vyos_api/` (reference only)
-- Updated `pyproject.toml` to include optional `validation` dependency group for Pydantic
-- Enhanced `RestClient` logging with structured fields (request_id, command, op, status, elapsed_ms)
-- Refactored internal structure to `pyvyos.core.*` while maintaining full backward compatibility via shims
+- `requires-python` lowered to `>=3.11` (was `>=3.13`). The codebase
+ does not use any 3.13-only syntax, and the lower floor makes the
+ package reachable for the typical VyOS automation environment. CI now
+ validates against 3.11, 3.12, and 3.13.
+- Runtime dependencies trimmed to `requests>=2.32.0,<3.0` only.
+ - `python-dotenv` was never imported by the library itself — it is now
+ a `dev` extra used by the bundled examples.
+ - `urllib3` was never used directly by `pyvyos` — it stays available
+ transitively through `requests`.
+- `vagrant/` lab setup moved to `examples/vagrant/`.
+- `pyvyos.core.*` is now the internal implementation layer; the supported
+ public API is `from pyvyos import VyDevice, ApiResponse`. The legacy
+ `pyvyos.device` and `pyvyos.rest` shims continue to work without
+ warnings.
+- `RestClient` logging is now plain and structured: command, op, status,
+ elapsed milliseconds. No request-ID field.
+- `pyproject.toml`:
+ - declared the wheel package explicitly,
+ - removed duplicated dependency blocks,
+ - dropped the unused `validation` extra,
+ - raised the pytest floor for Python 3.13.
+- GitHub Actions bumped off Node.js 20 and pinned by full commit SHA
+ (per [OpenSSF Scorecard guidance][1]) with a `# vX.Y.Z` comment so
+ Dependabot can keep them current:
+ `actions/checkout@v6.0.2`, `actions/setup-python@v6.2.0`,
+ `astral-sh/setup-uv@v8.1.0`, `pypa/gh-action-pypi-publish@v1.14.0`.
+ Pre-commit hooks bumped to `pre-commit/pre-commit-hooks@v6.0.0`.
+
+[1]: https://github.com/ossf/scorecard/blob/main/docs/checks.md#pinned-dependencies
+- `.env.example` default flipped to `VYDEVICE_VERIFY_SSL=true`; commented
+ with field descriptions.
+
+### Removed
+- Root-level stragglers: `CONTRIB.md` (superseded by `CONTRIBUTING.md`),
+ `requirements.txt` (duplicated `[project].dependencies`), `test_quick.py`
+ (ad-hoc smoke script, covered by `tests/test_shims.py`), and the
+ committed `uv.lock` (not appropriate for a library; now gitignored).
+- `pyvyos.specs` package (experimental Pydantic models). It was never
+ imported by the runtime and had 0% test coverage. Pydantic is no longer
+ an optional dependency.
+- `pyvyos.exceptions` module (`SDKError`, `HttpError`, `ApiError`,
+ `ValidationError`). The hierarchy was defined but never raised anywhere
+ in the codebase. Error reporting continues through `ApiResponse.error`.
+- `pyvyos.utils.ids.request_id` helper. The generated UUIDs were attached
+ to log records but never propagated to callers — half-implemented
+ tracing is worse than none. It will be reintroduced if and when real
+ observability hooks land.
+- `pyvyos.utils` no longer re-exports `request_id`.
+- Obsolete tooling: `Makefile` (hard-coded `env/bin/python`),
+ `run_tests.sh`, `run_tests.py`.
+- Dead workflow `.github/workflows/python-app.yml` (Python 3.12, only ran
+ flake8 with pytest commented out, referenced a non-existent
+ `requirements.txt`).
+- `sphinx/` source tree and `.readthedocs.yaml`: the RTD configuration
+ pointed to `docs/source/conf.py` while the Sphinx tree lived under
+ `sphinx/source/`, so the build never worked and no documentation was
+ ever published. The hand-written Markdown docs under `docs/` are
+ retained.
+- Stale development notes under `docs/development/` (architecture,
+ refactor roadmap, quality-and-utils) — they described the pre-cleanup
+ proposal that included specs/exceptions/request_id.
+- Tests for the removed modules (`tests/test_exceptions.py`,
+ `tests/utils/test_ids.py`).
### Fixed
-- **Fixed #25**: `config_file_save()` and `config_file_load()` now correctly include `path: []` in payload as required by VyOS API
-- Path parameter handling for `config-file` commands (always includes `path: []`)
-- Secret redaction in logs and sanitized payloads
+- `LICENSE` copyright now reads `2023 GravScale, Roberto Bertó`.
+- `pyproject.toml` `[tool.pytest.ini_options].env` entries now use the
+ `D:` (default) prefix so that operators running the live e2e harness
+ can override `VYDEVICE_HOSTNAME` and friends from the shell. The
+ previous values silently overwrote shell exports, making it look like
+ e2e tests pointed at a stale `192.168.56.100`.
-### Security
-- API keys are automatically redacted in logs and response payloads
+### Compatibility
+- Public imports are unchanged:
+ - `from pyvyos import VyDevice, ApiResponse`
+ - `from pyvyos.device import VyDevice`
+ - `from pyvyos.rest import RestClient, ApiResponse`
+- HTTP payload generation is unchanged.
+- Request and response behaviour is unchanged.
+- No compatibility shim has been deprecated in this release.
### Notes
-- This release maintains 100% backward compatibility with version 0.3.0
-- All existing imports and code will continue to work without changes
-- New internal structure (`pyvyos.core.*`) is available but not required for existing code
+- This release prepares the project for the upcoming `0.5.x` work:
+ contract tests for public payloads, fixes to the public method edges
+ (`image_add`/`image_delete`, `timeout`, mutable defaults), stdlib-based
+ validators, and a tidier internal core.
## [0.3.0] - 2024-XX-XX
+Tagged in git but never published to PyPI. Released to PyPI as part of
+`0.4.0`.
+
### Added
-- Initial release
-- Core functionality for VyOS REST API interaction
-- Support for configure, retrieve, show, generate, reset, config-file, reboot, poweroff, and image operations
+- Initial public release of the SDK structure.
+- Core functionality for VyOS HTTPS API: configure, retrieve, show,
+ generate, reset, config-file, reboot, poweroff, and image operations.
[Unreleased]: https://github.com/vyos-contrib/pyvyos/compare/v0.4.0...HEAD
-[0.4.0]: https://github.com/vyos-contrib/pyvyos/compare/v0.3.0...v0.4.0
+[0.4.0]: https://github.com/vyos-contrib/pyvyos/compare/v0.2.2...v0.4.0
[0.3.0]: https://github.com/vyos-contrib/pyvyos/releases/tag/v0.3.0
-
diff --git a/CONTRIB.md b/CONTRIB.md
deleted file mode 100644
index 3ff741d..0000000
--- a/CONTRIB.md
+++ /dev/null
@@ -1,79 +0,0 @@
-# Contributing to pyvyos
-
-We welcome contributions to the pyvyos project! If you're looking to contribute, please take a moment to read this guide to understand how you can be a part of our community and help make pyvyos better.
-
-## Code of Conduct
-
-We expect all contributors to adhere to our Code of Conduct. Respectful, collaborative behavior is crucial to maintaining a healthy environment.
-
-## How to Contribute
-
-There are many ways to contribute to pyvyos:
-
-- **Submitting bug reports and feature requests:** Use our issue tracker to report bugs or suggest features.
-- **Writing code:** Feel free to take on open issues or propose new changes. Please follow the guidelines outlined below.
-- **Improving documentation:** Good documentation is crucial. Contributions to improve or clarify documentation are always appreciated.
-
-## Development Process
-
-1. **Fork and Clone the Repository**
- - Start by forking the repository and then cloning it to your local machine.
-
-2. **Set Up Your Environment**
- - We recommend using [uv](https://docs.astral.sh/uv/) for dependency management. Ensure you have Uv installed and then run `uv pip install -e .[dev]` to set up your environment.
-
-3. **Create a Feature Branch**
- - Create a branch for your feature or bugfix, e.g., `git checkout -b feature/add-new-command`.
-
-4. **Write Your Code**
- - Write your code following Python best practices and adhering to PEP 8.
- - Include comments where necessary to explain complex parts of your code.
-
-5. **Write Tests**
- - We use PyTest for testing. Please add tests for your code and ensure all tests pass.
-
-6. **Document Your Changes**
- - Update the README.md or documentation if your changes require it.
-
-7. **Commit Your Changes**
- - Use clear and descriptive commit messages.
- - Make sure to commit your changes regularly.
-
-8. **Push to Your Fork and Submit a Pull Request**
- - Push your changes to your fork and then submit a pull request to our repository.
- - In your pull request, clearly describe the changes and their purpose.
-
-## Code Review Process
-
-- After you submit a pull request, the project maintainers will review your code.
-- Be open to feedback and willing to make changes to your code.
-- Once your pull request is approved, a maintainer will merge it.
-
-## Reporting Bugs
-
-- Use GitHub Issues to report bugs.
-- Be as detailed as possible in your bug report. Include steps to reproduce the bug and any relevant logs or screenshots.
-
-## Suggesting Enhancements
-
-- Use GitHub Issues to suggest enhancements.
-- Clearly explain the suggested feature and its benefits.
-
-## License
-
-By contributing to pyvyos, you agree that your contributions will be licensed under the same license that covers the project.
-
-Thank you for considering contributing to pyvyos. Your efforts are what make this project great!
-
-## Unit Tests
-Ensure you've installed development dependencies first:
-```bash
- uv pip install -e .[dev]
-```
-
-To view test coverage:
-```bash
- pytest -v --cov
-```
-
-**Note:** Tests should run against a development environment or mocked device to prevent production changes.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..2bfaaa0
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,71 @@
+# Contributing to pyvyos
+
+Thanks for considering a contribution. `pyvyos` is a small library with a
+narrow scope, which makes review and maintenance easier when contributions
+follow a few simple rules.
+
+## Scope
+
+`pyvyos` is a thin Python wrapper around the VyOS HTTPS API. Changes that
+fit naturally into this scope are welcome. Anything that adds a new
+dependency, introduces a parallel client (SSH, NETCONF, …), or expands
+beyond the HTTPS API surface is best discussed in an issue first.
+
+## Before opening a pull request
+
+1. **Open an issue first** for anything beyond a small fix or a typo. It
+ saves everyone time if the direction is agreed before the patch lands.
+2. **Keep the diff focused.** One topic per pull request. Refactors and
+ bug fixes are easier to review separately.
+3. **Do not change HTTP payload generation in passing.** Payload changes
+ require a deliberate review pass; they should be their own pull
+ request and include tests.
+4. **Run the test suite locally:** `uv run pytest`.
+5. **Match the existing style.** No formatter is enforced yet; just keep
+ diffs minimal and readable.
+
+## Development setup
+
+```bash
+git clone https://github.com/vyos-contrib/pyvyos.git
+cd pyvyos
+uv sync --extra dev
+uv run pytest
+```
+
+Optional pre-commit hooks (whitespace, EOF, YAML/TOML syntax):
+
+```bash
+pip install pre-commit
+pre-commit install
+```
+
+## Public API stability
+
+The supported public API is:
+
+```python
+from pyvyos import VyDevice, ApiResponse
+```
+
+Compatibility shims at `pyvyos.device` and `pyvyos.rest` are kept while
+the cost is trivial. Anything under `pyvyos.core.*` is internal and may
+change between minor releases.
+
+If your change touches the public API surface, please flag it explicitly
+in the pull request description.
+
+## Reporting bugs
+
+When reporting a bug, please include:
+
+- The `pyvyos` version (`pip show pyvyos`).
+- The Python version.
+- The VyOS version of the device.
+- A minimal reproducer and the resulting error (or, for behaviour bugs,
+ the observed vs expected behaviour).
+
+## License
+
+By contributing you agree that your contribution will be licensed under
+the [MIT License](LICENSE).
diff --git a/LICENSE b/LICENSE
index 6b976b2..9784c86 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2023 GravScale
+Copyright (c) 2023 GravScale, Roberto Bertó
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/Makefile b/Makefile
deleted file mode 100644
index 42b53e9..0000000
--- a/Makefile
+++ /dev/null
@@ -1,14 +0,0 @@
-.PHONY: help
-help:
- @echo "build - build the package"
- @echo "upload - upload the package to PyPI"
-
-.PHONY: build
-build:
- env/bin/python -m build --sdist
- env/bin/python -m build --wheel
-
-.PHONY: upload
-upload:
- env/bin/python -m twine check dist/*
- env/bin/python -m twine upload dist/*
diff --git a/README.md b/README.md
index af1f9fa..1f9ce19 100644
--- a/README.md
+++ b/README.md
@@ -1,149 +1,266 @@
-# pyvyos Documentation
+# pyvyos
-pyvyos is a Python library for interacting with VyOS devices via their API. This documentation provides a guide on how to use pyvyos to manage your VyOS devices programmatically.
+[![PyPI version](https://img.shields.io/pypi/v/pyvyos.svg)](https://pypi.org/project/pyvyos/)
+[![Python versions](https://img.shields.io/pypi/pyversions/pyvyos.svg)](https://pypi.org/project/pyvyos/)
+[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
+[![PR Validation](https://github.com/vyos-contrib/pyvyos/actions/workflows/python-pr-validation.yml/badge.svg)](https://github.com/vyos-contrib/pyvyos/actions/workflows/python-pr-validation.yml)
-You can find the complete pyvyos documentation on [Read the Docs](https://pyvyos.readthedocs.io/en/latest/).
+Python SDK for the [VyOS](https://vyos.io/) HTTPS API.
-## Installation
+`pyvyos` is a small, focused library that wraps the VyOS HTTPS API in an
+idiomatic Python interface. It is intended for automation scripts, internal
+tooling, and integrations with configuration management systems.
-You can install pyvyos using pip https://pypi.org/project/pyvyos/:
+## Installation
```bash
pip install pyvyos
```
-## Getting Started
+Requires **Python 3.11 or newer**. Tested on 3.11, 3.12, and 3.13.
-### Importing and Disabling Warnings for verify=False
-Before using pyvyos, it's a good practice to disable urllib3 warnings and import the required modules, IF you use verify=False:
+## Quick start
-```
-import urllib3
-urllib3.disable_warnings()
+Enable the HTTPS API on the VyOS device and create an API key:
+
+```text
+set service https api rest
+set service https api keys id my-key key 'your-secret-key'
+commit
```
-### Using API Response Class
-pyvyos uses a custom ApiResponse data class to handle API responses:
+> The `set service https api rest` line is required. Without it the
+> HTTPS service only exposes `/info` and every other endpoint returns
+> `404`. See the VyOS docs for the [HTTP API service](https://docs.vyos.io/en/latest/configuration/service/https.html).
-```
-@dataclass
-class ApiResponse:
- status: int
- request: dict
- result: dict
- error: str
-```
+Then, from Python:
-### Initializing a VyDevice Object
+```python
+import os
+from pyvyos import VyDevice
+device = 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() in ("1", "true", "yes"),
+)
-#### Configuring Your Environment for VyDevice
-1. Rename the file .env.example to .env.
-1. Open the .env file in a text editor.
-1. Replace the placeholder values with your VyOS device credentials:
- - **VYDEVICE_HOSTNAME**: Your device's hostname or IP address.
- - **VYDEVICE_APIKEY**: Your API key for authentication.
- - **VYDEVICE_PORT**: The port number for the API. Default 443
- - **VYDEVICE_PROTOCOL**: The protocol (e.g., http or https). Default https
- - **VYDEVICE_VERIFY_SSL**: Set to True or False for SSL verification.
+response = device.show(path=["system", "image"])
+if response.error:
+ print(f"Error {response.status}: {response.error}")
+else:
+ print(response.result)
+```
+If you use self-signed certificates, set `verify=False` **only in lab
+environments** and silence the urllib3 warning explicitly:
+```python
+import urllib3
+urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
```
-# Retrieve VyOS device connection details from environment variables and configure VyDevice
-from dotenv import load_dotenv
-load_dotenv()
-hostname = os.getenv('VYDEVICE_HOSTNAME')
-apikey = os.getenv('VYDEVICE_APIKEY')
-port = os.getenv('VYDEVICE_PORT')
-protocol = os.getenv('VYDEVICE_PROTOCOL')
-verify_ssl = os.getenv('VYDEVICE_VERIFY_SSL')
+## Environment variables
-# Convert the verify_ssl value to a boolean
-verify = verify_ssl.lower() == "true" if verify_ssl else True
+A `.env.example` is shipped with the project. The recognised variables are:
-device = VyDevice(hostname=hostname, apikey=apikey, port=port, protocol=protocol, verify=verify)
-```
+| Variable | Default | Purpose |
+| ---------------------- | ------- | -------------------------------------------------- |
+| `VYDEVICE_HOSTNAME` | — | Hostname or IP address of the VyOS device. |
+| `VYDEVICE_APIKEY` | — | API key configured on the device. |
+| `VYDEVICE_PORT` | `443` | HTTPS port of the VyOS API. |
+| `VYDEVICE_PROTOCOL` | `https` | `https` (recommended) or `http`. |
+| `VYDEVICE_VERIFY_SSL` | `true` | Verify the TLS certificate of the device. |
-## Using pyvyos
+`pyvyos` does not read these variables on its own — your application is
+responsible for loading them (for example with `python-dotenv`) and passing
+the values to `VyDevice`.
-### configure, then set
-The configure_set method sets a VyOS configuration:
+## API overview
-```
-# Set a VyOS configuration
-response = device.configure_set(path=["interfaces", "ethernet", "eth0", "address", "192.168.1.1/24"])
+All methods return an `ApiResponse` dataclass with four fields:
-# Check for errors and print the result
-if not response.error:
- print(response.result)
-```
-### configure, then show a single OBJECT value
-```
-# Retrieve VyOS return values for a specific interface
-response = device.retrieve_return_values(path=["interfaces", "dummy", "dum1", "address"])
-print(response.result)
+```python
+@dataclass
+class ApiResponse:
+ 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
```
-### configure, then show OBJECT
-The retrieve_show_config method retrieves the VyOS configuration:
+`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`.
-```
-# Retrieve the VyOS configuration
-response = device.retrieve_show_config(path=[])
+The recommended usage pattern is:
-# Check for errors and print the result
-if not response.error:
- print(response.result)
+```python
+response = device.retrieve_show_config(path=["interfaces"])
+if response.error:
+ raise RuntimeError(response.error)
+do_something_with(response.result)
```
-### configure, then delete OBJECT
-```
-# Delete a VyOS interface configuration
-response = device.configure_delete(path=["interfaces", "dummy", "dum1"])
-```
+### Configuration
-### configure, then save
+```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(op_path=[
+ {"op": "set", "path": ["interfaces", "dummy", "dum2", "address", "203.0.113.1/24"]},
+ {"op": "delete", "path": ["interfaces", "dummy", "dum1"]},
+])
```
-# Save VyOS configuration without specifying a file (default location)
-response = device.config_file_save()
+
+### Retrieval
+
+```python
+device.retrieve_show_config(path=["system"])
+device.retrieve_return_values(path=["interfaces", "dummy", "dum1", "address"])
```
-### configure, then save FILE
+### Operational
+
+```python
+device.show(path=["system", "image"])
+device.generate(path=["ssh", "client-key", "/tmp/key"])
+device.reset(path=["conntrack-sync", "internal-cache"])
```
-# Save VyOS configuration to a specific file
-response = device.config_file_save(file="/config/test300.config")
+
+### Configuration files
+
+```python
+device.config_file_save() # default location
+device.config_file_save(file="/config/backup.config")
+device.config_file_load(file="/config/backup.config")
```
-## show OBJECT
+### System control
+
+```python
+device.reboot() # equivalent to device.reboot(path=["now"])
+device.poweroff() # equivalent to device.poweroff(path=["now"])
```
-# Show VyOS system image information
-response = device.show(path=["system", "image"])
-print(response.result)
+
+### Image management
+
+```python
+device.image_add(url="https://downloads.vyos.io/.../vyos-1.4-image.iso")
+device.image_delete(name="1.4-rolling-...")
```
-### generate OBJECT
+## Public API stability
+
+The supported public API of pyvyos is:
+
+```python
+from pyvyos import VyDevice, ApiResponse
```
-# Generate an SSH key with a random string in the name
-randstring = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(20))
-keyrand = f'/tmp/key_{randstring}'
-response = device.generate(path=["ssh", "client-key", keyrand])
+
+These compatibility imports continue to work without warnings and will be
+kept while the migration cost remains trivial:
+
+```python
+from pyvyos.device import VyDevice
+from pyvyos.rest import RestClient, ApiResponse
```
-### reset OBJECT
-The reset method allows you to run a reset command:
+Anything under `pyvyos.core.*` is internal implementation detail and may
+change between minor releases.
+
+The deprecation timeline is:
+
+| Release | Status |
+| ------- | ----------------------------------------------------------------- |
+| `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` | Final shim behaviour decided before release, based on observed usage and maintenance cost. |
+
+## Examples
+- [`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
+
+`pyvyos` uses the standard `logging` module under the `pyvyos` namespace.
+To see request/response activity, configure the logger in your application:
+
+```python
+import logging
+logging.basicConfig(level=logging.INFO)
+logging.getLogger("pyvyos").setLevel(logging.DEBUG)
```
-# Execute the reset command
-response = device.reset(path=["conntrack-sync", "internal-cache"])
-# Check for errors and print the result
-if not response.error:
- print(response.result)
+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
+
+Tested live against:
+
+- VyOS rolling `2026.05.18-0045` (current rolling at release time)
+
+The library only depends on the HTTPS API surface, so older 1.4 / 1.5
+builds that expose the same endpoints should work without changes,
+but they are not exercised on every release. The live harness under
+[`tests/pve/`](tests/pve/) makes it easy to re-run the suite against
+any VyOS build you care about.
+
+Required device-side configuration for the live API:
+
+```text
+set service https api rest
+set service https api keys id <id> key '<secret>'
```
-### configure, then load FILE
+## Development
+
+The project uses [uv](https://docs.astral.sh/uv/) for environment
+management:
+
+```bash
+uv sync --extra dev
+uv run pytest
```
-# Load VyOS configuration from a specific file
-response = device.config_file_load(file="/config/test300.config")
+
+Optional code-style hooks:
+
+```bash
+pip install pre-commit
+pre-commit install
```
+
+### Live VyOS testing
+
+The regular test suite is mock-based and does not need a VyOS device.
+
+For maintainers, this repository ships an opt-in harness under
+[`tests/pve/`](tests/pve/) that creates a disposable VyOS VM on a
+local Proxmox host and runs `tests/e2e` against the real HTTPS API.
+It is not part of the default GitHub Actions workflow. See the
+harness README for setup.
+
+## Contributing
+
+Bug reports and pull requests are welcome. Please open an issue first to
+discuss anything beyond a small fix, and keep changes focused — payload and
+public-API changes go through a separate review cycle.
+
+## License
+
+MIT — see [LICENSE](LICENSE).
diff --git a/docs/development/architecture.md b/docs/development/architecture.md
deleted file mode 100644
index aceda08..0000000
--- a/docs/development/architecture.md
+++ /dev/null
@@ -1,77 +0,0 @@
-# Development Architecture (Proposed)
-
-## Goals
-- Strong separation of concerns (transport, domain, specs, utils)
-- Backward compatibility for 0.3.0 via shims
-- Clear public API surface and internal boundaries
-- Minimal maintenance burden for a public, official SDK
-
-## Source Layout (file tree)
-```
-pyvyos/
- __init__.py # public API exports (VyDevice, ApiResponse)
- core/ # internal, stable layers
- __init__.py
- rest_client.py # RestClient, ApiResponse (transport layer)
- device.py # VyDevice (domain layer)
- specs/ # optional Pydantic models (validation)
- __init__.py
- models.py # base models (ApiRequest/Response)
- commands/ # split by responsibility
- __init__.py
- configure.py # set, delete, multiple_op
- retrieve.py # show_config, return_values
- config_file.py # save, load (path: [])
- show.py # show
- generate.py # generate
- reset.py # reset
- system.py # reboot, poweroff
- image.py # add, delete
- utils/ # reusable helpers (no side effects)
- __init__.py
- json.py # safe_json, redact_key
- http.py # timeouts, retries (future)
- paths.py # path builder helpers
- ids.py # request_id helpers
- exceptions.py # typed exceptions (SDKError, HttpError, ApiError)
- types.py # public typing aliases if needed
- device.py # shim re-export (compat)
- rest.py # shim re-export (compat)
- vyos_api/ # JSON specs (docs/reference, optional at runtime)
-```
-
-## Layers & Responsibilities
-- Transport (core.rest_client): HTTP, payload assembly, response validation
-- Domain (core.device): high-level methods (configure, show, reset, etc.)
-- Specs (specs.*): optional Pydantic models to validate requests/answers
-- Utils: pure helpers (formatting, ids, path building)
-- Shims (device.py, rest.py): ensure 0.3.0 compatibility
-
-## Public API Surface
-- `from pyvyos import VyDevice, ApiResponse`
-- Stable imports; internal moves hidden by shims
-
-## Compatibility Strategy
-- Keep shims until 1.0.0
-- Deprecation policy: warn after N minors, remove at next major
-- Document migration path (import from `pyvyos.core` for new code)
-
-## Data Validation Flow (optional)
-- App builds `path` with utils.paths
-- If validation enabled, use `specs.commands.*` models
-- RestClient serializes payload and executes request
-- Response validated (structure: success/data/error)
-
-## Testing Strategy
-- Unit: core.rest_client and core.device isolated via monkeypatch
-- Contract: JSON fixtures mirror VyOS responses
-- E2E (future): optional with test VyOS VM
-
-## Packaging
-- Include `vyos_api/` only if needed at runtime
-- Otherwise treat it as docs/reference, not required by the package
-
-## Naming & Conventions
-- Use underscores `_` in file and module names
-- One responsibility per module (keep files small)
-- Typed public APIs, specific exceptions, no prints (only logging)
diff --git a/docs/development/quality-and-utils.md b/docs/development/quality-and-utils.md
deleted file mode 100644
index ff78ec2..0000000
--- a/docs/development/quality-and-utils.md
+++ /dev/null
@@ -1,72 +0,0 @@
-# Quality, Utils, and Pitfalls
-
-## Goals
-- Reduce regressions and supportability burden
-- Consistent error handling and logging
-- Clear utilities to avoid duplication
-
-## Exceptions (typed)
-- `SDKError` (base)
-- `HttpError(status, message)`
-- `ApiError(message, details=None)` # when success=False
-- `ValidationError(message)` # client-side validation
-
-## Logging
-- Use `logging.getLogger("pyvyos")`
-- Include: op, command, status, elapsed_ms, request_id
-- Redact secrets (api key) – via `utils.json.redact_key(data, keys=["key"])`
-- Default INFO; DEBUG guarded by env `PYVYOS_DEBUG=1`
-
-## Timeouts & Retries
-- Default timeout: 10s (configurable)
-- No implicit retries by default
-- Future: retry idempotent ops only (exponential backoff)
-
-## Security
-- `verify=True` by default
-- Document `urllib3.disable_warnings()` only for dev
-- Never log secrets or full payloads by default
-
-## Utilities (proposal)
-- `utils.paths.build(*segments) -> list[str]`
-- `utils.ids.request_id() -> str`
-- `utils.json.safe_dumps(obj) -> str` (with redaction)
-- `utils.http.timeout(seconds) -> int` (normalize)
-
-## Validation (optional)
-- `specs.commands.*` Pydantic models validate request structures
-- Enforce path rules (e.g., config-file requires `path=[]`)
-- Gate behind feature flag or optional dependency group
-
-## Testing
-- Unit: small, isolated, no real I/O
-- Contract: success/error fixtures per command
-- Naming: `test_should_<do>_when_<condition>`
-- Use monkeypatch on `_execute_request`
-
-## Documentation
-- Keep developer docs scoped and short (≤100 lines)
-- Update api-reference and path rules on changes
-
-## Style & Types
-- Type hints on public APIs, avoid `Any`
-- Early returns, no deep nesting, no bare `except`
-- Constants for command/operation strings
-
-## Release Hygiene
-- Conventional commits; CHANGELOG generated
-- Tag every release; ensure `git push --tags`
-- Patch bumps for fixes, minor for features
-
-## Potential Pitfalls & Fixes
-- Path handling inconsistencies → centralize in RestClient
-- Logging secrets → redact before logging
-- Tight coupling device↔transport → maintain clean core boundaries
-- Hidden breaking changes → keep shims until 1.0.0
-- Non-deterministic tests → remove sleeps, use fixed seeds
-
-## Next Steps (ordered)
-1. Introduce `exceptions.py` and wire into RestClient
-2. Add `utils/` with `json.py`, `paths.py`, `ids.py`
-3. Add optional `specs/` models for high-value commands
-4. CI: lint+type-check, codecov, test matrix
diff --git a/docs/development/refactor-roadmap.md b/docs/development/refactor-roadmap.md
deleted file mode 100644
index eeee879..0000000
--- a/docs/development/refactor-roadmap.md
+++ /dev/null
@@ -1,56 +0,0 @@
-# Refactor Roadmap (Prioritized)
-
-## Priorities
-- P0 (now): safety, compatibility, correctness
-- P1 (next): validation, observability, developer UX
-- P2 (later): performance, async, resiliency
-
-## P0 — Immediate
-- Stabilize transport: keep `_get_payload(include_empty_path)`; special-case `config-file`
-- Keep shims: `pyvyos/device.py` and `pyvyos/rest.py` re-export from `pyvyos/core/*`
-- Typed exceptions: introduce `SDKError`, `HttpError`, `ApiError`, `ValidationError`
-- Timeouts: ensure sane defaults, expose in `VyDevice`
-- Logging: centralize in transport, structured key fields (op, command, status)
-- Tests: payload assertions for `config-file` (path: []), regression for others
-- Docs: architecture and path rules (done)
-
-## P1 — Short Term
-- Specs (optional): Pydantic models in `pyvyos/specs/commands/*`
-- `utils/`: path builders, request_id, safe_json redaction
-- Deprecation policy: note in README; warn on internal imports (future only)
-- CI: test matrix (Python 3.13), codecov, lint (ruff/flake8), type-check (pyright/mypy)
-- Release: conventional commits + CHANGELOG; ensure tags push
-- Docs: contributor guide, release playbook (short)
-
-## P2 — Medium Term
-- Async client: `AsyncRestClient` (aiohttp/httpx), opt-in
-- Retries & backoff: idempotent ops only; circuit-breaker (future)
-- Rate limiting: client-side token bucket (opt-in)
-- Caching: read-only `show/retrieve` (TTL) optional
-- Batch ops: smarter `configure_multiple_op` planning
-
-## Migration & Compatibility
-- Public API: `from pyvyos import VyDevice, ApiResponse` (stable)
-- Internal: prefer `pyvyos.core.*` for new code
-- Shims stay until 1.0.0; removal at next major only
-
-## Testing Plan
-- Unit: transport (errors, timeouts), domain (paths)
-- Contract: sample JSON fixtures per command
-- E2E: optional job against a test VyOS (gated, non-blocking)
-
-## Release Flow (lean)
-- Branch: feature → PR → squash merge
-- Pre-release (optional): `-rcX` tags
-- Tag + publish: GitHub Action (uv build + PyPI publish)
-
-## Risks & Mitigations
-- Hidden breakages → keep shims, add regression tests
-- API drift (VyOS) → specs folder eases updates
-- Logging noise → default INFO; debug gated via env
-- Security → default `verify=True`, redact secrets in logs
-
-## Success Criteria
-- 0.3.x users unaffected
-- New structure adopted by contributors
-- Lower maintenance overhead (fewer regressions)
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 183c109..3cf4891 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -21,7 +21,7 @@ VYDEVICE_HOSTNAME=192.168.1.1
VYDEVICE_APIKEY=your-api-key-here
VYDEVICE_PORT=443
VYDEVICE_PROTOCOL=https
-VYDEVICE_VERIFY_SSL=False
+VYDEVICE_VERIFY_SSL=true
```
## Step 3: Create Your First Script
@@ -54,7 +54,7 @@ If using `verify=False`, disable urllib3 warnings:
```python
import urllib3
-urllib3.disable_warnings()
+urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
```
## Understanding API Responses
diff --git a/docs/index.md b/docs/index.md
index 1bc9cb7..7576f20 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -37,7 +37,7 @@ device = VyDevice(
apikey="your-api-key",
port=443,
protocol="https",
- verify=False
+ verify=True
)
response = device.show(path=["system", "image"])
@@ -48,8 +48,6 @@ print(response.result)
- [GitHub Repository](https://github.com/vyos-contrib/pyvyos)
- [PyPI Package](https://pypi.org/project/pyvyos/)
-- [Read the Docs](https://pyvyos.readthedocs.io/)
-
## License
MIT License - See LICENSE file for details
diff --git a/example.py b/example.py
deleted file mode 100644
index e48983a..0000000
--- a/example.py
+++ /dev/null
@@ -1,113 +0,0 @@
-# importing modules
-import warnings
-
-warnings.filterwarnings("ignore", category=RuntimeWarning)
-import os
-
-# adding pyvyos to sys.path
-# sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__))))
-
-from dotenv import load_dotenv
-import pprint
-import random
-import string
-
-# importing pyvyos modules
-from pyvyos.device import VyDevice
-
-# getting env variables
-load_dotenv()
-hostname = os.getenv("VYDEVICE_HOSTNAME")
-apikey = os.getenv("VYDEVICE_APIKEY")
-port = os.getenv("VYDEVICE_PORT")
-protocol = os.getenv("VYDEVICE_PROTOCOL")
-verify = os.getenv("VYDEVICE_VERIFY_SSL")
-
-if verify == "False":
- verify = False
-else:
- verify = True
-
-# running example
-if __name__ == "__main__":
- # preparing connection to vyos device
- device = VyDevice(
- hostname=hostname,
- apikey=apikey,
- port=port,
- protocol=protocol,
- verify=verify,
- timeout=60,
- )
-
- response = device.retrieve_show_config(["system"])
- pprint.pprint(response)
- response = device.configure_set(
- path=["interfaces", "dummy", "dum1", "address", "192.168.56.100/24"]
- )
- pprint.pprint(response)
-
- response = device.retrieve_return_values(
- path=["interfaces", "dummy", "dum1", "address"]
- )
- pprint.pprint(response)
-
- response = device.configure_delete(path=["interfaces", "dummy", "dum1"])
- pprint.pprint(response)
-
- randstring = "".join(
- random.choice(string.ascii_letters + string.digits) for _ in range(20)
- )
- keyrand = f"/tmp/key_{randstring}"
- response = device.generate(path=["ssh", "client-key", keyrand])
- pprint.pprint(response)
-
- response = device.show(path=["system", "image"])
- pprint.pprint(response)
-
- response = device.reset(path=["conntrack-sync", "internal-cache"])
- pprint.pprint(response)
-
- response = device.config_file_save(file="/config/test300.config")
- pprint.pprint(response)
-
- response = device.config_file_load(file="/config/test300.config")
- pprint.pprint(response)
-
- response = device.configure_multiple_op(
- op_path=[
- {
- "op": "set",
- "path": ["interfaces", "dummy", "dum1", "address", "192.168.56.100/24"],
- },
- {
- "op": "delete",
- "path": ["interfaces", "dummy", "dum1"],
- },
- ]
- )
- pprint.pprint(response)
-
- # print("### Generating ssh key ###")
- # randstring = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(20))
- # keyrand = f'/tmp/key_{randstring}'
- # response = device.generate(path=["ssh", "client-key", keyrand])
- # pprint.pprint(response)
-
- # response = device.retrieve_return_values(path=["interfaces", "ethernet", "eth0", "address"])
- # pprint.pprint(response)
-
- # response = device.reset(path=["conntrack-sync", "internal-cache"])
- # pprint.pprint(response)
-
- # response = device.reboot(path=["now"])
- # pprint.pprint(response)
-
- # response = device.shutdown(path=["now"])
- # pprint.pprint(response)
-
- # response = device.image_add(url="https://github.com/vyos/vyos-rolling-nightly-builds/releases/download/1.5-rolling-202312130023/vyos-1.5-rolling-202312130023-amd64.iso")
- # pprint.pprint(response)
-
- # response = device.image_delete(name="foo")
- # pprint.pprint(response)
diff --git a/examples/basic.py b/examples/basic.py
new file mode 100644
index 0000000..1e1b552
--- /dev/null
+++ b/examples/basic.py
@@ -0,0 +1,67 @@
+"""Basic read-only pyvyos usage example.
+
+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
+
+from dotenv import load_dotenv
+
+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=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()
+
+ print_response(
+ "Running system configuration",
+ device.retrieve_show_config(path=["system"]),
+ )
+
+ print_response(
+ "System images",
+ device.show(path=["system", "image"]),
+ )
+
+ print_response(
+ "Interface address values",
+ device.retrieve_return_values(path=["interfaces"]),
+ )
+
+
+if __name__ == "__main__":
+ main()
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()
diff --git a/vagrant/.env.example b/examples/vagrant/.env.example
index 905f259..905f259 100644
--- a/vagrant/.env.example
+++ b/examples/vagrant/.env.example
diff --git a/vagrant/VAGRANT.md b/examples/vagrant/VAGRANT.md
index e6312e2..e6312e2 100644
--- a/vagrant/VAGRANT.md
+++ b/examples/vagrant/VAGRANT.md
diff --git a/vagrant/Vagrantfile b/examples/vagrant/Vagrantfile
index 01dd23e..01dd23e 100644
--- a/vagrant/Vagrantfile
+++ b/examples/vagrant/Vagrantfile
diff --git a/vagrant/virtualbox-wsl2.sh b/examples/vagrant/virtualbox-wsl2.sh
index 4cd545d..4cd545d 100644
--- a/vagrant/virtualbox-wsl2.sh
+++ b/examples/vagrant/virtualbox-wsl2.sh
diff --git a/pyproject.toml b/pyproject.toml
index 7a0f401..5c3885a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,70 +6,66 @@ build-backend = "hatchling.build"
name = "pyvyos"
version = "0.4.0"
authors = [
- { name="Roberto Berto", email="463349+robertoberto@users.noreply.github.com" },
+ { name = "Roberto Berto", email = "463349+robertoberto@users.noreply.github.com" },
]
-description = "Python SDK for interacting with VyOS API"
+description = "Python SDK for interacting with the VyOS HTTPS API"
readme = "README.md"
-requires-python = ">=3.13"
+requires-python = ">=3.11"
+license = { file = "LICENSE" }
+keywords = ["vyos", "networking", "sdk", "api", "router", "firewall"]
classifiers = [
+ "Development Status :: 4 - Beta",
+ "Intended Audience :: System Administrators",
+ "Intended Audience :: Developers",
+ "Topic :: System :: Networking",
+ "Topic :: Software Development :: Libraries :: Python Modules",
"Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
+ "Typing :: Typed",
]
dependencies = [
+ "pytest>=9.0.3",
"requests>=2.32.0,<3.0",
- "python-dotenv>=1.0.1,<2.0",
- "urllib3>=2.5.0",
]
[project.optional-dependencies]
dev = [
- "pytest>=6.2.5,<9.0.0",
- "pytest-cov>=4.1,<6.0",
+ "python-dotenv>=1.0.1,<2.0",
+ "pytest>=8.0,<10.0",
+ "pytest-cov>=5.0,<7.0",
"pytest-env>=0.6.2,<1.3",
]
-validation = [
- "pydantic>=2.0,<3.0",
-]
[project.urls]
Homepage = "https://github.com/vyos-contrib/pyvyos"
Issues = "https://github.com/vyos-contrib/pyvyos/issues"
+Changelog = "https://github.com/vyos-contrib/pyvyos/blob/main/CHANGELOG.md"
-[tool.hatch.metadata]
-dependencies = [
- "requests>=2.32.0,<3.0",
- "python-dotenv>=1.0.1,<2.0",
- "urllib3>=2.5.0",
-]
+[tool.hatch.build.targets.wheel]
+packages = ["pyvyos"]
[tool.pytest.ini_options]
-testpaths = ["./tests"]
+testpaths = ["tests"]
log_cli = false
log_cli_level = "DEBUG"
filterwarnings = [
"ignore::DeprecationWarning:_pytest.assertion.rewrite",
- "ignore::DeprecationWarning:ast"
+ "ignore::DeprecationWarning:ast",
]
env = [
- "VYDEVICE_APIKEY='api_key'",
- "VYDEVICE_HOSTNAME=192.168.56.100",
- "VYDEVICE_PORT=443",
- "VYDEVICE_PROTOCOL=https",
- "VYDEVICE_VERIFY_SSL=False",
+ "D:VYDEVICE_APIKEY=api_key",
+ "D:VYDEVICE_HOSTNAME=192.168.56.100",
+ "D:VYDEVICE_PORT=443",
+ "D:VYDEVICE_PROTOCOL=https",
+ "D:VYDEVICE_VERIFY_SSL=False",
]
[tool.coverage.run]
-omit = [
- "tests/*",
-]
-
-[dependency-groups]
-dev = [
- "pytest>=8.4.2",
- "pytest-cov>=5.0.0",
- "pytest-env>=0.6.2",
-]
+omit = ["tests/*"]
[tool.uv]
package = true
diff --git a/pyvyos/core/rest_client.py b/pyvyos/core/rest_client.py
index eb77d9b..ddef7ca 100644
--- a/pyvyos/core/rest_client.py
+++ b/pyvyos/core/rest_client.py
@@ -232,18 +232,10 @@ class RestClient(ABC):
# Initialize mutable defaults safely
path = path or []
- # Generate request ID for tracing
- try:
- from ..utils.ids import request_id
- req_id = request_id()
- except ImportError:
- req_id = None
-
# Log request start (DEBUG level)
logger.debug(
"Request start",
extra={
- "request_id": req_id,
"command": command,
"op": op,
"has_path": bool(path),
@@ -261,7 +253,6 @@ class RestClient(ABC):
logger.info(
"Request completed",
extra={
- "request_id": req_id,
"command": command,
"op": op,
"status": status,
diff --git a/pyvyos/exceptions.py b/pyvyos/exceptions.py
deleted file mode 100644
index 5a79733..0000000
--- a/pyvyos/exceptions.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""PyVyOS exception hierarchy for typed error handling."""
-
-
-class SDKError(Exception):
- """Base exception for all PyVyOS SDK errors."""
- pass
-
-
-class HttpError(SDKError):
- """HTTP-level errors (network, timeout, status codes)."""
-
- def __init__(self, status: int, message: str):
- self.status = status
- self.message = message
- super().__init__(f"HTTP Error {status}: {message}")
-
-
-class ApiError(SDKError):
- """API-level errors (when VyOS API returns success=False)."""
-
- def __init__(self, message: str, details: dict = None):
- self.message = message
- self.details = details
- super().__init__(message)
-
-
-class ValidationError(SDKError):
- """Client-side validation errors (invalid parameters)."""
-
- def __init__(self, message: str):
- self.message = message
- super().__init__(message)
-
diff --git a/pyvyos/py.typed b/pyvyos/py.typed
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/pyvyos/py.typed
diff --git a/pyvyos/specs/__init__.py b/pyvyos/specs/__init__.py
deleted file mode 100644
index d12c764..0000000
--- a/pyvyos/specs/__init__.py
+++ /dev/null
@@ -1,12 +0,0 @@
-"""PyVyOS Pydantic specs for optional request/response validation."""
-
-# Feature detection: gracefully degrade if pydantic not available
-try:
- from pydantic import BaseModel
- PYDANTIC_AVAILABLE = True
-except ImportError:
- PYDANTIC_AVAILABLE = False
- BaseModel = None # type: ignore
-
-__all__ = ["PYDANTIC_AVAILABLE"]
-
diff --git a/pyvyos/specs/commands/__init__.py b/pyvyos/specs/commands/__init__.py
deleted file mode 100644
index 1d8e8b3..0000000
--- a/pyvyos/specs/commands/__init__.py
+++ /dev/null
@@ -1,13 +0,0 @@
-"""Pydantic models for individual VyOS API commands."""
-
-from .config_file import ConfigFileSaveRequest, ConfigFileLoadRequest
-from .configure import ConfigureSetRequest, ConfigureDeleteRequest, ConfigureMultipleOpRequest
-
-__all__ = [
- "ConfigFileSaveRequest",
- "ConfigFileLoadRequest",
- "ConfigureSetRequest",
- "ConfigureDeleteRequest",
- "ConfigureMultipleOpRequest",
-]
-
diff --git a/pyvyos/specs/commands/config_file.py b/pyvyos/specs/commands/config_file.py
deleted file mode 100644
index 911b094..0000000
--- a/pyvyos/specs/commands/config_file.py
+++ /dev/null
@@ -1,36 +0,0 @@
-"""Pydantic models for config-file operations."""
-
-from typing import List, Literal, Optional
-
-try:
- from pydantic import BaseModel, Field
- from ..models import ApiRequest
-except ImportError:
- BaseModel = None # type: ignore
- Field = None # type: ignore
- ApiRequest = None # type: ignore
-
-
-if BaseModel:
-
- class ConfigFileSaveRequest(ApiRequest):
- """Request model for config-file save operation."""
-
- op: Literal["save"] = "save"
- path: List[str] = Field(
- default_factory=list,
- description="Required by VyOS API, typically empty array"
- )
- file: Optional[str] = None
-
-
- class ConfigFileLoadRequest(ApiRequest):
- """Request model for config-file load operation."""
-
- op: Literal["load"] = "load"
- path: List[str] = Field(
- default_factory=list,
- description="Required by VyOS API, typically empty array"
- )
- file: str # Required for load
-
diff --git a/pyvyos/specs/commands/configure.py b/pyvyos/specs/commands/configure.py
deleted file mode 100644
index d80aa3f..0000000
--- a/pyvyos/specs/commands/configure.py
+++ /dev/null
@@ -1,38 +0,0 @@
-"""Pydantic models for configure operations."""
-
-from typing import Dict, List, Literal, Union, Any
-
-try:
- from pydantic import BaseModel, Field
- from ..models import ApiRequest
-except ImportError:
- BaseModel = None # type: ignore
- Field = None # type: ignore
- ApiRequest = None # type: ignore
-
-
-if BaseModel:
-
- class ConfigureSetRequest(ApiRequest):
- """Request model for configure set operation."""
-
- op: Literal["set"] = "set"
- path: Union[List[str], List[List[str]]] # Required
-
-
- class ConfigureDeleteRequest(ApiRequest):
- """Request model for configure delete operation."""
-
- op: Literal["delete"] = "delete"
- path: List[str] # Required
-
-
- class ConfigureMultipleOpRequest(BaseModel):
- """Request model for configure multiple operations."""
-
- op: Literal["set", "delete"]
- path: Union[List[str], List[List[str]]]
-
- class Config:
- extra = "allow"
-
diff --git a/pyvyos/specs/commands/generate.py b/pyvyos/specs/commands/generate.py
deleted file mode 100644
index 40aa2a4..0000000
--- a/pyvyos/specs/commands/generate.py
+++ /dev/null
@@ -1,20 +0,0 @@
-"""Pydantic models for generate operations."""
-
-from typing import List, Literal, Optional
-
-try:
- from pydantic import BaseModel
- from ..models import ApiRequest
-except ImportError:
- BaseModel = None # type: ignore
- ApiRequest = None # type: ignore
-
-
-if BaseModel:
-
- class GenerateRequest(ApiRequest):
- """Request model for generate operation."""
-
- op: Literal["generate"] = "generate"
- path: Optional[List[str]] = None
-
diff --git a/pyvyos/specs/commands/image.py b/pyvyos/specs/commands/image.py
deleted file mode 100644
index 973bae4..0000000
--- a/pyvyos/specs/commands/image.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""Pydantic models for image operations."""
-
-from typing import List, Literal, Optional
-
-try:
- from pydantic import BaseModel
- from ..models import ApiRequest
-except ImportError:
- BaseModel = None # type: ignore
- ApiRequest = None # type: ignore
-
-
-if BaseModel:
-
- class ImageAddRequest(ApiRequest):
- """Request model for image add operation."""
-
- op: Literal["add"] = "add"
- path: Optional[List[str]] = None
- url: Optional[str] = None
- file: Optional[str] = None
- name: Optional[str] = None
-
-
- class ImageDeleteRequest(ApiRequest):
- """Request model for image delete operation."""
-
- op: Literal["delete"] = "delete"
- path: Optional[List[str]] = None
- url: Optional[str] = None
- file: Optional[str] = None
- name: Optional[str] = None
-
diff --git a/pyvyos/specs/commands/reset.py b/pyvyos/specs/commands/reset.py
deleted file mode 100644
index ac02bfe..0000000
--- a/pyvyos/specs/commands/reset.py
+++ /dev/null
@@ -1,20 +0,0 @@
-"""Pydantic models for reset operations."""
-
-from typing import List, Literal, Optional
-
-try:
- from pydantic import BaseModel
- from ..models import ApiRequest
-except ImportError:
- BaseModel = None # type: ignore
- ApiRequest = None # type: ignore
-
-
-if BaseModel:
-
- class ResetRequest(ApiRequest):
- """Request model for reset operation."""
-
- op: Literal["reset"] = "reset"
- path: Optional[List[str]] = None
-
diff --git a/pyvyos/specs/commands/retrieve.py b/pyvyos/specs/commands/retrieve.py
deleted file mode 100644
index 65efbbf..0000000
--- a/pyvyos/specs/commands/retrieve.py
+++ /dev/null
@@ -1,27 +0,0 @@
-"""Pydantic models for retrieve operations."""
-
-from typing import List, Literal, Optional
-
-try:
- from pydantic import BaseModel
- from ..models import ApiRequest
-except ImportError:
- BaseModel = None # type: ignore
- ApiRequest = None # type: ignore
-
-
-if BaseModel:
-
- class RetrieveShowConfigRequest(ApiRequest):
- """Request model for retrieve showConfig operation."""
-
- op: Literal["showConfig"] = "showConfig"
- path: Optional[List[str]] = None
-
-
- class RetrieveReturnValuesRequest(ApiRequest):
- """Request model for retrieve returnValues operation."""
-
- op: Literal["returnValues"] = "returnValues"
- path: Optional[List[str]] = None
-
diff --git a/pyvyos/specs/commands/show.py b/pyvyos/specs/commands/show.py
deleted file mode 100644
index 2fb2b97..0000000
--- a/pyvyos/specs/commands/show.py
+++ /dev/null
@@ -1,20 +0,0 @@
-"""Pydantic models for show operations."""
-
-from typing import List, Literal, Optional
-
-try:
- from pydantic import BaseModel
- from ..models import ApiRequest
-except ImportError:
- BaseModel = None # type: ignore
- ApiRequest = None # type: ignore
-
-
-if BaseModel:
-
- class ShowRequest(ApiRequest):
- """Request model for show operation."""
-
- op: Literal["show"] = "show"
- path: Optional[List[str]] = None
-
diff --git a/pyvyos/specs/commands/system.py b/pyvyos/specs/commands/system.py
deleted file mode 100644
index 9f5e5c9..0000000
--- a/pyvyos/specs/commands/system.py
+++ /dev/null
@@ -1,27 +0,0 @@
-"""Pydantic models for system operations (reboot, poweroff)."""
-
-from typing import List, Literal
-
-try:
- from pydantic import BaseModel
- from ..models import ApiRequest
-except ImportError:
- BaseModel = None # type: ignore
- ApiRequest = None # type: ignore
-
-
-if BaseModel:
-
- class RebootRequest(ApiRequest):
- """Request model for reboot operation."""
-
- op: Literal["reboot"] = "reboot"
- path: List[str] = ["now"]
-
-
- class PoweroffRequest(ApiRequest):
- """Request model for poweroff operation."""
-
- op: Literal["poweroff"] = "poweroff"
- path: List[str] = ["now"]
-
diff --git a/pyvyos/specs/models.py b/pyvyos/specs/models.py
deleted file mode 100644
index a57ac82..0000000
--- a/pyvyos/specs/models.py
+++ /dev/null
@@ -1,31 +0,0 @@
-"""Base Pydantic models for PyVyOS API requests and responses."""
-
-from typing import Any, Dict, List, Optional, Union
-
-if False: # type checking only
- from pydantic import BaseModel
-else:
- try:
- from pydantic import BaseModel, Field
- except ImportError:
- BaseModel = None # type: ignore
- Field = None # type: ignore
-
-
-class ApiRequest(BaseModel):
- """Base request model for VyOS API operations."""
-
- op: str
- path: Optional[Union[List[str], List[List[str]], List[Dict[str, Any]]]] = None
-
- class Config:
- extra = "allow" # Allow additional fields (file, url, name, etc.)
-
-
-class ApiResponse(BaseModel):
- """Base response model for VyOS API responses."""
-
- success: bool
- data: Union[Dict[str, Any], List[Any], str, None]
- error: Optional[str] = None
-
diff --git a/pyvyos/utils/__init__.py b/pyvyos/utils/__init__.py
index adff044..6290fc1 100644
--- a/pyvyos/utils/__init__.py
+++ b/pyvyos/utils/__init__.py
@@ -1,8 +1,7 @@
"""Utility functions for PyVyOS."""
from .json import redact_key, safe_dumps
-from .ids import request_id
from .paths import build_path
-__all__ = ["redact_key", "safe_dumps", "request_id", "build_path"]
+__all__ = ["redact_key", "safe_dumps", "build_path"]
diff --git a/pyvyos/utils/ids.py b/pyvyos/utils/ids.py
deleted file mode 100644
index 5807ddf..0000000
--- a/pyvyos/utils/ids.py
+++ /dev/null
@@ -1,14 +0,0 @@
-"""ID generation utilities."""
-
-import uuid
-
-
-def request_id() -> str:
- """
- Generate a unique request ID for tracing.
-
- Returns:
- UUID4 string formatted for logging
- """
- return str(uuid.uuid4())
-
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index e4af08e..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-requests>=2.25.1,<3.0
-python-dotenv>=1.0.1,<2.0
-urllib3>=2.5.0
diff --git a/run_tests.py b/run_tests.py
deleted file mode 100644
index e09886e..0000000
--- a/run_tests.py
+++ /dev/null
@@ -1,64 +0,0 @@
-#!/usr/bin/env python3
-"""Script para executar todos os testes e validar a instalação."""
-
-import subprocess
-import sys
-
-
-def run_command(cmd, description):
- """Executa um comando e mostra o resultado."""
- print(f"\n{'='*60}")
- print(f"{description}")
- print(f"{'='*60}")
- try:
- result = subprocess.run(
- cmd, shell=True, check=True, capture_output=True, text=True
- )
- print(result.stdout)
- if result.stderr:
- print("STDERR:", result.stderr)
- return True
- except subprocess.CalledProcessError as e:
- print(f"ERRO: {e}")
- print("STDOUT:", e.stdout)
- print("STDERR:", e.stderr)
- return False
-
-
-def main():
- """Executa todos os testes."""
- print("=== PyVyOS Test Suite ===\n")
-
- steps = [
- ("uv sync", "1. Sincronizando dependências"),
- ("uv run python test_quick.py", "2. Verificação rápida de imports"),
- ("uv run pytest tests/test_shims.py -v", "3. Testes de shims"),
- ("uv run pytest tests/utils/ -v", "4. Testes de utils"),
- ("uv run pytest tests/test_exceptions.py -v", "5. Testes de exceptions"),
- (
- "uv run pytest tests/modules/test_vy_device.py::test_shim_compatibility -v",
- "6. Teste de compatibilidade",
- ),
- ("uv run pytest tests/ -v --tb=short", "7. TODOS os testes"),
- ]
-
- results = []
- for cmd, desc in steps:
- success = run_command(cmd, desc)
- results.append((desc, success))
- if not success:
- print(f"\n❌ Falha em: {desc}")
- sys.exit(1)
-
- print(f"\n{'='*60}")
- print("✅ TODOS OS TESTES PASSARAM!")
- print(f"{'='*60}\n")
-
- for desc, success in results:
- status = "✅" if success else "❌"
- print(f"{status} {desc}")
-
-
-if __name__ == "__main__":
- main()
-
diff --git a/run_tests.sh b/run_tests.sh
deleted file mode 100644
index c07a684..0000000
--- a/run_tests.sh
+++ /dev/null
@@ -1,36 +0,0 @@
-#!/bin/bash
-set -e
-
-echo "=== PyVyOS Test Suite ==="
-echo ""
-
-echo "1. Sincronizando dependências..."
-uv sync
-
-echo ""
-echo "2. Verificação rápida de imports..."
-uv run python test_quick.py
-
-echo ""
-echo "3. Executando testes de shims..."
-uv run pytest tests/test_shims.py -v
-
-echo ""
-echo "4. Executando testes de utils..."
-uv run pytest tests/utils/ -v
-
-echo ""
-echo "5. Executando testes de exceptions..."
-uv run pytest tests/test_exceptions.py -v
-
-echo ""
-echo "6. Executando teste de compatibilidade..."
-uv run pytest tests/modules/test_vy_device.py::test_shim_compatibility -v
-
-echo ""
-echo "7. Executando TODOS os testes..."
-uv run pytest tests/ -v --tb=short
-
-echo ""
-echo "✅ Todos os testes concluídos!"
-
diff --git a/sphinx/Makefile b/sphinx/Makefile
deleted file mode 100644
index d0c3cbf..0000000
--- a/sphinx/Makefile
+++ /dev/null
@@ -1,20 +0,0 @@
-# Minimal makefile for Sphinx documentation
-#
-
-# You can set these variables from the command line, and also
-# from the environment for the first two.
-SPHINXOPTS ?=
-SPHINXBUILD ?= sphinx-build
-SOURCEDIR = source
-BUILDDIR = build
-
-# Put it first so that "make" without argument is like "make help".
-help:
- @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
-
-.PHONY: help Makefile
-
-# Catch-all target: route all unknown targets to Sphinx using the new
-# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
-%: Makefile
- @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/sphinx/README.md b/sphinx/README.md
deleted file mode 100644
index 813f05d..0000000
--- a/sphinx/README.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# Sphinx Documentation Builder
-
-This directory contains the Sphinx configuration for generating API documentation from Python docstrings.
-
-## Building Documentation
-
-To build the HTML documentation:
-
-```bash
-cd sphinx
-make html
-```
-
-The generated documentation will be in `sphinx/build/html/`.
-
-## Requirements
-
-Install Sphinx dependencies:
-
-```bash
-pip install -r sphinx/requirements.txt
-```
-
-## Read the Docs
-
-This configuration is used by Read the Docs for automated documentation builds.
-
diff --git a/sphinx/make.bat b/sphinx/make.bat
deleted file mode 100644
index 747ffb7..0000000
--- a/sphinx/make.bat
+++ /dev/null
@@ -1,35 +0,0 @@
-@ECHO OFF
-
-pushd %~dp0
-
-REM Command file for Sphinx documentation
-
-if "%SPHINXBUILD%" == "" (
- set SPHINXBUILD=sphinx-build
-)
-set SOURCEDIR=source
-set BUILDDIR=build
-
-%SPHINXBUILD% >NUL 2>NUL
-if errorlevel 9009 (
- echo.
- echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
- echo.installed, then set the SPHINXBUILD environment variable to point
- echo.to the full path of the 'sphinx-build' executable. Alternatively you
- echo.may add the Sphinx directory to PATH.
- echo.
- echo.If you don't have Sphinx installed, grab it from
- echo.https://www.sphinx-doc.org/
- exit /b 1
-)
-
-if "%1" == "" goto help
-
-%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
-goto end
-
-:help
-%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
-
-:end
-popd
diff --git a/sphinx/requirements.txt b/sphinx/requirements.txt
deleted file mode 100644
index f1ce7d3..0000000
--- a/sphinx/requirements.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-Sphinx>=7.2.6
-sphinx-rtd-theme>=2.0.0
diff --git a/sphinx/source/conf.py b/sphinx/source/conf.py
deleted file mode 100644
index ff95b65..0000000
--- a/sphinx/source/conf.py
+++ /dev/null
@@ -1,36 +0,0 @@
-# Configuration file for the Sphinx documentation builder.
-#
-# For the full list of built-in configuration values, see the documentation:
-# https://www.sphinx-doc.org/en/master/usage/configuration.html
-
-# -- Project information -----------------------------------------------------
-# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
-
-project = "pyvyos"
-copyright = "2024, Roberto Berto"
-author = "Roberto Berto"
-release = "0.3.0"
-
-# -- General configuration ---------------------------------------------------
-# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
-
-templates_path = ["_templates"]
-exclude_patterns = []
-
-
-# -- Options for HTML output -------------------------------------------------
-# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
-
-html_theme = "sphinx_rtd_theme"
-html_static_path = ["_static"]
-
-
-import os
-import sys
-
-sys.path.insert(0, os.path.abspath("../../"))
-
-extensions = [
- "sphinx.ext.autodoc",
- "sphinx_rtd_theme",
-]
diff --git a/sphinx/source/index.rst b/sphinx/source/index.rst
deleted file mode 100644
index 05c2aa0..0000000
--- a/sphinx/source/index.rst
+++ /dev/null
@@ -1,214 +0,0 @@
-.. PyVyOS documentation master file, created by
- sphinx-quickstart on Wed Dec 13 13:02:59 2023.
- You can adapt this file completely to your liking, but it should at least
- contain the root `toctree` directive.
-
-PyVyOS - documentation
-==================================
-
-.. toctree::
- :maxdepth: 2
- :caption: Contents:
-
-pyvyos
-======
-
-.. toctree::
- :maxdepth: 4
-
- pyvyos
-
-PyVyOS Usage
-==================
-
-.. _pyvyos-documentation:
-
-PyVyOS Documentation
-====================
-
-PyVyOS is a Python library for interacting with VyOS devices via their API. This documentation provides a guide on how to use PyVyOS to manage your VyOS devices programmatically.
-
-Installation
-------------
-
-You can install PyVyOS using pip:
-
-.. code-block:: bash
-
- pip install pyvyos
-
-Getting Started
----------------
-
-Importing and Disabling Warnings for verify=False
---------------------------------------------------
-
-Before using PyVyOS, it's a good practice to disable urllib3 warnings and import the required modules, IF you use verify=False:
-
-.. code-block:: python
-
- import urllib3
- urllib3.disable_warnings()
-
-Using API Response Class
-------------------------
-
-PyVyOS uses a custom `ApiResponse` data class to handle API responses:
-
-.. code-block:: python
-
- @dataclass
- class ApiResponse:
- status: int
- request: dict
- result: dict
- error: str
-
-Initializing a VyDevice Object
-------------------------------
-
-To interact with your VyOS device, you'll need to create an instance of the `VyDevice` class. You can set up your device using the following code, assuming you've stored your credentials as environment variables:
-
-.. code-block:: python
-
- from dotenv import load_dotenv
-
- # Load environment variables from a .env file
- load_dotenv()
-
- # Retrieve VyOS device connection details from environment variables
- hostname = os.getenv('VYDEVICE_HOSTNAME')
- apikey = os.getenv('VYDEVICE_APIKEY')
- port = os.getenv('VYDEVICE_PORT')
- protocol = os.getenv('VYDEVICE_PROTOCOL')
- verify_ssl = os.getenv('VYDEVICE_VERIFY_SSL')
-
- # Convert the verify_ssl value to a boolean
- verify = verify_ssl.lower() == "true" if verify_ssl else True
-
- # Create an instance of the VyOS device
- device = VyDevice(hostname=hostname, apikey=apikey, port=port, protocol=protocol, verify=verify)
-
-Using PyVyOS
-------------
-
-Once you have created a VyDevice object, you can use it to interact with your VyOS device using various methods provided by the library.
-
-Reset
------
-
-The reset method allows you to run a reset command:
-
-.. code-block:: python
-
- # Execute the reset command
- response = device.reset(path=["conntrack-sync", "internal-cache"])
-
- # Check for errors and print the result
- if not response.error:
- print(response.result)
-
-Retrieve Show Configuration
----------------------------
-
-The retrieve_show_config method retrieves the VyOS configuration:
-
-.. code-block:: python
-
- # Retrieve the VyOS configuration
- response = device.retrieve_show_config(path=[])
-
- # Check for errors and print the result
- if not response.error:
- print(response.result)
-
-Retrieve Return Values
-------------------------
-
-.. code-block:: python
-
- # Retrieve VyOS return values for a specific interface
- response = device.retrieve_return_values(path=["interfaces", "dummy", "dum1", "address"])
- print(response.result)
-
-Configure Delete
-----------------
-
-.. code-block:: python
-
- # Delete a VyOS interface configuration
- response = device.configure_delete(path=["interfaces", "dummy", "dum1"])
-
-Generate
-----------
-
-.. code-block:: python
-
- # Generate an SSH key with a random string in the name
- randstring = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(20))
- keyrand = f'/tmp/key_{randstring}'
- response = device.generate(path=["ssh", "client-key", keyrand])
-
-Show
-------
-
-.. code-block:: python
-
- # Show VyOS system image information
- response = device.show(path=["system", "image"])
- print(response.result)
-
-Reset
-------
-
-.. code-block:: python
-
- # Reset VyOS with specific parameters
- response = device.reset(path=["conntrack-sync", "internal-cache"])
-
-Configure Set
--------------
-
-The configure_set method sets a VyOS configuration:
-
-.. code-block:: python
-
- # Set a VyOS configuration
- response = device.configure_set(path=["interfaces ethernet eth0 address '192.168.1.1/24'"])
-
- # Check for errors and print the result
- if not response.error:
- print(response.result)
-
-Config File Save
-----------------
-
-.. code-block:: python
-
- # Save VyOS configuration without specifying a file (default location)
- response = device.config_file_save()
-
-Config File Save with custom filename
--------------------------------------
-
-.. code-block:: python
-
- # Save VyOS configuration to a specific file
- response = device.config_file_save(file="/config/test300.config")
-
-Config File Load
-----------------
-
-.. code-block:: python
-
- # Load VyOS configuration from a specific file
- response = device.config_file_load(file="/config/test300.config")
-
-
-
-Indices and tables
-==================
-
-* :ref:`genindex`
-* :ref:`modindex`
-* :ref:`search` \ No newline at end of file
diff --git a/sphinx/source/pyvyos.rst b/sphinx/source/pyvyos.rst
deleted file mode 100644
index fc02f1a..0000000
--- a/sphinx/source/pyvyos.rst
+++ /dev/null
@@ -1,21 +0,0 @@
-pyvyos package
-==============
-
-Submodules
-----------
-
-pyvyos.device module
---------------------
-
-.. automodule:: pyvyos.device
- :members:
- :undoc-members:
- :show-inheritance:
-
-Module contents
----------------
-
-.. automodule:: pyvyos
- :members:
- :undoc-members:
- :show-inheritance:
diff --git a/test_quick.py b/test_quick.py
deleted file mode 100644
index 9e93ca6..0000000
--- a/test_quick.py
+++ /dev/null
@@ -1,40 +0,0 @@
-#!/usr/bin/env python3
-"""Quick test script to verify imports and shims work."""
-
-def test_imports():
- """Test all import paths."""
- print("Testing imports...")
-
- # Public API
- from pyvyos import VyDevice, ApiResponse
- print("✓ Public API imports work")
-
- # Shim modules
- from pyvyos.device import VyDevice as DeviceShim
- from pyvyos.rest import RestClient, ApiResponse as RestResponse
- print("✓ Shim module imports work")
-
- # Core modules
- from pyvyos.core.device import VyDevice as CoreDevice
- from pyvyos.core.rest_client import RestClient as CoreRestClient
- print("✓ Core module imports work")
-
- # Identity checks
- assert DeviceShim is VyDevice, "Device shim identity failed"
- assert RestResponse is ApiResponse, "Rest shim identity failed"
- assert CoreDevice is VyDevice, "Core device identity failed"
- print("✓ All identity checks pass")
-
- # Utils
- from pyvyos.utils import redact_key, request_id, build_path
- print("✓ Utils imports work")
-
- # Exceptions
- from pyvyos.exceptions import SDKError, HttpError, ApiError, ValidationError
- print("✓ Exceptions imports work")
-
- print("\n✅ All imports and shims working correctly!")
-
-if __name__ == "__main__":
- test_imports()
-
diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/e2e/__init__.py
diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py
new file mode 100644
index 0000000..893afce
--- /dev/null
+++ b/tests/e2e/conftest.py
@@ -0,0 +1,96 @@
+"""Shared fixtures for pyvyos live end-to-end tests.
+
+These tests run only when PYVYOS_E2E=1 is set in the environment.
+They expect a VyOS HTTPS API reachable at VYDEVICE_HOSTNAME with the
+key in VYDEVICE_APIKEY. The harness under tests/pve/ provides one
+way to stand up such a VM on a local Proxmox host, but any VyOS
+instance you can reach works.
+"""
+
+from __future__ import annotations
+
+import os
+import time
+
+import pytest
+
+from pyvyos import VyDevice
+
+
+E2E_ENABLED = os.environ.get("PYVYOS_E2E") == "1"
+
+
+def _env_bool(name: str, default: str = "false") -> bool:
+ return os.environ.get(name, default).lower() in {"1", "true", "yes"}
+
+
+@pytest.fixture(scope="session")
+def device() -> VyDevice:
+ """A VyDevice pointed at the live VyOS under test.
+
+ Session-scoped because the device is stateless on the client side
+ and reusing it keeps the suite fast.
+ """
+ missing = [
+ v for v in ("VYDEVICE_HOSTNAME", "VYDEVICE_APIKEY") if not os.environ.get(v)
+ ]
+ if missing:
+ pytest.fail(
+ f"missing required environment variables: {missing}. "
+ "See tests/pve/.env.example for the full set."
+ )
+
+ return VyDevice(
+ hostname=os.environ["VYDEVICE_HOSTNAME"],
+ apikey=os.environ["VYDEVICE_APIKEY"],
+ protocol=os.environ.get("VYDEVICE_PROTOCOL", "https"),
+ port=int(os.environ.get("VYDEVICE_PORT", "443")),
+ verify=_env_bool("VYDEVICE_VERIFY_SSL"),
+ timeout=10,
+ )
+
+
+@pytest.fixture(scope="session", autouse=True)
+def _wait_for_api(device: VyDevice) -> None:
+ """Block the suite until the HTTPS API answers, or fail loudly.
+
+ A freshly cloned VM may need a moment after `qm start` before the
+ HTTPS service binds. We retry a cheap read for up to two minutes.
+ """
+ if not E2E_ENABLED:
+ return
+
+ deadline = time.time() + 120
+ last_error: str | None = None
+ while time.time() < deadline:
+ try:
+ response = device.show(path=["system", "image"])
+ if not response.error:
+ return
+ last_error = response.error
+ except Exception as exc: # noqa: BLE001 — broad on purpose, we retry
+ last_error = repr(exc)
+ time.sleep(3)
+
+ pytest.fail(
+ f"VyOS HTTPS API at {os.environ.get('VYDEVICE_HOSTNAME')} did not become "
+ f"ready within 120s. Last error: {last_error}"
+ )
+
+
+def pytest_collection_modifyitems(config, items) -> None:
+ """Skip only e2e tests unless PYVYOS_E2E=1.
+
+ This conftest is auto-discovered by pytest at the session level,
+ so the hook sees every collected item in the run. Filter by path
+ to avoid skipping the unit suite under tests/modules/.
+ """
+ if E2E_ENABLED:
+ return
+ skip = pytest.mark.skip(
+ reason="set PYVYOS_E2E=1 to run live VyOS end-to-end tests"
+ )
+ e2e_dir = os.path.join("tests", "e2e") + os.sep
+ for item in items:
+ if e2e_dir in str(item.fspath):
+ item.add_marker(skip)
diff --git a/tests/e2e/test_vydevice_live.py b/tests/e2e/test_vydevice_live.py
new file mode 100644
index 0000000..7eaadd4
--- /dev/null
+++ b/tests/e2e/test_vydevice_live.py
@@ -0,0 +1,71 @@
+"""Live VyOS smoke tests for pyvyos.
+
+These are intentionally a small, opinionated subset of the SDK:
+just enough to prove that payloads round-trip against a real VyOS
+HTTPS API. Destructive operations (reboot/poweroff/image-add/
+image-delete/reset/config-file-load) live in a separate file when
+they exist; not in scope for v0.4.x.
+"""
+
+from __future__ import annotations
+
+import random
+
+from pyvyos import ApiResponse, VyDevice
+
+
+def _ok(response: ApiResponse) -> None:
+ assert response.status == 200, f"status={response.status} error={response.error!r}"
+ assert not response.error, f"unexpected error: {response.error!r}"
+
+
+def _dummy_name() -> str:
+ """VyOS requires dummy interfaces to match `dumN`.
+
+ Pick a high random N to avoid collision with anything an operator
+ may have created manually.
+ """
+ return f"dum{random.randint(900, 9999)}"
+
+
+def test_show_system_image(device: VyDevice) -> None:
+ response = device.show(path=["system", "image"])
+ _ok(response)
+ assert response.result is not None
+
+
+def test_retrieve_show_config_system(device: VyDevice) -> None:
+ response = device.retrieve_show_config(path=["system"])
+ _ok(response)
+ assert isinstance(response.result, dict)
+
+
+def test_configure_set_read_delete_dummy_iface(device: VyDevice) -> None:
+ name = _dummy_name()
+ address = "192.0.2.10/32"
+ iface_path = ["interfaces", "dummy", name]
+
+ try:
+ set_response = device.configure_set(path=iface_path + ["address", address])
+ _ok(set_response)
+
+ read_response = device.retrieve_return_values(path=iface_path + ["address"])
+ _ok(read_response)
+ # retrieve_return_values returns a list of stringified values.
+ assert address in str(read_response.result), read_response.result
+ finally:
+ cleanup = device.configure_delete(path=iface_path)
+ _ok(cleanup)
+
+
+def test_configure_multiple_op_batch(device: VyDevice) -> None:
+ name = _dummy_name()
+ iface_path = ["interfaces", "dummy", name]
+
+ response = device.configure_multiple_op(
+ op_path=[
+ {"op": "set", "path": iface_path + ["address", "192.0.2.11/32"]},
+ {"op": "delete", "path": iface_path},
+ ]
+ )
+ _ok(response)
diff --git a/tests/pve/.env.example b/tests/pve/.env.example
new file mode 100644
index 0000000..428b519
--- /dev/null
+++ b/tests/pve/.env.example
@@ -0,0 +1,70 @@
+# PVE end-to-end harness — sample environment.
+#
+# Copy this file to tests/pve/.env and fill it in.
+# tests/pve/.env is gitignored and never committed.
+#
+# These scripts ssh to a Proxmox VE host you control and create a
+# disposable VyOS VM to run pyvyos against. They do not modify any
+# production state and they do not run in CI.
+
+# --- PVE host access -------------------------------------------------
+# Anything `ssh` accepts: a bare hostname, an alias from ~/.ssh/config,
+# or user@host. The value below is a placeholder; replace it with your
+# own host or alias, e.g. `pve.example.local` or your ~/.ssh/config
+# alias.
+PVE_SSH_TARGET=pve.example.local
+
+# PVE node name as `hostname -s` reports it on the PVE host.
+PVE_NODE=pve
+
+# Storage where VM disks land. Anything `pvesm status` lists.
+PVE_STORAGE=local-lvm
+
+# Bridge the VyOS VM attaches to.
+PVE_BRIDGE=vmbr0
+
+# Directory on the PVE host that holds ISO and qcow2 images.
+# Standard PVE layout.
+PVE_IMAGE_DIR=/var/lib/vz/template/iso
+
+# --- VyOS install ISO ------------------------------------------------
+# Path on the PVE host to the VyOS rolling install ISO. The harness
+# does not download it; place it there yourself (e.g. from
+# https://github.com/vyos/vyos-nightly-build/releases) and pin the
+# filename here. Bump deliberately when you want a fresher build.
+VYOS_ISO_PATH=/var/lib/vz/template/iso/vyos-rolling.iso
+VYOS_IMAGE_URL=https://github.com/vyos/vyos-nightly-build/releases
+
+# --- VyOS template (built once, then reused) -------------------------
+# VMID reserved for the template. Pick a value well outside your
+# normal VM range. Must be free the first time you run ensure-template.
+VYOS_TEMPLATE_VMID=9203
+VYOS_TEMPLATE_NAME=vyos-pyvyos-e2e-template
+
+# --- e2e VM (clone of the template) ----------------------------------
+VYOS_E2E_VMID=9900
+VYOS_E2E_VM_NAME=pyvyos-e2e
+VYOS_E2E_MEMORY=1024
+VYOS_E2E_CORES=1
+
+# Static IP for the e2e VM. Must be free on PVE_BRIDGE.
+VYOS_E2E_IP=192.0.2.10
+VYOS_E2E_CIDR=24
+VYOS_E2E_GATEWAY=192.0.2.1
+
+# Seconds to wait for cloud-init to apply the seed inside the freshly
+# installed VyOS image. 120s is comfortable; lower it if your hardware
+# is fast and you are tired of waiting.
+VYOS_CLOUD_INIT_WAIT_SECONDS=120
+
+# --- VyOS HTTPS API --------------------------------------------------
+# API key baked into the template at template-build time. The clones
+# inherit it. Treat as a lab secret; rotate by rebuilding the template.
+VYDEVICE_APIKEY=pyvyos-e2e-please-change-me
+
+# pyvyos client settings. Derived from the e2e VM above; only override
+# if you change listen-address or port in cloud-init.
+VYDEVICE_HOSTNAME=192.0.2.10
+VYDEVICE_PORT=443
+VYDEVICE_PROTOCOL=https
+VYDEVICE_VERIFY_SSL=false
diff --git a/tests/pve/README.md b/tests/pve/README.md
new file mode 100644
index 0000000..b84ad05
--- /dev/null
+++ b/tests/pve/README.md
@@ -0,0 +1,162 @@
+# PVE end-to-end harness
+
+This directory holds a small, opinionated harness to run pyvyos
+against a real VyOS VM on a local Proxmox VE host.
+
+It is intentionally **not** part of CI. The unit test suite under
+`tests/modules/` is fast, mock-based, and runs on every PR. The
+harness here exists for maintainers who want a periodic reality
+check — the gap a mock cannot close.
+
+> This harness creates confidence against real VyOS without turning
+> the project into a CI of appliances.
+
+## What it gives you
+
+A reproducible flow:
+
+```text
+ensure-template.sh # build a reusable VyOS template once
+create-vm.sh # clone the template into a fresh e2e VM
+start-vm.sh # power it on
+run-e2e.sh # run pytest tests/e2e against it
+stop-vm.sh # power it off
+destroy-vm.sh # purge it
+```
+
+The template carries an API key baked at build time via cloud-init.
+Clones inherit it, so the e2e VM is reachable as soon as the API is
+up.
+
+## Requirements
+
+- a Proxmox VE host you can `ssh` to (preferably via an alias in
+ `~/.ssh/config`)
+- a VyOS rolling install ISO already on the PVE host, under
+ `/var/lib/vz/template/iso/` (download from
+ https://github.com/vyos/vyos-nightly-build/releases; this harness
+ does **not** download it for you)
+- `genisoimage` (or `mkisofs`) installed on the PVE host —
+ `apt install genisoimage` once
+- Python 3.11+ and `uv` (or `pytest`) on your workstation to run the
+ test suite itself
+
+## First-time setup
+
+```bash
+cp tests/pve/.env.example tests/pve/.env
+$EDITOR tests/pve/.env # fill in your host, VMIDs, IP, API key
+chmod +x tests/pve/*.sh
+
+tests/pve/preflight.sh # read-only sanity check
+tests/pve/ensure-template.sh # phase 1: creates VM with installer
+```
+
+The first run of `ensure-template.sh` boots a VM with the VyOS
+installer ISO attached and prints instructions:
+
+```text
+on the PVE host:
+ qm terminal <VMID> # Ctrl-] exits
+
+inside VyOS:
+ login: vyos / vyos
+ install image # accept defaults, set a password
+ poweroff
+```
+
+Do that once. Then run it again:
+
+```bash
+tests/pve/ensure-template.sh # phase 2: cloud-init applies API key,
+ # shuts down, flips template flag
+```
+
+The script is a state machine on the VMID, so re-running it from any
+point is safe.
+
+## Per-run workflow
+
+```bash
+tests/pve/create-vm.sh
+tests/pve/start-vm.sh
+tests/pve/run-e2e.sh
+tests/pve/stop-vm.sh
+tests/pve/destroy-vm.sh
+```
+
+`run-e2e.sh` exports the `VYDEVICE_*` variables from `.env` and runs
+`pytest tests/e2e -v` with `PYVYOS_E2E=1`. Without that variable the
+e2e tests are skipped automatically.
+
+## Scope
+
+The live suite is intentionally small:
+
+| Test | What it proves |
+| ------------------------------------ | ----------------------------- |
+| `test_show_system_image` | operational API responds |
+| `test_retrieve_show_config_system` | config retrieval works |
+| `test_configure_set_read_delete...` | set / read-back / delete loop |
+| `test_configure_multiple_op_batch` | the batch payload is correct |
+
+Out of scope on purpose: `reboot`, `poweroff`, `image_add`,
+`image_delete`, `reset`, `config_file_load`. Those are destructive or
+slow; they will get their own opt-in file if anyone needs them.
+
+## Troubleshooting
+
+### Cloud-init did not apply on first boot
+
+VyOS cloud-init handling varies between rolling builds. If the API
+key is not active after phase 2, boot the VM, configure the API
+manually once, save, poweroff, and rerun `ensure-template.sh`:
+
+```text
+configure
+set service https api rest
+set service https api keys id pyvyos key 'pyvyos-e2e-please-change-me'
+set service https listen-address '0.0.0.0'
+commit
+save
+exit
+poweroff
+```
+
+The `set service https api rest` line is essential. Without it the
+HTTPS service only exposes `/info`, and every other endpoint
+(`/retrieve`, `/configure`, `/show`, …) responds with `404`. See
+the VyOS docs for HTTP API service.
+
+### HTTP 400 "Dummy interface must be named dumN"
+
+The live tests intentionally use interface names like `dum1234` to
+match the VyOS naming policy for `dummy` interfaces. If you adapt
+the tests, keep that pattern; arbitrary names are rejected by VyOS
+config validation with a 400.
+
+### "VMID already exists"
+
+Each script checks before mutating. To start over from scratch:
+
+```bash
+tests/pve/destroy-vm.sh # clears the e2e VM
+ssh $PVE_SSH_TARGET qm destroy $VYOS_TEMPLATE_VMID --purge
+tests/pve/ensure-template.sh # phase 1 again
+```
+
+### API key in the template
+
+The key from `tests/pve/.env` is baked into the template. That is
+fine for a private lab. **Do not** export this template to a shared
+PVE or hand it to other people without rotating the key.
+
+## What this harness is not
+
+- Not Vagrant. `examples/vagrant/` already covers desktop labs.
+- Not Docker. VyOS is an appliance OS; real KVM is the honest test.
+- Not CI. GitHub Actions cannot run nested KVM cheaply, and a
+ scheduled live job on someone else's hardware is not worth the
+ operational cost for a thin SDK.
+- Not a packaging story. The harness is for maintainers and brave
+ users; nothing in `pyvyos/` depends on it.
diff --git a/tests/pve/_lib.sh b/tests/pve/_lib.sh
new file mode 100755
index 0000000..10a4242
--- /dev/null
+++ b/tests/pve/_lib.sh
@@ -0,0 +1,128 @@
+#!/usr/bin/env bash
+# tests/pve/_lib.sh — source-only helpers for the PVE e2e harness.
+#
+# Public surface:
+# pve_load_env [path] load tests/pve/.env (or argument) and validate
+# pve_require_reachable fail unless `ssh $PVE_SSH_TARGET true` works
+# pve_ssh "cmd..." run a command on the PVE host
+# pve_qm args... shorthand for `pve_ssh qm args...`
+# pve_scp src dst copy a local file to the PVE host
+# pve_template_phase echo: missing | running | stopped | template
+# hdr "title" print a section header to stderr
+#
+# Refuses direct execution.
+
+if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
+ echo "ERR: tests/pve/_lib.sh is source-only; do not execute it." >&2
+ exit 1
+fi
+
+set -euo pipefail
+
+PVE_LIB_REQUIRED_VARS=(
+ PVE_SSH_TARGET
+ PVE_NODE
+ PVE_STORAGE
+ PVE_BRIDGE
+ PVE_IMAGE_DIR
+ VYOS_ISO_PATH
+ VYOS_TEMPLATE_VMID
+ VYOS_TEMPLATE_NAME
+ VYOS_E2E_VMID
+ VYOS_E2E_VM_NAME
+ VYOS_E2E_IP
+ VYOS_E2E_CIDR
+ VYOS_E2E_GATEWAY
+ VYDEVICE_APIKEY
+)
+
+pve_load_env() {
+ local env_file="${1:-}"
+ if [[ -z "$env_file" ]]; then
+ env_file="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.env"
+ fi
+ if [[ ! -f "$env_file" ]]; then
+ echo "ERR: missing env file: $env_file" >&2
+ echo " copy tests/pve/.env.example to tests/pve/.env and fill it in." >&2
+ return 1
+ fi
+ set -a
+ # shellcheck disable=SC1090
+ source "$env_file"
+ set +a
+
+ local missing=()
+ local v
+ for v in "${PVE_LIB_REQUIRED_VARS[@]}"; do
+ if [[ -z "${!v:-}" ]]; then
+ missing+=("$v")
+ fi
+ done
+ if (( ${#missing[@]} > 0 )); then
+ echo "ERR: env file $env_file is missing values for: ${missing[*]}" >&2
+ return 1
+ fi
+}
+
+pve_ssh() {
+ # ssh joins remaining args with spaces and hands the result to the
+ # remote login shell, so any unquoted `;`, `&`, `|`, `$`, glob, etc.
+ # would be re-interpreted there. When the caller passes a single
+ # argument we treat it as a verbatim remote script. When several are
+ # passed we shell-escape each one with `printf %q` so the remote
+ # shell sees exactly one argv per local argv.
+ if (( $# == 1 )); then
+ ssh -o BatchMode=yes -o ConnectTimeout=10 "$PVE_SSH_TARGET" "$1"
+ else
+ local cmd
+ # shellcheck disable=SC2059
+ cmd="$(printf '%q ' "$@")"
+ ssh -o BatchMode=yes -o ConnectTimeout=10 "$PVE_SSH_TARGET" "$cmd"
+ fi
+}
+
+pve_qm() {
+ pve_ssh qm "$@"
+}
+
+pve_scp() {
+ local src="$1" dst="$2"
+ scp -o BatchMode=yes -o ConnectTimeout=10 "$src" "$PVE_SSH_TARGET:$dst"
+}
+
+pve_require_reachable() {
+ if ! pve_ssh true >/dev/null 2>&1; then
+ echo "ERR: cannot ssh to PVE host '$PVE_SSH_TARGET'." >&2
+ echo " check ~/.ssh/config, your key, and that the host is up." >&2
+ return 1
+ fi
+}
+
+# Echo one of:
+# missing — VMID not registered in PVE
+# running — VM exists and is running
+# stopped — VM exists, not a template, currently stopped
+# template — VM exists and is flagged as a template
+pve_template_phase() {
+ local vmid="$1"
+ if ! pve_qm status "$vmid" >/dev/null 2>&1; then
+ echo missing
+ return
+ fi
+ if pve_qm config "$vmid" | grep -q '^template: 1'; then
+ echo template
+ return
+ fi
+ # `qm status <vmid>` prints e.g. "status: running"
+ local s
+ s="$(pve_qm status "$vmid" | awk '{print $2}')"
+ if [[ "$s" == "running" ]]; then
+ echo running
+ else
+ echo stopped
+ fi
+}
+
+hdr() {
+ printf '\n=== %s ===\n' "$*" >&2
+}
diff --git a/tests/pve/cloud-init/meta-data.template b/tests/pve/cloud-init/meta-data.template
new file mode 100644
index 0000000..bf0e4fe
--- /dev/null
+++ b/tests/pve/cloud-init/meta-data.template
@@ -0,0 +1,2 @@
+instance-id: __INSTANCE_ID__
+local-hostname: __HOSTNAME__
diff --git a/tests/pve/cloud-init/user-data.template b/tests/pve/cloud-init/user-data.template
new file mode 100644
index 0000000..d1ac0ee
--- /dev/null
+++ b/tests/pve/cloud-init/user-data.template
@@ -0,0 +1,14 @@
+#cloud-config
+# VyOS NoCloud user-data baked into the template.
+#
+# Placeholders replaced at render time by ensure-template.sh:
+# __APIKEY__ pyvyos HTTPS API key
+# __HOSTNAME__ initial system hostname
+
+vyos_config_commands:
+ - set system host-name '__HOSTNAME__'
+ - set service https api rest
+ - set service https api keys id pyvyos key '__APIKEY__'
+ - set service https listen-address '0.0.0.0'
+ - set service https port '443'
+ - set service ssh port '22'
diff --git a/tests/pve/create-vm.sh b/tests/pve/create-vm.sh
new file mode 100755
index 0000000..af34a66
--- /dev/null
+++ b/tests/pve/create-vm.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+# tests/pve/create-vm.sh — clone the template into a fresh e2e VM.
+#
+# Refuses to overwrite an existing VMID. Use destroy-vm.sh first if
+# the e2e VMID is already taken.
+
+set -euo pipefail
+
+HERE="$(cd "$(dirname "$0")" && pwd)"
+# shellcheck source=_lib.sh
+source "$HERE/_lib.sh"
+
+pve_load_env
+pve_require_reachable
+
+hdr "Check e2e VMID $VYOS_E2E_VMID is free"
+if pve_qm status "$VYOS_E2E_VMID" >/dev/null 2>&1; then
+ echo "ERR: VMID $VYOS_E2E_VMID already exists." >&2
+ echo " run tests/pve/destroy-vm.sh first, or pick a different VYOS_E2E_VMID." >&2
+ exit 1
+fi
+
+hdr "Check template $VYOS_TEMPLATE_VMID exists"
+if ! pve_qm config "$VYOS_TEMPLATE_VMID" | grep -q '^template: 1'; then
+ echo "ERR: template VMID $VYOS_TEMPLATE_VMID not found." >&2
+ echo " run tests/pve/ensure-template.sh first." >&2
+ exit 1
+fi
+
+hdr "qm clone $VYOS_TEMPLATE_VMID -> $VYOS_E2E_VMID"
+pve_qm clone "$VYOS_TEMPLATE_VMID" "$VYOS_E2E_VMID" \
+ --name "$VYOS_E2E_VM_NAME" --full true
+
+hdr "qm set memory/cores/static-ip"
+# The template already carries the HTTPS API key (baked by cloud-init
+# at template-build time), so this clone does not need another seed.
+# We only override the static IP via PVE's ipconfig drive.
+pve_qm set "$VYOS_E2E_VMID" \
+ --memory "$VYOS_E2E_MEMORY" --cores "$VYOS_E2E_CORES" \
+ --ipconfig0 "ip=$VYOS_E2E_IP/$VYOS_E2E_CIDR,gw=$VYOS_E2E_GATEWAY"
+
+echo "ok: VM $VYOS_E2E_VMID created (not started). Next: tests/pve/start-vm.sh" >&2
diff --git a/tests/pve/destroy-vm.sh b/tests/pve/destroy-vm.sh
new file mode 100755
index 0000000..ad3b22a
--- /dev/null
+++ b/tests/pve/destroy-vm.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+set -euo pipefail
+HERE="$(cd "$(dirname "$0")" && pwd)"
+# shellcheck source=_lib.sh
+source "$HERE/_lib.sh"
+pve_load_env
+pve_require_reachable
+hdr "Destroy VM $VYOS_E2E_VMID (purge disks)"
+pve_ssh "qm status $VYOS_E2E_VMID >/dev/null 2>&1 || { echo 'not present, nothing to do'; exit 0; }"
+pve_ssh "qm stop $VYOS_E2E_VMID || true"
+pve_ssh "qm destroy $VYOS_E2E_VMID --purge"
+hdr "ok"
diff --git a/tests/pve/ensure-template.sh b/tests/pve/ensure-template.sh
new file mode 100755
index 0000000..5243dde
--- /dev/null
+++ b/tests/pve/ensure-template.sh
@@ -0,0 +1,175 @@
+#!/usr/bin/env bash
+# tests/pve/ensure-template.sh — idempotently build the VyOS template.
+#
+# State machine on $VYOS_TEMPLATE_VMID:
+#
+# template -> nothing to do; exit 0.
+#
+# missing -> phase 1:
+# qm create with the install ISO + seed CD + empty disk,
+# boot, print operator instructions, exit 0.
+# You then open the serial console, run `install image`,
+# poweroff, and rerun this script.
+#
+# running -> phase 1 still in progress; print instructions, exit 1.
+#
+# stopped -> phase 2:
+# swap boot order to the installed disk, start, wait for
+# cloud-init to apply the seed (vyos_config_commands sets
+# the HTTPS API key), shutdown, detach the install ISO
+# and the seed, qm template.
+
+set -euo pipefail
+
+HERE="$(cd "$(dirname "$0")" && pwd)"
+# shellcheck source=_lib.sh
+source "$HERE/_lib.sh"
+
+pve_load_env
+pve_require_reachable
+
+VYOS_CLOUD_INIT_WAIT_SECONDS="${VYOS_CLOUD_INIT_WAIT_SECONDS:-120}"
+
+SEED_ISO_REMOTE="$PVE_IMAGE_DIR/${VYOS_TEMPLATE_NAME}-seed.iso"
+
+render_and_upload_seed() {
+ # Generates the NoCloud seed ISO on the PVE host itself. Avoids
+ # requiring genisoimage/mkisofs on the workstation, and keeps the
+ # rendered API key off the local filesystem.
+ hdr "Render and build seed ISO on PVE host"
+ pve_ssh "command -v genisoimage >/dev/null 2>&1 || command -v mkisofs >/dev/null 2>&1 || {
+ echo 'ERR: PVE host has neither genisoimage nor mkisofs. apt install genisoimage' >&2
+ exit 1
+ }"
+
+ local user_data meta_data
+ user_data="$(sed -e "s|__APIKEY__|$VYDEVICE_APIKEY|g" \
+ -e "s|__HOSTNAME__|$VYOS_TEMPLATE_NAME|g" \
+ "$HERE/cloud-init/user-data.template")"
+ meta_data="$(sed -e "s|__INSTANCE_ID__|$VYOS_TEMPLATE_NAME-$(date +%s)|g" \
+ -e "s|__HOSTNAME__|$VYOS_TEMPLATE_NAME|g" \
+ "$HERE/cloud-init/meta-data.template")"
+
+ pve_ssh "set -e
+ tmp=\$(mktemp -d)
+ trap 'rm -rf \$tmp' EXIT
+ cat > \$tmp/user-data <<'PYVYOS_USERDATA_EOF'
+$user_data
+PYVYOS_USERDATA_EOF
+ cat > \$tmp/meta-data <<'PYVYOS_METADATA_EOF'
+$meta_data
+PYVYOS_METADATA_EOF
+ if command -v genisoimage >/dev/null 2>&1; then
+ genisoimage -output '$SEED_ISO_REMOTE' -volid cidata -joliet -rock \
+ \$tmp/user-data \$tmp/meta-data >/dev/null 2>&1
+ else
+ mkisofs -output '$SEED_ISO_REMOTE' -volid cidata -joliet -rock \
+ \$tmp/user-data \$tmp/meta-data >/dev/null 2>&1
+ fi
+ "
+ echo "seed ISO at $SEED_ISO_REMOTE" >&2
+}
+
+phase1_create_install_vm() {
+ hdr "Phase 1: create VM with install ISO and seed"
+
+ if ! pve_ssh "test -s '$VYOS_ISO_PATH'"; then
+ echo "ERR: VyOS install ISO not found on PVE host at $VYOS_ISO_PATH" >&2
+ echo " download a rolling build from $VYOS_IMAGE_URL" >&2
+ echo " and place it at that path, or update VYOS_ISO_PATH." >&2
+ return 1
+ fi
+
+ render_and_upload_seed
+
+ pve_qm create "$VYOS_TEMPLATE_VMID" \
+ --name "$VYOS_TEMPLATE_NAME" \
+ --memory 1024 --cores 1 \
+ --net0 "virtio,bridge=$PVE_BRIDGE" \
+ --serial0 socket --vga serial0 \
+ --scsihw virtio-scsi-pci \
+ --ostype l26 \
+ --agent enabled=1
+
+ # 4G disk is plenty for a router appliance.
+ pve_qm set "$VYOS_TEMPLATE_VMID" --scsi0 "$PVE_STORAGE:4"
+
+ # ide2 = install ISO (boot here first). ide3 = NoCloud seed.
+ pve_qm set "$VYOS_TEMPLATE_VMID" \
+ --ide2 "$VYOS_ISO_PATH,media=cdrom" \
+ --ide3 "$SEED_ISO_REMOTE,media=cdrom" \
+ --boot "order=ide2;scsi0"
+
+ pve_qm start "$VYOS_TEMPLATE_VMID"
+
+ cat >&2 <<EOF
+
+VyOS install VM started. Finish the install manually:
+
+ on the PVE host:
+ qm terminal $VYOS_TEMPLATE_VMID # press Ctrl-] to exit
+
+ inside VyOS:
+ login: vyos / vyos
+ install image
+ (accept defaults; set the new password to whatever you want)
+ poweroff
+
+When the VM is powered off, re-run this script to finalize the template.
+EOF
+}
+
+phase2_finalize_template() {
+ hdr "Phase 2: boot installed disk, let cloud-init apply seed, template"
+
+ # Boot from installed disk now (install ISO no longer needed) and
+ # keep the seed CD so cloud-init picks it up on first post-install
+ # boot. We do NOT detach the install ISO yet; some VyOS rolling
+ # builds re-trigger first-boot wizardry from the CD if removed
+ # before the installed system has fully run. We detach both at the
+ # end, atomically, just before flipping the template flag.
+ pve_qm set "$VYOS_TEMPLATE_VMID" --boot "order=scsi0"
+ pve_qm start "$VYOS_TEMPLATE_VMID"
+
+ echo "waiting ${VYOS_CLOUD_INIT_WAIT_SECONDS}s for cloud-init to apply..." >&2
+ sleep "$VYOS_CLOUD_INIT_WAIT_SECONDS"
+
+ hdr "Shutdown VM"
+ pve_qm shutdown "$VYOS_TEMPLATE_VMID" --timeout 60 || pve_qm stop "$VYOS_TEMPLATE_VMID"
+
+ hdr "Detach install ISO and seed, then flip template flag"
+ pve_qm set "$VYOS_TEMPLATE_VMID" --delete ide2 || true
+ pve_qm set "$VYOS_TEMPLATE_VMID" --delete ide3 || true
+ pve_qm template "$VYOS_TEMPLATE_VMID"
+
+ echo "template $VYOS_TEMPLATE_VMID is ready." >&2
+ echo "next: tests/pve/create-vm.sh" >&2
+}
+
+main() {
+ local phase
+ phase="$(pve_template_phase "$VYOS_TEMPLATE_VMID")"
+ case "$phase" in
+ template)
+ echo "template VMID $VYOS_TEMPLATE_VMID already exists; nothing to do." >&2
+ ;;
+ missing)
+ phase1_create_install_vm
+ ;;
+ running)
+ echo "ERR: VMID $VYOS_TEMPLATE_VMID is currently running." >&2
+ echo " finish the install in the serial console and poweroff," >&2
+ echo " then rerun this script." >&2
+ return 1
+ ;;
+ stopped)
+ phase2_finalize_template
+ ;;
+ *)
+ echo "ERR: unexpected template phase: $phase" >&2
+ return 1
+ ;;
+ esac
+}
+
+main "$@"
diff --git a/tests/pve/preflight.sh b/tests/pve/preflight.sh
new file mode 100755
index 0000000..3fc6a16
--- /dev/null
+++ b/tests/pve/preflight.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+# tests/pve/preflight.sh — read-only sanity check on the PVE host.
+#
+# Verifies the host is reachable, has the tools the other scripts use,
+# and reports the current state of the template VMID and the e2e VMID
+# so you know whether ensure-template.sh / create-vm.sh have work to
+# do. Mutates nothing.
+
+set -euo pipefail
+
+HERE="$(cd "$(dirname "$0")" && pwd)"
+# shellcheck source=_lib.sh
+source "$HERE/_lib.sh"
+
+pve_load_env
+pve_require_reachable
+
+hdr "PVE identity"
+pve_ssh 'hostname; pveversion'
+
+hdr "Required tools (qm, pvesm, ip, curl, sha256sum)"
+pve_ssh 'for c in qm pvesm ip curl sha256sum; do
+ if command -v "$c" >/dev/null 2>&1; then
+ printf " ok %s\n" "$c"
+ else
+ printf " MISSING %s\n" "$c"
+ fi
+done'
+
+hdr "Storage and bridge"
+pve_ssh "pvesm status; ip -br link show $PVE_BRIDGE 2>/dev/null || echo 'bridge $PVE_BRIDGE not found'"
+
+hdr "Image directory"
+pve_ssh "ls -la $PVE_IMAGE_DIR 2>/dev/null | head -20 || echo 'directory missing: $PVE_IMAGE_DIR'"
+
+hdr "Template VMID $VYOS_TEMPLATE_VMID"
+pve_ssh "qm status $VYOS_TEMPLATE_VMID 2>/dev/null \
+ && qm config $VYOS_TEMPLATE_VMID | grep -E '^(name|template|ostype|net0):' \
+ || echo 'template VMID $VYOS_TEMPLATE_VMID does not exist (ensure-template.sh will create it)'"
+
+hdr "e2e VMID $VYOS_E2E_VMID"
+pve_ssh "qm status $VYOS_E2E_VMID 2>/dev/null \
+ || echo 'e2e VMID $VYOS_E2E_VMID is free (create-vm.sh will clone it)'"
+
+hdr "ok"
diff --git a/tests/pve/run-e2e.sh b/tests/pve/run-e2e.sh
new file mode 100755
index 0000000..10955cb
--- /dev/null
+++ b/tests/pve/run-e2e.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+# tests/pve/run-e2e.sh — run the live pyvyos tests against the VM.
+#
+# Assumes the VM is already running and the HTTPS API is reachable.
+# Use start-vm.sh first (and create-vm.sh before that, if needed).
+
+set -euo pipefail
+HERE="$(cd "$(dirname "$0")" && pwd)"
+# shellcheck source=_lib.sh
+source "$HERE/_lib.sh"
+
+pve_load_env
+
+REPO_ROOT="$(cd "$HERE/../.." && pwd)"
+cd "$REPO_ROOT"
+
+export PYVYOS_E2E=1
+export VYDEVICE_HOSTNAME VYDEVICE_PORT VYDEVICE_PROTOCOL
+export VYDEVICE_VERIFY_SSL VYDEVICE_APIKEY
+
+hdr "pytest tests/e2e (PYVYOS_E2E=1)"
+if command -v uv >/dev/null 2>&1; then
+ uv run pytest tests/e2e -v
+else
+ pytest tests/e2e -v
+fi
diff --git a/tests/pve/start-vm.sh b/tests/pve/start-vm.sh
new file mode 100755
index 0000000..40ef11e
--- /dev/null
+++ b/tests/pve/start-vm.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+set -euo pipefail
+HERE="$(cd "$(dirname "$0")" && pwd)"
+# shellcheck source=_lib.sh
+source "$HERE/_lib.sh"
+pve_load_env
+pve_require_reachable
+hdr "qm start $VYOS_E2E_VMID"
+pve_ssh "qm start $VYOS_E2E_VMID"
+hdr "ok"
diff --git a/tests/pve/stop-vm.sh b/tests/pve/stop-vm.sh
new file mode 100755
index 0000000..8a035ab
--- /dev/null
+++ b/tests/pve/stop-vm.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+set -euo pipefail
+HERE="$(cd "$(dirname "$0")" && pwd)"
+# shellcheck source=_lib.sh
+source "$HERE/_lib.sh"
+pve_load_env
+pve_require_reachable
+hdr "qm shutdown $VYOS_E2E_VMID (60s timeout, then stop)"
+pve_ssh "qm shutdown $VYOS_E2E_VMID --timeout 60 || qm stop $VYOS_E2E_VMID"
+hdr "ok"
diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py
deleted file mode 100644
index 22a4f34..0000000
--- a/tests/test_exceptions.py
+++ /dev/null
@@ -1,51 +0,0 @@
-"""Tests for pyvyos.exceptions module."""
-
-import pytest
-
-from pyvyos.exceptions import SDKError, HttpError, ApiError, ValidationError
-
-
-def test_sdk_error_base():
- """Test SDKError is base exception."""
- assert issubclass(HttpError, SDKError)
- assert issubclass(ApiError, SDKError)
- assert issubclass(ValidationError, SDKError)
-
-
-def test_http_error():
- """Test HttpError creation and attributes."""
- error = HttpError(status=404, message="Not Found")
- assert error.status == 404
- assert error.message == "Not Found"
- assert "404" in str(error)
- assert "Not Found" in str(error)
-
-
-def test_api_error():
- """Test ApiError creation."""
- error = ApiError(message="API failure")
- assert error.message == "API failure"
- assert "API failure" in str(error)
-
-
-def test_api_error_with_details():
- """Test ApiError with details."""
- details = {"code": "ERR001", "field": "path"}
- error = ApiError(message="Validation failed", details=details)
- assert error.message == "Validation failed"
- assert error.details == details
-
-
-def test_validation_error():
- """Test ValidationError creation."""
- error = ValidationError(message="Invalid path format")
- assert error.message == "Invalid path format"
- assert "Invalid path format" in str(error)
-
-
-def test_exceptions_raiseable():
- """Test that exceptions can be raised and caught."""
- with pytest.raises(HttpError) as exc_info:
- raise HttpError(status=500, message="Internal Error")
- assert exc_info.value.status == 500
-
diff --git a/tests/utils/test_ids.py b/tests/utils/test_ids.py
deleted file mode 100644
index 19ff3a7..0000000
--- a/tests/utils/test_ids.py
+++ /dev/null
@@ -1,25 +0,0 @@
-"""Tests for pyvyos.utils.ids module."""
-
-import uuid
-
-from pyvyos.utils.ids import request_id
-
-
-def test_request_id_returns_string():
- """Test that request_id returns a string."""
- rid = request_id()
- assert isinstance(rid, str)
-
-
-def test_request_id_valid_uuid():
- """Test that request_id returns a valid UUID."""
- rid = request_id()
- uuid.UUID(rid) # Should not raise
-
-
-def test_request_id_unique():
- """Test that request_id generates unique IDs."""
- id1 = request_id()
- id2 = request_id()
- assert id1 != id2
-
diff --git a/uv.lock b/uv.lock
deleted file mode 100644
index 19e2671..0000000
--- a/uv.lock
+++ /dev/null
@@ -1,361 +0,0 @@
-version = 1
-revision = 3
-requires-python = ">=3.13"
-
-[[package]]
-name = "annotated-types"
-version = "0.7.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
-]
-
-[[package]]
-name = "certifi"
-version = "2025.8.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
-]
-
-[[package]]
-name = "charset-normalizer"
-version = "3.4.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" },
- { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" },
- { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" },
- { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" },
- { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" },
- { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" },
- { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" },
- { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" },
- { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" },
- { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" },
- { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" },
- { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" },
- { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" },
- { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" },
- { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" },
- { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" },
- { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" },
- { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" },
- { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" },
- { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" },
- { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" },
- { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" },
- { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" },
-]
-
-[[package]]
-name = "colorama"
-version = "0.4.6"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
-]
-
-[[package]]
-name = "coverage"
-version = "7.10.6"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/14/70/025b179c993f019105b79575ac6edb5e084fb0f0e63f15cdebef4e454fb5/coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90", size = 823736, upload-time = "2025-08-29T15:35:16.668Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/bd/e7/917e5953ea29a28c1057729c1d5af9084ab6d9c66217523fd0e10f14d8f6/coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6", size = 217351, upload-time = "2025-08-29T15:33:45.438Z" },
- { url = "https://files.pythonhosted.org/packages/eb/86/2e161b93a4f11d0ea93f9bebb6a53f113d5d6e416d7561ca41bb0a29996b/coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80", size = 217600, upload-time = "2025-08-29T15:33:47.269Z" },
- { url = "https://files.pythonhosted.org/packages/0e/66/d03348fdd8df262b3a7fb4ee5727e6e4936e39e2f3a842e803196946f200/coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003", size = 248600, upload-time = "2025-08-29T15:33:48.953Z" },
- { url = "https://files.pythonhosted.org/packages/73/dd/508420fb47d09d904d962f123221bc249f64b5e56aa93d5f5f7603be475f/coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27", size = 251206, upload-time = "2025-08-29T15:33:50.697Z" },
- { url = "https://files.pythonhosted.org/packages/e9/1f/9020135734184f439da85c70ea78194c2730e56c2d18aee6e8ff1719d50d/coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4", size = 252478, upload-time = "2025-08-29T15:33:52.303Z" },
- { url = "https://files.pythonhosted.org/packages/a4/a4/3d228f3942bb5a2051fde28c136eea23a761177dc4ff4ef54533164ce255/coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d", size = 250637, upload-time = "2025-08-29T15:33:53.67Z" },
- { url = "https://files.pythonhosted.org/packages/36/e3/293dce8cdb9a83de971637afc59b7190faad60603b40e32635cbd15fbf61/coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc", size = 248529, upload-time = "2025-08-29T15:33:55.022Z" },
- { url = "https://files.pythonhosted.org/packages/90/26/64eecfa214e80dd1d101e420cab2901827de0e49631d666543d0e53cf597/coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc", size = 250143, upload-time = "2025-08-29T15:33:56.386Z" },
- { url = "https://files.pythonhosted.org/packages/3e/70/bd80588338f65ea5b0d97e424b820fb4068b9cfb9597fbd91963086e004b/coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e", size = 219770, upload-time = "2025-08-29T15:33:58.063Z" },
- { url = "https://files.pythonhosted.org/packages/a7/14/0b831122305abcc1060c008f6c97bbdc0a913ab47d65070a01dc50293c2b/coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32", size = 220566, upload-time = "2025-08-29T15:33:59.766Z" },
- { url = "https://files.pythonhosted.org/packages/83/c6/81a83778c1f83f1a4a168ed6673eeedc205afb562d8500175292ca64b94e/coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2", size = 219195, upload-time = "2025-08-29T15:34:01.191Z" },
- { url = "https://files.pythonhosted.org/packages/d7/1c/ccccf4bf116f9517275fa85047495515add43e41dfe8e0bef6e333c6b344/coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b", size = 218059, upload-time = "2025-08-29T15:34:02.91Z" },
- { url = "https://files.pythonhosted.org/packages/92/97/8a3ceff833d27c7492af4f39d5da6761e9ff624831db9e9f25b3886ddbca/coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393", size = 218287, upload-time = "2025-08-29T15:34:05.106Z" },
- { url = "https://files.pythonhosted.org/packages/92/d8/50b4a32580cf41ff0423777a2791aaf3269ab60c840b62009aec12d3970d/coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27", size = 259625, upload-time = "2025-08-29T15:34:06.575Z" },
- { url = "https://files.pythonhosted.org/packages/7e/7e/6a7df5a6fb440a0179d94a348eb6616ed4745e7df26bf2a02bc4db72c421/coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df", size = 261801, upload-time = "2025-08-29T15:34:08.006Z" },
- { url = "https://files.pythonhosted.org/packages/3a/4c/a270a414f4ed5d196b9d3d67922968e768cd971d1b251e1b4f75e9362f75/coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb", size = 264027, upload-time = "2025-08-29T15:34:09.806Z" },
- { url = "https://files.pythonhosted.org/packages/9c/8b/3210d663d594926c12f373c5370bf1e7c5c3a427519a8afa65b561b9a55c/coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282", size = 261576, upload-time = "2025-08-29T15:34:11.585Z" },
- { url = "https://files.pythonhosted.org/packages/72/d0/e1961eff67e9e1dba3fc5eb7a4caf726b35a5b03776892da8d79ec895775/coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4", size = 259341, upload-time = "2025-08-29T15:34:13.159Z" },
- { url = "https://files.pythonhosted.org/packages/3a/06/d6478d152cd189b33eac691cba27a40704990ba95de49771285f34a5861e/coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21", size = 260468, upload-time = "2025-08-29T15:34:14.571Z" },
- { url = "https://files.pythonhosted.org/packages/ed/73/737440247c914a332f0b47f7598535b29965bf305e19bbc22d4c39615d2b/coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0", size = 220429, upload-time = "2025-08-29T15:34:16.394Z" },
- { url = "https://files.pythonhosted.org/packages/bd/76/b92d3214740f2357ef4a27c75a526eb6c28f79c402e9f20a922c295c05e2/coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5", size = 221493, upload-time = "2025-08-29T15:34:17.835Z" },
- { url = "https://files.pythonhosted.org/packages/fc/8e/6dcb29c599c8a1f654ec6cb68d76644fe635513af16e932d2d4ad1e5ac6e/coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b", size = 219757, upload-time = "2025-08-29T15:34:19.248Z" },
- { url = "https://files.pythonhosted.org/packages/d3/aa/76cf0b5ec00619ef208da4689281d48b57f2c7fde883d14bf9441b74d59f/coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e", size = 217331, upload-time = "2025-08-29T15:34:20.846Z" },
- { url = "https://files.pythonhosted.org/packages/65/91/8e41b8c7c505d398d7730206f3cbb4a875a35ca1041efc518051bfce0f6b/coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb", size = 217607, upload-time = "2025-08-29T15:34:22.433Z" },
- { url = "https://files.pythonhosted.org/packages/87/7f/f718e732a423d442e6616580a951b8d1ec3575ea48bcd0e2228386805e79/coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034", size = 248663, upload-time = "2025-08-29T15:34:24.425Z" },
- { url = "https://files.pythonhosted.org/packages/e6/52/c1106120e6d801ac03e12b5285e971e758e925b6f82ee9b86db3aa10045d/coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1", size = 251197, upload-time = "2025-08-29T15:34:25.906Z" },
- { url = "https://files.pythonhosted.org/packages/3d/ec/3a8645b1bb40e36acde9c0609f08942852a4af91a937fe2c129a38f2d3f5/coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a", size = 252551, upload-time = "2025-08-29T15:34:27.337Z" },
- { url = "https://files.pythonhosted.org/packages/a1/70/09ecb68eeb1155b28a1d16525fd3a9b65fbe75337311a99830df935d62b6/coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb", size = 250553, upload-time = "2025-08-29T15:34:29.065Z" },
- { url = "https://files.pythonhosted.org/packages/c6/80/47df374b893fa812e953b5bc93dcb1427a7b3d7a1a7d2db33043d17f74b9/coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d", size = 248486, upload-time = "2025-08-29T15:34:30.897Z" },
- { url = "https://files.pythonhosted.org/packages/4a/65/9f98640979ecee1b0d1a7164b589de720ddf8100d1747d9bbdb84be0c0fb/coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747", size = 249981, upload-time = "2025-08-29T15:34:32.365Z" },
- { url = "https://files.pythonhosted.org/packages/1f/55/eeb6603371e6629037f47bd25bef300387257ed53a3c5fdb159b7ac8c651/coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5", size = 220054, upload-time = "2025-08-29T15:34:34.124Z" },
- { url = "https://files.pythonhosted.org/packages/15/d1/a0912b7611bc35412e919a2cd59ae98e7ea3b475e562668040a43fb27897/coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713", size = 220851, upload-time = "2025-08-29T15:34:35.651Z" },
- { url = "https://files.pythonhosted.org/packages/ef/2d/11880bb8ef80a45338e0b3e0725e4c2d73ffbb4822c29d987078224fd6a5/coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32", size = 219429, upload-time = "2025-08-29T15:34:37.16Z" },
- { url = "https://files.pythonhosted.org/packages/83/c0/1f00caad775c03a700146f55536ecd097a881ff08d310a58b353a1421be0/coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65", size = 218080, upload-time = "2025-08-29T15:34:38.919Z" },
- { url = "https://files.pythonhosted.org/packages/a9/c4/b1c5d2bd7cc412cbeb035e257fd06ed4e3e139ac871d16a07434e145d18d/coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6", size = 218293, upload-time = "2025-08-29T15:34:40.425Z" },
- { url = "https://files.pythonhosted.org/packages/3f/07/4468d37c94724bf6ec354e4ec2f205fda194343e3e85fd2e59cec57e6a54/coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0", size = 259800, upload-time = "2025-08-29T15:34:41.996Z" },
- { url = "https://files.pythonhosted.org/packages/82/d8/f8fb351be5fee31690cd8da768fd62f1cfab33c31d9f7baba6cd8960f6b8/coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e", size = 261965, upload-time = "2025-08-29T15:34:43.61Z" },
- { url = "https://files.pythonhosted.org/packages/e8/70/65d4d7cfc75c5c6eb2fed3ee5cdf420fd8ae09c4808723a89a81d5b1b9c3/coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5", size = 264220, upload-time = "2025-08-29T15:34:45.387Z" },
- { url = "https://files.pythonhosted.org/packages/98/3c/069df106d19024324cde10e4ec379fe2fb978017d25e97ebee23002fbadf/coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7", size = 261660, upload-time = "2025-08-29T15:34:47.288Z" },
- { url = "https://files.pythonhosted.org/packages/fc/8a/2974d53904080c5dc91af798b3a54a4ccb99a45595cc0dcec6eb9616a57d/coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5", size = 259417, upload-time = "2025-08-29T15:34:48.779Z" },
- { url = "https://files.pythonhosted.org/packages/30/38/9616a6b49c686394b318974d7f6e08f38b8af2270ce7488e879888d1e5db/coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0", size = 260567, upload-time = "2025-08-29T15:34:50.718Z" },
- { url = "https://files.pythonhosted.org/packages/76/16/3ed2d6312b371a8cf804abf4e14895b70e4c3491c6e53536d63fd0958a8d/coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7", size = 220831, upload-time = "2025-08-29T15:34:52.653Z" },
- { url = "https://files.pythonhosted.org/packages/d5/e5/d38d0cb830abede2adb8b147770d2a3d0e7fecc7228245b9b1ae6c24930a/coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930", size = 221950, upload-time = "2025-08-29T15:34:54.212Z" },
- { url = "https://files.pythonhosted.org/packages/f4/51/e48e550f6279349895b0ffcd6d2a690e3131ba3a7f4eafccc141966d4dea/coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b", size = 219969, upload-time = "2025-08-29T15:34:55.83Z" },
- { url = "https://files.pythonhosted.org/packages/44/0c/50db5379b615854b5cf89146f8f5bd1d5a9693d7f3a987e269693521c404/coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3", size = 208986, upload-time = "2025-08-29T15:35:14.506Z" },
-]
-
-[[package]]
-name = "idna"
-version = "3.10"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
-]
-
-[[package]]
-name = "iniconfig"
-version = "2.1.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
-]
-
-[[package]]
-name = "packaging"
-version = "25.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
-]
-
-[[package]]
-name = "pluggy"
-version = "1.6.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
-]
-
-[[package]]
-name = "pydantic"
-version = "2.12.3"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "annotated-types" },
- { name = "pydantic-core" },
- { name = "typing-extensions" },
- { name = "typing-inspection" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/f3/1e/4f0a3233767010308f2fd6bd0814597e3f63f1dc98304a9112b8759df4ff/pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74", size = 819383, upload-time = "2025-10-17T15:04:21.222Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a1/6b/83661fa77dcefa195ad5f8cd9af3d1a7450fd57cc883ad04d65446ac2029/pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf", size = 462431, upload-time = "2025-10-17T15:04:19.346Z" },
-]
-
-[[package]]
-name = "pydantic-core"
-version = "2.41.4"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/13/d0/c20adabd181a029a970738dfe23710b52a31f1258f591874fcdec7359845/pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746", size = 2105688, upload-time = "2025-10-14T10:20:54.448Z" },
- { url = "https://files.pythonhosted.org/packages/00/b6/0ce5c03cec5ae94cca220dfecddc453c077d71363b98a4bbdb3c0b22c783/pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced", size = 1910807, upload-time = "2025-10-14T10:20:56.115Z" },
- { url = "https://files.pythonhosted.org/packages/68/3e/800d3d02c8beb0b5c069c870cbb83799d085debf43499c897bb4b4aaff0d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a", size = 1956669, upload-time = "2025-10-14T10:20:57.874Z" },
- { url = "https://files.pythonhosted.org/packages/60/a4/24271cc71a17f64589be49ab8bd0751f6a0a03046c690df60989f2f95c2c/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02", size = 2051629, upload-time = "2025-10-14T10:21:00.006Z" },
- { url = "https://files.pythonhosted.org/packages/68/de/45af3ca2f175d91b96bfb62e1f2d2f1f9f3b14a734afe0bfeff079f78181/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1", size = 2224049, upload-time = "2025-10-14T10:21:01.801Z" },
- { url = "https://files.pythonhosted.org/packages/af/8f/ae4e1ff84672bf869d0a77af24fd78387850e9497753c432875066b5d622/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2", size = 2342409, upload-time = "2025-10-14T10:21:03.556Z" },
- { url = "https://files.pythonhosted.org/packages/18/62/273dd70b0026a085c7b74b000394e1ef95719ea579c76ea2f0cc8893736d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84", size = 2069635, upload-time = "2025-10-14T10:21:05.385Z" },
- { url = "https://files.pythonhosted.org/packages/30/03/cf485fff699b4cdaea469bc481719d3e49f023241b4abb656f8d422189fc/pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d", size = 2194284, upload-time = "2025-10-14T10:21:07.122Z" },
- { url = "https://files.pythonhosted.org/packages/f9/7e/c8e713db32405dfd97211f2fc0a15d6bf8adb7640f3d18544c1f39526619/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d", size = 2137566, upload-time = "2025-10-14T10:21:08.981Z" },
- { url = "https://files.pythonhosted.org/packages/04/f7/db71fd4cdccc8b75990f79ccafbbd66757e19f6d5ee724a6252414483fb4/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2", size = 2316809, upload-time = "2025-10-14T10:21:10.805Z" },
- { url = "https://files.pythonhosted.org/packages/76/63/a54973ddb945f1bca56742b48b144d85c9fc22f819ddeb9f861c249d5464/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab", size = 2311119, upload-time = "2025-10-14T10:21:12.583Z" },
- { url = "https://files.pythonhosted.org/packages/f8/03/5d12891e93c19218af74843a27e32b94922195ded2386f7b55382f904d2f/pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c", size = 1981398, upload-time = "2025-10-14T10:21:14.584Z" },
- { url = "https://files.pythonhosted.org/packages/be/d8/fd0de71f39db91135b7a26996160de71c073d8635edfce8b3c3681be0d6d/pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4", size = 2030735, upload-time = "2025-10-14T10:21:16.432Z" },
- { url = "https://files.pythonhosted.org/packages/72/86/c99921c1cf6650023c08bfab6fe2d7057a5142628ef7ccfa9921f2dda1d5/pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564", size = 1973209, upload-time = "2025-10-14T10:21:18.213Z" },
- { url = "https://files.pythonhosted.org/packages/36/0d/b5706cacb70a8414396efdda3d72ae0542e050b591119e458e2490baf035/pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4", size = 1877324, upload-time = "2025-10-14T10:21:20.363Z" },
- { url = "https://files.pythonhosted.org/packages/de/2d/cba1fa02cfdea72dfb3a9babb067c83b9dff0bbcb198368e000a6b756ea7/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2", size = 1884515, upload-time = "2025-10-14T10:21:22.339Z" },
- { url = "https://files.pythonhosted.org/packages/07/ea/3df927c4384ed9b503c9cc2d076cf983b4f2adb0c754578dfb1245c51e46/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf", size = 2042819, upload-time = "2025-10-14T10:21:26.683Z" },
- { url = "https://files.pythonhosted.org/packages/6a/ee/df8e871f07074250270a3b1b82aad4cd0026b588acd5d7d3eb2fcb1471a3/pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2", size = 1995866, upload-time = "2025-10-14T10:21:28.951Z" },
- { url = "https://files.pythonhosted.org/packages/fc/de/b20f4ab954d6d399499c33ec4fafc46d9551e11dc1858fb7f5dca0748ceb/pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89", size = 1970034, upload-time = "2025-10-14T10:21:30.869Z" },
- { url = "https://files.pythonhosted.org/packages/54/28/d3325da57d413b9819365546eb9a6e8b7cbd9373d9380efd5f74326143e6/pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1", size = 2102022, upload-time = "2025-10-14T10:21:32.809Z" },
- { url = "https://files.pythonhosted.org/packages/9e/24/b58a1bc0d834bf1acc4361e61233ee217169a42efbdc15a60296e13ce438/pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac", size = 1905495, upload-time = "2025-10-14T10:21:34.812Z" },
- { url = "https://files.pythonhosted.org/packages/fb/a4/71f759cc41b7043e8ecdaab81b985a9b6cad7cec077e0b92cff8b71ecf6b/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554", size = 1956131, upload-time = "2025-10-14T10:21:36.924Z" },
- { url = "https://files.pythonhosted.org/packages/b0/64/1e79ac7aa51f1eec7c4cda8cbe456d5d09f05fdd68b32776d72168d54275/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e", size = 2052236, upload-time = "2025-10-14T10:21:38.927Z" },
- { url = "https://files.pythonhosted.org/packages/e9/e3/a3ffc363bd4287b80f1d43dc1c28ba64831f8dfc237d6fec8f2661138d48/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616", size = 2223573, upload-time = "2025-10-14T10:21:41.574Z" },
- { url = "https://files.pythonhosted.org/packages/28/27/78814089b4d2e684a9088ede3790763c64693c3d1408ddc0a248bc789126/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af", size = 2342467, upload-time = "2025-10-14T10:21:44.018Z" },
- { url = "https://files.pythonhosted.org/packages/92/97/4de0e2a1159cb85ad737e03306717637842c88c7fd6d97973172fb183149/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12", size = 2063754, upload-time = "2025-10-14T10:21:46.466Z" },
- { url = "https://files.pythonhosted.org/packages/0f/50/8cb90ce4b9efcf7ae78130afeb99fd1c86125ccdf9906ef64b9d42f37c25/pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d", size = 2196754, upload-time = "2025-10-14T10:21:48.486Z" },
- { url = "https://files.pythonhosted.org/packages/34/3b/ccdc77af9cd5082723574a1cc1bcae7a6acacc829d7c0a06201f7886a109/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad", size = 2137115, upload-time = "2025-10-14T10:21:50.63Z" },
- { url = "https://files.pythonhosted.org/packages/ca/ba/e7c7a02651a8f7c52dc2cff2b64a30c313e3b57c7d93703cecea76c09b71/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a", size = 2317400, upload-time = "2025-10-14T10:21:52.959Z" },
- { url = "https://files.pythonhosted.org/packages/2c/ba/6c533a4ee8aec6b812c643c49bb3bd88d3f01e3cebe451bb85512d37f00f/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025", size = 2312070, upload-time = "2025-10-14T10:21:55.419Z" },
- { url = "https://files.pythonhosted.org/packages/22/ae/f10524fcc0ab8d7f96cf9a74c880243576fd3e72bd8ce4f81e43d22bcab7/pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e", size = 1982277, upload-time = "2025-10-14T10:21:57.474Z" },
- { url = "https://files.pythonhosted.org/packages/b4/dc/e5aa27aea1ad4638f0c3fb41132f7eb583bd7420ee63204e2d4333a3bbf9/pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894", size = 2024608, upload-time = "2025-10-14T10:21:59.557Z" },
- { url = "https://files.pythonhosted.org/packages/3e/61/51d89cc2612bd147198e120a13f150afbf0bcb4615cddb049ab10b81b79e/pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d", size = 1967614, upload-time = "2025-10-14T10:22:01.847Z" },
- { url = "https://files.pythonhosted.org/packages/0d/c2/472f2e31b95eff099961fa050c376ab7156a81da194f9edb9f710f68787b/pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da", size = 1876904, upload-time = "2025-10-14T10:22:04.062Z" },
- { url = "https://files.pythonhosted.org/packages/4a/07/ea8eeb91173807ecdae4f4a5f4b150a520085b35454350fc219ba79e66a3/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e", size = 1882538, upload-time = "2025-10-14T10:22:06.39Z" },
- { url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183, upload-time = "2025-10-14T10:22:08.812Z" },
- { url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542, upload-time = "2025-10-14T10:22:11.332Z" },
- { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" },
-]
-
-[[package]]
-name = "pygments"
-version = "2.19.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
-]
-
-[[package]]
-name = "pytest"
-version = "8.4.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "iniconfig" },
- { name = "packaging" },
- { name = "pluggy" },
- { name = "pygments" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
-]
-
-[[package]]
-name = "pytest-cov"
-version = "5.0.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "coverage" },
- { name = "pytest" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042, upload-time = "2024-03-24T20:16:34.856Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990, upload-time = "2024-03-24T20:16:32.444Z" },
-]
-
-[[package]]
-name = "pytest-env"
-version = "0.6.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pytest" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/f9/6e/31efb8dc1d17052c12f39262223e94038bfcc4cc7a124235630a6d50f166/pytest-env-0.6.2.tar.gz", hash = "sha256:7e94956aef7f2764f3c147d216ce066bf6c42948bb9e293169b1b1c880a580c2", size = 1693, upload-time = "2017-06-16T19:51:35.86Z" }
-
-[[package]]
-name = "python-dotenv"
-version = "1.1.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
-]
-
-[[package]]
-name = "pyvyos"
-version = "0.3.0"
-source = { editable = "." }
-dependencies = [
- { name = "python-dotenv" },
- { name = "requests" },
- { name = "urllib3" },
-]
-
-[package.optional-dependencies]
-dev = [
- { name = "pytest" },
- { name = "pytest-cov" },
- { name = "pytest-env" },
-]
-validation = [
- { name = "pydantic" },
-]
-
-[package.dev-dependencies]
-dev = [
- { name = "pytest" },
- { name = "pytest-cov" },
- { name = "pytest-env" },
-]
-
-[package.metadata]
-requires-dist = [
- { name = "pydantic", marker = "extra == 'validation'", specifier = ">=2.0,<3.0" },
- { name = "pytest", marker = "extra == 'dev'", specifier = ">=6.2.5,<9.0.0" },
- { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1,<6.0" },
- { name = "pytest-env", marker = "extra == 'dev'", specifier = ">=0.6.2,<0.7" },
- { name = "python-dotenv", specifier = ">=1.0.1,<2.0" },
- { name = "requests", specifier = ">=2.32.0,<3.0" },
- { name = "urllib3", specifier = ">=2.5.0" },
-]
-provides-extras = ["dev", "validation"]
-
-[package.metadata.requires-dev]
-dev = [
- { name = "pytest", specifier = ">=8.4.2" },
- { name = "pytest-cov", specifier = ">=5.0.0" },
- { name = "pytest-env", specifier = ">=0.6.2" },
-]
-
-[[package]]
-name = "requests"
-version = "2.32.5"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "certifi" },
- { name = "charset-normalizer" },
- { name = "idna" },
- { name = "urllib3" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
-]
-
-[[package]]
-name = "typing-extensions"
-version = "4.15.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
-]
-
-[[package]]
-name = "typing-inspection"
-version = "0.4.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
-]
-
-[[package]]
-name = "urllib3"
-version = "2.5.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
-]