From 0f76bcc7179976e893b1d9f296b1b1e7988b0031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Bert=C3=B3?= <463349+robertoberto@users.noreply.github.com> Date: Tue, 19 May 2026 02:32:04 +0000 Subject: [PATCH 01/12] refactor: remove unused specs, exceptions, and request_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pyvyos.specs Pydantic models were never imported by the runtime and had 0% test coverage. The pyvyos.exceptions hierarchy was defined but never raised anywhere. The request_id helper generated UUIDs that were attached to log records but never propagated to callers — half-implemented tracing is worse than none. This commit does not alter request/response logic or HTTP payloads. It only removes code that was never executed and tests for that code. Removed: - pyvyos/specs/ (Pydantic models package) - pyvyos/exceptions.py (SDKError, HttpError, ApiError, ValidationError) - pyvyos/utils/ids.py (request_id helper) - tests/test_exceptions.py - tests/utils/test_ids.py Edited: - pyvyos/core/rest_client.py: drop request_id import and log extras - pyvyos/utils/__init__.py: drop request_id export Tests: 66 -> 57 passing. Coverage: 54% -> 87%. Public API unchanged. --- pyvyos/core/rest_client.py | 9 ----- pyvyos/exceptions.py | 33 ------------------ pyvyos/specs/__init__.py | 12 ------- pyvyos/specs/commands/__init__.py | 13 ------- pyvyos/specs/commands/config_file.py | 36 -------------------- pyvyos/specs/commands/configure.py | 38 --------------------- pyvyos/specs/commands/generate.py | 20 ----------- pyvyos/specs/commands/image.py | 33 ------------------ pyvyos/specs/commands/reset.py | 20 ----------- pyvyos/specs/commands/retrieve.py | 27 --------------- pyvyos/specs/commands/show.py | 20 ----------- pyvyos/specs/commands/system.py | 27 --------------- pyvyos/specs/models.py | 31 ----------------- pyvyos/utils/__init__.py | 3 +- pyvyos/utils/ids.py | 14 -------- tests/test_exceptions.py | 51 ---------------------------- tests/utils/test_ids.py | 25 -------------- 17 files changed, 1 insertion(+), 411 deletions(-) delete mode 100644 pyvyos/exceptions.py delete mode 100644 pyvyos/specs/__init__.py delete mode 100644 pyvyos/specs/commands/__init__.py delete mode 100644 pyvyos/specs/commands/config_file.py delete mode 100644 pyvyos/specs/commands/configure.py delete mode 100644 pyvyos/specs/commands/generate.py delete mode 100644 pyvyos/specs/commands/image.py delete mode 100644 pyvyos/specs/commands/reset.py delete mode 100644 pyvyos/specs/commands/retrieve.py delete mode 100644 pyvyos/specs/commands/show.py delete mode 100644 pyvyos/specs/commands/system.py delete mode 100644 pyvyos/specs/models.py delete mode 100644 pyvyos/utils/ids.py delete mode 100644 tests/test_exceptions.py delete mode 100644 tests/utils/test_ids.py 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 @@ def _prepare_request() -> Dict[str, Any]: # 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 @@ def _prepare_request() -> Dict[str, Any]: 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/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/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 - From ea8c349f6dce955696850198b8544d0203b467fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Bert=C3=B3?= <463349+robertoberto@users.noreply.github.com> Date: Tue, 19 May 2026 02:35:08 +0000 Subject: [PATCH 02/12] chore: clean packaging and development tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit modernizes the project's tooling and packaging without changing the runtime code. It addresses dead workflows, obsolete helper scripts, duplicated configuration, and stale developer docs. Removed: - .github/workflows/python-app.yml: targeted Python 3.12, referenced a non-existent requirements.txt, ran only flake8 with pytest commented out - Makefile: hard-coded env/bin/python paths that do not work with uv - run_tests.sh and run_tests.py: duplicated each other and referenced removed modules (test_exceptions, test_quick) - sphinx/ and .readthedocs.yaml: the RTD config pointed to docs/source/conf.py while the sphinx tree lived under sphinx/source, so the build never worked and no docs were ever published - docs/development/architecture.md, refactor-roadmap.md, and quality-and-utils.md: described the pre-cleanup proposal that included specs/exceptions/request_id, now contradicted by the code Edited: - pyproject.toml: - dropped the validation extra (Pydantic) — specs/ is gone - dropped the duplicated [tool.hatch.metadata].dependencies block - dropped the duplicated [dependency-groups].dev block - declared the wheel package explicitly via [tool.hatch.build.targets.wheel].packages - bumped pytest floor to >=8.0 (Python 3.13 compatible) - added richer classifiers (Development Status, audience, topic, Typing :: Typed), keywords, license file pointer, and a Changelog URL - .github/workflows/python-pr-validation.yml: upgraded to actions/checkout@v4 and setup-python@v5, switched to astral-sh/setup-uv, removed obsolete architecture argument - .github/dependabot.yml: added the github-actions ecosystem so workflow versions stay current Added: - pyvyos/py.typed: PEP 561 marker advertising the package as typed - .pre-commit-config.yaml: neutral hooks only (whitespace, EOF, YAML/TOML syntax, large-file guard); no formatters or linters yet Kept: - docs/development/vyos_api/: JSON reference for the VyOS HTTPS API, useful for future contract tests Tests still pass: 57/57. --- .github/dependabot.yml | 20 +- .github/workflows/python-app.yml | 39 ---- .github/workflows/python-pr-validation.yml | 34 ++-- .pre-commit-config.yaml | 17 ++ .readthedocs.yaml | 33 ---- Makefile | 14 -- docs/development/architecture.md | 77 -------- docs/development/quality-and-utils.md | 72 ------- docs/development/refactor-roadmap.md | 56 ------ pyproject.toml | 44 ++--- pyvyos/py.typed | 0 run_tests.py | 64 ------ run_tests.sh | 36 ---- sphinx/Makefile | 20 -- sphinx/README.md | 27 --- sphinx/make.bat | 35 ---- sphinx/requirements.txt | 2 - sphinx/source/conf.py | 36 ---- sphinx/source/index.rst | 214 --------------------- sphinx/source/pyvyos.rst | 21 -- 20 files changed, 69 insertions(+), 792 deletions(-) delete mode 100644 .github/workflows/python-app.yml create mode 100644 .pre-commit-config.yaml delete mode 100644 .readthedocs.yaml delete mode 100644 Makefile delete mode 100644 docs/development/architecture.md delete mode 100644 docs/development/quality-and-utils.md delete mode 100644 docs/development/refactor-roadmap.md create mode 100644 pyvyos/py.typed delete mode 100644 run_tests.py delete mode 100644 run_tests.sh delete mode 100644 sphinx/Makefile delete mode 100644 sphinx/README.md delete mode 100644 sphinx/make.bat delete mode 100644 sphinx/requirements.txt delete mode 100644 sphinx/source/conf.py delete mode 100644 sphinx/source/index.rst delete mode 100644 sphinx/source/pyvyos.rst 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..611372b 100644 --- a/.github/workflows/python-pr-validation.yml +++ b/.github/workflows/python-pr-validation.yml @@ -1,29 +1,31 @@ -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 steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - - name: Set up Python 3.12 - uses: actions/setup-python@v2 + - name: Set up Python 3.13 + uses: actions/setup-python@v5 with: python-version: "3.13" - architecture: "x64" + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true - 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 - - name: Python Run Tests - run: | - uv run pytest -v + - name: Run tests + run: uv run pytest -v diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7dcae7d --- /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: v5.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/.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/Makefile b/Makefile deleted file mode 100644 index 42b53e9..0000000 --- a/Makefile +++ /dev/null @@ -1,14 +0,0 @@ -.PHONY: help -help: - @echo "build - build the package" - @echo "upload - upload the package to PyPI" - -.PHONY: build -build: - env/bin/python -m build --sdist - env/bin/python -m build --wheel - -.PHONY: upload -upload: - env/bin/python -m twine check dist/* - env/bin/python -m twine upload dist/* diff --git a/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__when_` -- 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/pyproject.toml b/pyproject.toml index 7a0f401..1f35384 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,15 +6,24 @@ 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" +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.13", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", + "Typing :: Typed", ] dependencies = [ "requests>=2.32.0,<3.0", @@ -24,32 +33,26 @@ dependencies = [ [project.optional-dependencies] dev = [ - "pytest>=6.2.5,<9.0.0", - "pytest-cov>=4.1,<6.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'", @@ -60,16 +63,7 @@ env = [ ] [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/py.typed b/pyvyos/py.typed new file mode 100644 index 0000000..e69de29 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: From bf4e309a698592cdb815580f97db97b268fd6668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Bert=C3=B3?= <463349+robertoberto@users.noreply.github.com> Date: Tue, 19 May 2026 02:39:01 +0000 Subject: [PATCH 03/12] docs: prepare v0.4.0 release Rewrite the README around the supported public API, add a public API stability policy, document the deprecation timeline, and refresh the CHANGELOG with the full 0.4.0 scope. Add a short CONTRIBUTING guide. Apply minor pending fixes to LICENSE and .env.example. Fix a couple of verify=False antipatterns in the docs/ pages. README: - Badges (PyPI version, Python versions, license, CI). - Quick start that actually runs (no missing import, no global disable_warnings, correct VYDEVICE_VERIFY_SSL parse). - Environment variable table with defaults. - ApiResponse contract documented as a dataclass. - Compact API overview (configure / retrieve / show / generate / reset / config-file / system / image). - Public API stability section with the 0.4 -> 0.5 -> 0.6 -> 1.0 deprecation timeline. - Logging section: NullHandler default, pyvyos logger, redacted key. - VyOS compatibility note (1.4 LTS, 1.5 rolling). - Development with uv; optional pre-commit. CHANGELOG: - Consolidated 0.4.0 entry covering both the architecture refactor and this cleanup, since 0.4.0 was tagged in git but never published. - Explicit Added / Changed / Removed / Fixed / Compatibility / Notes sections. - 0.3.0 entry annotated as 'tagged but never released to PyPI'. CONTRIBUTING.md: - Scope statement (thin HTTPS API wrapper). - Pull request rules; explicit 'do not change HTTP payload in passing'. - Development setup with uv. - Public API stability summary. Fixes from the previous review-fixes branch: - LICENSE copyright now reads 'GravScale, Roberto Berto'. - .env.example default flipped to VYDEVICE_VERIFY_SSL=true with field descriptions. - docs/getting-started.md: VERIFY_SSL default to true; urllib3 disable_warnings now passes the specific InsecureRequestWarning. - docs/index.md: quick example uses verify=True; removed RTD link (RTD config was removed in this release because the build never worked). --- .env.example | 16 ++- CHANGELOG.md | 109 +++++++++++----- CONTRIBUTING.md | 71 +++++++++++ LICENSE | 2 +- README.md | 272 +++++++++++++++++++++++++--------------- docs/getting-started.md | 4 +- docs/index.md | 4 +- 7 files changed, 342 insertions(+), 136 deletions(-) create mode 100644 CONTRIBUTING.md 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 key 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/CHANGELOG.md b/CHANGELOG.md index 15b9314..3132e02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,50 +5,97 @@ 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` +- `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`. ### 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 +- `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. +- PR validation workflow upgraded to `actions/checkout@v4`, + `actions/setup-python@v5`, and `astral-sh/setup-uv@v3`. +- `.env.example` default flipped to `VYDEVICE_VERIFY_SSL=true`; commented + with field descriptions. + +### Removed +- `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ó`. -### 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/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2bfaaa0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,71 @@ +# Contributing to pyvyos + +Thanks for considering a contribution. `pyvyos` is a small library with a +narrow scope, which makes review and maintenance easier when contributions +follow a few simple rules. + +## Scope + +`pyvyos` is a thin Python wrapper around the VyOS HTTPS API. Changes that +fit naturally into this scope are welcome. Anything that adds a new +dependency, introduces a parallel client (SSH, NETCONF, …), or expands +beyond the HTTPS API surface is best discussed in an issue first. + +## Before opening a pull request + +1. **Open an issue first** for anything beyond a small fix or a typo. It + saves everyone time if the direction is agreed before the patch lands. +2. **Keep the diff focused.** One topic per pull request. Refactors and + bug fixes are easier to review separately. +3. **Do not change HTTP payload generation in passing.** Payload changes + require a deliberate review pass; they should be their own pull + request and include tests. +4. **Run the test suite locally:** `uv run pytest`. +5. **Match the existing style.** No formatter is enforced yet; just keep + diffs minimal and readable. + +## Development setup + +```bash +git clone https://github.com/vyos-contrib/pyvyos.git +cd pyvyos +uv sync --extra dev +uv run pytest +``` + +Optional pre-commit hooks (whitespace, EOF, YAML/TOML syntax): + +```bash +pip install pre-commit +pre-commit install +``` + +## Public API stability + +The supported public API is: + +```python +from pyvyos import VyDevice, ApiResponse +``` + +Compatibility shims at `pyvyos.device` and `pyvyos.rest` are kept while +the cost is trivial. Anything under `pyvyos.core.*` is internal and may +change between minor releases. + +If your change touches the public API surface, please flag it explicitly +in the pull request description. + +## Reporting bugs + +When reporting a bug, please include: + +- The `pyvyos` version (`pip show pyvyos`). +- The Python version. +- The VyOS version of the device. +- A minimal reproducer and the resulting error (or, for behaviour bugs, + the observed vs expected behaviour). + +## License + +By contributing you agree that your contribution will be licensed under +the [MIT License](LICENSE). diff --git a/LICENSE b/LICENSE index 6b976b2..9784c86 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 GravScale +Copyright (c) 2023 GravScale, Roberto Bertó Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index af1f9fa..2afffea 100644 --- a/README.md +++ b/README.md @@ -1,149 +1,225 @@ -# pyvyos Documentation +# pyvyos -pyvyos is a Python library for interacting with VyOS devices via their API. This documentation provides a guide on how to use pyvyos to manage your VyOS devices programmatically. +[![PyPI version](https://img.shields.io/pypi/v/pyvyos.svg)](https://pypi.org/project/pyvyos/) +[![Python versions](https://img.shields.io/pypi/pyversions/pyvyos.svg)](https://pypi.org/project/pyvyos/) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![PR Validation](https://github.com/vyos-contrib/pyvyos/actions/workflows/python-pr-validation.yml/badge.svg)](https://github.com/vyos-contrib/pyvyos/actions/workflows/python-pr-validation.yml) -You can find the complete pyvyos documentation on [Read the Docs](https://pyvyos.readthedocs.io/en/latest/). +Python SDK for the [VyOS](https://vyos.io/) HTTPS API. -## Installation +`pyvyos` is a small, focused library that wraps the VyOS HTTPS API in an +idiomatic Python interface. It is intended for automation scripts, internal +tooling, and integrations with configuration management systems. -You can install pyvyos using pip https://pypi.org/project/pyvyos/: +## Installation ```bash pip install pyvyos ``` -## Getting Started +Requires **Python 3.13 or newer**. -### 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: - -``` -import urllib3 -urllib3.disable_warnings() -``` +## Quick start -### Using API Response Class -pyvyos uses a custom ApiResponse data class to handle API responses: +Enable the HTTPS API on the VyOS device and create an API key: -``` -@dataclass -class ApiResponse: - status: int - request: dict - result: dict - error: str +```text +set service https api keys id my-key key 'your-secret-key' +commit ``` -### Initializing a VyDevice Object +Then, from Python: +```python +import os +from pyvyos import VyDevice -#### 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. +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() == "true", +) +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 removed) + result: dict | list # 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: +The recommended usage pattern is: +```python +response = device.retrieve_show_config(path=["interfaces"]) +if response.error: + raise RuntimeError(response.error) +do_something_with(response.result) ``` -# Retrieve the VyOS configuration -response = device.retrieve_show_config(path=[]) -# Check for errors and print the result -if not response.error: - print(response.result) -``` +### Configuration -### configure, then delete OBJECT -``` -# Delete a VyOS interface configuration -response = device.configure_delete(path=["interfaces", "dummy", "dum1"]) +```python +device.configure_set(path=["interfaces", "ethernet", "eth0", "address", "192.0.2.1/24"]) +device.configure_delete(path=["interfaces", "dummy", "dum1"]) +device.configure_multiple_op(path=[ + {"op": "set", "path": ["interfaces", "dummy", "dum2", "address", "203.0.113.1/24"]}, + {"op": "delete", "path": ["interfaces", "dummy", "dum1"]}, +]) ``` -### configure, then save -``` -# Save VyOS configuration without specifying a file (default location) -response = device.config_file_save() -``` +### Retrieval -### configure, then save FILE +```python +device.retrieve_show_config(path=["system"]) +device.retrieve_return_values(path=["interfaces", "dummy", "dum1", "address"]) ``` -# Save VyOS configuration to a specific file -response = device.config_file_save(file="/config/test300.config") + +### Operational + +```python +device.show(path=["system", "image"]) +device.generate(path=["ssh", "client-key", "/tmp/key"]) +device.reset(path=["conntrack-sync", "internal-cache"]) ``` -## show OBJECT +### 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 VyOS system image information -response = device.show(path=["system", "image"]) -print(response.result) + +### System control + +```python +device.reboot() # equivalent to device.reboot(path=["now"]) +device.poweroff() # equivalent to device.poweroff(path=["now"]) ``` -### generate OBJECT +### Image management + +```python +device.image_add(url="https://downloads.vyos.io/.../vyos-1.4-image.iso") +device.image_delete(name="1.4-rolling-...") ``` -# 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]) + +## Public API stability + +The supported public API of pyvyos is: + +```python +from pyvyos import VyDevice, ApiResponse ``` -### reset OBJECT -The reset method allows you to run a reset command: +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 ``` -# 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) +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` | Shims are removed or kept, depending on observed usage. | + +## Logging + +`pyvyos` uses the standard `logging` module under the `pyvyos` namespace and +attaches a `NullHandler` so a default install does not print anything. + +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) ``` -### configure, then load FILE +Request payloads are sanitised before logging — the `key` field is replaced +with `***REDACTED***`. + +## VyOS compatibility + +Tested against: + +- VyOS 1.4 LTS (stable) +- VyOS 1.5 rolling + +The library only depends on the HTTPS API surface, so versions that expose +the same endpoints should work without changes. + +## 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 ``` + +## 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/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 From a2df70609a4711b4eb673f28cee3e6f72414148a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Bert=C3=B3?= <463349+robertoberto@users.noreply.github.com> Date: Tue, 19 May 2026 02:54:59 +0000 Subject: [PATCH 04/12] chore: trim root, move examples, drop committed uv.lock - delete CONTRIB.md (superseded by CONTRIBUTING.md) - delete requirements.txt (duplicated [project].dependencies) - delete test_quick.py (ad-hoc smoke covered by tests/test_shims.py) - rewrite example.py as examples/basic.py using the public API (from pyvyos import VyDevice) and drop the RuntimeWarning suppression - move vagrant/ to examples/vagrant/ - stop tracking uv.lock and add it to .gitignore (library, not app) - README: link the examples/ directory - CHANGELOG: document the moves and removals --- .gitignore | 3 +- CHANGELOG.md | 7 + CONTRIB.md | 79 ---- README.md | 5 + example.py | 113 ------ examples/basic.py | 87 +++++ {vagrant => examples/vagrant}/.env.example | 0 {vagrant => examples/vagrant}/VAGRANT.md | 0 {vagrant => examples/vagrant}/Vagrantfile | 0 .../vagrant}/virtualbox-wsl2.sh | 0 requirements.txt | 3 - test_quick.py | 40 -- uv.lock | 361 ------------------ 13 files changed, 101 insertions(+), 597 deletions(-) delete mode 100644 CONTRIB.md delete mode 100644 example.py create mode 100644 examples/basic.py rename {vagrant => examples/vagrant}/.env.example (100%) rename {vagrant => examples/vagrant}/VAGRANT.md (100%) rename {vagrant => examples/vagrant}/Vagrantfile (100%) rename {vagrant => examples/vagrant}/virtualbox-wsl2.sh (100%) delete mode 100644 requirements.txt delete mode 100644 test_quick.py delete mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index 4e3c616..3f617ed 100644 --- a/.gitignore +++ b/.gitignore @@ -160,11 +160,12 @@ cython_debug/ #.idea/ -vagrant/.vagrant +examples/vagrant/.vagrant packer/ *.code-workspace .env dev/ .idea +uv.lock docs/vyos-documentation/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3132e02..13bc332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ This release **does not change HTTP payload generation or request/response behavior**. ### Added +- `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). @@ -24,6 +26,7 @@ behavior**. - Documented public API stability and deprecation timeline in `README.md`. ### Changed +- `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 @@ -41,6 +44,10 @@ behavior**. 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. 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/README.md b/README.md index 2afffea..9f9f727 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,11 @@ The deprecation timeline is: | `0.6.x` | Compatibility shims emit a `DeprecationWarning`. | | `1.0.0` | Shims are removed or kept, depending on observed usage. | +## Examples + +A runnable end-to-end example lives in [`examples/basic.py`](examples/basic.py), +and a Vagrant-based lab setup under [`examples/vagrant/`](examples/vagrant/). + ## Logging `pyvyos` uses the standard `logging` module under the `pyvyos` namespace and 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..3a275f4 --- /dev/null +++ b/examples/basic.py @@ -0,0 +1,87 @@ +"""Basic pyvyos usage example. + +Run with environment variables loaded from a .env file (see ../.env.example) +or exported in your shell: + + VYDEVICE_HOSTNAME=192.0.2.1 \\ + VYDEVICE_APIKEY=secret \\ + python examples/basic.py +""" + +import os +import pprint +import random +import string + +from dotenv import load_dotenv + +from pyvyos import VyDevice + +load_dotenv() + + +def make_device() -> VyDevice: + return VyDevice( + hostname=os.environ["VYDEVICE_HOSTNAME"], + apikey=os.environ["VYDEVICE_APIKEY"], + port=int(os.environ.get("VYDEVICE_PORT", "443")), + protocol=os.environ.get("VYDEVICE_PROTOCOL", "https"), + verify=os.environ.get("VYDEVICE_VERIFY_SSL", "true").lower() == "true", + timeout=60, + ) + + +def main() -> None: + device = make_device() + + # Retrieve the running configuration for the system tree. + pprint.pprint(device.retrieve_show_config(path=["system"])) + + # Configure a dummy interface, read it back, then delete it. + pprint.pprint( + device.configure_set( + path=["interfaces", "dummy", "dum1", "address", "192.168.56.100/24"] + ) + ) + pprint.pprint( + device.retrieve_return_values( + path=["interfaces", "dummy", "dum1", "address"] + ) + ) + pprint.pprint(device.configure_delete(path=["interfaces", "dummy", "dum1"])) + + # Generate a one-shot SSH client key. + randstring = "".join( + random.choice(string.ascii_letters + string.digits) for _ in range(20) + ) + pprint.pprint( + device.generate(path=["ssh", "client-key", f"/tmp/key_{randstring}"]) + ) + + # Operational commands. + pprint.pprint(device.show(path=["system", "image"])) + pprint.pprint(device.reset(path=["conntrack-sync", "internal-cache"])) + + # Save and reload the running configuration to/from a file on the device. + pprint.pprint(device.config_file_save(file="/config/test300.config")) + pprint.pprint(device.config_file_load(file="/config/test300.config")) + + # Batch multiple configuration operations in a single request. + pprint.pprint( + device.configure_multiple_op( + path=[ + { + "op": "set", + "path": [ + "interfaces", "dummy", "dum1", "address", + "192.168.56.100/24", + ], + }, + {"op": "delete", "path": ["interfaces", "dummy", "dum1"]}, + ] + ) + ) + + +if __name__ == "__main__": + main() diff --git a/vagrant/.env.example b/examples/vagrant/.env.example similarity index 100% rename from vagrant/.env.example rename to examples/vagrant/.env.example diff --git a/vagrant/VAGRANT.md b/examples/vagrant/VAGRANT.md similarity index 100% rename from vagrant/VAGRANT.md rename to examples/vagrant/VAGRANT.md diff --git a/vagrant/Vagrantfile b/examples/vagrant/Vagrantfile similarity index 100% rename from vagrant/Vagrantfile rename to examples/vagrant/Vagrantfile diff --git a/vagrant/virtualbox-wsl2.sh b/examples/vagrant/virtualbox-wsl2.sh similarity index 100% rename from vagrant/virtualbox-wsl2.sh rename to examples/vagrant/virtualbox-wsl2.sh 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/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/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" }, -] From 4c19d27c2cf87176dfb983ccc65f2c817ff1f650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Bert=C3=B3?= <463349+robertoberto@users.noreply.github.com> Date: Tue, 19 May 2026 02:57:11 +0000 Subject: [PATCH 05/12] docs: correct README and example to match actual API - configure_multiple_op uses op_path=, not path= - ApiResponse.result is dict | list | str | None (varies per endpoint), not just dict | list - VYDEVICE_VERIFY_SSL parsing accepts 1/true/yes - describe logging accurately: log records only carry structural fields (no payload, no key); sanitization applies to ApiResponse.request, not to logs; do not claim a NullHandler is attached (none is) - shims at 1.0.0: phrase as a decision deferred to release time --- README.md | 30 ++++++++++++++++++------------ examples/basic.py | 2 +- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 9f9f727..be28fa0 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ device = VyDevice( apikey=os.environ["VYDEVICE_APIKEY"], port=int(os.environ.get("VYDEVICE_PORT", "443")), protocol=os.environ.get("VYDEVICE_PROTOCOL", "https"), - verify=os.environ.get("VYDEVICE_VERIFY_SSL", "true").lower() == "true", + verify=os.environ.get("VYDEVICE_VERIFY_SSL", "true").lower() in ("1", "true", "yes"), ) response = device.show(path=["system", "image"]) @@ -80,12 +80,16 @@ All methods return an `ApiResponse` dataclass with four fields: ```python @dataclass class ApiResponse: - status: int # HTTP status code - request: dict # the request payload (API key removed) - result: dict | list # parsed `data` field from the response - error: str | bool # error message, or False on success + status: int # HTTP status code + request: dict # the request payload (API key redacted) + result: dict | list | str | None # parsed `data` field from the response + error: str | bool # error message, or False on success ``` +`result` varies per endpoint: configuration retrieval returns a `dict` or +`list`, operational commands like `show`/`generate` often return a `str`, +and some endpoints return `None`. + The recommended usage pattern is: ```python @@ -100,7 +104,7 @@ do_something_with(response.result) ```python device.configure_set(path=["interfaces", "ethernet", "eth0", "address", "192.0.2.1/24"]) device.configure_delete(path=["interfaces", "dummy", "dum1"]) -device.configure_multiple_op(path=[ +device.configure_multiple_op(op_path=[ {"op": "set", "path": ["interfaces", "dummy", "dum2", "address", "203.0.113.1/24"]}, {"op": "delete", "path": ["interfaces", "dummy", "dum1"]}, ]) @@ -169,7 +173,7 @@ The deprecation timeline is: | `0.4.x` | Compatibility shims work without warnings. | | `0.5.x` | Internal solidity work; shims still silent. | | `0.6.x` | Compatibility shims emit a `DeprecationWarning`. | -| `1.0.0` | Shims are removed or kept, depending on observed usage. | +| `1.0.0` | Final shim behaviour decided before release, based on observed usage and maintenance cost. | ## Examples @@ -178,9 +182,7 @@ and a Vagrant-based lab setup under [`examples/vagrant/`](examples/vagrant/). ## Logging -`pyvyos` uses the standard `logging` module under the `pyvyos` namespace and -attaches a `NullHandler` so a default install does not print anything. - +`pyvyos` uses the standard `logging` module under the `pyvyos` namespace. To see request/response activity, configure the logger in your application: ```python @@ -189,8 +191,12 @@ logging.basicConfig(level=logging.INFO) logging.getLogger("pyvyos").setLevel(logging.DEBUG) ``` -Request payloads are sanitised before logging — the `key` field is replaced -with `***REDACTED***`. +Log records contain structural fields only (`command`, `op`, `status`, +`elapsed_ms`) and never include the request payload or the API key. + +The request payload returned via `ApiResponse.request` is sanitised — the +`key` field is replaced with `***REDACTED***` before the response is +handed back to the caller. ## VyOS compatibility diff --git a/examples/basic.py b/examples/basic.py index 3a275f4..c133d41 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -69,7 +69,7 @@ def main() -> None: # Batch multiple configuration operations in a single request. pprint.pprint( device.configure_multiple_op( - path=[ + op_path=[ { "op": "set", "path": [ From b2f1765d286502307ea07a62648718d0414e4f39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Bert=C3=B3?= <463349+robertoberto@users.noreply.github.com> Date: Tue, 19 May 2026 03:02:52 +0000 Subject: [PATCH 06/12] docs: split examples into read-only basic and guarded smoke The previous examples/basic.py ran destructive operations (configure_set, configure_delete, generate, reset, config_file_load) by default. That is not what a public 'basic' example should do. - examples/basic.py: rewritten as a read-only example (retrieve_show_config, show, retrieve_return_values), uses a robust env-bool parser, prints response.result via a small helper, and uses the supported public import 'from pyvyos import ApiResponse, VyDevice'. - examples/integration_smoke.py: renamed from the old basic.py, keeps the mutating operations, and refuses to run unless PYVYOS_ALLOW_MUTATING_EXAMPLE=1 is set. - README: link both examples and describe what each one does. Note: the dev/ note about a future Docker-based e2e harness is tracked in the unreleased issue drafts; it is not part of this release. --- README.md | 10 +++- examples/basic.py | 80 ++++++++++---------------- examples/integration_smoke.py | 105 ++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 52 deletions(-) create mode 100644 examples/integration_smoke.py diff --git a/README.md b/README.md index be28fa0..96c1ec8 100644 --- a/README.md +++ b/README.md @@ -177,8 +177,14 @@ The deprecation timeline is: ## Examples -A runnable end-to-end example lives in [`examples/basic.py`](examples/basic.py), -and a Vagrant-based lab setup under [`examples/vagrant/`](examples/vagrant/). +- [`examples/basic.py`](examples/basic.py) — read-only end-to-end usage + example. Safe to run against any reachable device. +- [`examples/integration_smoke.py`](examples/integration_smoke.py) — + exercises mutating operations (`configure_set/delete`, `generate`, + `config_file_save/load`). Intended for a disposable lab; guarded by the + `PYVYOS_ALLOW_MUTATING_EXAMPLE=1` environment variable. +- [`examples/vagrant/`](examples/vagrant/) — Vagrant-based VyOS lab for + local development and integration testing. ## Logging diff --git a/examples/basic.py b/examples/basic.py index c133d41..1e1b552 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -1,85 +1,65 @@ -"""Basic pyvyos usage example. +"""Basic read-only pyvyos usage example. -Run with environment variables loaded from a .env file (see ../.env.example) -or exported in your shell: +Run with environment variables loaded from a .env file or exported in your +shell: VYDEVICE_HOSTNAME=192.0.2.1 \\ VYDEVICE_APIKEY=secret \\ python examples/basic.py + +This example only runs read-only commands. """ import os import pprint -import random -import string from dotenv import load_dotenv -from pyvyos import VyDevice +from pyvyos import ApiResponse, VyDevice load_dotenv() +def env_bool(name: str, default: str = "true") -> bool: + return os.environ.get(name, default).lower() in ("1", "true", "yes") + + def make_device() -> VyDevice: return VyDevice( hostname=os.environ["VYDEVICE_HOSTNAME"], apikey=os.environ["VYDEVICE_APIKEY"], port=int(os.environ.get("VYDEVICE_PORT", "443")), protocol=os.environ.get("VYDEVICE_PROTOCOL", "https"), - verify=os.environ.get("VYDEVICE_VERIFY_SSL", "true").lower() == "true", - timeout=60, + verify=env_bool("VYDEVICE_VERIFY_SSL"), + timeout=int(os.environ.get("VYDEVICE_TIMEOUT", "60")), ) +def print_response(label: str, response: ApiResponse) -> None: + print(f"\n== {label} ==") + if response.error: + print(f"Error {response.status}: {response.error}") + return + + pprint.pprint(response.result) + + def main() -> None: device = make_device() - # Retrieve the running configuration for the system tree. - pprint.pprint(device.retrieve_show_config(path=["system"])) - - # Configure a dummy interface, read it back, then delete it. - pprint.pprint( - device.configure_set( - path=["interfaces", "dummy", "dum1", "address", "192.168.56.100/24"] - ) + print_response( + "Running system configuration", + device.retrieve_show_config(path=["system"]), ) - pprint.pprint( - device.retrieve_return_values( - path=["interfaces", "dummy", "dum1", "address"] - ) - ) - pprint.pprint(device.configure_delete(path=["interfaces", "dummy", "dum1"])) - # Generate a one-shot SSH client key. - randstring = "".join( - random.choice(string.ascii_letters + string.digits) for _ in range(20) - ) - pprint.pprint( - device.generate(path=["ssh", "client-key", f"/tmp/key_{randstring}"]) + print_response( + "System images", + device.show(path=["system", "image"]), ) - # Operational commands. - pprint.pprint(device.show(path=["system", "image"])) - pprint.pprint(device.reset(path=["conntrack-sync", "internal-cache"])) - - # Save and reload the running configuration to/from a file on the device. - pprint.pprint(device.config_file_save(file="/config/test300.config")) - pprint.pprint(device.config_file_load(file="/config/test300.config")) - - # Batch multiple configuration operations in a single request. - pprint.pprint( - device.configure_multiple_op( - op_path=[ - { - "op": "set", - "path": [ - "interfaces", "dummy", "dum1", "address", - "192.168.56.100/24", - ], - }, - {"op": "delete", "path": ["interfaces", "dummy", "dum1"]}, - ] - ) + print_response( + "Interface address values", + device.retrieve_return_values(path=["interfaces"]), ) diff --git a/examples/integration_smoke.py b/examples/integration_smoke.py new file mode 100644 index 0000000..137ca9b --- /dev/null +++ b/examples/integration_smoke.py @@ -0,0 +1,105 @@ +"""Integration smoke test for pyvyos. + +This script changes configuration on the target VyOS device and should only +be used against a disposable lab device, such as the one provided by +``examples/vagrant/``. + +It may create and delete dummy interfaces, generate temporary files on the +device, and save/load configuration files. + +Run only against a lab device:: + + PYVYOS_ALLOW_MUTATING_EXAMPLE=1 \\ + VYDEVICE_HOSTNAME=127.0.0.1 \\ + VYDEVICE_APIKEY=secret \\ + python examples/integration_smoke.py +""" + +import os +import pprint +import random +import string +import sys + +from dotenv import load_dotenv + +from pyvyos import VyDevice + +load_dotenv() + + +def env_bool(name: str, default: str = "true") -> bool: + return os.environ.get(name, default).lower() in ("1", "true", "yes") + + +def make_device() -> VyDevice: + return VyDevice( + hostname=os.environ["VYDEVICE_HOSTNAME"], + apikey=os.environ["VYDEVICE_APIKEY"], + port=int(os.environ.get("VYDEVICE_PORT", "443")), + protocol=os.environ.get("VYDEVICE_PROTOCOL", "https"), + verify=env_bool("VYDEVICE_VERIFY_SSL"), + timeout=int(os.environ.get("VYDEVICE_TIMEOUT", "60")), + ) + + +def main() -> None: + if os.environ.get("PYVYOS_ALLOW_MUTATING_EXAMPLE") != "1": + sys.exit( + "This example mutates the target device. " + "Set PYVYOS_ALLOW_MUTATING_EXAMPLE=1 to run it." + ) + + device = make_device() + + # Retrieve the running configuration for the system tree. + pprint.pprint(device.retrieve_show_config(path=["system"])) + + # Configure a dummy interface, read it back, then delete it. + pprint.pprint( + device.configure_set( + path=["interfaces", "dummy", "dum1", "address", "192.168.56.100/24"] + ) + ) + pprint.pprint( + device.retrieve_return_values( + path=["interfaces", "dummy", "dum1", "address"] + ) + ) + pprint.pprint(device.configure_delete(path=["interfaces", "dummy", "dum1"])) + + # Generate a one-shot SSH client key. + randstring = "".join( + random.choice(string.ascii_letters + string.digits) for _ in range(20) + ) + pprint.pprint( + device.generate(path=["ssh", "client-key", f"/tmp/key_{randstring}"]) + ) + + # Operational commands. + pprint.pprint(device.show(path=["system", "image"])) + pprint.pprint(device.reset(path=["conntrack-sync", "internal-cache"])) + + # Save and reload the running configuration to/from a file on the device. + pprint.pprint(device.config_file_save(file="/config/test300.config")) + pprint.pprint(device.config_file_load(file="/config/test300.config")) + + # Batch multiple configuration operations in a single request. + pprint.pprint( + device.configure_multiple_op( + op_path=[ + { + "op": "set", + "path": [ + "interfaces", "dummy", "dum1", "address", + "192.168.56.100/24", + ], + }, + {"op": "delete", "path": ["interfaces", "dummy", "dum1"]}, + ] + ) + ) + + +if __name__ == "__main__": + main() From 7aa4989061950a336287cd8b581813ccd60b1832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Bert=C3=B3?= <463349+robertoberto@users.noreply.github.com> Date: Tue, 19 May 2026 03:06:11 +0000 Subject: [PATCH 07/12] build: tighten runtime deps and lower Python floor to 3.11 - requires-python: >=3.13 -> >=3.11. The code does not use any 3.13-only feature. typing.List/Dict/Union/Optional throughout, no PEP 695 generics, no Self, no match. Classifiers updated to advertise 3.11/3.12/3.13. - runtime dependencies: trim to requests only. - python-dotenv is only used by examples; move to the dev extra. - urllib3 is never imported by pyvyos directly; it remains available transitively through requests. - pre-existing dev tooling (pytest, pytest-cov, pytest-env) untouched. - CI: PR validation runs on a 3.11/3.12/3.13 matrix. - README: reword the Python requirement. - .python-version: 3.13.0 -> 3.13 (CI uses the matrix). - Wheel build verified to include pyvyos/py.typed and to declare Requires-Python: >=3.11 with requests as the only runtime requirement. --- .github/workflows/python-pr-validation.yml | 12 ++++++++---- .python-version | 2 +- CHANGELOG.md | 9 +++++++++ README.md | 2 +- pyproject.toml | 7 ++++--- 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/.github/workflows/python-pr-validation.yml b/.github/workflows/python-pr-validation.yml index 611372b..081f7a8 100644 --- a/.github/workflows/python-pr-validation.yml +++ b/.github/workflows/python-pr-validation.yml @@ -10,14 +10,18 @@ permissions: jobs: validate: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up Python 3.13 + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: - python-version: "3.13" + python-version: ${{ matrix.python-version }} - name: Install uv uses: astral-sh/setup-uv@v3 @@ -25,7 +29,7 @@ jobs: enable-cache: true - name: Install dependencies - run: uv sync --extra dev + run: uv sync --extra dev --python ${{ matrix.python-version }} - name: Run tests - run: uv run pytest -v + run: uv run --python ${{ matrix.python-version }} pytest -v 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/CHANGELOG.md b/CHANGELOG.md index 13bc332..293a942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,15 @@ behavior**. - Documented public API stability and deprecation timeline in `README.md`. ### Changed +- `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 diff --git a/README.md b/README.md index 96c1ec8..83dbd90 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ tooling, and integrations with configuration management systems. pip install pyvyos ``` -Requires **Python 3.13 or newer**. +Requires **Python 3.11 or newer**. Tested on 3.11, 3.12, and 3.13. ## Quick start diff --git a/pyproject.toml b/pyproject.toml index 1f35384..b266351 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ authors = [ ] 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 = [ @@ -20,6 +20,8 @@ classifiers = [ "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", @@ -27,12 +29,11 @@ classifiers = [ ] dependencies = [ "requests>=2.32.0,<3.0", - "python-dotenv>=1.0.1,<2.0", - "urllib3>=2.5.0", ] [project.optional-dependencies] dev = [ + "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", From c5a2ae114835791de16c5b982632569377c39bfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Bert=C3=B3?= <463349+robertoberto@users.noreply.github.com> Date: Tue, 19 May 2026 03:20:53 +0000 Subject: [PATCH 08/12] ci: pin uv cache key to pyproject.toml The matrix job in commit 7aa4989 left astral-sh/setup-uv@v3 with its default cache fingerprint of "**/uv.lock". This repo is a library and intentionally does not commit uv.lock (commit a2df706), so the glob matches nothing and the action errors out. Switch the fingerprint to pyproject.toml, the actual source of truth for dependencies. Cache hits remain across PRs that don't touch dependencies; cache invalidates on dependency changes. --- .github/workflows/python-pr-validation.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/python-pr-validation.yml b/.github/workflows/python-pr-validation.yml index 081f7a8..349fe99 100644 --- a/.github/workflows/python-pr-validation.yml +++ b/.github/workflows/python-pr-validation.yml @@ -27,6 +27,7 @@ jobs: uses: astral-sh/setup-uv@v3 with: enable-cache: true + cache-dependency-glob: "pyproject.toml" - name: Install dependencies run: uv sync --extra dev --python ${{ matrix.python-version }} From bfcaffe9c55b687038eab337a718ea0a43f5e668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Bert=C3=B3?= <463349+robertoberto@users.noreply.github.com> Date: Tue, 19 May 2026 06:05:43 +0000 Subject: [PATCH 09/12] tests: add live VyOS e2e harness on Proxmox Adds an opt-in end-to-end harness that runs pyvyos against a real VyOS HTTPS API on a Proxmox VE host. tests/pve/ shell-based VM lifecycle on a remote PVE host: preflight, ensure-template (state-machine over the VMID with manual-install phase 1 and cloud-init phase 2), create/start/stop/destroy, run-e2e. Cloud-init seed ISO is generated on the PVE host itself; nothing local-side beyond ssh is required. .env is gitignored; .env.example documents the full set of variables. tests/e2e/ pytest suite that exercises the public methods most likely to regress on a payload change: show, retrieve_show_config, configure_set / retrieve_return_values / configure_delete round trip, and configure_multiple_op batch. Auto-skipped unless PYVYOS_E2E=1. The cloud-init template now sets 'service https api rest' before the API key. Without that flag VyOS only exposes /info; the other HTTPS routes return 404. README and tests/pve/README document the requirement, both for cloud-init and for the manual-fallback path. Also fixes a pre-existing footgun in pyproject.toml: the pytest-env defaults overwrote VYDEVICE_HOSTNAME from the shell, which made the e2e tests silently aim at the stale 192.168.56.100 fixture host. The entries now use the 'D:' (default) prefix so live runs can override from the environment as expected. Validated against VyOS rolling 2026.05.18-0045: 4 e2e + 57 unit tests pass. This commit does not change pyvyos HTTP payloads, request handling, or response parsing. --- .gitignore | 1 + CHANGELOG.md | 9 ++ README.md | 34 ++++- pyproject.toml | 11 +- tests/e2e/__init__.py | 0 tests/e2e/conftest.py | 96 +++++++++++++ tests/e2e/test_vydevice_live.py | 71 ++++++++++ tests/pve/.env.example | 70 ++++++++++ tests/pve/README.md | 162 ++++++++++++++++++++++ tests/pve/_lib.sh | 128 +++++++++++++++++ tests/pve/cloud-init/meta-data.template | 2 + tests/pve/cloud-init/user-data.template | 14 ++ tests/pve/create-vm.sh | 42 ++++++ tests/pve/destroy-vm.sh | 12 ++ tests/pve/ensure-template.sh | 175 ++++++++++++++++++++++++ tests/pve/preflight.sh | 45 ++++++ tests/pve/run-e2e.sh | 26 ++++ tests/pve/start-vm.sh | 10 ++ tests/pve/stop-vm.sh | 10 ++ 19 files changed, 908 insertions(+), 10 deletions(-) create mode 100644 tests/e2e/__init__.py create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/test_vydevice_live.py create mode 100644 tests/pve/.env.example create mode 100644 tests/pve/README.md create mode 100755 tests/pve/_lib.sh create mode 100644 tests/pve/cloud-init/meta-data.template create mode 100644 tests/pve/cloud-init/user-data.template create mode 100755 tests/pve/create-vm.sh create mode 100755 tests/pve/destroy-vm.sh create mode 100755 tests/pve/ensure-template.sh create mode 100755 tests/pve/preflight.sh create mode 100755 tests/pve/run-e2e.sh create mode 100755 tests/pve/start-vm.sh create mode 100755 tests/pve/stop-vm.sh diff --git a/.gitignore b/.gitignore index 3f617ed..5839e48 100644 --- a/.gitignore +++ b/.gitignore @@ -161,6 +161,7 @@ cython_debug/ examples/vagrant/.vagrant +tests/pve/.env packer/ *.code-workspace .env diff --git a/CHANGELOG.md b/CHANGELOG.md index 293a942..411fa9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ behavior**. - 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 - `requires-python` lowered to `>=3.11` (was `>=3.13`). The codebase @@ -86,6 +90,11 @@ behavior**. ### Fixed - `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`. ### Compatibility - Public imports are unchanged: diff --git a/README.md b/README.md index 83dbd90..1f9ce19 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,15 @@ Requires **Python 3.11 or newer**. Tested on 3.11, 3.12, and 3.13. 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 ``` +> 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). + Then, from Python: ```python @@ -206,13 +211,22 @@ handed back to the caller. ## VyOS compatibility -Tested against: +Tested live against: + +- VyOS rolling `2026.05.18-0045` (current rolling at release time) -- VyOS 1.4 LTS (stable) -- VyOS 1.5 rolling +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. -The library only depends on the HTTPS API surface, so versions that expose -the same endpoints should work without changes. +Required device-side configuration for the live API: + +```text +set service https api rest +set service https api keys id key '' +``` ## Development @@ -231,6 +245,16 @@ 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 diff --git a/pyproject.toml b/pyproject.toml index b266351..5c3885a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ + "pytest>=9.0.3", "requests>=2.32.0,<3.0", ] @@ -56,11 +57,11 @@ filterwarnings = [ "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] diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 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 # 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 ` 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 <&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" From 62aa8b885e45ad9cd8f8ce5d2a48b4aeff82e502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Bert=C3=B3?= <463349+robertoberto@users.noreply.github.com> Date: Tue, 19 May 2026 06:08:52 +0000 Subject: [PATCH 10/12] ci: bump actions off Node.js 20 GitHub deprecation notice: Node.js 20 will be removed from runners in September 2026 and forced to Node 24 in June 2026. Pin to current majors that already run on Node 24. - actions/checkout@v4 -> v6 - actions/setup-python@v5 -> v6 - astral-sh/setup-uv@v3 -> v8 - pypa/gh-action-pypi-publish@v1.13.0 -> v1.14.0 - pre-commit/pre-commit-hooks@v5.0.0 -> v6.0.0 This commit does not change pyvyos HTTP payloads, request handling, or response parsing. --- .github/workflows/python-pr-validation.yml | 6 +++--- .github/workflows/python-publish.yml | 6 +++--- .pre-commit-config.yaml | 2 +- CHANGELOG.md | 7 +++++-- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/python-pr-validation.yml b/.github/workflows/python-pr-validation.yml index 349fe99..1123ee3 100644 --- a/.github/workflows/python-pr-validation.yml +++ b/.github/workflows/python-pr-validation.yml @@ -16,15 +16,15 @@ jobs: python-version: ["3.11", "3.12", "3.13"] steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v8 with: enable-cache: true cache-dependency-glob: "pyproject.toml" diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 57189af..2ebf94e 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@v6 - name: Set up Python 3.13 - uses: actions/setup-python@v5.0.0 + uses: actions/setup-python@v6 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@v1.14.0 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7dcae7d..4d771e1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer diff --git a/CHANGELOG.md b/CHANGELOG.md index 411fa9b..e3a4391 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,8 +51,11 @@ behavior**. - removed duplicated dependency blocks, - dropped the unused `validation` extra, - raised the pytest floor for Python 3.13. -- PR validation workflow upgraded to `actions/checkout@v4`, - `actions/setup-python@v5`, and `astral-sh/setup-uv@v3`. +- PR validation workflow upgraded to `actions/checkout@v6`, + `actions/setup-python@v6`, and `astral-sh/setup-uv@v8`. Publish + workflow upgraded to `actions/checkout@v6`, `actions/setup-python@v6`, + and `pypa/gh-action-pypi-publish@v1.14.0`. Pre-commit hooks bumped to + `pre-commit/pre-commit-hooks@v6.0.0`. All move off Node.js 20. - `.env.example` default flipped to `VYDEVICE_VERIFY_SSL=true`; commented with field descriptions. From 4080faf81dcfe248394643cfce14112f3346b62f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Bert=C3=B3?= <463349+robertoberto@users.noreply.github.com> Date: Tue, 19 May 2026 06:10:07 +0000 Subject: [PATCH 11/12] ci: pin setup-uv to v8.1.0 (no floating v8 tag yet) --- .github/workflows/python-pr-validation.yml | 2 +- CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-pr-validation.yml b/.github/workflows/python-pr-validation.yml index 1123ee3..d4e55de 100644 --- a/.github/workflows/python-pr-validation.yml +++ b/.github/workflows/python-pr-validation.yml @@ -24,7 +24,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install uv - uses: astral-sh/setup-uv@v8 + uses: astral-sh/setup-uv@v8.1.0 with: enable-cache: true cache-dependency-glob: "pyproject.toml" diff --git a/CHANGELOG.md b/CHANGELOG.md index e3a4391..e0a915d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,7 @@ behavior**. - dropped the unused `validation` extra, - raised the pytest floor for Python 3.13. - PR validation workflow upgraded to `actions/checkout@v6`, - `actions/setup-python@v6`, and `astral-sh/setup-uv@v8`. Publish + `actions/setup-python@v6`, and `astral-sh/setup-uv@v8.1.0`. Publish workflow upgraded to `actions/checkout@v6`, `actions/setup-python@v6`, and `pypa/gh-action-pypi-publish@v1.14.0`. Pre-commit hooks bumped to `pre-commit/pre-commit-hooks@v6.0.0`. All move off Node.js 20. From 6071528289e4a8b11a772433c33851136d30f133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Bert=C3=B3?= <463349+robertoberto@users.noreply.github.com> Date: Tue, 19 May 2026 06:14:08 +0000 Subject: [PATCH 12/12] ci: pin actions by full commit SHA Per OpenSSF Scorecard 'Pinned-Dependencies' guidance, third-party GitHub Actions should be referenced by full commit SHA, not by tag. Tags are mutable; a SHA is not. Each pin keeps a '# vX.Y.Z' trailing comment so Dependabot can read the current version and propose updates while still pinning by SHA. - actions/checkout de0fac2 # v6.0.2 - actions/setup-python a309ff8 # v6.2.0 - astral-sh/setup-uv 0880764 # v8.1.0 - pypa/gh-action-pypi-publish cef2210 # v1.14.0 This commit does not change pyvyos HTTP payloads, request handling, or response parsing. --- .github/workflows/python-pr-validation.yml | 6 +++--- .github/workflows/python-publish.yml | 6 +++--- CHANGELOG.md | 13 ++++++++----- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.github/workflows/python-pr-validation.yml b/.github/workflows/python-pr-validation.yml index d4e55de..5defe21 100644 --- a/.github/workflows/python-pr-validation.yml +++ b/.github/workflows/python-pr-validation.yml @@ -16,15 +16,15 @@ jobs: python-version: ["3.11", "3.12", "3.13"] steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true cache-dependency-glob: "pyproject.toml" diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 2ebf94e..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@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Python 3.13 - uses: actions/setup-python@v6 + 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.14.0 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index e0a915d..28c5e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,11 +51,14 @@ behavior**. - removed duplicated dependency blocks, - dropped the unused `validation` extra, - raised the pytest floor for Python 3.13. -- PR validation workflow upgraded to `actions/checkout@v6`, - `actions/setup-python@v6`, and `astral-sh/setup-uv@v8.1.0`. Publish - workflow upgraded to `actions/checkout@v6`, `actions/setup-python@v6`, - and `pypa/gh-action-pypi-publish@v1.14.0`. Pre-commit hooks bumped to - `pre-commit/pre-commit-hooks@v6.0.0`. All move off Node.js 20. +- 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.