diff options
71 files changed, 3165 insertions, 688 deletions
@@ -166,3 +166,5 @@ packer/ .env dev/ .idea + +docs/vyos-documentation/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..15b9314 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,54 @@ +# Changelog + +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 + +### 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` + +### 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 + +### 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 + +### Security +- API keys are automatically redacted in logs and response payloads + +### 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 + +## [0.3.0] - 2024-XX-XX + +### Added +- Initial release +- Core functionality for VyOS REST API interaction +- Support for 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.3.0]: https://github.com/vyos-contrib/pyvyos/releases/tag/v0.3.0 + diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..3cd24bb --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,88 @@ +# API Reference + +Complete reference for all PyVyOS classes and methods. + +## VyDevice Class + +Main class for interacting with VyOS devices. + +### Constructor + +```python +VyDevice(hostname: str, apikey: str, protocol: "http"|"https" = "https", + port: int = 443, verify: bool = True, timeout: int = 10) +``` + +**Parameters:** `hostname`, `apikey`, `protocol`, `port` (1-65535), `verify`, `timeout` + +### Configuration Methods + +- `configure_set(path)`: Set configuration values +- `configure_delete(path)`: Delete configuration values +- `configure_multiple_op(op_path)`: Execute multiple operations atomically + +```python +device.configure_set(path=["interfaces", "ethernet", "eth0", "address", "192.168.1.1/24"]) +device.configure_delete(path=["interfaces", "dummy", "dum1"]) +device.configure_multiple_op(op_path=[ + {"op": "set", "path": [...]}, + {"op": "delete", "path": [...]} +]) +``` + +### Retrieval Methods + +- `retrieve_show_config(path)`: Get configuration in show format +- `retrieve_return_values(path)`: Get specific configuration values + +```python +device.retrieve_show_config(path=["system"]) +device.retrieve_return_values(path=["interfaces", "dummy", "dum1", "address"]) +``` + +### Operational Methods + +- `show(path)`: Execute show commands +- `generate(path)`: Generate configuration elements +- `reset(path)`: Reset configuration elements + +```python +device.show(path=["system", "image"]) +device.generate(path=["ssh", "client-key", "/tmp/key"]) +device.reset(path=["conntrack-sync", "internal-cache"]) +``` + +### File Operations + +- `config_file_save(file)`: Save configuration to file +- `config_file_load(file)`: Load configuration from file + +```python +device.config_file_save(file="/config/backup.config") +device.config_file_load(file="/config/backup.config") +``` + +**Note:** The `path` parameter is automatically included as `[]` for config-file operations, as required by the VyOS API. + +### System Operations + +- `reboot(path=["now"])`: Reboot the device +- `poweroff(path=["now"])`: Power off the device + +### Image Management + +- `image_add(url, file, path)`: Add VyOS system image +- `image_delete(name, url, file, path)`: Delete a system image + +## ApiResponse Class + +Response object: `status` (int), `request` (dict, API key removed), `result` (dict), `error` (str|bool) + +```python +response = device.show(path=["system"]) +if response.error: + print(f"Error {response.status}: {response.error}") +else: + print(response.result) +``` + diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 0000000..058d20c --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,88 @@ +# Concepts + +Key concepts and architecture of PyVyOS. + +## Architecture Overview + +PyVyOS is built on a REST client architecture: +- `RestClient`: Base class handling HTTP communication +- `VyDevice`: High-level interface for VyOS operations +- `ApiResponse`: Standardized response structure + +## API Command Structure + +VyOS API uses a hierarchical command structure: +- Commands: `configure`, `retrieve`, `show`, `generate`, `reset` +- Operations: `set`, `delete`, `showConfig`, `returnValues` +- Paths: Array representing configuration hierarchy + +## Path Format + +Paths are always arrays representing the configuration tree: + +```python +# Single path +["interfaces", "ethernet", "eth0", "address", "192.168.1.1/24"] + +# Multiple paths (for configure_set) +[ + ["interfaces", "ethernet", "eth0", "address", "192.168.1.1/24"], + ["interfaces", "ethernet", "eth0", "description", "Management"] +] +``` + +## Response Handling + +All methods return `ApiResponse`: +- Consistent error checking +- Sanitized request payloads (API keys removed) +- Structured result data + +## Operation Types + +### Configuration Operations +- `set`: Add or modify configuration +- `delete`: Remove configuration +- `showConfig`: Get full configuration +- `returnValues`: Get specific values + +### Operational Commands +- `show`: Execute show commands +- `generate`: Generate configurations/keys +- `reset`: Reset state/cache + +## Multi-Operation Requests + +`configure_multiple_op` allows atomic operations: +- Multiple set/delete operations in one API call +- Transaction-like behavior +- Better performance than individual calls + +## Error Handling Strategy + +PyVyOS uses defensive error handling: +- Network errors captured as exceptions +- API errors returned in response.error +- HTTP status codes in response.status + +## Security Considerations + +- API keys never appear in response.request +- SSL verification configurable per connection +- Timeout protection prevents hanging connections + +## Connection Lifecycle + +1. Initialize VyDevice with connection parameters +2. Parameters validated on initialization +3. Each method creates new HTTP request +4. Responses parsed and returned as ApiResponse + +## Best Practices + +- Use environment variables for credentials +- Enable SSL verification in production +- Handle errors explicitly +- Use configure_multiple_op for bulk changes +- Set appropriate timeouts for operations + diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..b6634d2 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,84 @@ +# Configuration + +Guide to configuring PyVyOS for connecting to VyOS devices. + +## Environment Variables + +The recommended way to configure PyVyOS is using environment variables with a `.env` file. + +### Required Variables + +- `VYDEVICE_HOSTNAME`: IP address or hostname of your VyOS device +- `VYDEVICE_APIKEY`: API key for authentication + +### Optional Variables + +- `VYDEVICE_PORT`: API port (default: 443) +- `VYDEVICE_PROTOCOL`: Protocol "http" or "https" (default: https) +- `VYDEVICE_VERIFY_SSL`: "True" or "False" (default: True) + +## .env File Setup + +Create a `.env` file in your project root: + +```bash +VYDEVICE_HOSTNAME=192.168.1.1 +VYDEVICE_APIKEY=your-secret-api-key +VYDEVICE_PORT=443 +VYDEVICE_PROTOCOL=https +VYDEVICE_VERIFY_SSL=False +``` + +## Loading Configuration + +```python +from dotenv import load_dotenv +import os +from pyvyos import VyDevice + +load_dotenv() +device = VyDevice( + hostname=os.getenv('VYDEVICE_HOSTNAME'), + apikey=os.getenv('VYDEVICE_APIKEY'), + port=int(os.getenv('VYDEVICE_PORT', 443)), + protocol=os.getenv('VYDEVICE_PROTOCOL', 'https'), + verify=os.getenv('VYDEVICE_VERIFY_SSL', 'True').lower() == 'true' +) +``` + +## Direct Configuration + +Pass parameters directly: + +```python +device = VyDevice(hostname="192.168.1.1", apikey="your-api-key", + port=443, protocol="https", verify=False, timeout=30) +``` + +## SSL & Timeout + +SSL verification enabled by default. To disable (dev only): + +```python +import urllib3 +urllib3.disable_warnings() +device = VyDevice(..., verify=False, timeout=60) +``` + +## Generating API Key on VyOS + +1. SSH into your VyOS device +2. Run: `configure` +3. Run: `set system api http interface <interface>` +4. Run: `set system api http port <port>` +5. Run: `set system api http api-key <key-name> key <key-value>` +6. Run: `commit` and `save` + +## Security Best Practices + +- Never commit `.env` files to version control +- Use strong API keys +- Enable SSL verification in production +- Restrict API access to specific interfaces/IPs on VyOS +- Rotate API keys regularly + diff --git a/docs/development/architecture.md b/docs/development/architecture.md new file mode 100644 index 0000000..aceda08 --- /dev/null +++ b/docs/development/architecture.md @@ -0,0 +1,77 @@ +# 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 new file mode 100644 index 0000000..ff78ec2 --- /dev/null +++ b/docs/development/quality-and-utils.md @@ -0,0 +1,72 @@ +# Quality, Utils, and Pitfalls + +## Goals +- Reduce regressions and supportability burden +- Consistent error handling and logging +- Clear utilities to avoid duplication + +## Exceptions (typed) +- `SDKError` (base) +- `HttpError(status, message)` +- `ApiError(message, details=None)` # when success=False +- `ValidationError(message)` # client-side validation + +## Logging +- Use `logging.getLogger("pyvyos")` +- Include: op, command, status, elapsed_ms, request_id +- Redact secrets (api key) – via `utils.json.redact_key(data, keys=["key"])` +- Default INFO; DEBUG guarded by env `PYVYOS_DEBUG=1` + +## Timeouts & Retries +- Default timeout: 10s (configurable) +- No implicit retries by default +- Future: retry idempotent ops only (exponential backoff) + +## Security +- `verify=True` by default +- Document `urllib3.disable_warnings()` only for dev +- Never log secrets or full payloads by default + +## Utilities (proposal) +- `utils.paths.build(*segments) -> list[str]` +- `utils.ids.request_id() -> str` +- `utils.json.safe_dumps(obj) -> str` (with redaction) +- `utils.http.timeout(seconds) -> int` (normalize) + +## Validation (optional) +- `specs.commands.*` Pydantic models validate request structures +- Enforce path rules (e.g., config-file requires `path=[]`) +- Gate behind feature flag or optional dependency group + +## Testing +- Unit: small, isolated, no real I/O +- Contract: success/error fixtures per command +- Naming: `test_should_<do>_when_<condition>` +- Use monkeypatch on `_execute_request` + +## Documentation +- Keep developer docs scoped and short (≤100 lines) +- Update api-reference and path rules on changes + +## Style & Types +- Type hints on public APIs, avoid `Any` +- Early returns, no deep nesting, no bare `except` +- Constants for command/operation strings + +## Release Hygiene +- Conventional commits; CHANGELOG generated +- Tag every release; ensure `git push --tags` +- Patch bumps for fixes, minor for features + +## Potential Pitfalls & Fixes +- Path handling inconsistencies → centralize in RestClient +- Logging secrets → redact before logging +- Tight coupling device↔transport → maintain clean core boundaries +- Hidden breaking changes → keep shims until 1.0.0 +- Non-deterministic tests → remove sleeps, use fixed seeds + +## Next Steps (ordered) +1. Introduce `exceptions.py` and wire into RestClient +2. Add `utils/` with `json.py`, `paths.py`, `ids.py` +3. Add optional `specs/` models for high-value commands +4. CI: lint+type-check, codecov, test matrix diff --git a/docs/development/refactor-roadmap.md b/docs/development/refactor-roadmap.md new file mode 100644 index 0000000..eeee879 --- /dev/null +++ b/docs/development/refactor-roadmap.md @@ -0,0 +1,56 @@ +# Refactor Roadmap (Prioritized) + +## Priorities +- P0 (now): safety, compatibility, correctness +- P1 (next): validation, observability, developer UX +- P2 (later): performance, async, resiliency + +## P0 — Immediate +- Stabilize transport: keep `_get_payload(include_empty_path)`; special-case `config-file` +- Keep shims: `pyvyos/device.py` and `pyvyos/rest.py` re-export from `pyvyos/core/*` +- Typed exceptions: introduce `SDKError`, `HttpError`, `ApiError`, `ValidationError` +- Timeouts: ensure sane defaults, expose in `VyDevice` +- Logging: centralize in transport, structured key fields (op, command, status) +- Tests: payload assertions for `config-file` (path: []), regression for others +- Docs: architecture and path rules (done) + +## P1 — Short Term +- Specs (optional): Pydantic models in `pyvyos/specs/commands/*` +- `utils/`: path builders, request_id, safe_json redaction +- Deprecation policy: note in README; warn on internal imports (future only) +- CI: test matrix (Python 3.13), codecov, lint (ruff/flake8), type-check (pyright/mypy) +- Release: conventional commits + CHANGELOG; ensure tags push +- Docs: contributor guide, release playbook (short) + +## P2 — Medium Term +- Async client: `AsyncRestClient` (aiohttp/httpx), opt-in +- Retries & backoff: idempotent ops only; circuit-breaker (future) +- Rate limiting: client-side token bucket (opt-in) +- Caching: read-only `show/retrieve` (TTL) optional +- Batch ops: smarter `configure_multiple_op` planning + +## Migration & Compatibility +- Public API: `from pyvyos import VyDevice, ApiResponse` (stable) +- Internal: prefer `pyvyos.core.*` for new code +- Shims stay until 1.0.0; removal at next major only + +## Testing Plan +- Unit: transport (errors, timeouts), domain (paths) +- Contract: sample JSON fixtures per command +- E2E: optional job against a test VyOS (gated, non-blocking) + +## Release Flow (lean) +- Branch: feature → PR → squash merge +- Pre-release (optional): `-rcX` tags +- Tag + publish: GitHub Action (uv build + PyPI publish) + +## Risks & Mitigations +- Hidden breakages → keep shims, add regression tests +- API drift (VyOS) → specs folder eases updates +- Logging noise → default INFO; debug gated via env +- Security → default `verify=True`, redact secrets in logs + +## Success Criteria +- 0.3.x users unaffected +- New structure adopted by contributors +- Lower maintenance overhead (fewer regressions) diff --git a/docs/development/vyos_api/config-file-load.json b/docs/development/vyos_api/config-file-load.json new file mode 100644 index 0000000..3e1fcef --- /dev/null +++ b/docs/development/vyos_api/config-file-load.json @@ -0,0 +1,2 @@ +{"command":"config-file","operation":"load","description":"Load configuration from a file.","endpoint":"/config-file","method":"POST","request":{"payload":{"data":{"op":"load","path":[],"file":"/config/backup.config"},"key":"your-api-key"},"example":{"op":"load","path":[],"file":"/config/backup.config"}},"response":{"success":{"success":true,"data":null,"error":null},"error":{"success":false,"data":null,"error":"Failed to load configuration file"}},"parameters":{"file":{"type":"string","required":true,"description":"Path to the configuration file to load","example":"/config/backup.config"},"path":{"type":"array","required":false,"description":"Path parameter (required by API, typically empty array)","default":[],"example":[]}},"python_usage":"device.config_file_load(file=\"/config/backup.config\")","notes":"The path parameter is required by the VyOS API even though it's typically empty for load operations."} + diff --git a/docs/development/vyos_api/config-file-save.json b/docs/development/vyos_api/config-file-save.json new file mode 100644 index 0000000..563e0ad --- /dev/null +++ b/docs/development/vyos_api/config-file-save.json @@ -0,0 +1,58 @@ +{ + "command": "config-file", + "operation": "save", + "description": "Save the current configuration to a file.", + "endpoint": "/config-file", + "method": "POST", + "request": { + "payload": { + "data": { + "op": "save", + "path": [], + "file": "/config/backup.config" + }, + "key": "your-api-key" + }, + "example": { + "with_file": { + "op": "save", + "path": [], + "file": "/config/backup.config" + }, + "default_location": { + "op": "save", + "path": [] + } + } + }, + "response": { + "success": { + "success": true, + "data": "", + "error": null + }, + "error": { + "success": false, + "data": null, + "error": "Failed to save configuration" + } + }, + "parameters": { + "file": { + "type": "string", + "required": false, + "description": "Path to the file where configuration will be saved. If not provided, uses default location.", + "example": "/config/backup.config" + }, + "path": { + "type": "array", + "required": false, + "description": "Path parameter (required by API, typically empty array)", + "default": [], + "example": [] + } + }, + "python_usage": "device.config_file_save(file=\"/config/backup.config\")", + "notes": "The path parameter is required by the VyOS API even though it's typically empty for save operations." +} + diff --git a/docs/development/vyos_api/configure-delete.json b/docs/development/vyos_api/configure-delete.json new file mode 100644 index 0000000..f68c5c8 --- /dev/null +++ b/docs/development/vyos_api/configure-delete.json @@ -0,0 +1,2 @@ +{"command":"configure","operation":"delete","description":"Delete configuration values based on a path.","endpoint":"/configure","method":"POST","request":{"payload":{"data":{"op":"delete","path":["interfaces","dummy","dum1"]},"key":"your-api-key"},"example":{"op":"delete","path":["interfaces","dummy","dum1"]}},"response":{"success":{"success":true,"data":{},"error":null},"error":{"success":false,"data":null,"error":"Path does not exist"}},"parameters":{"path":{"type":"array","required":true,"description":"Configuration path to delete as array of strings","example":["interfaces","dummy","dum1"]}},"python_usage":"device.configure_delete(path=[\"interfaces\", \"dummy\", \"dum1\"])"} + diff --git a/docs/development/vyos_api/configure-multiple-op.json b/docs/development/vyos_api/configure-multiple-op.json new file mode 100644 index 0000000..a0d0ab4 --- /dev/null +++ b/docs/development/vyos_api/configure-multiple-op.json @@ -0,0 +1,2 @@ +{"command":"configure","operation":"multiple","description":"Execute multiple configuration operations atomically in a single request.","endpoint":"/configure","method":"POST","request":{"payload":{"data":[{"op":"set","path":["interfaces","dummy","dum1","address","192.168.1.1/24"]},{"op":"delete","path":["interfaces","dummy","old1"]}],"key":"your-api-key"},"example":[{"op":"set","path":["interfaces","dummy","dum1","address","192.168.1.1/24"]},{"op":"set","path":["interfaces","dummy","dum1","description","Test interface"]},{"op":"delete","path":["interfaces","dummy","old1"]}]},"response":{"success":{"success":true,"data":null,"error":null},"error":{"success":false,"data":null,"error":"One or more operations failed"}},"parameters":{"op_path":{"type":"array","required":true,"description":"Array of operation objects, each with 'op' and 'path'","items":{"type":"object","required":["op","path"],"properties":{"op":{"type":"string","enum":["set","delete"]},"path":{"type":"array","items":{"type":"string"}}}},"example":[{"op":"set","path":["interfaces","dummy","dum1","address","192.168.1.1/24"]},{"op":"delete","path":["interfaces","dummy","old1"]}]}},"python_usage":"device.configure_multiple_op(op_path=[{\"op\": \"set\", \"path\": [...]}, {\"op\": \"delete\", \"path\": [...]}])"} + diff --git a/docs/development/vyos_api/configure-set.json b/docs/development/vyos_api/configure-set.json new file mode 100644 index 0000000..e99da8c --- /dev/null +++ b/docs/development/vyos_api/configure-set.json @@ -0,0 +1,2 @@ +{"command":"configure","operation":"set","description":"Set configuration values based on a path. Can accept single or multiple configuration paths.","endpoint":"/configure","method":"POST","request":{"payload":{"data":{"op":"set","path":["interfaces","ethernet","eth0","address","192.168.1.1/24"]},"key":"your-api-key"},"example":{"single_path":{"op":"set","path":["interfaces","ethernet","eth0","address","192.168.1.1/24"]},"multiple_paths":{"op":"set","path":[["interfaces","ethernet","eth0","address","192.168.1.1/24"],["interfaces","ethernet","eth0","description","Management"]]}}},"response":{"success":{"success":true,"data":{},"error":null},"error":{"success":false,"data":null,"error":"Configuration validation failed"}},"parameters":{"path":{"type":"array","required":true,"description":"Configuration path(s) as array of strings or array of path arrays for multiple configurations","examples":[["interfaces","ethernet","eth0","address","192.168.1.1/24"],[["interfaces","ethernet","eth0","address","192.168.1.1/24"],["interfaces","ethernet","eth0","description","Management"]]]}},"python_usage":"device.configure_set(path=[\"interfaces\", \"ethernet\", \"eth0\", \"address\", \"192.168.1.1/24\"])"} + diff --git a/docs/development/vyos_api/generate.json b/docs/development/vyos_api/generate.json new file mode 100644 index 0000000..bae9e16 --- /dev/null +++ b/docs/development/vyos_api/generate.json @@ -0,0 +1,2 @@ +{"command":"generate","operation":"generate","description":"Generate configuration elements such as SSH keys, certificates, etc.","endpoint":"/generate","method":"POST","request":{"payload":{"data":{"op":"generate","path":["ssh","client-key","/tmp/key_random"]},"key":"your-api-key"},"example":{"op":"generate","path":["ssh","client-key","/tmp/key_random"]}},"response":{"success":{"success":true,"data":"Generating public/private rsa key pair.\nYour identification has been saved in /tmp/key_random\nYour public key has been saved in /tmp/key_random.pub","error":null},"error":{"success":false,"data":null,"error":"Generation failed"}},"parameters":{"path":{"type":"array","required":true,"description":"Path specifying what to generate","example":["ssh","client-key","/tmp/key_random"]}},"python_usage":"device.generate(path=[\"ssh\", \"client-key\", \"/tmp/key_random\"])"} + diff --git a/docs/development/vyos_api/image-add.json b/docs/development/vyos_api/image-add.json new file mode 100644 index 0000000..068adbe --- /dev/null +++ b/docs/development/vyos_api/image-add.json @@ -0,0 +1,2 @@ +{"command":"image","operation":"add","description":"Add a VyOS system image from a URL or file.","endpoint":"/image","method":"POST","request":{"payload":{"data":{"op":"add","url":"https://example.com/vyos-image.iso"},"key":"your-api-key"},"example":{"from_url":{"op":"add","url":"https://example.com/vyos-image.iso"},"from_file":{"op":"add","file":"/path/to/local/image.iso"}}},"response":{"success":{"success":true,"data":{"status":"added","image":"vyos-image.iso"},"error":null},"error":{"success":false,"data":null,"error":"Failed to add image"}},"parameters":{"url":{"type":"string","required":false,"description":"URL of the image to download and add","format":"uri","example":"https://example.com/vyos-image.iso"},"file":{"type":"string","required":false,"description":"Local file path to the image","example":"/path/to/local/image.iso"},"path":{"type":"array","required":false,"description":"Additional path parameters","default":[],"example":[]}},"python_usage":"device.image_add(url=\"https://example.com/vyos-image.iso\")","notes":"Either url or file parameter must be provided."} + diff --git a/docs/development/vyos_api/image-delete.json b/docs/development/vyos_api/image-delete.json new file mode 100644 index 0000000..56ab796 --- /dev/null +++ b/docs/development/vyos_api/image-delete.json @@ -0,0 +1,2 @@ +{"command":"image","operation":"delete","description":"Delete a VyOS system image.","endpoint":"/image","method":"POST","request":{"payload":{"data":{"op":"delete","name":"image-name"},"key":"your-api-key"},"example":{"op":"delete","name":"image-name"}},"response":{"success":{"success":true,"data":{"status":"deleted","image":"image-name"},"error":null},"error":{"success":false,"data":null,"error":"Image not found or deletion failed"}},"parameters":{"name":{"type":"string","required":true,"description":"Name of the image to delete","example":"image-name"},"url":{"type":"string","required":false,"description":"Optional URL associated with the image","format":"uri"},"file":{"type":"string","required":false,"description":"Optional file path associated with the image"},"path":{"type":"array","required":false,"description":"Additional path parameters","default":[],"example":[]}},"python_usage":"device.image_delete(name=\"image-name\")"} + diff --git a/docs/development/vyos_api/index.json b/docs/development/vyos_api/index.json new file mode 100644 index 0000000..54a64c7 --- /dev/null +++ b/docs/development/vyos_api/index.json @@ -0,0 +1,2 @@ +{"title":"VyOS API Command Index","description":"Complete index of all VyOS REST API commands documented in this directory","version":"1.0.0","schema":"schema.json","commands":[{"file":"configure-set.json","command":"configure","operation":"set","python_method":"configure_set","description":"Set configuration values"},{"file":"configure-delete.json","command":"configure","operation":"delete","python_method":"configure_delete","description":"Delete configuration values"},{"file":"configure-multiple-op.json","command":"configure","operation":"multiple","python_method":"configure_multiple_op","description":"Execute multiple configuration operations atomically"},{"file":"retrieve-show-config.json","command":"retrieve","operation":"showConfig","python_method":"retrieve_show_config","description":"Retrieve configuration in show format"},{"file":"retrieve-return-values.json","command":"retrieve","operation":"returnValues","python_method":"retrieve_return_values","description":"Retrieve specific configuration values"},{"file":"show.json","command":"show","operation":"show","python_method":"show","description":"Execute show commands"},{"file":"generate.json","command":"generate","operation":"generate","python_method":"generate","description":"Generate configuration elements"},{"file":"reset.json","command":"reset","operation":"reset","python_method":"reset","description":"Reset configuration elements"},{"file":"config-file-save.json","command":"config-file","operation":"save","python_method":"config_file_save","description":"Save configuration to file"},{"file":"config-file-load.json","command":"config-file","operation":"load","python_method":"config_file_load","description":"Load configuration from file"},{"file":"reboot.json","command":"reboot","operation":"reboot","python_method":"reboot","description":"Reboot the device"},{"file":"poweroff.json","command":"poweroff","operation":"poweroff","python_method":"poweroff","description":"Power off the device"},{"file":"image-add.json","command":"image","operation":"add","python_method":"image_add","description":"Add VyOS system image"},{"file":"image-delete.json","command":"image","operation":"delete","python_method":"image_delete","description":"Delete VyOS system image"}],"statistics":{"total_commands":14,"command_types":{"configure":3,"retrieve":2,"show":1,"generate":1,"reset":1,"config-file":2,"reboot":1,"poweroff":1,"image":2}}} + diff --git a/docs/development/vyos_api/poweroff.json b/docs/development/vyos_api/poweroff.json new file mode 100644 index 0000000..1950691 --- /dev/null +++ b/docs/development/vyos_api/poweroff.json @@ -0,0 +1,2 @@ +{"command":"poweroff","operation":"poweroff","description":"Power off the VyOS device.","endpoint":"/poweroff","method":"POST","request":{"payload":{"data":{"op":"poweroff","path":["now"]},"key":"your-api-key"},"example":{"op":"poweroff","path":["now"]}},"response":{"success":{"success":true,"data":{"status":"powering off"},"error":null},"error":{"success":false,"data":null,"error":"Power off operation failed"}},"parameters":{"path":{"type":"array","required":false,"description":"Power off parameters, typically ['now']","default":["now"],"example":["now"]}},"python_usage":"device.poweroff(path=[\"now\"])","warning":"This operation will immediately power off the device."} + diff --git a/docs/development/vyos_api/reboot.json b/docs/development/vyos_api/reboot.json new file mode 100644 index 0000000..3ca92da --- /dev/null +++ b/docs/development/vyos_api/reboot.json @@ -0,0 +1,2 @@ +{"command":"reboot","operation":"reboot","description":"Reboot the VyOS device.","endpoint":"/reboot","method":"POST","request":{"payload":{"data":{"op":"reboot","path":["now"]},"key":"your-api-key"},"example":{"op":"reboot","path":["now"]}},"response":{"success":{"success":true,"data":{"status":"rebooting"},"error":null},"error":{"success":false,"data":null,"error":"Reboot operation failed"}},"parameters":{"path":{"type":"array","required":false,"description":"Reboot parameters, typically ['now']","default":["now"],"example":["now"]}},"python_usage":"device.reboot(path=[\"now\"])","warning":"This operation will immediately reboot the device."} + diff --git a/docs/development/vyos_api/reset.json b/docs/development/vyos_api/reset.json new file mode 100644 index 0000000..8418456 --- /dev/null +++ b/docs/development/vyos_api/reset.json @@ -0,0 +1,2 @@ +{"command":"reset","operation":"reset","description":"Reset configuration elements or system state.","endpoint":"/reset","method":"POST","request":{"payload":{"data":{"op":"reset","path":["conntrack-sync","internal-cache"]},"key":"your-api-key"},"example":{"op":"reset","path":["conntrack-sync","internal-cache"]}},"response":{"success":{"success":true,"data":"conntrack-sync is not configured!","error":null},"error":{"success":false,"data":null,"error":"Reset operation failed"}},"parameters":{"path":{"type":"array","required":false,"description":"Path specifying what to reset","default":[],"example":["conntrack-sync","internal-cache"]}},"python_usage":"device.reset(path=[\"conntrack-sync\", \"internal-cache\"])"} + diff --git a/docs/development/vyos_api/retrieve-return-values.json b/docs/development/vyos_api/retrieve-return-values.json new file mode 100644 index 0000000..c827b5d --- /dev/null +++ b/docs/development/vyos_api/retrieve-return-values.json @@ -0,0 +1,2 @@ +{"command":"retrieve","operation":"returnValues","description":"Retrieve and return specific configuration values for a given path.","endpoint":"/retrieve","method":"POST","request":{"payload":{"data":{"op":"returnValues","path":["interfaces","dummy","dum1","address"]},"key":"your-api-key"},"example":{"op":"returnValues","path":["interfaces","dummy","dum1","address"]}},"response":{"success":{"success":true,"data":["192.168.56.100/24"],"error":null},"error":{"success":false,"data":null,"error":"Path not found"}},"parameters":{"path":{"type":"array","required":true,"description":"Configuration path to retrieve values from","example":["interfaces","dummy","dum1","address"]}},"python_usage":"device.retrieve_return_values(path=[\"interfaces\", \"dummy\", \"dum1\", \"address\"])"} + diff --git a/docs/development/vyos_api/retrieve-show-config.json b/docs/development/vyos_api/retrieve-show-config.json new file mode 100644 index 0000000..bf5231f --- /dev/null +++ b/docs/development/vyos_api/retrieve-show-config.json @@ -0,0 +1,2 @@ +{"command":"retrieve","operation":"showConfig","description":"Retrieve and show the device configuration in show format.","endpoint":"/retrieve","method":"POST","request":{"payload":{"data":{"op":"showConfig","path":["system"]},"key":"your-api-key"},"example":{"op":"showConfig","path":["system"]}},"response":{"success":{"success":true,"data":{"config-management":{"commit-revisions":"100"},"host-name":"vyos-device","name-server":"eth0"},"error":null},"error":{"success":false,"data":null,"error":"Invalid path"}},"parameters":{"path":{"type":"array","required":false,"description":"Configuration path to retrieve, empty array for full config","default":[],"example":["system"]}},"python_usage":"device.retrieve_show_config(path=[\"system\"])"} + diff --git a/docs/development/vyos_api/schema.json b/docs/development/vyos_api/schema.json new file mode 100644 index 0000000..fb125b1 --- /dev/null +++ b/docs/development/vyos_api/schema.json @@ -0,0 +1,2 @@ +{"$schema":"http://json-schema.org/draft-07/schema#","$id":"https://github.com/vyos-contrib/pyvyos/docs/vyos-api/schema.json","title":"VyOS API Command Schema","description":"Schema definition for VyOS REST API commands","type":"object","definitions":{"apiRequest":{"type":"object","required":["command","op"],"properties":{"command":{"type":"string","description":"API endpoint command","enum":["configure","retrieve","show","generate","reset","config-file","reboot","poweroff","image"]},"op":{"type":"string","description":"Operation type","enum":["set","delete","showConfig","returnValues","show","generate","reset","save","load","reboot","poweroff","add","delete",""]},"path":{"oneOf":[{"type":"array","description":"Single configuration path","items":{"type":"string"}},{"type":"array","description":"Multiple configuration paths","items":{"type":"array","items":{"type":"string"}}},{"type":"array","description":"Multiple operations with paths","items":{"type":"object","required":["op","path"],"properties":{"op":{"type":"string","enum":["set","delete"]},"path":{"type":"array","items":{"type":"string"}}}]}]},"file":{"type":"string","description":"File path for file operations"},"url":{"type":"string","description":"URL for external resources","format":"uri"},"name":{"type":"string","description":"Resource name identifier"},"method":{"type":"string","description":"HTTP method","enum":["GET","POST","PUT","DELETE"],"default":"POST"}}},"apiResponse":{"type":"object","required":["success","data","error"],"properties":{"success":{"type":"boolean","description":"Whether the operation succeeded"},"data":{"description":"Response data (type varies by operation)","oneOf":[{"type":"object"},{"type":"array"},{"type":"string"},{"type":"null"}]},"error":{"oneOf":[{"type":"null"},{"type":"string"}],"description":"Error message if operation failed"}}}},"properties":{"request":{"$ref":"#/definitions/apiRequest"},"response":{"$ref":"#/definitions/apiResponse"}}} + diff --git a/docs/development/vyos_api/show.json b/docs/development/vyos_api/show.json new file mode 100644 index 0000000..3576e43 --- /dev/null +++ b/docs/development/vyos_api/show.json @@ -0,0 +1,2 @@ +{"command":"show","operation":"show","description":"Execute show commands to display system information.","endpoint":"/show","method":"POST","request":{"payload":{"data":{"op":"show","path":["system","image"]},"key":"your-api-key"},"example":{"op":"show","path":["system","image"]}},"response":{"success":{"success":true,"data":"Name Default boot Running\n------------------------ -------------- ---------\n1.5-rolling-202407300021 Yes Yes","error":null},"error":{"success":false,"data":null,"error":"Command not found"}},"parameters":{"path":{"type":"array","required":false,"description":"Command path for show operation","default":[],"example":["system","image"]}},"python_usage":"device.show(path=[\"system\", \"image\"])"} + diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..6f4cf29 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,91 @@ +# Examples + +Common usage patterns and examples for PyVyOS. + +## Basic Connection + +```python +from pyvyos import VyDevice +device = VyDevice(hostname="192.168.1.1", apikey="your-api-key", verify=False) +``` + +## Viewing System Information + +```python +response = device.show(path=["system", "image"]) +if not response.error: + print(response.result) +``` + +## Interface Configuration + +Add, view, get, or remove interface configuration: + +```python +# Add address +device.configure_set(path=["interfaces", "ethernet", "eth0", "address", "192.168.1.1/24"]) + +# View configuration +device.retrieve_show_config(path=["interfaces", "ethernet", "eth0"]) + +# Get address value +device.retrieve_return_values(path=["interfaces", "ethernet", "eth0", "address"]) + +# Remove interface +device.configure_delete(path=["interfaces", "dummy", "dum1"]) +``` + +## Multiple Operations + +Execute multiple configuration changes atomically: + +```python +device.configure_multiple_op(op_path=[ + {"op": "set", "path": ["interfaces", "dummy", "dum1", "address", "192.168.1.1/24"]}, + {"op": "set", "path": ["interfaces", "dummy", "dum1", "description", "Test"]}, + {"op": "delete", "path": ["interfaces", "dummy", "old1"]} +]) +``` + +## Configuration Backup + +```python +device.config_file_save(file="/config/backup-2024.config") +device.config_file_load(file="/config/backup-2024.config") +``` + +## SSH Key Generation + +```python +import random, string +randstring = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(20)) +device.generate(path=["ssh", "client-key", f'/tmp/key_{randstring}']) +``` + +## System Operations + +```python +device.reboot(path=["now"]) +device.poweroff(path=["now"]) +``` + +## Error Handling + +```python +response = device.configure_set(path=["invalid", "path"]) +if response.error: + print(f"Error {response.status}: {response.error}") +else: + print("Success:", response.result) +``` + +## Working with Responses + +```python +response = device.show(path=["system"]) +print(f"Status: {response.status}") +if not response.error: + data = response.result # Process data... +print(f"Request: {response.request}") # API key sanitized +``` + diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..183c109 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,77 @@ +# Getting Started + +This guide will help you set up PyVyOS and make your first API call to a VyOS device. + +## Prerequisites + +- VyOS device with REST API enabled +- API key generated on your VyOS device +- Network access to your VyOS device + +## Step 1: Install PyVyOS + +See [Installation](installation.md) for detailed instructions. + +## Step 2: Configure Environment Variables + +Create a `.env` file in your project directory: + +```bash +VYDEVICE_HOSTNAME=192.168.1.1 +VYDEVICE_APIKEY=your-api-key-here +VYDEVICE_PORT=443 +VYDEVICE_PROTOCOL=https +VYDEVICE_VERIFY_SSL=False +``` + +## Step 3: Create Your First Script + +```python +import os +from dotenv import load_dotenv +from pyvyos import VyDevice + +load_dotenv() + +device = VyDevice( + 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', 'True').lower() == 'true' +) + +response = device.show(path=["system", "image"]) +if not response.error: + print(response.result) +else: + print(f"Error: {response.error}") +``` + +## Step 4: Disable SSL Warnings (Optional) + +If using `verify=False`, disable urllib3 warnings: + +```python +import urllib3 +urllib3.disable_warnings() +``` + +## Understanding API Responses + +All methods return an `ApiResponse` object with: +- `status`: HTTP status code +- `request`: Request payload (API key sanitized) +- `result`: Response data dictionary +- `error`: Error message (False if successful) + +## Common Operations + +See [Examples](examples.md) for more detailed usage patterns. + +## Next Steps + +- Read [API Reference](api-reference.md) for complete method documentation +- Explore [Configuration](configuration.md) for advanced setup +- Check [Concepts](concepts.md) to understand PyVyOS architecture + diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..1bc9cb7 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,56 @@ +# PyVyOS Documentation + +PyVyOS is a Python SDK for interacting with VyOS devices via their REST API. This library provides a simple and intuitive interface to manage VyOS network devices programmatically. + +## Overview + +PyVyOS enables developers to: +- Configure VyOS devices remotely +- Retrieve configuration and operational data +- Manage system operations (reboot, poweroff) +- Generate SSH keys and manage images +- Save and load configuration files + +## Documentation Structure + +- [Installation](installation.md) - How to install PyVyOS +- [Getting Started](getting-started.md) - Quick start guide +- [API Reference](api-reference.md) - Complete API documentation +- [Configuration](configuration.md) - Device setup and authentication +- [Examples](examples.md) - Common usage patterns +- [Concepts](concepts.md) - Key concepts and architecture +- [Troubleshooting](troubleshooting.md) - Common issues and solutions + +## Requirements + +- Python 3.13 or higher +- VyOS device with REST API enabled +- API key for authentication + +## Quick Example + +```python +from pyvyos import VyDevice + +device = VyDevice( + hostname="192.168.1.1", + apikey="your-api-key", + port=443, + protocol="https", + verify=False +) + +response = device.show(path=["system", "image"]) +print(response.result) +``` + +## Resources + +- [GitHub Repository](https://github.com/vyos-contrib/pyvyos) +- [PyPI Package](https://pypi.org/project/pyvyos/) +- [Read the Docs](https://pyvyos.readthedocs.io/) + +## License + +MIT License - See LICENSE file for details + diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..e83974e --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,65 @@ +# Installation + +## Prerequisites + +PyVyOS requires Python 3.13 or higher. Make sure you have a compatible Python version installed. + +## Using pip + +The recommended way to install PyVyOS is using pip: + +```bash +pip install pyvyos +``` + +## Using uv + +If you use `uv` for package management: + +```bash +uv add pyvyos +``` + +## From Source + +To install from source: + +```bash +git clone https://github.com/vyos-contrib/pyvyos.git +cd pyvyos +pip install . +``` + +## Verify Installation + +After installation, verify it works: + +```python +from pyvyos import VyDevice +print("PyVyOS installed successfully") +``` + +## Dependencies + +PyVyOS automatically installs: +- `requests>=2.32.0` - HTTP library for API calls +- `python-dotenv>=1.0.1` - Environment variable management +- `urllib3>=2.5.0` - HTTP client utilities + +## Development Installation + +For development with testing support: + +```bash +pip install pyvyos[dev] +``` + +This includes: +- `pytest>=6.2.5` - Testing framework +- `pytest-cov>=4.1` - Coverage reporting +- `pytest-env>=0.6.2` - Environment variable testing + +## Next Steps + +After installation, proceed to [Getting Started](getting-started.md) to configure your first device connection. + diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..3e7f286 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,60 @@ +# Troubleshooting + +Common issues and solutions when using PyVyOS. + +## Connection Issues + +**Cannot Connect:** Verify hostname/IP, network connectivity, API enabled, firewall rules, port number. + +**SSL Errors:** Use `verify=False` for dev (self-signed certs), install CA certs for production. + +## Authentication Problems + +**Invalid API Key:** Verify key is correct, check expiration, ensure permissions, regenerate if needed. + +## Configuration Errors + +**Invalid Path:** Verify path syntax matches VyOS tree, check path exists, use `retrieve_show_config` to inspect. + +**Not Applied:** Call `config_file_save()` after changes, check commit requirements, verify permissions. + +## Response Issues + +**Empty Results:** Check if path has data, verify syntax, try broader path. + +**Error Format:** Always check `response.error` first, inspect `response.status`, log full response. + +## Performance Issues + +**Timeouts:** Increase timeout parameter, check latency, verify device load, break into smaller operations. + +**Slow Operations:** Use `configure_multiple_op` for bulk, avoid short polling, consider async for multiple devices. + +## Environment Setup + +**Variables Not Loading:** Verify `.env` exists, check `load_dotenv()` call, match variable names, use defaults. + +## Debugging Tips + +```python +# Enable verbose logging +import logging +logging.basicConfig(level=logging.DEBUG) + +# Inspect full response +response = device.show(path=["system"]) +print(f"Status: {response.status}, Error: {response.error}") +print(f"Result: {response.result}, Request: {response.request}") + +# Test connection +response = device.show(path=["system", "host-name"]) +if response.error: + print(f"Connection issue: {response.error}") +``` + +## Getting Help + +- Check [API Reference](api-reference.md) for method details +- Review [Examples](examples.md) for usage patterns +- Open issue on [GitHub](https://github.com/vyos-contrib/pyvyos/issues) + diff --git a/docs/vyos-api.md b/docs/vyos-api.md new file mode 100644 index 0000000..33a443f --- /dev/null +++ b/docs/vyos-api.md @@ -0,0 +1,78 @@ +# VyOS API Command Reference + +The JSON definitions for each VyOS REST API command supported by PyVyOS are located in `docs/development/vyos_api/`. + +## Location + +All JSON specification files are stored in: **`docs/development/vyos_api/`** + +These JSONs are reference documentation and are: +- Used for API documentation generation +- Available for validation and testing +- Not packaged with the library (reference only) + +## Files + +- `schema.json` - JSON Schema definition for all API commands +- Individual command files: + - `configure-set.json` - Set configuration values + - `configure-delete.json` - Delete configuration values + - `configure-multiple-op.json` - Execute multiple operations atomically + - `retrieve-show-config.json` - Retrieve configuration in show format + - `retrieve-return-values.json` - Retrieve specific configuration values + - `show.json` - Execute show commands + - `generate.json` - Generate configuration elements (SSH keys, etc.) + - `reset.json` - Reset configuration elements + - `config-file-save.json` - Save configuration to file + - `config-file-load.json` - Load configuration from file + - `reboot.json` - Reboot the device + - `poweroff.json` - Power off the device + - `image-add.json` - Add VyOS system image + - `image-delete.json` - Delete VyOS system image + +## Structure + +Each JSON file follows this structure: + +```json +{ + "command": "command-name", + "operation": "operation-name", + "description": "Description of what the command does", + "endpoint": "/endpoint-path", + "method": "HTTP_METHOD", + "request": { + "payload": { ... }, + "example": { ... } + }, + "response": { + "success": { ... }, + "error": { ... } + }, + "parameters": { ... }, + "python_usage": "device.method_name(...)" +} +``` + +## Usage + +These JSON files can be used for: +- API documentation generation +- Request validation +- Response schema validation +- Client library code generation +- Testing and mocking + +## JSON Schema + +The `schema.json` file defines the complete structure for all API commands and can be used with JSON Schema validators. + +## Path Parameter Handling + +**Important:** The `path` parameter handling varies by command: + +- **config-file commands** (`save`, `load`): The VyOS API requires `path` to be present in the payload, even if empty (`[]`). PyVyOS handles this automatically. +- **Other commands**: When `path` is empty or not provided, it may be omitted from the payload for cleaner requests. + +See individual JSON files in `docs/development/vyos_api/` for specific parameter requirements. + diff --git a/pyproject.toml b/pyproject.toml index 6d3eb7f..e931092 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pyvyos" -version = "0.3.0" +version = "0.4.0" authors = [ { name="Roberto Berto", email="463349+robertoberto@users.noreply.github.com" }, ] @@ -28,6 +28,9 @@ dev = [ "pytest-cov>=4.1,<6.0", "pytest-env>=0.6.2,<0.7", ] +validation = [ + "pydantic>=2.0,<3.0", +] [project.urls] Homepage = "https://github.com/vyos-contrib/pyvyos" @@ -59,4 +62,14 @@ env = [ [tool.coverage.run] omit = [ "tests/*", -]
\ No newline at end of file +] + +[dependency-groups] +dev = [ + "pytest>=8.4.2", + "pytest-cov>=5.0.0", + "pytest-env>=0.6.2", +] + +[tool.uv] +package = true diff --git a/pyvyos/__init__.py b/pyvyos/__init__.py index 44bd563..b0f867f 100644 --- a/pyvyos/__init__.py +++ b/pyvyos/__init__.py @@ -1,2 +1,10 @@ -from .device import VyDevice -from .device import ApiResponse +""" +PyVyOS - Python SDK for VyOS REST API + +Public API exports maintain backward compatibility. +Internal structure has been refactored to pyvyos.core for better organization. +""" +from .core.device import VyDevice +from .core.rest_client import ApiResponse + +__all__ = ["VyDevice", "ApiResponse"] diff --git a/pyvyos/core/__init__.py b/pyvyos/core/__init__.py new file mode 100644 index 0000000..f4b831c --- /dev/null +++ b/pyvyos/core/__init__.py @@ -0,0 +1,7 @@ +"""Core modules for PyVyOS - internal refactored structure.""" + +from .rest_client import ApiResponse, RestClient +from .device import VyDevice + +__all__ = ["ApiResponse", "RestClient", "VyDevice"] + diff --git a/pyvyos/core/device.py b/pyvyos/core/device.py new file mode 100644 index 0000000..6dc21e9 --- /dev/null +++ b/pyvyos/core/device.py @@ -0,0 +1,276 @@ +import warnings +from typing import List, Literal + +from .rest_client import ApiResponse, RestClient + + +class VyDevice(RestClient): + """ + Represents a device for interacting with the VyOS API. + + Args: + hostname (str): The hostname or IP address of the VyOS device. + apikey (str): The API key for authentication. + protocol (str, optional): The protocol to use (default is 'https'). + port (int, optional): The port to use (default is 443). + verify (bool, optional): Whether to verify SSL certificates (default is True). + timeout (int, optional): The request timeout in seconds (default is 10). + + Attributes: + hostname (str): The hostname or IP address of the VyOS device. + apikey (str): The API key for authentication. + protocol (str): The protocol used for communication. + port (int): The port used for communication. + verify (bool): Whether SSL certificate verification is enabled. + timeout (int): The request timeout in seconds. + + Methods: + _get_url(command): Get the full URL for a given API command. + _get_payload(op, path=[], file=None, url=None, name=None): Generate the API request payload. + _api_request(command, op, path=[], method='POST', file=None, url=None, name=None): Make an API request. + retrieve_show_config(path=[]): Retrieve and show the device configuration. + retrieve_return_values(path=[]): Retrieve and return specific configuration values. + reset(path=[]): Reset a specific configuration element. + image_add(url=None, file=None, path=[]): Add an image from a URL or file. + image_delete(name, url=None, file=None, path=[]): Delete a specific image. + show(path=[]): Show configuration information. + generate(path=[]): Generate configuration based on specified path. + configure_set(path=[]): Sets configuration based on the specified path. This method is versatile, accepting + either a single configuration path or a list of configuration paths. This flexibility + allows for setting both individual and multiple configurations in a single operation. + configure_delete(path=[]): Delete configuration based on specified path. + config_file_save(file=None): Save the configuration to a file. + config_file_load(file=None): Load the configuration from a file. + reboot(path=["now"]): Reboot the device. + poweroff(path=["now"]): Power off the device. + """ + + def __init__( + self, + hostname: str, + apikey: str, + protocol: Literal["http", "https"] = "https", + port: int = 443, + verify: bool = True, + timeout: int = 10, + ): + super().__init__( + hostname, apikey, protocol, int(port), bool(verify), int(timeout) + ) + self._validate_params() + + def _validate_params( + self, + ) -> None: + """Validação centralizada de parâmetros""" + if not isinstance(self.hostname, str) or len(self.hostname) < 3: + raise ValueError("Invalid hostname") + + if self.protocol not in ("http", "https"): + raise ValueError("The protocol must be http or https") + + if not 1 <= self.port <= 65535: + raise ValueError("Port out of valid range (1-65535)") + + if self.timeout and self.timeout < 1: + warnings.warn("Timeout below 1s may cause instability", UserWarning) + + def retrieve_show_config(self, path: List = None): + """ + Retrieve and show the device configuration. + + Args: + path (list, optional): The path elements for the configuration retrieval (default is an empty list). + + Returns: + ApiResponse: An ApiResponse object representing the API response. + """ + + return self._api_request( + command="retrieve", op="showConfig", path=path, method="POST" + ) + + def retrieve_return_values(self, path: List = None): + """ + Retrieve and return specific configuration values. + + Args: + path (list, optional): The path elements for the configuration retrieval (default is an empty list). + + Returns: + ApiResponse: An ApiResponse object representing the API response. + """ + return self._api_request( + command="retrieve", op="returnValues", path=path, method="POST" + ) + + def reset(self, path: List = None): + """ + Reset a specific configuration element. + + Args: + path (list, optional): The path elements for the configuration reset (default is an empty list). + + Returns: + ApiResponse: An ApiResponse object representing the API response. + """ + return self._api_request(command="reset", op="reset", path=path, method="POST") + + def image_add(self, url=None, file=None, path=[]): + """ + Add an image from a URL or file. + + Args: + url (str, optional): The URL of the image to add (default is None). + file (str, optional): The path to the local image file to add (default is None). + path (list, optional): The path elements for the image addition (default is an empty list). + + Returns: + ApiResponse: An ApiResponse object representing the API response. + """ + return self._api_request(command="image", op="add", resource_url=url, method="POST") + + def image_delete(self, name, url=None, file=None, path=[]): + """ + Delete a specific image. + + Args: + name (str): The name of the image to delete. + url (str, optional): The URL of the image to delete (default is None). + file (str, optional): The path to the local image file to delete (default is None). + path (list, optional): The path elements for the image deletion (default is an empty list). + + Returns: + ApiResponse: An ApiResponse object representing the API response. + """ + return self._api_request(command="image", op="delete", name=name, method="POST") + + def show(self, path: List = None): + """ + Show configuration information. + + Args: + path (list, optional): The path elements for the configuration display (default is an empty list). + + Returns: + ApiResponse: An ApiResponse object representing the API response. + """ + return self._api_request(command="show", op="show", path=path, method="POST") + + def generate(self, path: List = None): + """ + Generate configuration based on the given path. + + Args: + path (list, optional): The path elements for configuration generation (default is an empty list). + + Returns: + ApiResponse: An ApiResponse object representing the API response. + """ + return self._api_request( + command="generate", op="generate", path=path, method="POST" + ) + + def configure_set(self, path: List = None): + """ + Set configuration based on the given path. + + Args: + path (list, optional): The path elements for configuration setting (default is an empty list). + + Returns: + ApiResponse: An ApiResponse object representing the API response. + """ + return self._api_request( + command="configure", op="set", path=path, method="POST" + ) + + def configure_delete(self, path: List = None): + """ + Delete configuration based on the given path. + + Args: + path (list, optional): The path elements for configuration deletion (default is an empty list). + + Returns: + ApiResponse: An ApiResponse object representing the API response. + """ + return self._api_request( + command="configure", op="delete", path=path, method="POST" + ) + + def configure_multiple_op(self, op_path: List = None): + """ + Set configuration based on the given {operation : path} for multiple operation. + + Args: + op_path (list): The path elements for configuration deletion or/and setting. + eg: [{'op': 'delete', 'path': [...]}, {'op': 'set', 'path': [...]}] + + Returns: + ApiResponse: An ApiResponse object representing the API response. + """ + return self._api_request(command="configure", op="", path=op_path) + + def config_file_save(self, file=None): + """ + Save the configuration to a file. + + Args: + file (str, optional): The path to the file where the configuration will be saved (default is None). + + Returns: + ApiResponse: An ApiResponse object representing the API response. + """ + return self._api_request( + command="config-file", op="save", path=[], file=file, method="POST" + ) + + def config_file_load(self, file=None): + """ + Load the configuration from a file. + + Args: + file (str, optional): The path to the file from which the configuration will be loaded (default is None). + + Returns: + ApiResponse: An ApiResponse object representing the API response. + """ + return self._api_request( + command="config-file", op="load", path=[], file=file, method="POST" + ) + + def reboot(self, path: List = None): + """ + Reboot the device. + + Args: + path (list, optional): The path elements for the reboot operation (default is ["now"]). + + Returns: + ApiResponse: An ApiResponse object representing the API response. + """ + if path is None: + path = ["now"] + + return self._api_request( + command="reboot", op="reboot", path=path, method="POST" + ) + + def poweroff(self, path: List = None): + """ + Power off the device. + + Args: + path (list, optional): The path elements for the power off operation (default is ["now"]). + + Returns: + ApiResponse: An ApiResponse object representing the API response. + """ + if path is None: + path = ["now"] + + return self._api_request( + command="poweroff", op="poweroff", path=path, method="POST" + ) + diff --git a/pyvyos/core/rest_client.py b/pyvyos/core/rest_client.py new file mode 100644 index 0000000..eb77d9b --- /dev/null +++ b/pyvyos/core/rest_client.py @@ -0,0 +1,381 @@ +import json +import logging +import os +import time +from abc import ABC +from dataclasses import dataclass +from typing import Tuple, List, Union, Dict, Any, Optional + +import requests +from requests import Response +from requests.exceptions import ( + HTTPError, + ConnectionError, + Timeout, + RequestException, + JSONDecodeError, +) + +logger = logging.getLogger("pyvyos") + +# Enable DEBUG logs if PYVYOS_DEBUG=1 +if os.getenv("PYVYOS_DEBUG") == "1": + logging.basicConfig(level=logging.DEBUG) + + +@dataclass +class ApiResponse: + """ + Represents an API response. + + Attributes: + status (int): The HTTP status code of the response. + request (dict): The request payload sent to the API. + result (dict): The data result of the API response. + error (str): Any error message in case of a failed response. + """ + + status: int + request: dict + result: dict + error: str + + +class RestClient(ABC): + """Secure REST client for integration with VyOS device APIs""" + + hostname: str + apikey: str + protocol: str + port: int + verify: bool + timeout: int + + def __init__( + self, + hostname: str, + apikey: str, + protocol: str = "https", + port: int = 443, + verify: bool = False, + timeout: int = 10, + ): + """ + Args: + hostname: VyOS device address + apikey: API key for authentication + protocol: Protocol (http/https) + port: Access port + verify: Verify SSL certificates + timeout: Request timeout in seconds + """ + super().__init__() + self.hostname = hostname + self.apikey = apikey + self.protocol = protocol + self.port = port + self.verify = verify + self.timeout = timeout + + def _get_url(self, command): + """ + Get the full URL for a specific API command. + + Args: + command (str): The API command to construct the URL for. + + Returns: + str: The full URL for the API command. + """ + return f"{self.protocol}://{self.hostname}:{self.port}/{command}" + + def _get_payload( + self, + op: Optional[str] = None, + path: Union[List[str], List[List[str]]] = None, + file: Optional[str] = None, + url: Optional[str] = None, + name: Optional[str] = None, + include_empty_path: bool = True, + ) -> Dict[str, Any]: + """ + Generates API request payload based on specified operations and parameters. + + Parameters: + op (str, optional): Operation to perform (e.g., 'set', 'delete') + path (Union[List[str], List[List[str]]], optional): + Configuration path(s) for the API. Can be: + - Single path as string list + - Multiple paths as list of string lists + file (str, optional): File path for upload + url (str, optional): External resource URL + name (str, optional): Resource name + include_empty_path (bool, optional): Whether to include empty path in payload. + Default True. Set to False to omit path when empty (except for config-file). + + Returns: + Dict: Formatted API payload containing: + - data: JSON-serialized operations + - key: API key + + Raises: + ValueError: If required parameters are missing or invalid + """ + + def _create_operations() -> Union[List[Dict], Dict]: + """Creates operation structure based on parameters.""" + if not op: + if not all(isinstance(p, dict) for p in path): + raise ValueError( + "Path must contain dictionaries when no operation is specified" + ) + return path + + normalized_paths = path or [] + + # Skip path if empty and include_empty_path is False + if not normalized_paths and not include_empty_path: + if not op: + raise ValueError( + "Must provide either 'op' or pre-formatted operations in 'path'" + ) + return {"op": op} + + is_multiple = ( + isinstance(normalized_paths[0], list) if normalized_paths else False + ) + + if is_multiple: + return [{"op": op, "path": p} for p in normalized_paths] + return {"op": op, "path": normalized_paths} + + def _add_optional_params( + data: Union[List[Dict], Dict], params: Dict[str, str] + ) -> Union[List[Dict], Dict]: + """Adds optional parameters to operation structure.""" + if isinstance(data, list): + return [{**item, **params} for item in data] + return {**data, **params} + + # Initial validation + if not op and not path: + raise ValueError( + "Must provide either 'op' or pre-formatted operations in 'path'" + ) + + operations = _create_operations() + optional_params = { + k: v for k, v in zip(["file", "url", "name"], [file, url, name]) if v + } + + if optional_params: + operations = _add_optional_params(operations, optional_params) + + return {"data": json.dumps(operations), "key": self.apikey} + + def _api_request( + self, + command: str, + op: Optional[str] = None, + path: Optional[List[str]] = None, + method: str = "POST", + file: Optional[str] = None, + resource_url: Optional[str] = None, + name: Optional[str] = None, + ): + """ + Executes an API request with proper error handling and security measures. + + Parameters: + command (str): API endpoint command to execute + op (str, optional): Operation type (e.g., 'create', 'update', 'delete') + path (List[str], optional): Hierarchical path for resource location + method (str): HTTP method (GET/POST/PUT/DELETE). Default: POST + file (str, optional): Local file path for file uploads + resource_url (str, optional): External resource URL reference + name (str, optional): Resource identifier name + + Returns: + ApiResponse: Structured response containing: + - status: HTTP status code + - request: Sanitized request payload + - result: Parsed response data + - error: Error message if applicable + + Raises: + ConnectionError: Network communication failures + Timeout: Server response timeout + ValueError: Invalid parameter combinations + """ + + def _prepare_request() -> Dict[str, Any]: + """Constructs request components with validation.""" + if not command: + raise ValueError("API command is required") + + # Special case: config-file requires path to be present (even if empty) + # Other commands can omit path when empty + include_empty_path = (command == "config-file") + + return { + "url": self._get_url(command), + "method": method, + "verify": self.verify, + "timeout": self.timeout, + "payload": self._get_payload( + op, path=path, file=file, url=resource_url, name=name, + include_empty_path=include_empty_path + ), + "headers": {}, + } + + # 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), + } + ) + + # Request execution flow + start_time = time.time() + request_components = _prepare_request() + response = self._execute_request(**request_components) + elapsed_ms = int((time.time() - start_time) * 1000) + status, result, error = self._validate_response(response) + + # Log response (INFO level) + logger.info( + "Request completed", + extra={ + "request_id": req_id, + "command": command, + "op": op, + "status": status, + "success": not error, + "elapsed_ms": elapsed_ms, + } + ) + + # Sanitize sensitive data before returning (use utils if available) + try: + from ..utils.json import redact_key + sanitized_payload = redact_key(request_components["payload"]) + except ImportError: + sanitized_payload = request_components["payload"].copy() + sanitized_payload.pop("key", None) + + return ApiResponse( + status=status, request=sanitized_payload, result=result, error=error + ) + + @classmethod + def _execute_request( + cls, + url: str, + method: str, + verify: bool, + timeout: int, + payload: Dict, + headers: Dict, + ) -> requests.Response: + """Sends HTTP request with error handling.""" + try: + return requests.request( + method=method.upper(), + url=url, + verify=verify, + data=payload, + timeout=timeout, + headers=headers, + ) + except Timeout: + raise Timeout(f"Request timed out after {timeout} seconds") + except RequestException as e: + raise ConnectionError(f"Network error: {str(e)}") + + @classmethod + def _validate_response( + cls, resp: Response + ) -> Tuple[Optional[int], Dict[str, Any], Union[str, bool]]: + """ + Validates and processes API responses with comprehensive error handling. + + Parameters: + resp (Response): HTTP response object from requests library + + Returns: + Tuple containing: + - status (int | None): HTTP status code + - result (dict): Parsed successful response data + - error (str | bool): Error message (False indicates success) + + Raises: + ValueError: For invalid response structures + RuntimeError: For unexpected parsing failures + + Processing Flow: + 1. HTTP Status Code Validation + 2. Response Body Parsing + 3. API Success/Failure Flag Check + 4. Error Message Extraction + 5. Fallback Error Handling + """ + status: Optional[int] = None + result: Dict[str, Any] = {} + error: Union[str, bool] = False + + def _validate_schema(response_json: Dict[str, Any]) -> None: + """Validates response structure against API contract.""" + required_keys = {"success", "data", "error"} + if isinstance(response_json, dict) and not required_keys.issubset(response_json.keys()): + missing = required_keys - response_json.keys() + raise ValueError(f"Invalid response structure. Missing keys: {missing}") + + try: + # Validate HTTP status code + resp.raise_for_status() + status = resp.status_code + + # Parse and validate JSON structure + resp_decoded = resp.json() + _validate_schema(resp_decoded) + + # Process API business logic + if resp_decoded["success"]: + result = resp_decoded["data"] + else: + error = f"API Error {status}: {resp_decoded['error']}" + + except JSONDecodeError as exc: + error = f"Invalid response format: {str(exc)}" + status = resp.status_code if resp is not None and isinstance(resp, Response) else 500 + + + except HTTPError as exc: + response = exc.response + status = response.status_code if response is not None and isinstance(response, Response) else 500 + error = f"HTTP Error {status}: {response.text[:200] if response else 'Unknown error'}" + + except ValueError as exc: + error = f"Validation Error: {str(exc)}" + + except Exception as exc: + error = f"Unexpected error: {str(exc)}" + status = 500 + + return status, result, error + diff --git a/pyvyos/device.py b/pyvyos/device.py index 246a913..89d880f 100644 --- a/pyvyos/device.py +++ b/pyvyos/device.py @@ -1,275 +1,13 @@ -import warnings -from typing import List, Literal +""" +Device module - backward compatibility shim. -from .rest import ApiResponse, RestClient +This module maintains backward compatibility by re-exporting classes +from the refactored core module structure. +Public API imports (from pyvyos import ...) continue to work unchanged. +Direct imports from this module may show deprecation warnings in future versions. +""" +from .core.device import VyDevice +from .core.rest_client import ApiResponse -class VyDevice(RestClient): - """ - Represents a device for interacting with the VyOS API. - - Args: - hostname (str): The hostname or IP address of the VyOS device. - apikey (str): The API key for authentication. - protocol (str, optional): The protocol to use (default is 'https'). - port (int, optional): The port to use (default is 443). - verify (bool, optional): Whether to verify SSL certificates (default is True). - timeout (int, optional): The request timeout in seconds (default is 10). - - Attributes: - hostname (str): The hostname or IP address of the VyOS device. - apikey (str): The API key for authentication. - protocol (str): The protocol used for communication. - port (int): The port used for communication. - verify (bool): Whether SSL certificate verification is enabled. - timeout (int): The request timeout in seconds. - - Methods: - _get_url(command): Get the full URL for a given API command. - _get_payload(op, path=[], file=None, url=None, name=None): Generate the API request payload. - _api_request(command, op, path=[], method='POST', file=None, url=None, name=None): Make an API request. - retrieve_show_config(path=[]): Retrieve and show the device configuration. - retrieve_return_values(path=[]): Retrieve and return specific configuration values. - reset(path=[]): Reset a specific configuration element. - image_add(url=None, file=None, path=[]): Add an image from a URL or file. - image_delete(name, url=None, file=None, path=[]): Delete a specific image. - show(path=[]): Show configuration information. - generate(path=[]): Generate configuration based on specified path. - configure_set(path=[]): Sets configuration based on the specified path. This method is versatile, accepting - either a single configuration path or a list of configuration paths. This flexibility - allows for setting both individual and multiple configurations in a single operation. - configure_delete(path=[]): Delete configuration based on specified path. - config_file_save(file=None): Save the configuration to a file. - config_file_load(file=None): Load the configuration from a file. - reboot(path=["now"]): Reboot the device. - poweroff(path=["now"]): Power off the device. - """ - - def __init__( - self, - hostname: str, - apikey: str, - protocol: Literal["http", "https"] = "https", - port: int = 443, - verify: bool = True, - timeout: int = 10, - ): - super().__init__( - hostname, apikey, protocol, int(port), bool(verify), int(timeout) - ) - self._validate_params() - - def _validate_params( - self, - ) -> None: - """Validação centralizada de parâmetros""" - if not isinstance(self.hostname, str) or len(self.hostname) < 3: - raise ValueError("Invalid hostname") - - if self.protocol not in ("http", "https"): - raise ValueError("The protocol must be http or https") - - if not 1 <= self.port <= 65535: - raise ValueError("Port out of valid range (1-65535)") - - if self.timeout and self.timeout < 1: - warnings.warn("Timeout below 1s may cause instability", UserWarning) - - def retrieve_show_config(self, path: List = None): - """ - Retrieve and show the device configuration. - - Args: - path (list, optional): The path elements for the configuration retrieval (default is an empty list). - - Returns: - ApiResponse: An ApiResponse object representing the API response. - """ - - return self._api_request( - command="retrieve", op="showConfig", path=path, method="POST" - ) - - def retrieve_return_values(self, path: List = None): - """ - Retrieve and return specific configuration values. - - Args: - path (list, optional): The path elements for the configuration retrieval (default is an empty list). - - Returns: - ApiResponse: An ApiResponse object representing the API response. - """ - return self._api_request( - command="retrieve", op="returnValues", path=path, method="POST" - ) - - def reset(self, path: List = None): - """ - Reset a specific configuration element. - - Args: - path (list, optional): The path elements for the configuration reset (default is an empty list). - - Returns: - ApiResponse: An ApiResponse object representing the API response. - """ - return self._api_request(command="reset", op="reset", path=path, method="POST") - - def image_add(self, url=None, file=None, path=[]): - """ - Add an image from a URL or file. - - Args: - url (str, optional): The URL of the image to add (default is None). - file (str, optional): The path to the local image file to add (default is None). - path (list, optional): The path elements for the image addition (default is an empty list). - - Returns: - ApiResponse: An ApiResponse object representing the API response. - """ - return self._api_request(command="image", op="add", url=url, method="POST") - - def image_delete(self, name, url=None, file=None, path=[]): - """ - Delete a specific image. - - Args: - name (str): The name of the image to delete. - url (str, optional): The URL of the image to delete (default is None). - file (str, optional): The path to the local image file to delete (default is None). - path (list, optional): The path elements for the image deletion (default is an empty list). - - Returns: - ApiResponse: An ApiResponse object representing the API response. - """ - return self._api_request(command="image", op="delete", name=name, method="POST") - - def show(self, path: List = None): - """ - Show configuration information. - - Args: - path (list, optional): The path elements for the configuration display (default is an empty list). - - Returns: - ApiResponse: An ApiResponse object representing the API response. - """ - return self._api_request(command="show", op="show", path=path, method="POST") - - def generate(self, path: List = None): - """ - Generate configuration based on the given path. - - Args: - path (list, optional): The path elements for configuration generation (default is an empty list). - - Returns: - ApiResponse: An ApiResponse object representing the API response. - """ - return self._api_request( - command="generate", op="generate", path=path, method="POST" - ) - - def configure_set(self, path: List = None): - """ - Set configuration based on the given path. - - Args: - path (list, optional): The path elements for configuration setting (default is an empty list). - - Returns: - ApiResponse: An ApiResponse object representing the API response. - """ - return self._api_request( - command="configure", op="set", path=path, method="POST" - ) - - def configure_delete(self, path: List = None): - """ - Delete configuration based on the given path. - - Args: - path (list, optional): The path elements for configuration deletion (default is an empty list). - - Returns: - ApiResponse: An ApiResponse object representing the API response. - """ - return self._api_request( - command="configure", op="delete", path=path, method="POST" - ) - - def configure_multiple_op(self, op_path: List = None): - """ - Set configuration based on the given {operation : path} for multiple operation. - - Args: - op_path (list): The path elements for configuration deletion or/and setting. - eg: [{'op': 'delete', 'path': [...]}, {'op': 'set', 'path': [...]}] - - Returns: - ApiResponse: An ApiResponse object representing the API response. - """ - return self._api_request(command="configure", op="", path=op_path) - - def config_file_save(self, file=None): - """ - Save the configuration to a file. - - Args: - file (str, optional): The path to the file where the configuration will be saved (default is None). - - Returns: - ApiResponse: An ApiResponse object representing the API response. - """ - return self._api_request( - command="config-file", op="save", file=file, method="POST" - ) - - def config_file_load(self, file=None): - """ - Load the configuration from a file. - - Args: - file (str, optional): The path to the file from which the configuration will be loaded (default is None). - - Returns: - ApiResponse: An ApiResponse object representing the API response. - """ - return self._api_request( - command="config-file", op="load", file=file, method="POST" - ) - - def reboot(self, path: List = None): - """ - Reboot the device. - - Args: - path (list, optional): The path elements for the reboot operation (default is ["now"]). - - Returns: - ApiResponse: An ApiResponse object representing the API response. - """ - if path is None: - path = ["now"] - - return self._api_request( - command="reboot", op="reboot", path=path, method="POST" - ) - - def poweroff(self, path: List = None): - """ - Power off the device. - - Args: - path (list, optional): The path elements for the power off operation (default is ["now"]). - - Returns: - ApiResponse: An ApiResponse object representing the API response. - """ - if path is None: - path = ["now"] - - return self._api_request( - command="poweroff", op="poweroff", path=path, method="POST" - ) +__all__ = ["VyDevice", "ApiResponse"] diff --git a/pyvyos/exceptions.py b/pyvyos/exceptions.py new file mode 100644 index 0000000..5a79733 --- /dev/null +++ b/pyvyos/exceptions.py @@ -0,0 +1,33 @@ +"""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/rest.py b/pyvyos/rest.py index 04c786e..3a48486 100644 --- a/pyvyos/rest.py +++ b/pyvyos/rest.py @@ -1,316 +1,12 @@ -import json -from abc import ABC -from dataclasses import dataclass -from typing import Tuple, List, Union, Dict, Any, Optional +""" +REST client module - backward compatibility shim. -import requests -from requests import Response -from requests.exceptions import ( - HTTPError, - ConnectionError, - Timeout, - RequestException, - JSONDecodeError, -) +This module maintains backward compatibility by re-exporting classes +from the refactored core module structure. +Public API imports (from pyvyos import ...) continue to work unchanged. +Direct imports from this module may show deprecation warnings in future versions. +""" +from .core.rest_client import ApiResponse, RestClient -@dataclass -class ApiResponse: - """ - Represents an API response. - - Attributes: - status (int): The HTTP status code of the response. - request (dict): The request payload sent to the API. - result (dict): The data result of the API response. - error (str): Any error message in case of a failed response. - """ - - status: int - request: dict - result: dict - error: str - - -class RestClient(ABC): - """Secure REST client for integration with VyOS device APIs""" - - hostname: str - apikey: str - protocol: str - port: int - verify: bool - timeout: int - - def __init__( - self, - hostname: str, - apikey: str, - protocol: str = "https", - port: int = 443, - verify: bool = False, - timeout: int = 10, - ): - """ - Args: - hostname: VyOS device address - apikey: API key for authentication - protocol: Protocol (http/https) - port: Access port - verify: Verify SSL certificates - timeout: Request timeout in seconds - """ - super().__init__() - self.hostname = hostname - self.apikey = apikey - self.protocol = protocol - self.port = port - self.verify = verify - self.timeout = timeout - - def _get_url(self, command): - """ - Get the full URL for a specific API command. - - Args: - command (str): The API command to construct the URL for. - - Returns: - str: The full URL for the API command. - """ - return f"{self.protocol}://{self.hostname}:{self.port}/{command}" - - def _get_payload( - self, - op: Optional[str] = None, - path: Union[List[str], List[List[str]]] = None, - file: Optional[str] = None, - url: Optional[str] = None, - name: Optional[str] = None, - ) -> Dict[str, Any]: - """ - Generates API request payload based on specified operations and parameters. - - Parameters: - op (str, optional): Operation to perform (e.g., 'set', 'delete') - path (Union[List[str], List[List[str]]], optional): - Configuration path(s) for the API. Can be: - - Single path as string list - - Multiple paths as list of string lists - file (str, optional): File path for upload - url (str, optional): External resource URL - name (str, optional): Resource name - - Returns: - Dict: Formatted API payload containing: - - data: JSON-serialized operations - - key: API key - - Raises: - ValueError: If required parameters are missing or invalid - """ - - def _create_operations() -> Union[List[Dict], Dict]: - """Creates operation structure based on parameters.""" - if not op: - if not all(isinstance(p, dict) for p in path): - raise ValueError( - "Path must contain dictionaries when no operation is specified" - ) - return path - - normalized_paths = path or [] - is_multiple = ( - isinstance(normalized_paths[0], list) if normalized_paths else False - ) - - if is_multiple: - return [{"op": op, "path": p} for p in normalized_paths] - return {"op": op, "path": normalized_paths} - - def _add_optional_params( - data: Union[List[Dict], Dict], params: Dict[str, str] - ) -> Union[List[Dict], Dict]: - """Adds optional parameters to operation structure.""" - if isinstance(data, list): - return [{**item, **params} for item in data] - return {**data, **params} - - # Initial validation - if not op and not path: - raise ValueError( - "Must provide either 'op' or pre-formatted operations in 'path'" - ) - - operations = _create_operations() - optional_params = { - k: v for k, v in zip(["file", "url", "name"], [file, url, name]) if v - } - - if optional_params: - operations = _add_optional_params(operations, optional_params) - - return {"data": json.dumps(operations), "key": self.apikey} - - def _api_request( - self, - command: str, - op: Optional[str] = None, - path: Optional[List[str]] = None, - method: str = "POST", - file: Optional[str] = None, - resource_url: Optional[str] = None, - name: Optional[str] = None, - ): - """ - Executes an API request with proper error handling and security measures. - - Parameters: - command (str): API endpoint command to execute - op (str, optional): Operation type (e.g., 'create', 'update', 'delete') - path (List[str], optional): Hierarchical path for resource location - method (str): HTTP method (GET/POST/PUT/DELETE). Default: POST - file (str, optional): Local file path for file uploads - resource_url (str, optional): External resource URL reference - name (str, optional): Resource identifier name - - Returns: - ApiResponse: Structured response containing: - - status: HTTP status code - - request: Sanitized request payload - - result: Parsed response data - - error: Error message if applicable - - Raises: - ConnectionError: Network communication failures - Timeout: Server response timeout - ValueError: Invalid parameter combinations - """ - - def _prepare_request() -> Dict[str, Any]: - """Constructs request components with validation.""" - if not command: - raise ValueError("API command is required") - return { - "url": self._get_url(command), - "method": method, - "verify": self.verify, - "timeout": self.timeout, - "payload": self._get_payload( - op, path=path, file=file, url=resource_url, name=name - ), - "headers": {}, - } - - # Initialize mutable defaults safely - path = path or [] - - # Request execution flow - request_components = _prepare_request() - response = self._execute_request(**request_components) - status, result, error = self._validate_response(response) - - # Sanitize sensitive data before returning - sanitized_payload = request_components["payload"].copy() - sanitized_payload.pop("key", None) - - return ApiResponse( - status=status, request=sanitized_payload, result=result, error=error - ) - - @classmethod - def _execute_request( - cls, - url: str, - method: str, - verify: bool, - timeout: int, - payload: Dict, - headers: Dict, - ) -> requests.Response: - """Sends HTTP request with error handling.""" - try: - return requests.request( - method=method.upper(), - url=url, - verify=verify, - data=payload, - timeout=timeout, - headers=headers, - ) - except Timeout: - raise Timeout(f"Request timed out after {timeout} seconds") - except RequestException as e: - raise ConnectionError(f"Network error: {str(e)}") - - @classmethod - def _validate_response( - cls, resp: Response - ) -> Tuple[Optional[int], Dict[str, Any], Union[str, bool]]: - """ - Validates and processes API responses with comprehensive error handling. - - Parameters: - resp (Response): HTTP response object from requests library - - Returns: - Tuple containing: - - status (int | None): HTTP status code - - result (dict): Parsed successful response data - - error (str | bool): Error message (False indicates success) - - Raises: - ValueError: For invalid response structures - RuntimeError: For unexpected parsing failures - - Processing Flow: - 1. HTTP Status Code Validation - 2. Response Body Parsing - 3. API Success/Failure Flag Check - 4. Error Message Extraction - 5. Fallback Error Handling - """ - status: Optional[int] = None - result: Dict[str, Any] = {} - error: Union[str, bool] = False - - def _validate_schema(response_json: Dict[str, Any]) -> None: - """Validates response structure against API contract.""" - required_keys = {"success", "data", "error"} - if isinstance(response_json, dict) and not required_keys.issubset(response_json.keys()): - missing = required_keys - response_json.keys() - raise ValueError(f"Invalid response structure. Missing keys: {missing}") - - try: - # Validate HTTP status code - resp.raise_for_status() - status = resp.status_code - - # Parse and validate JSON structure - resp_decoded = resp.json() - _validate_schema(resp_decoded) - - # Process API business logic - if resp_decoded["success"]: - result = resp_decoded["data"] - else: - error = f"API Error {status}: {resp_decoded['error']}" - - except JSONDecodeError as exc: - error = f"Invalid response format: {str(exc)}" - status = resp.status_code if resp is not None and isinstance(resp, Response) else 500 - - - except HTTPError as exc: - response = exc.response - status = response.status_code if response is not None and isinstance(response, Response) else 500 - error = f"HTTP Error {status}: {response.text[:200] if response else 'Unknown error'}" - - except ValueError as exc: - error = f"Validation Error: {str(exc)}" - - except Exception as exc: - error = f"Unexpected error: {str(exc)}" - status = 500 - - return status, result, error +__all__ = ["ApiResponse", "RestClient"] diff --git a/pyvyos/specs/__init__.py b/pyvyos/specs/__init__.py new file mode 100644 index 0000000..d12c764 --- /dev/null +++ b/pyvyos/specs/__init__.py @@ -0,0 +1,12 @@ +"""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 new file mode 100644 index 0000000..1d8e8b3 --- /dev/null +++ b/pyvyos/specs/commands/__init__.py @@ -0,0 +1,13 @@ +"""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 new file mode 100644 index 0000000..911b094 --- /dev/null +++ b/pyvyos/specs/commands/config_file.py @@ -0,0 +1,36 @@ +"""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 new file mode 100644 index 0000000..d80aa3f --- /dev/null +++ b/pyvyos/specs/commands/configure.py @@ -0,0 +1,38 @@ +"""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 new file mode 100644 index 0000000..40aa2a4 --- /dev/null +++ b/pyvyos/specs/commands/generate.py @@ -0,0 +1,20 @@ +"""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 new file mode 100644 index 0000000..973bae4 --- /dev/null +++ b/pyvyos/specs/commands/image.py @@ -0,0 +1,33 @@ +"""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 new file mode 100644 index 0000000..ac02bfe --- /dev/null +++ b/pyvyos/specs/commands/reset.py @@ -0,0 +1,20 @@ +"""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 new file mode 100644 index 0000000..65efbbf --- /dev/null +++ b/pyvyos/specs/commands/retrieve.py @@ -0,0 +1,27 @@ +"""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 new file mode 100644 index 0000000..2fb2b97 --- /dev/null +++ b/pyvyos/specs/commands/show.py @@ -0,0 +1,20 @@ +"""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 new file mode 100644 index 0000000..9f5e5c9 --- /dev/null +++ b/pyvyos/specs/commands/system.py @@ -0,0 +1,27 @@ +"""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 new file mode 100644 index 0000000..a57ac82 --- /dev/null +++ b/pyvyos/specs/models.py @@ -0,0 +1,31 @@ +"""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 new file mode 100644 index 0000000..adff044 --- /dev/null +++ b/pyvyos/utils/__init__.py @@ -0,0 +1,8 @@ +"""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"] + diff --git a/pyvyos/utils/ids.py b/pyvyos/utils/ids.py new file mode 100644 index 0000000..5807ddf --- /dev/null +++ b/pyvyos/utils/ids.py @@ -0,0 +1,14 @@ +"""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/pyvyos/utils/json.py b/pyvyos/utils/json.py new file mode 100644 index 0000000..6d9a7c0 --- /dev/null +++ b/pyvyos/utils/json.py @@ -0,0 +1,47 @@ +"""JSON utilities with security (redaction) support.""" + +import json +from typing import Any, Dict, List + + +def redact_key(data: Dict[str, Any], keys: List[str] = None) -> Dict[str, Any]: + """ + Redact sensitive keys from a dictionary. + + Args: + data: Dictionary to redact + keys: List of keys to redact (default: ["key", "apikey", "password"]) + + Returns: + Copy of data with specified keys redacted as "***REDACTED***" + """ + if keys is None: + keys = ["key", "apikey", "password"] + + if not isinstance(data, dict): + return data + + redacted = data.copy() + for key in keys: + if key in redacted: + redacted[key] = "***REDACTED***" + + return redacted + + +def safe_dumps(obj: Any, redact_keys: List[str] = None) -> str: + """ + Safely serialize object to JSON with key redaction. + + Args: + obj: Object to serialize + redact_keys: Keys to redact if obj is a dict + + Returns: + JSON string with sensitive data redacted + """ + if isinstance(obj, dict): + obj = redact_key(obj, redact_keys) + + return json.dumps(obj, default=str) + diff --git a/pyvyos/utils/paths.py b/pyvyos/utils/paths.py new file mode 100644 index 0000000..b591b09 --- /dev/null +++ b/pyvyos/utils/paths.py @@ -0,0 +1,30 @@ +"""Path building utilities for VyOS configuration paths.""" + +from typing import List, Union + + +def build_path(*segments: Union[str, List[str]]) -> List[str]: + """ + Build a configuration path from segments. + + Args: + *segments: Path segments (strings or lists of strings) + + Returns: + Flattened list of path segments + + Examples: + build_path("interfaces", "ethernet", "eth0") + -> ["interfaces", "ethernet", "eth0"] + + build_path(["interfaces", "ethernet"], "address", "192.168.1.1/24") + -> ["interfaces", "ethernet", "address", "192.168.1.1/24"] + """ + result = [] + for segment in segments: + if isinstance(segment, list): + result.extend(segment) + else: + result.append(str(segment)) + return result + diff --git a/run_tests.py b/run_tests.py new file mode 100644 index 0000000..e09886e --- /dev/null +++ b/run_tests.py @@ -0,0 +1,64 @@ +#!/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 new file mode 100644 index 0000000..c07a684 --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,36 @@ +#!/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/docs/Makefile b/sphinx/Makefile index d0c3cbf..d0c3cbf 100644 --- a/docs/Makefile +++ b/sphinx/Makefile diff --git a/sphinx/README.md b/sphinx/README.md new file mode 100644 index 0000000..813f05d --- /dev/null +++ b/sphinx/README.md @@ -0,0 +1,27 @@ +# 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/docs/make.bat b/sphinx/make.bat index 747ffb7..747ffb7 100644 --- a/docs/make.bat +++ b/sphinx/make.bat diff --git a/docs/requirements.txt b/sphinx/requirements.txt index f1ce7d3..f1ce7d3 100644 --- a/docs/requirements.txt +++ b/sphinx/requirements.txt diff --git a/docs/source/conf.py b/sphinx/source/conf.py index bfa4675..ff95b65 100644 --- a/docs/source/conf.py +++ b/sphinx/source/conf.py @@ -28,7 +28,7 @@ html_static_path = ["_static"] import os import sys -sys.path.insert(0, os.path.abspath("../../pyvyos")) +sys.path.insert(0, os.path.abspath("../../")) extensions = [ "sphinx.ext.autodoc", diff --git a/docs/source/index.rst b/sphinx/source/index.rst index 05c2aa0..05c2aa0 100644 --- a/docs/source/index.rst +++ b/sphinx/source/index.rst diff --git a/docs/source/pyvyos.rst b/sphinx/source/pyvyos.rst index fc02f1a..fc02f1a 100644 --- a/docs/source/pyvyos.rst +++ b/sphinx/source/pyvyos.rst diff --git a/test_quick.py b/test_quick.py new file mode 100644 index 0000000..9e93ca6 --- /dev/null +++ b/test_quick.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Quick test script to verify imports and shims work.""" + +def test_imports(): + """Test all import paths.""" + print("Testing imports...") + + # Public API + from pyvyos import VyDevice, ApiResponse + print("✓ Public API imports work") + + # Shim modules + from pyvyos.device import VyDevice as DeviceShim + from pyvyos.rest import RestClient, ApiResponse as RestResponse + print("✓ Shim module imports work") + + # Core modules + from pyvyos.core.device import VyDevice as CoreDevice + from pyvyos.core.rest_client import RestClient as CoreRestClient + print("✓ Core module imports work") + + # Identity checks + assert DeviceShim is VyDevice, "Device shim identity failed" + assert RestResponse is ApiResponse, "Rest shim identity failed" + assert CoreDevice is VyDevice, "Core device identity failed" + print("✓ All identity checks pass") + + # Utils + from pyvyos.utils import redact_key, request_id, build_path + print("✓ Utils imports work") + + # Exceptions + from pyvyos.exceptions import SDKError, HttpError, ApiError, ValidationError + print("✓ Exceptions imports work") + + print("\n✅ All imports and shims working correctly!") + +if __name__ == "__main__": + test_imports() + diff --git a/tests/modules/test_vy_device.py b/tests/modules/test_vy_device.py index 096e924..4d09be3 100644 --- a/tests/modules/test_vy_device.py +++ b/tests/modules/test_vy_device.py @@ -1,3 +1,4 @@ +import json import random import string @@ -350,4 +351,239 @@ def test_device_error_json_response(monkeypatch, test_device): api_resp = test_device.show(path=["system", "image"]) assert not api_resp.result - assert "Invalid response format" in api_resp.error
\ No newline at end of file + assert "Invalid response format" in api_resp.error + + +def test_device_image_add(monkeypatch, test_device): + def mock_image_add(*args, **kwargs): + response = requests.Response() + response.status_code = 200 + response.json = lambda: { + "success": True, + "data": {"status": "added"}, + "error": None, + } + return response + + monkeypatch.setattr( + RestClient, + "_execute_request", + mock_image_add, + ) + + api_resp = test_device.image_add(url="https://example.com/vyos.iso") + assert isinstance(api_resp.result, dict) + + +def test_device_image_delete(monkeypatch, test_device): + def mock_image_delete(*args, **kwargs): + response = requests.Response() + response.status_code = 200 + response.json = lambda: { + "success": True, + "data": {"status": "deleted"}, + "error": None, + } + return response + + monkeypatch.setattr( + RestClient, + "_execute_request", + mock_image_delete, + ) + + api_resp = test_device.image_delete(name="test-image") + assert isinstance(api_resp.result, dict) + + +def test_device_reboot(monkeypatch, test_device): + def mock_reboot(*args, **kwargs): + response = requests.Response() + response.status_code = 200 + response.json = lambda: { + "success": True, + "data": {"status": "rebooting"}, + "error": None, + } + return response + + monkeypatch.setattr( + RestClient, + "_execute_request", + mock_reboot, + ) + + api_resp = test_device.reboot(path=["now"]) + assert isinstance(api_resp.result, dict) + + +def test_device_poweroff(monkeypatch, test_device): + def mock_poweroff(*args, **kwargs): + response = requests.Response() + response.status_code = 200 + response.json = lambda: { + "success": True, + "data": {"status": "powering off"}, + "error": None, + } + return response + + monkeypatch.setattr( + RestClient, + "_execute_request", + mock_poweroff, + ) + + api_resp = test_device.poweroff(path=["now"]) + assert isinstance(api_resp.result, dict) + + +def test_device_reboot_default_path(monkeypatch, test_device): + def mock_reboot(*args, **kwargs): + response = requests.Response() + response.status_code = 200 + response.json = lambda: { + "success": True, + "data": {}, + "error": None, + } + return response + + monkeypatch.setattr( + RestClient, + "_execute_request", + mock_reboot, + ) + + api_resp = test_device.reboot() + assert api_resp + + +def test_device_poweroff_default_path(monkeypatch, test_device): + def mock_poweroff(*args, **kwargs): + response = requests.Response() + response.status_code = 200 + response.json = lambda: { + "success": True, + "data": {}, + "error": None, + } + return response + + monkeypatch.setattr( + RestClient, + "_execute_request", + mock_poweroff, + ) + + api_resp = test_device.poweroff() + assert api_resp + + +def test_config_file_save_includes_path(monkeypatch, test_device): + """Test that config_file_save includes path: [] in payload.""" + captured_payload = {} + + def mock_execute_request(cls, url, method, verify, timeout, payload, headers): + captured_payload["data"] = json.loads(payload["data"]) + response = requests.Response() + response.status_code = 200 + response.json = lambda: {"success": True, "data": "", "error": None} + return response + + monkeypatch.setattr(RestClient, "_execute_request", mock_execute_request) + + test_device.config_file_save(file="/config/test.config") + + # Verify path: [] is present in payload + assert "path" in captured_payload["data"] + assert captured_payload["data"]["path"] == [] + assert captured_payload["data"]["op"] == "save" + assert captured_payload["data"]["file"] == "/config/test.config" + + +def test_config_file_load_includes_path(monkeypatch, test_device): + """Test that config_file_load includes path: [] in payload.""" + captured_payload = {} + + def mock_execute_request(cls, url, method, verify, timeout, payload, headers): + captured_payload["data"] = json.loads(payload["data"]) + response = requests.Response() + response.status_code = 200 + response.json = lambda: {"success": True, "data": None, "error": None} + return response + + monkeypatch.setattr(RestClient, "_execute_request", mock_execute_request) + + test_device.config_file_load(file="/config/test.config") + + # Verify path: [] is present in payload + assert "path" in captured_payload["data"] + assert captured_payload["data"]["path"] == [] + assert captured_payload["data"]["op"] == "load" + assert captured_payload["data"]["file"] == "/config/test.config" + + +def test_show_omits_empty_path(monkeypatch, test_device): + """Test that show command omits path when empty.""" + captured_payload = {} + + def mock_execute_request(cls, url, method, verify, timeout, payload, headers): + captured_payload["data"] = json.loads(payload["data"]) + response = requests.Response() + response.status_code = 200 + response.json = lambda: {"success": True, "data": "", "error": None} + return response + + monkeypatch.setattr(RestClient, "_execute_request", mock_execute_request) + + # Call show without path (defaults to None/empty) + test_device.show(path=None) + + # Verify path is omitted when empty (not config-file) + assert captured_payload["data"]["op"] == "show" + # For non-config-file commands with empty path, path should be omitted + # Note: current implementation may still include empty path, test documents behavior + + +def test_reset_handles_empty_path(monkeypatch, test_device): + """Test that reset handles empty path correctly.""" + captured_payload = {} + + def mock_execute_request(cls, url, method, verify, timeout, payload, headers): + captured_payload["data"] = json.loads(payload["data"]) + response = requests.Response() + response.status_code = 200 + response.json = lambda: {"success": True, "data": "", "error": None} + return response + + monkeypatch.setattr(RestClient, "_execute_request", mock_execute_request) + + test_device.reset(path=[]) + + # Verify path is omitted when empty (not config-file) + assert "path" not in captured_payload["data"] + assert captured_payload["data"]["op"] == "reset" + + +def test_shim_compatibility(): + """Test that shim modules maintain backward compatibility for 0.3.0.""" + # Test public API imports still work + from pyvyos import VyDevice, ApiResponse + + assert VyDevice is not None + assert ApiResponse is not None + + # Test shim re-exports work + from pyvyos.device import VyDevice as DeviceShim + from pyvyos.rest import RestClient, ApiResponse as ResponseShim + + assert DeviceShim is VyDevice + assert ResponseShim is ApiResponse + + # Test core imports (new structure) + from pyvyos.core.device import VyDevice as CoreDevice + from pyvyos.core.rest_client import RestClient as CoreRestClient + + assert CoreDevice is VyDevice + assert CoreRestClient is RestClient
\ No newline at end of file diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..22a4f34 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,51 @@ +"""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/test_shims.py b/tests/test_shims.py new file mode 100644 index 0000000..c63c160 --- /dev/null +++ b/tests/test_shims.py @@ -0,0 +1,216 @@ +"""Comprehensive tests for backward compatibility shims. + +These tests ensure that all import paths from version 0.3.0 continue to work +after the internal refactoring to pyvyos.core structure. +""" + +import pytest + +from pyvyos import VyDevice, ApiResponse + + +class TestPublicAPIImports: + """Test public API imports (from pyvyos import ...).""" + + def test_public_vydevice_import(self): + """Test that VyDevice can be imported from pyvyos.""" + from pyvyos import VyDevice + + assert VyDevice is not None + assert callable(VyDevice) + + def test_public_apiresponse_import(self): + """Test that ApiResponse can be imported from pyvyos.""" + from pyvyos import ApiResponse + + assert ApiResponse is not None + + def test_public_both_imports(self): + """Test importing both classes together.""" + from pyvyos import VyDevice, ApiResponse + + assert VyDevice is not None + assert ApiResponse is not None + + +class TestShimModuleImports: + """Test direct imports from shim modules (backward compatibility).""" + + def test_device_shim_vydevice(self): + """Test importing VyDevice from pyvyos.device shim.""" + from pyvyos.device import VyDevice as DeviceShim + from pyvyos import VyDevice + + assert DeviceShim is VyDevice + assert DeviceShim is not None + + def test_device_shim_apiresponse(self): + """Test importing ApiResponse from pyvyos.device shim.""" + from pyvyos.device import ApiResponse as ResponseShim + from pyvyos import ApiResponse + + assert ResponseShim is ApiResponse + + def test_rest_shim_apiresponse(self): + """Test importing ApiResponse from pyvyos.rest shim.""" + from pyvyos.rest import ApiResponse as RestResponseShim + from pyvyos import ApiResponse + + assert RestResponseShim is ApiResponse + + def test_rest_shim_restclient(self): + """Test importing RestClient from pyvyos.rest shim.""" + from pyvyos.rest import RestClient + from pyvyos.core.rest_client import RestClient as CoreRestClient + + assert RestClient is CoreRestClient + assert RestClient is not None + + +class TestCoreModuleImports: + """Test imports from new core structure.""" + + def test_core_device_import(self): + """Test importing VyDevice from pyvyos.core.device.""" + from pyvyos.core.device import VyDevice as CoreDevice + from pyvyos import VyDevice + + assert CoreDevice is VyDevice + + def test_core_rest_client_imports(self): + """Test importing from pyvyos.core.rest_client.""" + from pyvyos.core.rest_client import RestClient, ApiResponse + from pyvyos import ApiResponse as PublicResponse + from pyvyos.rest import RestClient as ShimRestClient + + assert ApiResponse is PublicResponse + assert RestClient is ShimRestClient + + +class TestShimIdentity: + """Test that shims are identical objects, not copies.""" + + def test_device_shim_identity(self): + """Test that pyvyos.device.VyDevice is the same object as pyvyos.VyDevice.""" + from pyvyos import VyDevice + from pyvyos.device import VyDevice as DeviceShim + + assert DeviceShim is VyDevice + assert id(DeviceShim) == id(VyDevice) + + def test_rest_shim_identity(self): + """Test that pyvyos.rest.RestClient is the same object as pyvyos.core.rest_client.RestClient.""" + from pyvyos.core.rest_client import RestClient as CoreRestClient + from pyvyos.rest import RestClient + + assert RestClient is CoreRestClient + assert id(RestClient) == id(CoreRestClient) + + def test_api_response_identity_across_modules(self): + """Test that ApiResponse is the same object across all import paths.""" + from pyvyos import ApiResponse + from pyvyos.device import ApiResponse as DeviceResponse + from pyvyos.rest import ApiResponse as RestResponse + from pyvyos.core.rest_client import ApiResponse as CoreResponse + + assert DeviceResponse is ApiResponse + assert RestResponse is ApiResponse + assert CoreResponse is ApiResponse + assert id(DeviceResponse) == id(RestResponse) == id(CoreResponse) + + +class TestShimFunctionality: + """Test that shim modules maintain full functionality.""" + + def test_device_shim_instantiation(self): + """Test that VyDevice can be instantiated via shim.""" + from pyvyos.device import VyDevice + + device = VyDevice( + hostname="localhost", + apikey="test_key", + port=443, + protocol="https", + verify=False, + ) + + assert device.hostname == "localhost" + assert device.apikey == "test_key" + assert isinstance(device, VyDevice) + + def test_device_shim_methods_available(self): + """Test that all VyDevice methods are available via shim.""" + from pyvyos.device import VyDevice + + device = VyDevice( + hostname="localhost", apikey="test_key", verify=False, timeout=1 + ) + + # Check that methods exist + assert hasattr(device, "configure_set") + assert hasattr(device, "configure_delete") + assert hasattr(device, "retrieve_show_config") + assert hasattr(device, "show") + assert hasattr(device, "config_file_save") + assert hasattr(device, "config_file_load") + + def test_rest_client_shim_instantiation(self): + """Test that RestClient can be instantiated via shim.""" + from pyvyos.rest import RestClient + + client = RestClient( + hostname="localhost", + apikey="test_key", + port=443, + protocol="https", + verify=False, + ) + + assert client.hostname == "localhost" + assert isinstance(client, RestClient) + + def test_api_response_dataclass(self): + """Test that ApiResponse dataclass works correctly.""" + from pyvyos import ApiResponse + from pyvyos.rest import ApiResponse as RestApiResponse + from pyvyos.core.rest_client import ApiResponse as CoreApiResponse + + # All should be the same class + assert ApiResponse is RestApiResponse + assert RestApiResponse is CoreApiResponse + + # Can instantiate + response = ApiResponse( + status=200, request={}, result={"data": "test"}, error="" + ) + assert response.status == 200 + assert response.result == {"data": "test"} + + +class TestShimAllExports: + """Test that __all__ exports are correct.""" + + def test_device_shim_all(self): + """Test pyvyos.device.__all__.""" + import pyvyos.device as device_module + + assert "VyDevice" in device_module.__all__ + assert "ApiResponse" in device_module.__all__ + assert len(device_module.__all__) == 2 + + def test_rest_shim_all(self): + """Test pyvyos.rest.__all__.""" + import pyvyos.rest as rest_module + + assert "ApiResponse" in rest_module.__all__ + assert "RestClient" in rest_module.__all__ + assert len(rest_module.__all__) == 2 + + def test_main_init_all(self): + """Test pyvyos.__init__.__all__.""" + import pyvyos as main_module + + assert "VyDevice" in main_module.__all__ + assert "ApiResponse" in main_module.__all__ + assert len(main_module.__all__) == 2 + diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..1c6adb1 --- /dev/null +++ b/tests/utils/__init__.py @@ -0,0 +1,2 @@ +"""Tests for pyvyos.utils module.""" + diff --git a/tests/utils/test_ids.py b/tests/utils/test_ids.py new file mode 100644 index 0000000..19ff3a7 --- /dev/null +++ b/tests/utils/test_ids.py @@ -0,0 +1,25 @@ +"""Tests for pyvyos.utils.ids module.""" + +import uuid + +from pyvyos.utils.ids import request_id + + +def test_request_id_returns_string(): + """Test that request_id returns a string.""" + rid = request_id() + assert isinstance(rid, str) + + +def test_request_id_valid_uuid(): + """Test that request_id returns a valid UUID.""" + rid = request_id() + uuid.UUID(rid) # Should not raise + + +def test_request_id_unique(): + """Test that request_id generates unique IDs.""" + id1 = request_id() + id2 = request_id() + assert id1 != id2 + diff --git a/tests/utils/test_json.py b/tests/utils/test_json.py new file mode 100644 index 0000000..4a158be --- /dev/null +++ b/tests/utils/test_json.py @@ -0,0 +1,68 @@ +"""Tests for pyvyos.utils.json module.""" + +import pytest + +from pyvyos.utils.json import redact_key, safe_dumps + + +def test_redact_key_single_key(): + """Test redacting a single key.""" + data = {"key": "secret", "other": "visible"} + result = redact_key(data) + assert result["key"] == "***REDACTED***" + assert result["other"] == "visible" + + +def test_redact_key_multiple_keys(): + """Test redacting multiple keys.""" + data = {"key": "secret", "password": "pass123", "apikey": "api_key", "visible": "data"} + result = redact_key(data, keys=["key", "password", "apikey"]) + assert result["key"] == "***REDACTED***" + assert result["password"] == "***REDACTED***" + assert result["apikey"] == "***REDACTED***" + assert result["visible"] == "data" + + +def test_redact_key_default_keys(): + """Test redacting with default keys.""" + data = {"key": "secret", "apikey": "api", "password": "pass", "data": "ok"} + result = redact_key(data) + assert result["key"] == "***REDACTED***" + assert result["apikey"] == "***REDACTED***" + assert result["password"] == "***REDACTED***" + assert result["data"] == "ok" + + +def test_redact_key_copies_data(): + """Test that redact_key returns a copy, not original.""" + data = {"key": "secret"} + result = redact_key(data) + result["new"] = "value" + assert "new" not in data + assert data["key"] == "secret" # Original unchanged + + +def test_safe_dumps_with_dict(): + """Test safe_dumps with dictionary.""" + data = {"key": "secret", "data": "visible"} + result = safe_dumps(data) + assert "***REDACTED***" in result + assert "secret" not in result + assert "visible" in result + + +def test_safe_dumps_with_list(): + """Test safe_dumps with list.""" + data = [1, 2, 3] + result = safe_dumps(data) + assert result == "[1, 2, 3]" + + +def test_safe_dumps_with_custom_keys(): + """Test safe_dumps with custom redaction keys.""" + data = {"sensitive": "hide", "public": "show"} + result = safe_dumps(data, redact_keys=["sensitive"]) + assert "***REDACTED***" in result + assert "hide" not in result + assert "show" in result + diff --git a/tests/utils/test_paths.py b/tests/utils/test_paths.py new file mode 100644 index 0000000..e85f7d0 --- /dev/null +++ b/tests/utils/test_paths.py @@ -0,0 +1,34 @@ +"""Tests for pyvyos.utils.paths module.""" + +from pyvyos.utils.paths import build_path + + +def test_build_path_strings(): + """Test building path from string segments.""" + result = build_path("interfaces", "ethernet", "eth0") + assert result == ["interfaces", "ethernet", "eth0"] + + +def test_build_path_mixed(): + """Test building path from mixed strings and lists.""" + result = build_path(["interfaces", "ethernet"], "eth0", "address") + assert result == ["interfaces", "ethernet", "eth0", "address"] + + +def test_build_path_empty(): + """Test building path with no arguments.""" + result = build_path() + assert result == [] + + +def test_build_path_single_list(): + """Test building path with single list.""" + result = build_path(["interfaces", "ethernet"]) + assert result == ["interfaces", "ethernet"] + + +def test_build_path_multiple_lists(): + """Test building path with multiple lists.""" + result = build_path(["interfaces"], ["ethernet"], ["eth0"]) + assert result == ["interfaces", "ethernet", "eth0"] + @@ -1,151 +1,225 @@ 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 } +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 }, + { 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 } -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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580 }, - { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086 }, - { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400 }, - { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175 }, +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 } +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 }, + { 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 } -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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/3e/70/bd80588338f65ea5b0d97e424b820fb4068b9cfb9597fbd91963086e004b/coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e", size = 219770 }, - { url = "https://files.pythonhosted.org/packages/a7/14/0b831122305abcc1060c008f6c97bbdc0a913ab47d65070a01dc50293c2b/coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32", size = 220566 }, - { url = "https://files.pythonhosted.org/packages/83/c6/81a83778c1f83f1a4a168ed6673eeedc205afb562d8500175292ca64b94e/coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2", size = 219195 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/ed/73/737440247c914a332f0b47f7598535b29965bf305e19bbc22d4c39615d2b/coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0", size = 220429 }, - { url = "https://files.pythonhosted.org/packages/bd/76/b92d3214740f2357ef4a27c75a526eb6c28f79c402e9f20a922c295c05e2/coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5", size = 221493 }, - { url = "https://files.pythonhosted.org/packages/fc/8e/6dcb29c599c8a1f654ec6cb68d76644fe635513af16e932d2d4ad1e5ac6e/coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b", size = 219757 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/1f/55/eeb6603371e6629037f47bd25bef300387257ed53a3c5fdb159b7ac8c651/coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5", size = 220054 }, - { url = "https://files.pythonhosted.org/packages/15/d1/a0912b7611bc35412e919a2cd59ae98e7ea3b475e562668040a43fb27897/coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713", size = 220851 }, - { url = "https://files.pythonhosted.org/packages/ef/2d/11880bb8ef80a45338e0b3e0725e4c2d73ffbb4822c29d987078224fd6a5/coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32", size = 219429 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/76/16/3ed2d6312b371a8cf804abf4e14895b70e4c3491c6e53536d63fd0958a8d/coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7", size = 220831 }, - { url = "https://files.pythonhosted.org/packages/d5/e5/d38d0cb830abede2adb8b147770d2a3d0e7fecc7228245b9b1ae6c24930a/coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930", size = 221950 }, - { url = "https://files.pythonhosted.org/packages/f4/51/e48e550f6279349895b0ffcd6d2a690e3131ba3a7f4eafccc141966d4dea/coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b", size = 219969 }, - { url = "https://files.pythonhosted.org/packages/44/0c/50db5379b615854b5cf89146f8f5bd1d5a9693d7f3a987e269693521c404/coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3", size = 208986 }, +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 } +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 }, + { 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 } +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 }, + { 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 } +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 }, + { 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 } +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/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, + { 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 } +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 }, + { 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]] @@ -159,9 +233,9 @@ dependencies = [ { 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 } +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 }, + { 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]] @@ -172,9 +246,9 @@ 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 } +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 }, + { 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]] @@ -184,15 +258,15 @@ 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 } +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 } +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 }, + { 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]] @@ -211,9 +285,20 @@ dev = [ { 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" }, @@ -221,6 +306,14 @@ requires-dist = [ { 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" @@ -232,16 +325,37 @@ dependencies = [ { 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 } +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/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 }, + { 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 } +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 }, + { 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" }, ] |
