diff options
| author | Roberto Bertó <463349+robertoberto@users.noreply.github.com> | 2026-05-19 03:15:55 -0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-19 03:15:55 -0300 |
| commit | 294d060ac1557ed70cab7a12f97d930f9dc4baf9 (patch) | |
| tree | 25e03a160fb1dc05a27a9118a7a6f573f0ffd123 | |
| parent | ffd5ba16eb1ada42a582db4ac8bdaf29f66a868f (diff) | |
| parent | 6071528289e4a8b11a772433c33851136d30f133 (diff) | |
| download | pyvyos-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
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 }} @@ -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). @@ -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/* @@ -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. +[](https://pypi.org/project/pyvyos/) +[](https://pypi.org/project/pyvyos/) +[](LICENSE) +[](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" }, -] |
