diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..b3189ca --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + lint: + runs-on: rassbery + container: catthehacker/ubuntu:act-22.04 + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v6 + - name: Sync dependencies + run: uv sync --group dev + - name: Ruff lint + run: uv run ruff check --output-format=github . + - name: Ruff format check + run: uv run ruff format --check . + + test: + runs-on: rassbery + container: catthehacker/ubuntu:act-22.04 + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Sync dependencies + run: uv sync --group dev + - name: Run tests + run: uv run pytest -q diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f5da988 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v6 + - name: Sync dependencies + run: uv sync --group dev + - name: Ruff lint + run: uv run ruff check --output-format=github . + - name: Ruff format check + run: uv run ruff format --check . + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Sync dependencies + run: uv sync --group dev + - name: Run tests + run: uv run pytest -q diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..517cdfa --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info +main.py +# Virtual environments +.venv +.python-version +.idea +.DS_Store +.vscode +# etc files +*.txt \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d2a37d3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, +and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by +the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all +other entities that control, are controlled by, or are under common +control with that entity. For the purposes of this definition, +"control" means (i) the power, direct or indirect, to cause the +direction or management of such entity, whether by contract or +otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity +exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation +source, and configuration files. + +"Object" form shall mean any form resulting from mechanical +transformation or translation of a Source form, including but +not limited to compiled object code, generated documentation, +and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or +Object form, made available under the License, as indicated by a +copyright notice that is included in or attached to the work +(an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object +form, that is based on (or derived from) the Work and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. For the purposes +of this License, Derivative Works shall not include works that remain +separable from, or merely link (or bind by name) to the interfaces of, +the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including +the original version of the Work and any modifications or additions +to that Work or Derivative Works thereof, that is intentionally +submitted to Licensor for inclusion in the Work by the copyright owner +or by an individual or Legal Entity authorized to submit on behalf of +the copyright owner. For the purposes of this definition, "submitted" +means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, +and issue tracking systems that are managed by, or on behalf of, the +Licensor for the purpose of discussing and improving the Work, but +excluding communication that is conspicuously marked or otherwise +designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity +on behalf of whom a Contribution has been received by Licensor and +subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the +Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +(except as stated in this section) patent license to make, have made, +use, offer to sell, sell, import, and otherwise transfer the Work, +where such license applies only to those patent claims licensable +by such Contributor that are necessarily infringed by their +Contribution(s) alone or by combination of their Contribution(s) +with the Work to which such Contribution(s) was submitted. If You +institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work +or a Contribution incorporated within the Work constitutes direct +or contributory patent infringement, then any patent licenses +granted to You under this License for that Work shall terminate +as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the +Work or Derivative Works thereof in any medium, with or without +modifications, and in Source or Object form, provided that You +meet the following conditions: + +(a) You must give any other recipients of the Work or +Derivative Works a copy of this License; and + +(b) You must cause any modified files to carry prominent notices +stating that You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works +that You distribute, all copyright, patent, trademark, and +attribution notices from the Source form of the Work, +excluding those notices that do not pertain to any part of +the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its +distribution, then any Derivative Works that You distribute must +include a readable copy of the attribution notices contained +within such NOTICE file, excluding those notices that do not +pertain to any part of the Derivative Works, in at least one +of the following places: within a NOTICE text file distributed +as part of the Derivative Works; within the Source form or +documentation, if provided along with the Derivative Works; or, +within a display generated by the Derivative Works, if and +wherever such third-party notices normally appear. The contents +of the NOTICE file are for informational purposes only and +do not modify the License. You may add Your own attribution +notices within Derivative Works that You distribute, alongside +or as an addendum to the NOTICE text from the Work, provided +that such additional attribution notices cannot be construed +as modifying the License. + +You may add Your own copyright statement to Your modifications and +may provide additional or different license terms and conditions +for use, reproduction, or distribution of Your modifications, or +for any such Derivative Works as a whole, provided Your use, +reproduction, and distribution of the Work otherwise complies with +the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, +any Contribution intentionally submitted for inclusion in the Work +by You to the Licensor shall be under the terms and conditions of +this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify +the terms of any separate license agreement you may have executed +with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade +names, trademarks, service marks, or product names of the Licensor, +except as required for reasonable and customary use in describing the +origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or +agreed to in writing, Licensor provides the Work (and each +Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied, including, without limitation, any warranties or conditions +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any +risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, +whether in tort (including negligence), contract, or otherwise, +unless required by applicable law (such as deliberate and grossly +negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, +incidental, or consequential damages of any character arising as a +result of this License or out of the use or inability to use the +Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all +other commercial damages or losses), even if such Contributor +has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing +the Work or Derivative Works thereof, You may choose to offer, +and charge a fee for, acceptance of support, warranty, indemnity, +or other liability obligations and/or rights consistent with this +License. However, in accepting such obligations, You may act only +on Your own behalf and on Your sole responsibility, not on behalf +of any other Contributor, and only if You agree to indemnify, +defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason +of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following +boilerplate notice, with the fields enclosed by brackets "[]" +replaced with your own identifying information. (Don't include +the brackets!) The text should be enclosed in the appropriate +comment syntax for the file format. We also recommend that a +file or class name and description of purpose be included on the +same "printed page" as the copyright notice for easier +identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md index e69de29..2ca7943 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,262 @@ +# oxipy + +`oxipy` is a Python client for the [Oxidized](https://github.com/ytti/oxidized) API. +It fetches device configurations from Oxidized and parses them into structured +Pydantic models using bundled TTP templates. + +Oxidized remains responsible for collecting and storing configuration backups. +`oxipy` focuses on consuming those backups from Python code and exposing common +configuration sections such as system data, interfaces, and VLANs. + +## Contents + +- [Installation](#installation) +- [Quick Start](#quick-start) +- [API Reference](#api-reference) + - [OxiAPI](#oxiapi) + - [NodeView](#nodeview) + - [NodeConfig](#nodeconfig) + - [ModelView](#modelview) +- [Supported Devices](#supported-devices) +- [Additional Documentation](#additional-documentation) + +## Installation + +The package is distributed from the source repository. It is not published to +PyPI yet. + +**Requirements:** Python 3.10+ + +### From GitHub Source + +Install directly from the repository: + +```bash +pip install git+https://github.com/sttarsky/oxipy.git +``` + +Install a specific tag or branch: + +```bash +pip install git+https://github.com/sttarsky/oxipy.git@v0.1.0 +pip install git+https://github.com/sttarsky/oxipy.git@dev +``` + +For local development: + +```bash +git clone https://github.com/sttarsky/oxipy +cd oxipy +pip install -e . +``` + +## Quick Start + +```python +from oxi import OxiAPI + +api = OxiAPI(url="https://oxi.example.com", verify=False) + +node = api.node("Router_HOME") + +print(node.ip) +print(node.model) +print(node.full_name) + +print(node.config.system.model) +print(node.config.interfaces.dump_json()) +print(node.config.vlans.dump_json()) +``` + +Example output: + +```text +192.168.1.1 +keenetic +router/HQ +Sprinter (KN-3710) +[ + {"interface": "Bridge1", "ip_address": "192.168.1.1", "mask": 24, "description": "Guest network"}, + {"interface": "Bridge0", "ip_address": "172.16.1.1", "mask": 24, "description": "Home network"} +] +[ + {"vlan_id": 1, "description": "Home VLAN"}, + {"vlan_id": 2, "description": "Ethernet uplink"}, + {"vlan_id": 3, "description": "Home network"} +] +``` + +## API Reference + +### OxiAPI + +`OxiAPI` is the entry point. It manages the HTTP session and provides access to +Oxidized nodes. + +```python +OxiAPI( + url: str, + username: str | None = None, + password: str | None = None, + verify: bool = True, +) +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `url` | `str` | Base URL of the Oxidized API, for example `https://oxi.example.com`. | +| `username` | `str | None` | Optional username for HTTP basic authentication. | +| `password` | `str | None` | Optional password for HTTP basic authentication. | +| `verify` | `bool` | Whether to verify TLS certificates. Defaults to `True`. | + +Example: + +```python +# Without authentication +api = OxiAPI(url="https://oxi.example.com") + +# With HTTP basic authentication +api = OxiAPI( + url="https://oxi.example.com", + username="admin", + password="secret", +) + +# As a context manager. The HTTP session is closed automatically. +with OxiAPI(url="https://oxi.example.com") as api: + node = api.node("HQ") + print(node.ip) +``` + +#### `api.node(name)` + +Returns a `NodeView` for the requested Oxidized node. + +```python +node = api.node("HQ") +``` + +### NodeView + +`NodeView` represents one network device. It contains metadata returned by +Oxidized and lazy access to the fetched configuration. + +| Property | Type | Description | +| --- | --- | --- | +| `ip` | `str` | Node IP address. | +| `full_name` | `str` | Full node name in Oxidized. | +| `group` | `str` | Oxidized group the node belongs to. | +| `model` | `str` | Device model key used to select a parser. | +| `config` | `NodeConfig` | Device configuration, fetched and parsed on first access. | + +Example: + +```python +node = api.node("HQ") + +print(node.ip) +print(node.group) +print(node.model) +``` + +### NodeConfig + +`NodeConfig` fetches and parses a device configuration. The parser is selected +from the device registry by the node `model` value returned by Oxidized. + +Configuration sections are exposed through properties that return `ModelView` +objects. + +| Property | Returns | Description | +| --- | --- | --- | +| `system` | `ModelView[System]` | System information. | +| `interfaces` | `ModelView[list[Interfaces]]` | Parsed interface list. | +| `vlans` | `ModelView[list[Vlans]]` | Parsed VLAN list, if the template provides VLAN data. | +| `text` | `str` | Raw configuration text fetched from Oxidized. | + +Example: + +```python +cfg = node.config + +print(cfg.system.model) +print(cfg.system.serial_number) +print(cfg.system.version) + +for iface in cfg.interfaces: + print(iface.name, iface.ip_address, iface.mask) + +first_iface = cfg.interfaces[0] +print(first_iface.name) +print(len(cfg.interfaces)) + +print(cfg.interfaces.dump_json()) +print(cfg.vlans.dump_json()) +print(cfg.system.dump_json()) + +print(cfg.text) +``` + +`NodeConfig` also provides `dump()` and `dump_json()` methods for the whole +parsed device object. + +### ModelView + +`ModelView` wraps either a single Pydantic model or a list of Pydantic models. +It provides serialization, iteration for list sections, and transparent access +to model attributes. + +| Method / operation | Applies to | Description | +| --- | --- | --- | +| `.dump()` | single model and list | Returns a Python `dict` or `list` using aliases. | +| `.dump_json()` | single model and list | Returns a JSON string using aliases. | +| `.` | single model and list | Proxies attribute access to the wrapped model. | +| `iter(view)` | list only | Iterates over wrapped models. | +| `len(view)` | list only | Returns the number of wrapped models. | +| `view[i]` | list only | Returns an item or slice. | + +`__iter__`, `__len__`, and `__getitem__` are available only for list-backed +sections such as `interfaces` and `vlans`. Calling them on `system` raises +`TypeError`. + +Examples: + +```python +system = node.config.system +print(system.dump_json()) +print(system.model) +print(system.serial_number) + +interfaces = node.config.interfaces + +for iface in interfaces: + print(iface.name, iface.ip_address) + +print(len(interfaces)) +print(interfaces[0]) +print(interfaces[:3]) +print(interfaces.dump()) +``` + +## Supported Devices + +Registry keys are compared with the Oxidized node `model` value +case-insensitively. + +| Device | Registry keys | +| --- | --- | +| Keenetic | `ndms`, `keenetic`, `keeneticos` | +| MikroTik | `routeros`, `ros`, `mikrotik` | +| Qtech | `qtech` | +| Huawei | `huawei`, `vrp` | +| Eltex | `eltex` | +| H3C | `h3c` | +| Quasar | `qos`, `quasar` | + +You can add support for another device family by creating a new device model +and TTP template. See [Extending Device Models](docs/extending-models.md). + +## Additional Documentation + +- [Writing TTP Templates](docs/templates.md) +- [Extending Device Models](docs/extending-models.md) diff --git a/docs/extending-models.md b/docs/extending-models.md new file mode 100644 index 0000000..f6dd620 --- /dev/null +++ b/docs/extending-models.md @@ -0,0 +1,326 @@ +# Extending Device Models + +`oxipy` parses an Oxidized configuration in two stages. A TTP template first +extracts raw dictionaries from the text, then a device model normalizes those +dictionaries before Pydantic validates them against the public contract. + +Device models extend `BaseDevice`. Override `system()`, `interfaces()`, or +`vlans()` when the raw TTP result needs vendor-specific cleanup. + +## Contents + +- [Data Flow](#data-flow) +- [Registering a Device](#registering-a-device) +- [Method Overrides](#method-overrides) + - [interfaces()](#interfaces) + - [vlans()](#vlans) + - [system()](#system) +- [Complete Example](#complete-example) +- [Expected Contract](#expected-contract) + +## Data Flow + +```text +configuration text + | + v + TTP template (.ttp) + | + v + self.raw: dict + | + +--> system() -> dict + +--> interfaces() -> list[dict] + +--> vlans() -> list[dict] + | + v + Pydantic validation + | + v + Device(system, interfaces, vlans) +``` + +The extension methods are intentionally small. The base implementation returns +data directly from `self.raw`: + +```python +def interfaces(self) -> list[dict]: + return self.raw.get("interfaces", []) + +def vlans(self) -> list[dict]: + return self.raw.get("vlans", []) + +def system(self) -> dict: + return self.raw.get("system", None) +``` + +## Registering a Device + +To add support for a new vendor: + +1. Create a Python file in `oxi/interfaces/models/`, for example `cisco.py`. +2. Create a template in `oxi/interfaces/models/templates/`, for example + `cisco.ttp`. +3. Subclass `BaseDevice` and register it with `@register_parser`. + +```python +from oxi.interfaces import register_parser +from oxi.interfaces.base import BaseDevice + + +@register_parser(["ios", "cisco", "cisco_ios"]) +class CiscoIOS(BaseDevice): + template = "cisco.ttp" +``` + +`@register_parser` accepts a string or a list of strings. These values are the +registry keys used to match the Oxidized node `model` field. Matching is +case-insensitive. + +Model modules are imported automatically through `pkgutil` when +`oxi.interfaces` is loaded, so you do not need to import your model class +manually. + +## Method Overrides + +### interfaces() + +Override `interfaces()` when you need to: + +- Convert dotted decimal netmasks to prefix lengths. +- Decode escaped descriptions. +- Rename keys that do not match the contract. +- Filter service-only interfaces. + +Example: convert a netmask to a prefix length. + +```python +from ipaddress import ip_interface +from oxi.interfaces import register_parser +from oxi.interfaces.base import BaseDevice + + +@register_parser(["myvendor"]) +class MyVendor(BaseDevice): + template = "myvendor.ttp" + + def interfaces(self) -> list[dict]: + result = [] + for item in self.raw.get("interfaces", []): + if item.get("ip_address") and item.get("netmask"): + iface = ip_interface(f"{item['ip_address']}/{item['netmask']}") + item["mask"] = iface.network.prefixlen + item.pop("netmask", None) + result.append(item) + return result +``` + +Example: filter management interfaces. + +```python +def interfaces(self) -> list[dict]: + return [ + item for item in self.raw.get("interfaces", []) + if not item.get("interface", "").startswith("Mgmt") + ] +``` + +Example: decode escaped UTF-8 descriptions. + +```python +def _decode_utf(self, text: str) -> str: + if "\\x" in text: + return ( + text.strip('"') + .encode("utf-8") + .decode("unicode_escape") + .encode("latin1") + .decode("utf-8") + ) + return text + + +def interfaces(self) -> list[dict]: + interfaces = self.raw.get("interfaces", []) + for item in interfaces: + if item.get("description"): + item["description"] = self._decode_utf(item["description"]) + return interfaces +``` + +### vlans() + +Override `vlans()` to normalize VLAN IDs, expand compressed ranges, decode +names, or merge details from multiple template groups. + +Example: add a generated VLAN name. + +```python +def vlans(self) -> list[dict]: + result = [] + for item in self.raw.get("vlans", []): + item["description"] = f"VLAN_{item.get('vlan_id', '?')}" + result.append(item) + return result +``` + +Example: merge data from another raw group. + +```python +def vlans(self) -> list[dict]: + vlans = {item["vlan_id"]: item for item in self.raw.get("vlans", [])} + for extra in self.raw.get("vlan_details", []): + vlan_id = extra.get("vlan_id") + if vlan_id in vlans: + vlans[vlan_id].update(extra) + return list(vlans.values()) +``` + +Example: expand a comma-separated VLAN range. + +```python +def _expand_vlan_range(value: str) -> list[str]: + result = [] + for part in value.split(","): + if "-" not in part: + result.append(part.strip()) + continue + start, end = (int(item) for item in part.split("-", 1)) + result.extend(str(vlan_id) for vlan_id in range(start, end + 1)) + return result +``` + +### system() + +Override `system()` when the system section needs computed fields or data from +another raw group. + +Example: assemble a serial number from two fields. + +```python +def system(self) -> dict: + raw_system = self.raw.get("system", {}) + part1 = raw_system.get("serial_part1", "") + part2 = raw_system.get("serial_part2", "") + raw_system["serial_number"] = f"{part1}-{part2}" + return raw_system +``` + +Example: normalize a version string. + +```python +def system(self) -> dict: + raw_system = self.raw.get("system", {}) + version = raw_system.get("version", "") + raw_system["version"] = version.split()[0] if version else version + return raw_system +``` + +## Complete Example + +Assume a Cisco IOS-like device where: + +- IP address and netmask are separated by a space. +- Interface descriptions can contain several words. +- System fields are present in separate lines. + +Template: `oxi/interfaces/models/templates/cisco.ttp` + +```xml + +default_system = { + "model": "", + "serial_number": "", + "version": "" +} + + + +Cisco IOS Software, {{ ignore }} Version {{ version }},{{ ignore('.*') }} +Model Number : {{ model }} +System serial number : {{ serial_number }} + + + +interface {{ interface | _start_ }} + description {{ description | ORPHRASE }} + ip address {{ ip_address }} {{ netmask }} + + + +vlan {{ vlan_id | _start_ }} + name {{ name | ORPHRASE }} + +``` + +Device model: `oxi/interfaces/models/cisco.py` + +```python +from ipaddress import ip_interface +from oxi.interfaces import register_parser +from oxi.interfaces.base import BaseDevice + + +@register_parser(["ios", "cisco", "cisco_ios"]) +class CiscoIOS(BaseDevice): + template = "cisco.ttp" + + def interfaces(self) -> list[dict]: + result = [] + for item in self.raw.get("interfaces", []): + if item.get("ip_address") and item.get("netmask"): + iface = ip_interface(f"{item['ip_address']}/{item['netmask']}") + item["mask"] = iface.network.prefixlen + item.pop("netmask", None) + if item.get("interface", "").startswith("Mgmt"): + continue + result.append(item) + return result + + def system(self) -> dict: + raw_system = self.raw.get("system", {}) + if raw_system.get("model"): + raw_system["model"] = raw_system["model"].strip() + return raw_system +``` + +## Expected Contract + +Methods must return structures accepted by `oxi.interfaces.contract`. + +### `system() -> dict` + +```python +{ + "model": "RB951Ui-2nD", + "serial_number": "B88C0B31117B", + "version": "7.12.1", +} +``` + +### `interfaces() -> list[dict]` + +```python +[ + { + "interface": "ether1", + "ip_address": "192.168.1.1", + "mask": 24, + "description": "LAN", + }, +] +``` + +### `vlans() -> list[dict]` + +```python +[ + { + "vlan_id": 10, + "description": "MGMT", + }, +] +``` + +The Pydantic models use `populate_by_name=True` for aliased models, so both +field names and aliases are accepted where aliases exist. diff --git a/docs/templates.md b/docs/templates.md new file mode 100644 index 0000000..3a4a23e --- /dev/null +++ b/docs/templates.md @@ -0,0 +1,299 @@ +# Writing TTP Templates + +`oxipy` uses [TTP (Template Text Parser)](https://ttp.readthedocs.io/) to turn +network device configurations fetched from Oxidized into structured data. +Templates are stored in `oxi/interfaces/models/templates/`. + +## Contents + +- [Template Structure](#template-structure) +- [Required Groups](#required-groups) +- [The system Group](#the-system-group) +- [The interfaces Group](#the-interfaces-group) +- [The vlans Group](#the-vlans-group) +- [Useful TTP Features](#useful-ttp-features) +- [Default Variables](#default-variables) +- [Full Example](#full-example) +- [Validation](#validation) + +## Template Structure + +Each template is a `.ttp` file with a small set of conventional blocks: + +```xml + + Optional template documentation. + + + + + + + + + + + + + + + + + +``` + +Use `oxi/interfaces/models/templates/_template.ttp` as the starting point for a +new parser. + +## Required Groups + +The framework requires two groups in every template: + +| Group | Required | Description | +| --- | --- | --- | +| `system` | Yes | Device system information. | +| `interfaces` | Yes | Interface configuration. | +| `vlans` | No | VLAN configuration. | + +If a required group is missing from the template or from the TTP result, +`BaseDevice` raises `ValueError`. + +If a template declares an optional `vlans` group, `oxipy` expects TTP to return +that group. Omit the group completely for devices where VLAN parsing is not +implemented. + +## The system Group + +The `system` group must return one dictionary with these fields: + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `model` | `str` | Yes | Device model. | +| `serial_number` | `str` | Yes | Device serial number. | +| `version` | `str` | Yes | Firmware, software, or build version chosen by the parser. | + +Example for MikroTik: + +```text +# version: 7.12.1 (stable) +# model = RB951Ui-2nD +# serial number = B88C0B31117B +``` + +```xml + +# version: {{ version }}{{ ignore('.*') }} +# model = {{ model }} +# serial number = {{ serial_number }} + +``` + +Example for Keenetic: + +```text +! release: 4.1.7.1-1 +! model: Keenetic Extra +! hw_version: F02B4E7A1C90 +``` + +```xml + +! release: {{ version }} +! model: {{ model | ORPHRASE }} +! hw_version: {{ serial_number }} + +``` + +## The interfaces Group + +The `interfaces` group must return a list of dictionaries. Each dictionary +describes one interface. + +The `Interfaces` contract expects these fields: + +| Contract field | TTP name / alias | Type | Required | +| --- | --- | --- | --- | +| `name` | `interface` | `str` | Yes | +| `ip_address` | `ip_address` | `IPv4Address | None` | No | +| `mask` | `mask` | `int | None` | No | +| `description` | `description` | `str | None` | No | + +The Pydantic field `name` has the alias `interface`, so templates should usually +emit `interface`. You can also emit `name` because the models allow population +by field name, or you can normalize keys in the device class by overriding +`interfaces()`. + +Example for MikroTik: + +```text +/ip address +add address=192.168.1.1/24 interface=ether1 network=192.168.1.0 +add address=10.0.0.1/30 comment="WAN link" interface=ether2 network=10.0.0.0 +``` + +```xml + +/ip address +add address={{ ip_address | _start_ }}/{{ mask }} interface={{ interface }} network={{ network }} +add address={{ ip_address | _start_ }}/{{ mask }} comment={{ description | ORPHRASE | strip('"') }} interface={{ interface }} network={{ network }} + +``` + +Example for CLI-style devices: + +```text +interface Vlanif120 + description SSH + ip address 10.26.196.254 255.255.255.0 +``` + +```xml + +interface {{ interface | _start_ }} + description {{ description | ORPHRASE }} + ip address {{ ip_address }} {{ mask | to_cidr }} + +``` + +Use TTP's `to_cidr` formatter when the device uses dotted decimal masks. + +## The vlans Group + +The `vlans` group is optional. If it is declared, it must return a list of VLAN +dictionaries. + +The `Vlans` contract expects these fields: + +| Contract field | Alias | Type | Required | +| --- | --- | --- | --- | +| `vlan_id` | none | `int` | Yes | +| `name` | `description` | `str | None` | No | + +`name` has the alias `description`, so either key is accepted. Existing parsers +use both forms depending on the vendor format. + +Example: + +```text +vlan 10 + name MGMT +``` + +```xml + +vlan {{ vlan_id | _start_ }} + name {{ name | ORPHRASE }} + +``` + +For compressed vendor syntax such as `vlan batch 101 to 103 110`, parse the raw +range in the template and normalize it in the device class when needed. + +## Useful TTP Features + +### Line markers + +| Marker | Description | +| --- | --- | +| `_start_` | Starts a new group match from the current line. | +| `_end_` | Ends the current group match. | + +```xml +interface {{ interface | _start_ }} +``` + +### Variable modifiers + +| Modifier | Description | +| --- | --- | +| `ORPHRASE` | Captures a word or phrase to the end of the line. | +| `exclude("pattern")` | Skips the match when the captured value contains the pattern. | +| `strip('"')` | Removes a character from both ends of the captured value. | +| `replace("old","new")` | Replaces text inside the captured value. | +| `re("pattern")` | Accepts the value only if it matches the regex. | +| `ignore` | Captures and discards the value. | +| `ignore('.*')` | Discards the rest of the line. | +| `to_cidr` | Converts a dotted decimal netmask to a prefix length. | +| `unrange("-", ",")` | Expands ranges such as `10-12` using a comma separator. | +| `split(",")` | Splits a captured string into a list. | + +### Template comments + +Lines beginning with `##` are TTP comments: + +```xml +## disabled no comment +add address={{ ip_address | _start_ }}/{{ mask }} interface={{ interface }} +``` + +## Default Variables + +The `` block can define default values for a group through the group's +`default` attribute: + +```xml + +default_system = { + "model": "", + "serial_number": "", + "version": "" +} + + + +# version: {{ version }} +# model = {{ model }} +# serial number = {{ serial_number }} + +``` + +If the group does not match anything, TTP returns the default dictionary. + +## Full Example + +This simplified Cisco IOS-style example shows the expected shape of a complete +template: + +```xml + +Cisco IOS running-config parser. + + + +default_system = { + "model": "", + "serial_number": "", + "version": "" +} + + + +Cisco IOS Software, {{ ignore }} Version {{ version }},{{ ignore('.*') }} +Model Number : {{ model }} +System serial number : {{ serial_number }} + + + +interface {{ interface | _start_ }} + description {{ description | ORPHRASE }} + ip address {{ ip_address }} {{ mask | to_cidr }} + + + +vlan {{ vlan_id | _start_ }} + name {{ name | ORPHRASE }} + +``` + +## Validation + +`BaseDevice` performs two validation passes: + +1. Template structure validation checks that the template declares the required + `system` and `interfaces` groups. +2. Parse result validation checks that TTP actually returned the required groups + for the given configuration. + +After that, parsed data is validated by Pydantic models from +`oxi.interfaces.contract`. Invalid structures raise the original Pydantic +validation error. diff --git a/oxi/__init__.py b/oxi/__init__.py new file mode 100644 index 0000000..492517d --- /dev/null +++ b/oxi/__init__.py @@ -0,0 +1,5 @@ +from .core import OxiAPI + +__all__ = [ + "OxiAPI", +] diff --git a/oxi/adapter.py b/oxi/adapter.py new file mode 100644 index 0000000..d8b26cc --- /dev/null +++ b/oxi/adapter.py @@ -0,0 +1,20 @@ +from requests.adapters import HTTPAdapter +from urllib3.util import Retry + + +class OxiAdapter(HTTPAdapter): + def __init__( + self, + timeout: int | None = None, + max_retries: int = 3, + *args, + **kwargs, + ): + self.timeout = timeout + retry = Retry(total=max_retries, backoff_factor=0.3) + super().__init__(*args, max_retries=retry, **kwargs) + + def send(self, request, **kwargs): + if kwargs.get("timeout") is None: + kwargs["timeout"] = self.timeout + return super().send(request, **kwargs) diff --git a/oxi/conf.py b/oxi/conf.py new file mode 100644 index 0000000..d354396 --- /dev/null +++ b/oxi/conf.py @@ -0,0 +1,95 @@ +import json +from collections.abc import Iterator +from functools import cached_property +from typing import TYPE_CHECKING, Generic, TypeVar + +from pydantic import BaseModel + +from .interfaces import BaseDevice, device_registry + +if TYPE_CHECKING: + from requests import Session + +TModel = TypeVar("TModel", bound=BaseModel) + + +class ModelView(Generic[TModel]): + def __init__(self, model: TModel | list[TModel]): + self._model = model + + def dump_json(self) -> str: + if isinstance(self._model, list): + return json.dumps( + [item.model_dump(by_alias=True) for item in self._model], + ensure_ascii=False, + ) + return self._model.model_dump_json(by_alias=True) + + def dump(self) -> dict | list: + if isinstance(self._model, list): + return [item.model_dump(by_alias=True) for item in self._model] + return self._model.model_dump(by_alias=True) + + def __iter__(self) -> Iterator[TModel]: + if isinstance(self._model, list): + return iter(self._model) + raise TypeError("This view wraps a single model, not a list") + + def __len__(self) -> int: + if isinstance(self._model, list): + return len(self._model) + raise TypeError("This view wraps a single model, not a list") + + def __getitem__(self, item): + if isinstance(self._model, list): + return self._model[item] + raise TypeError("This view wraps a single model, not a list") + + def __getattr__(self, item): + return getattr(self._model, item) + + def __repr__(self) -> str: + return repr(self._model) + + +class NodeConfig: + def __init__(self, session: "Session", full_name: str, model: str, base_url: str): + self._session = session + self._full_name = full_name + self._model = model.lower() + self._url = f"{base_url}/node/fetch/{full_name}" + self._device: type[BaseDevice] = device_registry.get(self._model.lower()) + if self._device is None: + raise ValueError(f"Device model '{self._model}' not found in registry") + self._parsed_data = self._device(self.text, name=self._full_name).parse() + + @cached_property + def _response(self): + response = self._session.get(self._url) + response.raise_for_status() + return response + + @property + def text(self): + return self._response.text + + def dump_json(self): + return self._parsed_data.model_dump_json(by_alias=True) + + def dump(self): + return self._parsed_data.model_dump(by_alias=True) + + def __str__(self): + return self.text + + @property + def vlans(self): + return ModelView(self._parsed_data.vlans) + + @property + def interfaces(self): + return ModelView(self._parsed_data.interfaces) + + @property + def system(self): + return ModelView(self._parsed_data.system) diff --git a/oxi/core.py b/oxi/core.py new file mode 100644 index 0000000..d28017c --- /dev/null +++ b/oxi/core.py @@ -0,0 +1,51 @@ +from requests import HTTPError, Session + +from oxi.adapter import OxiAdapter +from oxi.exception import OxiAPIError + +from .node import Node + + +class OxiAPI: + def __init__( + self, + url: str, + username: str | None = None, + password: str | None = None, + verify: bool = True, + ): + self.base_url = url.rstrip("/") + self._session = self.__create_session(username, password, verify) + self.node = Node(self._session, self.base_url) + + def __create_session( + self, + username: str | None = None, + password: str | None = None, + verify: bool = True, + ) -> Session: + session = Session() + adapter = OxiAdapter(timeout=10, max_retries=3) + session.mount("https://", adapter) + session.mount("http://", adapter) + session.verify = verify + if username and password: + session.auth = (username, password) + return session + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() + + def close(self): + return self._session.close() + + def reload(self): + try: + reload_response = self._session.get(f"{self.base_url}/reload") + reload_response.raise_for_status() + except HTTPError as e: + raise OxiAPIError.from_http_error(e, context="Reload Oxidized") from e + return reload_response.status_code diff --git a/oxi/exception.py b/oxi/exception.py new file mode 100644 index 0000000..f55ed78 --- /dev/null +++ b/oxi/exception.py @@ -0,0 +1,65 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from requests import HTTPError + +_STATUS_MESSAGES: dict[int, str] = { + 401: "Unauthorized", + 403: "Forbidden", + 500: "Internal Server Error", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", +} + + +def _looks_like_node_not_found_html(e: "HTTPError") -> bool: + resp = getattr(e, "response", None) + if resp is None: + return False + try: + content_type = (resp.headers or {}).get("Content-Type", "") + except Exception: + content_type = "" + if "text/html" not in (content_type or "").lower(): + return False + try: + body = (resp.text or "")[:20_000] + except Exception: + return False + return ( + "Oxidized::NodeNotFound" in body + or "NodeNotFound" in body + or "Oxidized::NodeNotFound" in body + ) + + +class OxiAPIError(Exception): + def __init__(self, message: str, status_code: int | None = None): + super().__init__(message) + self.status_code = status_code + self.message = message + + def __str__(self): + if self.status_code is not None: + return f"OxiAPIError: {self.args[0]} (HTTP {self.status_code})" + return f"OxiAPIError: {self.args[0]}" + + @classmethod + def from_http_error(cls, e: "HTTPError", context: str = "") -> "OxiAPIError": + resp = getattr(e, "response", None) + status = resp.status_code if resp is not None else None + + if status == 500 and _looks_like_node_not_found_html(e): + status = 404 + + if status == 404: + message = f"{context} not found" if context else "Not found" + else: + base = ( + (_STATUS_MESSAGES.get(status) if status is not None else None) + or (resp.reason if resp is not None else None) + or (f"HTTP {status}" if status is not None else "Request failed") + ) + message = f"{context}: {base}" if context else base + return cls(message, status) diff --git a/oxi/interfaces/__init__.py b/oxi/interfaces/__init__.py new file mode 100644 index 0000000..f401ed6 --- /dev/null +++ b/oxi/interfaces/__init__.py @@ -0,0 +1,26 @@ +from collections.abc import Callable + +from .base import BaseDevice + +device_registry = {} + + +def register_parser( + name: list[str] | str, +) -> Callable[[type[BaseDevice]], type[BaseDevice]]: + def wrapper(cls): + name_list = [] + if isinstance(name, str): + name_list.append(name) + else: + name_list.extend(name) + for item in name_list: + device_registry[item.lower()] = cls + return cls + + return wrapper + + +from . import models # noqa: E402, F401 + +__all__ = ["register_parser", "device_registry", "BaseDevice"] diff --git a/oxi/interfaces/base.py b/oxi/interfaces/base.py new file mode 100644 index 0000000..5c5667b --- /dev/null +++ b/oxi/interfaces/base.py @@ -0,0 +1,153 @@ +import xml.etree.ElementTree as ET +from abc import ABC, abstractmethod +from pathlib import Path + +from ttp import ttp + +from oxi.exception import OxiAPIError +from oxi.interfaces.contract import Device, Interfaces, System, Vlans + + +class BaseDevice(ABC): + _REQUIRED_SECTIONS: frozenset[str] = frozenset({"system", "interfaces"}) + _OPTIONAL_SECTIONS: frozenset[str] = frozenset({"vlans"}) + + def __init__(self, config: str, name: str | None = None): + self.config: str = config + self.name = name + + self._loaded_template = self._load_template() + self._declared_sections = None + self._validate_template_groups() + self.raw: dict = self._run_ttp() + + @property + @abstractmethod + def template(self) -> str: + """ + Name of the TTP template file used by this device parser. + """ + + def vlans(self) -> list[dict]: + """ + Parse VLAN configuration from self.raw['vlans']. + + Expected structure: + [{"vlan_id": 10, "description": "MGMT"}, {"vlan_id": 15, "name": "SSH"}, ...] + + Returns: + list[Vlans]: VLANs from the vlans section, or an empty list + when the section is absent. + + Raises: + ValueError: if raw data cannot be validated by the contract. + """ # noqa: E501 + return self.raw.get("vlans", []) + + def interfaces(self) -> list[dict]: + """ + Parse Interface configuration from self.raw['interfaces']. + + Expected raw structure: + [{"interface": "GEthernet1/0/1", "ip_address": "192.168.1.1", "mask": "24", "description": "IPBB interface"}] + + Raises: + ValueError: if raw data cannot be validated by the contract. + """ # noqa: E501 + return self.raw.get("interfaces", []) + + def system(self) -> dict: + """ + Parse System configuration from self.raw['system']. + + Expected raw structure: + {"model":"RB951Ui-2nD", serial_number: "B88C0B31117B", "version": "7.12.1"} + + Raises: + ValueError: if raw data cannot be validated by the contract. + """ + return self.raw.get("system", None) + + @staticmethod + def _as_list(data) -> list: + """Normalize a TTP group result to a list. + + TTP returns a single dict when a group matches exactly one entry and a + list when it matches several. Callers always expect a list. + """ + if data is None: + return [] + if isinstance(data, dict): + return [data] + return data + + def _validate_contract(self) -> dict: + if self.raw is None: + msg = f"Node {self.name} not found" if self.name else "Node not found" + raise OxiAPIError(msg, status_code=404) + system_data = self.system() + interfaces_data = self._as_list(self.interfaces()) + result = { + "system": System(**system_data), + "interfaces": [Interfaces(**item) for item in interfaces_data], + "vlans": [], + } + + if "vlans" in self._declared_sections: + if "vlans" not in self.raw: + raise ValueError( + f"{self.__class__.__name__}: template '{self.template}' " + f"declares optional group 'vlans', but TTP did not return it." + ) + vlans_data = self._as_list(self.vlans()) + result["vlans"] = [Vlans(**item) for item in vlans_data] + return result + + def _load_template(self): + """Load the device TTP template from models/templates.""" + path = Path(__file__).parent / "models" / "templates" / self.template + if not path.exists(): + raise FileNotFoundError(f"Template {self.template} not found") + return path.read_text(encoding="utf-8") + + def _validate_template_groups(self) -> None: + """Validate that the template declares all required groups.""" + try: + root = ET.fromstring(self._loaded_template) + except ET.ParseError: + root = ET.fromstring(f"<template>{self._loaded_template}</template>") + + declared = {g.get("name") for g in root.iter("group") if g.get("name")} + self._declared_sections = declared + + missing_required = self._REQUIRED_SECTIONS - declared + if missing_required: + raise ValueError( + f"{self.__class__.__name__}: template '{self.template}' " + f"missing required groups: {sorted(missing_required)}. " + f"Declared groups: {sorted(declared)}" + ) + + def _run_ttp(self) -> dict: + """Run the node-not-found check and then parse the config with TTP.""" + pattern = """node not {{found}}""" + parser = ttp(data=self.config, template=pattern) + parser.parse() + res = parser.result() + if res[0][0]: + # raise OxiAPIError(f"Node {self.name} not found", status_code=404) + return None + p = ttp(data=self.config, template=self._loaded_template) + p.parse() + raw: dict = p.result()[0][0] + missing = self._REQUIRED_SECTIONS - raw.keys() + if missing: + raise ValueError( + f"{self.__class__.__name__}: TTP template '{self.template}' " + f"did not produce required groups: {sorted(missing)}. " + f"Return only: {(raw.keys())}" + ) + return raw + + def parse(self) -> Device: + return Device(**self._validate_contract()) diff --git a/oxi/interfaces/contract.py b/oxi/interfaces/contract.py new file mode 100644 index 0000000..44a289f --- /dev/null +++ b/oxi/interfaces/contract.py @@ -0,0 +1,43 @@ +from ipaddress import IPv4Address + +from pydantic import BaseModel, ConfigDict, Field + + +class Base(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + +class System(BaseModel): + """ + Required + """ + + model: str + serial_number: str + version: str + + +class Interfaces(Base): + """ + Required + """ + + name: str = Field(alias="interface") + ip_address: IPv4Address | None = None + mask: int | None = None + description: str | None = None + + +class Vlans(Base): + """ + Optional + """ + + vlan_id: int + name: str | None = Field(default=None, alias="description") + + +class Device(BaseModel): + system: System + interfaces: list[Interfaces] + vlans: list[Vlans] = [] diff --git a/oxi/interfaces/models/__init__.py b/oxi/interfaces/models/__init__.py new file mode 100644 index 0000000..af8e78a --- /dev/null +++ b/oxi/interfaces/models/__init__.py @@ -0,0 +1,7 @@ +import importlib +import pkgutil + +package = __package__ + +for _, module_name, _ in pkgutil.iter_modules(__path__): + importlib.import_module(f"{package}.{module_name}") diff --git a/oxi/interfaces/models/eltex.py b/oxi/interfaces/models/eltex.py new file mode 100644 index 0000000..4bf652d --- /dev/null +++ b/oxi/interfaces/models/eltex.py @@ -0,0 +1,38 @@ +from oxi.interfaces import register_parser +from oxi.interfaces.base import BaseDevice +from oxi.interfaces.utils import expand_vlan_range + + +@register_parser("eltex") +class Eltex(BaseDevice): + template = "eltex.ttp" + + def system(self) -> dict: + system = self.raw["system"] + serial_num = self.raw["serial"] + if serial_num: + if len(serial_num) > 1: + serial_num = serial_num[0] + system["serial_number"] = serial_num.get("serial_number") + return system + + def vlans(self) -> list[dict]: + vlans_ttp = self.raw.get("vlans", []) + vlans: list[dict] = [] + named_vlan: set[str] = set() + for item in vlans_ttp: + vlan_id = item.get("vlan_id") + if vlan_id: + named_vlan.add(str(vlan_id)) + vlans.append(item) + continue + + ids = item.get("vlan_ids", "") + tail = item.get("vlan_tail") + if tail: + ids = [*ids, tail] if isinstance(ids, list) else f"{ids},{tail}" + for vid in expand_vlan_range(ids): + if vid in named_vlan: + continue + vlans.append({"vlan_id": vid}) + return vlans diff --git a/oxi/interfaces/models/h3c.py b/oxi/interfaces/models/h3c.py new file mode 100644 index 0000000..f956607 --- /dev/null +++ b/oxi/interfaces/models/h3c.py @@ -0,0 +1,17 @@ +from oxi.interfaces import BaseDevice, register_parser + + +@register_parser("h3c") +class H3C(BaseDevice): + template = "h3c.ttp" + + def vlans(self) -> list[dict]: + vlan_list = self.raw.get("vlans", []) + vlans: list[dict] = [] + for item in vlan_list: + vlan_ids = item.get("vlans_id") + if not vlan_ids: + vlans.append(item) + continue + vlans.extend({"vlan_id": vlan_id} for vlan_id in vlan_ids) + return vlans diff --git a/oxi/interfaces/models/huawei.py b/oxi/interfaces/models/huawei.py new file mode 100644 index 0000000..a6d8938 --- /dev/null +++ b/oxi/interfaces/models/huawei.py @@ -0,0 +1,11 @@ +from oxi.interfaces import register_parser +from oxi.interfaces.base import BaseDevice + + +@register_parser(["vrp", "huawei"]) +class Huawei(BaseDevice): + template = "huawei.ttp" + + def vlans(self) -> list[dict]: + vlan_ids = self.raw.get("vlans", {}).get("vlan_ids", []) + return [{"vlan_id": vlan} for vlan in vlan_ids] diff --git a/oxi/interfaces/models/keenetic.py b/oxi/interfaces/models/keenetic.py new file mode 100644 index 0000000..41d5248 --- /dev/null +++ b/oxi/interfaces/models/keenetic.py @@ -0,0 +1,32 @@ +from ipaddress import ip_interface + +from oxi.interfaces import register_parser +from oxi.interfaces.base import BaseDevice +from oxi.interfaces.utils import decode_utf + + +@register_parser(["NDMS", "keenetic", "KeeneticOS"]) +class Keenetic(BaseDevice): + template = "keenetic.ttp" + + def interfaces(self): + interfaces: list[dict] = self.raw["interfaces"] + for item in interfaces: + if item.get("ip_address") and item.get("netmask"): + ipaddress = ip_interface( + f"{item.get('ip_address')}/{item.get('netmask')}" + ) + item["mask"] = ipaddress.network.prefixlen + item.pop("netmask", "Key not found") + if item.get("description"): + decoded = decode_utf(item.get("description", "")) + item["description"] = decoded + return interfaces + + def vlans(self): + vlans = self.raw["vlans"] + for item in vlans: + if item.get("description"): + decoded = decode_utf(item.get("description", "")) + item["description"] = decoded + return vlans diff --git a/oxi/interfaces/models/mikrotik.py b/oxi/interfaces/models/mikrotik.py new file mode 100644 index 0000000..6d5acd4 --- /dev/null +++ b/oxi/interfaces/models/mikrotik.py @@ -0,0 +1,7 @@ +from oxi.interfaces import register_parser +from oxi.interfaces.base import BaseDevice + + +@register_parser(["routeros", "ros", "mikrotik"]) +class Mikrotik(BaseDevice): + template = "mikrotik.ttp" diff --git a/oxi/interfaces/models/qtech.py b/oxi/interfaces/models/qtech.py new file mode 100644 index 0000000..fc70cf9 --- /dev/null +++ b/oxi/interfaces/models/qtech.py @@ -0,0 +1,29 @@ +from oxi.interfaces import register_parser +from oxi.interfaces.base import BaseDevice +from oxi.interfaces.utils import expand_vlan_range + + +@register_parser(["QTECH"]) +class Qtech(BaseDevice): + template = "qtech.ttp" + + def vlans(self) -> list[dict]: + vlans_ttp = self.raw.get("vlans", []) + vlans: list[dict] = [] + named_vlan: set[str] = set() + for item in vlans_ttp: + vlan_id = item.get("vlan_id") + if vlan_id and "," not in vlan_id and "-" not in vlan_id: + named_vlan.add(vlan_id) + vlans.append(item) + continue + + ids = item.get("vlan_ids") or vlan_id or "" + tail = item.get("vlan_tail") + if tail: + ids = [*ids, tail] if isinstance(ids, list) else f"{ids},{tail}" + for vid in expand_vlan_range(ids): + if vid in named_vlan: + continue + vlans.append({"vlan_id": vid}) + return vlans diff --git a/oxi/interfaces/models/quasar.py b/oxi/interfaces/models/quasar.py new file mode 100644 index 0000000..a5c8859 --- /dev/null +++ b/oxi/interfaces/models/quasar.py @@ -0,0 +1,23 @@ +from oxi.interfaces import BaseDevice, register_parser + + +@register_parser(["quasar", "qos"]) +class Quasar(BaseDevice): + template = "quasar.ttp" + + def interfaces(self) -> list[dict]: + ether_interface: dict = self.raw.get("interfaces", {}) + interfaces: list[dict] = [] + bulk_interfaces: dict = self.raw.get("bulkinterfaces", {}) + for key, value in bulk_interfaces.items(): + interfaces.append( + { + "interface": key, + "description": value.get("description"), + "ip_address": value.get("ip_address"), + "mask": value.get("mask"), + } + ) + if ether_interface: + interfaces.append(ether_interface) + return interfaces diff --git a/oxi/interfaces/models/templates/_template.ttp b/oxi/interfaces/models/templates/_template.ttp new file mode 100644 index 0000000..c582b1a --- /dev/null +++ b/oxi/interfaces/models/templates/_template.ttp @@ -0,0 +1,37 @@ +<doc> +Base template for a new device parser. Copy this file, rename it to +<vendor>.ttp, and fill the groups for the target configuration format. + +Required groups: system, interfaces. +Optional group: vlans. Add it only when VLAN parsing is implemented. + +system must return one dictionary with: model, serial_number, version. +interfaces must return a list of dictionaries with: interface, ip_address, +mask, description. Use a prefix length for mask; convert dotted decimal masks +with `to_cidr` or in the device class. +vlans must return dictionaries with vlan_id and optional name/description. + +Useful TTP modifiers: ORPHRASE, _start_, strip(), replace(), exclude(), +ignore, ignore('.*'), to_cidr, unrange(), split(). + +See docs/templates.md for details. +</doc> +<vars> +default_system = { + "model": "", + "serial_number": "", + "version": "" +} +</vars> + +<group name="system" default="default_system"> + ... +</group> + +<group name="interfaces"> + ... +</group> + +<group name="vlans"> + ... +</group> diff --git a/oxi/interfaces/models/templates/eltex.ttp b/oxi/interfaces/models/templates/eltex.ttp new file mode 100644 index 0000000..57240b6 --- /dev/null +++ b/oxi/interfaces/models/templates/eltex.ttp @@ -0,0 +1,39 @@ +<doc> +Eltex configuration parser. + +The system group reads software version data and the serial group extracts +serial numbers from the unit table. The interfaces group parses interface IP +settings. The vlans group supports named VLAN interfaces and compressed VLAN +lists. +</doc> +<vars> +default_system = { + "model": "", + "serial_number": "", + "version": "" +} +</vars> + +<group name="system" default="default_system"> +Active-image: {{ ignore }} {{ _start_ }} +! Version: {{ version }} +</group> +<group name="serial" method="table"> +! Unit MAC address Hardware version Serial number +! {{ unit | exclude("-") }} {{ mac_address }} {{ hardware_version }} {{ serial_number }} +</group> + +<group name="interfaces"> +interface {{ interface | ORPHRASE }} + ip address {{ ip_address }} {{ mask | to_cidr }} + name {{ description | ORPHRASE}} + description {{ description | ORPHRASE }} +</group> + +<group name="vlans"> +interface vlan {{ vlan_id | _start_ }} + name {{ name | ORPHRASE }} + +vlan {{ _db_ | _start_ }} + vlan {{ vlan_ids | joinmatches(',') | unrange("-", ",") | split(",")}} +</group> diff --git a/oxi/interfaces/models/templates/h3c.ttp b/oxi/interfaces/models/templates/h3c.ttp new file mode 100644 index 0000000..dcf1e2c --- /dev/null +++ b/oxi/interfaces/models/templates/h3c.ttp @@ -0,0 +1,35 @@ +<doc> +H3C configuration parser. + +The system group reads boot image version and board model data. The interfaces +group parses interface IP settings. The vlans groups parse both named VLANs and +range-style VLAN declarations. +</doc> +<vars> +default_system = { + "model": "", + "serial_number": "", + "version": "" +} +</vars> + +<group name="system" default="default_system"> +# Boot image version: {{ version }}, Release {{ release }} +# {{ mpu }} Slot {{ slot }}: +# BOARD TYPE: {{ model }} +</group> + +<group name="interfaces"> +interface {{ interface }} + description {{ description | ORPHRASE }} + ip address {{ ip_address }} {{ mask | to_cidr }} +</group> + +<group name="vlans"> +vlan {{ vlan_id }} + name {{ name }} + description {{ description }} +</group> +<group name="vlans"> +vlan {{ vlans_id | ORPHRASE | contains(" to ") | unrange(" to ", ",") | split(",") }} +</group> \ No newline at end of file diff --git a/oxi/interfaces/models/templates/huawei.ttp b/oxi/interfaces/models/templates/huawei.ttp new file mode 100644 index 0000000..4464e2c --- /dev/null +++ b/oxi/interfaces/models/templates/huawei.ttp @@ -0,0 +1,28 @@ +<doc> +Huawei VRP configuration parser. + +The system group reads VRP version and slot ESN data. The interfaces group +parses interface blocks and converts dotted decimal masks to prefix lengths. +The vlans group parses `vlan batch` declarations and emits VLAN IDs. +</doc> +<vars> +default_system = { + "model": "", + "serial_number": "", + "version": "" +} +</vars> + +<group name="system" default="default_system"> +# VRP (R) software, Version {{ version }} ({{ model }} {{ _line_ }} +# ESN of slot {{ slot_number }}: {{ serial_number }} +</group> + +<group name="interfaces"> +interface {{ interface }} + description {{ description | ORPHRASE }} + ip address {{ ip_address }} {{ mask | to_cidr }} +</group> +<group name="vlans"> +vlan batch {{ vlan_ids | ORPHRASE | unrange(" to ", " ") | split(" ")}} +</group> diff --git a/oxi/interfaces/models/templates/keenetic.ttp b/oxi/interfaces/models/templates/keenetic.ttp new file mode 100644 index 0000000..85eb929 --- /dev/null +++ b/oxi/interfaces/models/templates/keenetic.ttp @@ -0,0 +1,29 @@ +<doc> +</doc> +<vars> +default_system = { + "model": "", + "serial_number": "" +} +default_interfaces = {} +</vars> + +<group name="system" default="default_system"> +! release: {{ version }} +! model: {{ model | ORPHRASE }} +! hw_version: {{ serial_number }} +</group> + +<group name="interfaces"> +interface {{ name | _start_ | exclude("Vlan") }} + rename {{ rename }} + description {{ description | ORPHRASE | strip('"') }} + ip address {{ ip_address }} {{ netmask }} + {{ shutdown | re("up") | replace("up","False") | strip('"') }} + {{ shutdown | re("down") | replace("down","True") | strip('"') }} +</group> + +<group name="vlans"> +interface {{ ignore }}/Vlan{{ vlan_id }} + description {{ description | ORPHRASE | strip('"') }} +</group> \ No newline at end of file diff --git a/oxi/interfaces/models/templates/mikrotik.ttp b/oxi/interfaces/models/templates/mikrotik.ttp new file mode 100644 index 0000000..47b5028 --- /dev/null +++ b/oxi/interfaces/models/templates/mikrotik.ttp @@ -0,0 +1,47 @@ +<doc> + some templates +</doc> +<vars> +default_system = { + "model": "", + "serial_number": "" +} +default_interfaces = { + "disabled": "False" +} +default_vlans = { + "disabled": "False", + "mtu": None + } +</vars> + +<group name="system" default="default_system"> +# version: {{ version }}{{ ignore('.*') }} +# model = {{ model }} +# serial number = {{ serial_number }} +</group> + +<group name="interfaces" default="default_interfaces"> +/ip address +## not disabled and no comment +add address={{ ip_address | _start_ }}/{{ mask }} interface={{ name }} network={{ network }} +## not disabled and comment with/without quotes +add address={{ ip_address | _start_ }}/{{ mask }} comment={{ description | ORPHRASE | exclude("disabled=") | strip('"')}} interface={{ name }} network={{ network }} +## disabled no comment +add address={{ ip_address | _start_ }}/{{ mask }} disabled={{ disabled | replace("yes","True") | strip('"')}} interface={{ name }} network={{ network }} +## disabled with comment with/without quotes +add address={{ ip_address | _start_ }}/{{ mask }} comment={{ description | ORPHRASE | exclude("disabled=") | strip('"') }} disabled={{ disabled }} interface={{ name }} network={{ network }} +</group> + +<group name="vlans"> +/interface vlan +## not disabled and no comment +add interface={{ interface | _start_ }} name={{ name | ORPHRASE | strip('"') }} vlan-id={{ vlan_id }} +## not disabled and comment with/without quotes +add comment={{ comment | _start_ | ORPHRASE | exclude("disabled=") | strip('"')}} interface={{ interface }} name={{ name | ORPHRASE | strip('"') }} vlan-id={{ vlan_id }} +## disabled with comment with/without quotes +add comment={{ comment | _start_ | ORPHRASE | exclude("disabled=") | strip('"')}} disabled={{ disabled | replace("yes","True") | strip('"') }} interface={{ interface }} name={{ name | ORPHRASE | strip('"') }} vlan-id={{ vlan_id }} +## disabled no comment +add disabled={{ disabled | _start_ | replace("yes","True") | strip('"') }} interface={{ interface }} name={{ name | ORPHRASE | strip('"') }} vlan-id={{ vlan_id }} +</group> + diff --git a/oxi/interfaces/models/templates/qtech.ttp b/oxi/interfaces/models/templates/qtech.ttp new file mode 100644 index 0000000..1db628d --- /dev/null +++ b/oxi/interfaces/models/templates/qtech.ttp @@ -0,0 +1,41 @@ +<doc> +Qtech switch configuration parser. + +The system group reads the model, serial number, and build number. For Qtech, +system.version intentionally stores the build number from lines like +`Version 2.2.0C Build 96279`. + +The interfaces group parses CLI interface blocks and converts dotted decimal +masks to prefix lengths. The vlans group supports named VLANs, comma-separated +VLAN lists, ranges, and continuation lines. +</doc> +<vars> +default_system = { + "model": "", + "serial_number": "", + "version": "" +} +</vars> + +<group name="system" default="default_system"> +! {{ model | ORPHRASE | _start_ }} Series Software, Version {{ ignore }} Build {{ version | strip(",") }}{{ ignore('.*') }} +! Serial num:{{ serial_number | strip(",") }}{{ ignore('.*') }} + +! System description : {{ description | PHRASE | _start_ }}({{ model }}) By {{ vendor }} +! System description : {{ description | PHRASE | _start_ }}({{ model }}) +! System software version : {{ description | PHRASE }}, Release({{ version }}) +! System serial number : {{ serial_number }} +</group> + +<group name="interfaces"> +interface {{ interface | ORPHRASE }} + description {{ description | ORPHRASE }} + ip address {{ ip_address }} {{ mask | to_cidr }} +</group> + +<group name="vlans"> +vlan {{ vlan_ids | contains(",", "-") | unrange("-", ",") }} +,{{ vlan_tail | unrange("-", ",") }} +vlan {{ vlan_id | _start_ }} + name {{ name | ORPHRASE }} +</group> diff --git a/oxi/interfaces/models/templates/quasar.ttp b/oxi/interfaces/models/templates/quasar.ttp new file mode 100644 index 0000000..9408d9f --- /dev/null +++ b/oxi/interfaces/models/templates/quasar.ttp @@ -0,0 +1,35 @@ +<doc> +Quasar configuration parser. + +The system group supports Assembly-based and Engine-based firmware blocks. The +interfaces group parses the management Ethernet address, while bulkinterfaces +collects per-port descriptions that the Python model merges into interface +records. +</doc> +<vars> +default_system = { + "model": "", + "serial_number": "", + "version": "" +} +</vars> + +<group name="system" default="default_system"> +# Component Version {{ _start_ }} +# Assembly {{ version }} +# Product Name {{ model | ORPHRASE }} +# Product Serial Number {{ serial_number }} + +# Subsystem Version {{ _start_ }} +# Engine {{ version }} +# Product Name {{ model | ORPHRASE }} +# Product Serial Number {{ serial_number }} +</group> + +<group name="interfaces"> +{{ ignore("\\s*") }}config {{ interface }} ipv4 address {{ ip_address }}/{{ mask }} gateway {{ gateway }} {{ ignore }} +</group> + +<group name="bulkinterfaces.{{ interface }}"> +{{ ignore("\\s*") }}config interface {{ interface | _start_ }} description {{ description | ORPHRASE | strip('"')}} +</group> \ No newline at end of file diff --git a/oxi/interfaces/utils.py b/oxi/interfaces/utils.py new file mode 100644 index 0000000..7348000 --- /dev/null +++ b/oxi/interfaces/utils.py @@ -0,0 +1,39 @@ +def expand_vlan_range(value: str | list[str]) -> list[str]: + """Expand values like '1,7,14-15' into individual VLAN IDs.""" + if isinstance(value, list): + value = ",".join(str(item) for item in value) + + result: list[str] = [] + if not value: + return result + for part in value.split(","): + part = part.strip() + if not part: + continue + if "-" in part: + start_s, end_s = part.split("-", 1) + try: + start, end = int(start_s), int(end_s) + except ValueError: + result.append(part) + continue + if start > end: + start, end = end, start + result.extend(str(i) for i in range(start, end + 1)) + else: + result.append(part) + return result + + +def decode_utf(text: str): + """Decode escaped UTF-8 descriptions.""" + if "\\x" in text: + desc = text.strip('"') + decoded = ( + desc.encode("utf-8") + .decode("unicode_escape") + .encode("latin1") + .decode("utf-8") + ) + return decoded + return text diff --git a/oxi/node.py b/oxi/node.py new file mode 100644 index 0000000..c88df7e --- /dev/null +++ b/oxi/node.py @@ -0,0 +1,29 @@ +from typing import TYPE_CHECKING + +from requests import HTTPError + +from oxi.exception import OxiAPIError + +from .view import NodeView + +if TYPE_CHECKING: + from requests import Session + + +class Node: + def __init__(self, session: "Session", base_url: str): + self._session = session + self._base_url = base_url + + def __call__(self, name: str) -> NodeView: + try: + url = f"{self._base_url}/node/show/{name}" + if not url.endswith(".json"): + url += ".json" + response = self._session.get(url) + response.raise_for_status() + except HTTPError as e: + raise OxiAPIError.from_http_error(e, context=f"Node {name}") from e + return NodeView( + session=self._session, base_url=self._base_url, data=response.json() + ) diff --git a/oxi/view.py b/oxi/view.py new file mode 100644 index 0000000..951b234 --- /dev/null +++ b/oxi/view.py @@ -0,0 +1,59 @@ +from functools import cached_property +from typing import TYPE_CHECKING + +from .conf import NodeConfig + +if TYPE_CHECKING: + from requests import Session + + +class NodeView: + def __init__(self, session: "Session", base_url: str, data: dict): + self._session = session + self._base_url = base_url + self._data = data + + def _updater(self) -> int: + response = self._session.get(f"{self._base_url}/node/next/{self.full_name}") + response.raise_for_status() + return response.status_code + + @property + def name(self) -> str: + return self._data.get("name") + + @property + def ip(self) -> str: + return self._data.get("ip") + + @property + def full_name(self) -> str: + return self._data.get("full_name") + + @property + def group(self) -> str: + return self._data.get("group") + + @property + def model(self) -> str: + return self._data.get("model") + + @property + def last_status(self) -> str: + last = self._data.get("last") or {} + return last.get("status") + + @property + def last_check(self) -> str: + last = self._data.get("last") or {} + return last.get("start") + + def refresh(self) -> str: + result = self._updater() + if result != 200: + raise ValueError(f"Failed to refresh node {self.full_name}") + return "OK" + + @cached_property + def config(self) -> NodeConfig: + return NodeConfig(self._session, self.full_name, self.model, self._base_url) diff --git a/pyproject.toml b/pyproject.toml index 7096929..e1a6eb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,58 @@ [build-system] -requires = ["setuptools>=61"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] name = "oxipy" version = "0.1.0" -description = "Oxi API client" +description = "Python client for Oxidized API with TTP-based config parsing" readme = "README.md" -requires-python = ">=3.13" -dependencies = [ - "requests>=2.32.5", +license = "Apache-2.0" +license-files = ["LICENSE"] +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3", + "Operating System :: OS Independent", ] +dependencies = [ + "pydantic>=2.12.5", + "requests>=2.32.5", + "ttp>=0.10.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=9.0.3", + "responses>=0.26.1", +] + +[project.urls] +Repository = "https://github.com/sttarsky/oxipy" + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] + [tool.setuptools.packages.find] where = ["."] -include = ["oxi*"] \ No newline at end of file +include = ["oxi*"] + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.package-data] +"oxi" = ["**/*.ttp"] + +[dependency-groups] +dev = [ + "pytest>=9.0.3", + "responses>=0.26.1", + "ruff>=0.15.17", +] + +[tool.ruff] +target-version = "py310" +line-length = 88 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4c39a0a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +from pathlib import Path + +FIXTURES = Path(__file__).parent / "fixtures" + + +def load(device: str, name: str = "config.conf") -> str: + """Read a device config fixture from tests/fixtures/<device>/<name>.""" + return (FIXTURES / device / name).read_text(encoding="utf-8") diff --git a/tests/fixtures/eltex/config.conf b/tests/fixtures/eltex/config.conf new file mode 100644 index 0000000..c212bc3 --- /dev/null +++ b/tests/fixtures/eltex/config.conf @@ -0,0 +1,55 @@ +! +Active-image: flash://system/images/mes3300-669-3R3.ros +! Version: 6.6.9.3 +! Commit: 3a5c2e39 +! Build: 3 (master) +! MD5 Digest: 7bc289cc18be560954bd5cb0afd9b2d5 +! Date: 22-Sep-2025 +! Time: 12:38:20 +! Inactive-image: flash://system/images/_image1.bin +! Version: 6.6.2 +! Commit: 3ebc7503 +! Build: 5 (master) +! MD5 Digest: a3f15a788c97c71e07e90d84c0ff3b12 +! Date: 20-Nov-2023 +! Time: 16:39:20 +! +! Unit MAC address Hardware version Serial number +! ---- ----------------- ---------------- ------------- +! 1 90:54:b7:6b:9d:40 01.01.01 ESG7007778 +! 2 90:54:b7:6b:bb:80 01.01.01 ESG7007777 +! +! +! +interface TenGigabitEthernet1/0/2 + shutdown + description FREE + storm-control broadcast pps 3000 + storm-control multicast pps 3000 +exit +! +interface TenGigabitEthernet1/0/11 + shutdown + description FREE + storm-control broadcast pps 3000 + storm-control multicast pps 3000 +exit +! +interface vlan 1700 + name sw-test_HW + ip address 13.36.8.1 255.255.255.0 +exit +! +vlan database + vlan 114-115,120,130,414,610,999-1000,1701-1703,1705,1801,2001,2011 + vlan 2021-2022,3157-3158,3333-3334 +exit +! +interface vlan 666 + name test +exit +! +interface vlan 777 + name test2 +exit +! diff --git a/tests/fixtures/eltex/config.expected.json b/tests/fixtures/eltex/config.expected.json new file mode 100644 index 0000000..c6b9ead --- /dev/null +++ b/tests/fixtures/eltex/config.expected.json @@ -0,0 +1,137 @@ +{ + "system": { + "model": "", + "serial_number": "ESG7007778", + "version": "6.6.9.3" + }, + "interfaces": [ + { + "interface": "TenGigabitEthernet1/0/2", + "ip_address": null, + "mask": null, + "description": "FREE" + }, + { + "interface": "TenGigabitEthernet1/0/11", + "ip_address": null, + "mask": null, + "description": "FREE" + }, + { + "interface": "vlan 1700", + "ip_address": "13.36.8.1", + "mask": 24, + "description": "sw-test_HW" + }, + { + "interface": "vlan 666", + "ip_address": null, + "mask": null, + "description": "test" + }, + { + "interface": "vlan 777", + "ip_address": null, + "mask": null, + "description": "test2" + } + ], + "vlans": [ + { + "vlan_id": 1700, + "description": "sw-test_HW" + }, + { + "vlan_id": 114, + "description": null + }, + { + "vlan_id": 115, + "description": null + }, + { + "vlan_id": 120, + "description": null + }, + { + "vlan_id": 130, + "description": null + }, + { + "vlan_id": 414, + "description": null + }, + { + "vlan_id": 610, + "description": null + }, + { + "vlan_id": 999, + "description": null + }, + { + "vlan_id": 1000, + "description": null + }, + { + "vlan_id": 1701, + "description": null + }, + { + "vlan_id": 1702, + "description": null + }, + { + "vlan_id": 1703, + "description": null + }, + { + "vlan_id": 1705, + "description": null + }, + { + "vlan_id": 1801, + "description": null + }, + { + "vlan_id": 2001, + "description": null + }, + { + "vlan_id": 2011, + "description": null + }, + { + "vlan_id": 2021, + "description": null + }, + { + "vlan_id": 2022, + "description": null + }, + { + "vlan_id": 3157, + "description": null + }, + { + "vlan_id": 3158, + "description": null + }, + { + "vlan_id": 3333, + "description": null + }, + { + "vlan_id": 3334, + "description": null + }, + { + "vlan_id": 666, + "description": "test" + }, + { + "vlan_id": 777, + "description": "test2" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/eltex/not_found.conf b/tests/fixtures/eltex/not_found.conf new file mode 100644 index 0000000..205464c --- /dev/null +++ b/tests/fixtures/eltex/not_found.conf @@ -0,0 +1 @@ +node not found \ No newline at end of file diff --git a/tests/fixtures/h3c/config.conf b/tests/fixtures/h3c/config.conf new file mode 100644 index 0000000..81eca56 --- /dev/null +++ b/tests/fixtures/h3c/config.conf @@ -0,0 +1,169 @@ +# H3C Comware Software, Version 7.1.070, Release 6616P01 +# Copyright (c) 2004-2021 New H3C Technologies Co., Ltd. All rights reserved. +# Last reboot reason : User reboot +# +# Boot image: flash:/s9820-cmw710-boot-r6616p01.bin +# Boot image version: 7.1.070, Release 6616P01 +# Compiled May 06 2021 11:00:00 +# System image: flash:/s9820-cmw710-system-r6616p01.bin +# System image version: 7.1.070, Release 6616P01 +# Compiled May 06 2021 11:00:00 +# +# +# MPU(M) Slot 1: +# H3C S9820-64H MPU(M) with 1 C2538 Processor(s) +# BOARD TYPE: S9820-64H +# DRAM: 8192M bytes +# FLASH: 3630M bytes +# NVRAM: 0K bytes +# PCB 1 Version: VER.A +# PCB 2 Version: VER.B +# PCB 3 Version: VER.A +# PCB 4 Version: VER.A +# Basic BootWare Version: 105 +# Extended BootWare Version: 108 +# CPLD 1 Version: 002 +# CPLD 2 Version: 027 +# CPLD 3 Version: 002 +# CPLD 4 Version: 002 +# FPGA 1 Version: 001 +# Release Version: H3C S9820-64H-6616P01 +# Patch Version: None +# Reboot Cause: UserReboot +# [SubSlot 0] 64QSFP28 +# +# MPU(S) Slot 2: +# H3C S9820-64H MPU(S) with 1 C2538 Processor(s) +# BOARD TYPE: S9820-64H +# DRAM: 8192M bytes +# FLASH: 3630M bytes +# NVRAM: 0K bytes +# PCB 1 Version: VER.A +# PCB 2 Version: VER.B +# PCB 3 Version: VER.A +# PCB 4 Version: VER.A +# Basic BootWare Version: 105 +# Extended BootWare Version: 108 +# CPLD 1 Version: 002 +# CPLD 2 Version: 027 +# CPLD 3 Version: 002 +# CPLD 4 Version: 002 +# FPGA 1 Version: 001 +# Release Version: H3C S9820-64H-6616P01 +# Patch Version: None +# Reboot Cause: IRFMergeReboot +# [SubSlot 0] 64QSFP28 +# Slot Type State Subslot Soft Ver Patch Ver +# 1 S9820-64H Master 0 S9820-64H-6616P01 None +# 2 S9820-64H Standby 0 S9820-64H-6616P01 None +# +vlan 1 +# +vlan 12 + name BGP to OSPF1 +# +vlan 13 + name BGP to OSPF2 +# +vlan 15 + name HW_TEST_1 + description HW_TEST_1 +# +vlan 222 + name MGMT + description MGMT +# +vlan 222 +# +vlan 1112 to 1116 +# +vlan 1122 + name DATA +# +vlan 1123 +# +vlan 1200 +# +vlan 1512 +# +vlan 1513 + description cluster HW_TEST_1 +# +vlan 2000 + description cluster HW_TEST_2 +# +vlan 3377 + name VRF3377 +# +irf-port 1/2 + port group interface HundredGigE1/0/63 + port group interface HundredGigE1/0/64 +# +irf-port 2/1 + port group interface HundredGigE2/0/63 + port group interface HundredGigE2/0/64 +# +interface Bridge-Aggregation1 + description HW_TEST_1 + port link-type trunk + undo port trunk permit vlan 1 + port trunk permit vlan 221 1112 to 1116 1512 2000 + link-aggregation mode dynamic +# +interface Bridge-Aggregation2 + description HW_TEST_2 + port link-type trunk + undo port trunk permit vlan 1 + port trunk permit vlan 221 1112 to 1116 1512 + link-aggregation mode dynamic +# +interface Bridge-Aggregation3 + description HW_TEST_3 + port link-type trunk + undo port trunk permit vlan 1 + port trunk permit vlan 221 1112 to 1116 1512 2000 + link-aggregation mode dynamic +# +interface Bridge-Aggregation4 + description HW_TEST_4 + port link-type trunk + undo port trunk permit vlan 1 + port trunk permit vlan 221 1112 to 1116 1512 + link-aggregation mode dynamic + # +interface NULL0 +# +interface Vlan-interface1 + dhcp client identifier ascii 0098a92d5735b0-VLAN0001 +# +interface Vlan-interface12 + description BGP to OSPF1 + mtu 9008 + ip address 15.12.16.246 255.255.255.252 +# +interface Vlan-interface3000 + description L3 to HW_TEST_3 + ip binding vpn-instance HW_TEST_3 + ip address 192.168.19.254 255.255.255.128 +# +interface HundredGigE1/0/3 + port link-mode bridge + description HW_TEST_1 + port link-type trunk + undo port trunk permit vlan 1 + port trunk permit vlan 221 1112 to 1116 1512 + storm-constrain broadcast pps 1100 1000 + storm-constrain multicast pps 1100 1000 + storm-constrain control shutdown + port link-aggregation group 2 +# +interface HundredGigE1/0/63 + description HW_TEST_2 +# +interface M-GigabitEthernet0/0/0 + ip address 192.168.10.101 255.255.255.0 + dhcp client identifier hex 0298a92d5735b0 +# +interface M-GigabitEthernet0/0/1 + dhcp client identifier hex 0298a92d5735b0 +# diff --git a/tests/fixtures/h3c/config.expected.json b/tests/fixtures/h3c/config.expected.json new file mode 100644 index 0000000..f255686 --- /dev/null +++ b/tests/fixtures/h3c/config.expected.json @@ -0,0 +1,155 @@ +{ + "system": { + "model": "S9820-64H", + "serial_number": "", + "version": "7.1.070" + }, + "interfaces": [ + { + "interface": "Bridge-Aggregation1", + "ip_address": null, + "mask": null, + "description": "HW_TEST_1" + }, + { + "interface": "Bridge-Aggregation2", + "ip_address": null, + "mask": null, + "description": "HW_TEST_2" + }, + { + "interface": "Bridge-Aggregation3", + "ip_address": null, + "mask": null, + "description": "HW_TEST_3" + }, + { + "interface": "Bridge-Aggregation4", + "ip_address": null, + "mask": null, + "description": "HW_TEST_4" + }, + { + "interface": "NULL0", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "Vlan-interface1", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "Vlan-interface12", + "ip_address": "15.12.16.246", + "mask": 30, + "description": "BGP to OSPF1" + }, + { + "interface": "Vlan-interface3000", + "ip_address": "192.168.19.254", + "mask": 25, + "description": "L3 to HW_TEST_3" + }, + { + "interface": "HundredGigE1/0/3", + "ip_address": null, + "mask": null, + "description": "HW_TEST_1" + }, + { + "interface": "HundredGigE1/0/63", + "ip_address": null, + "mask": null, + "description": "HW_TEST_2" + }, + { + "interface": "M-GigabitEthernet0/0/0", + "ip_address": "192.168.10.101", + "mask": 24, + "description": null + }, + { + "interface": "M-GigabitEthernet0/0/1", + "ip_address": null, + "mask": null, + "description": null + } + ], + "vlans": [ + { + "vlan_id": 1, + "description": null + }, + { + "vlan_id": 12, + "description": null + }, + { + "vlan_id": 13, + "description": null + }, + { + "vlan_id": 15, + "description": "HW_TEST_1" + }, + { + "vlan_id": 222, + "description": "MGMT" + }, + { + "vlan_id": 222, + "description": null + }, + { + "vlan_id": 1122, + "description": "DATA" + }, + { + "vlan_id": 1123, + "description": null + }, + { + "vlan_id": 1200, + "description": null + }, + { + "vlan_id": 1512, + "description": null + }, + { + "vlan_id": 1513, + "description": null + }, + { + "vlan_id": 2000, + "description": null + }, + { + "vlan_id": 3377, + "description": "HW_TEST_1" + }, + { + "vlan_id": 1112, + "description": null + }, + { + "vlan_id": 1113, + "description": null + }, + { + "vlan_id": 1114, + "description": null + }, + { + "vlan_id": 1115, + "description": null + }, + { + "vlan_id": 1116, + "description": null + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/huawei/config.conf b/tests/fixtures/huawei/config.conf new file mode 100644 index 0000000..990fb8f --- /dev/null +++ b/tests/fixtures/huawei/config.conf @@ -0,0 +1,38 @@ +# Huawei Versatile Routing Platform Software +# VRP (R) software, Version 5.170 (S5731 V200R019C00SPC500) +# Copyright (C) 2000-2019 HUAWEI TECH Co., Ltd. +# +# DDR Memory Size : 4096 M bytes +# FLASH Total Memory Size : 1024 M bytes +# FLASH Available Memory Size : 739 M bytes +# Pcb Version : VER.B +# BootROM Version : 0000.04e4 +# BootLoad Version : 0213.0000 +# CPLD Version : 0104 +# Software Version : VRP (R) Software, Version 5.170 (V200R019C00SPC500) +# FLASH Version : 0000.0000 +# PWR1 information +# Pcb Version : PWR VER.D +# PWR2 information +# Pcb Version : PWR VER.D +# FAN1 information +# Pcb Version : NA +# FAN2 information +# Pcb Version : NA +# ESN of slot 1: 102266666666 +# ESN of slot 2: 102288888888 +interface GigabitEthernet0/0/33 + port link-type access + port default vlan 101 + loopback-detect enable + stp disable + storm-control broadcast min-rate 1500 max-rate 2500 + storm-control multicast min-rate 1000 max-rate 2000 + storm-control action error-down +# +interface Vlanif120 + description SSH + ip address 10.26.196.254 255.255.255.0 +# +vlan batch 13 26 101 to 103 110 120 130 201 to 204 209 to 212 350 360 +# \ No newline at end of file diff --git a/tests/fixtures/huawei/config.expected.json b/tests/fixtures/huawei/config.expected.json new file mode 100644 index 0000000..39280d0 --- /dev/null +++ b/tests/fixtures/huawei/config.expected.json @@ -0,0 +1,95 @@ +{ + "system": { + "model": "S5731", + "serial_number": "102266666666", + "version": "5.170" + }, + "interfaces": [ + { + "interface": "GigabitEthernet0/0/33", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "Vlanif120", + "ip_address": "10.26.196.254", + "mask": 24, + "description": "SSH" + } + ], + "vlans": [ + { + "vlan_id": 13, + "description": null + }, + { + "vlan_id": 26, + "description": null + }, + { + "vlan_id": 101, + "description": null + }, + { + "vlan_id": 102, + "description": null + }, + { + "vlan_id": 103, + "description": null + }, + { + "vlan_id": 110, + "description": null + }, + { + "vlan_id": 120, + "description": null + }, + { + "vlan_id": 130, + "description": null + }, + { + "vlan_id": 201, + "description": null + }, + { + "vlan_id": 202, + "description": null + }, + { + "vlan_id": 203, + "description": null + }, + { + "vlan_id": 204, + "description": null + }, + { + "vlan_id": 209, + "description": null + }, + { + "vlan_id": 210, + "description": null + }, + { + "vlan_id": 211, + "description": null + }, + { + "vlan_id": 212, + "description": null + }, + { + "vlan_id": 350, + "description": null + }, + { + "vlan_id": 360, + "description": null + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/keenetic/config.conf b/tests/fixtures/keenetic/config.conf new file mode 100644 index 0000000..dfab0d1 --- /dev/null +++ b/tests/fixtures/keenetic/config.conf @@ -0,0 +1,341 @@ +! +! release: 4.03.C.6.2-7 +! sandbox: stable +! title: 4.3.6.2 +! arch: mips +! +! ndm: +! exact: 0-a3057529fd +! cdate: 29 Sep 2025 +! +! bsp: +! exact: 0-03b50470c4 +! cdate: 30 Sep 2025 +! +! ndw: +! features: dual_image,led_control,wifi_button,wifi5ghz, +! vht2ghz,mimo2ghz,mimo5ghz,atf2ghz,atf5ghz,wifi6,wifi_ft, +! wpa3,hwnat +! components: base,cloudcontrol,corewireless,ddns,dhcpd, +! dns-filter,dns-https,dns-tls,dot1x,easyconfig,igmp,ip6, +! lang-en,lang-ru,miniupnpd,mws,nathelper-ftp,nathelper- +! h323,nathelper-pptp,nathelper-rtsp,nathelper-sip,ndmp, +! ndns,openvpn,pingcheck,ppe,pppoe,pptp,ssh,trafficcontrol, +! wireguard +! +! ndw3: +! version: 1.101.18.1 +! +! ndw4: +! version: 4.3.C.6.2 +! +! manufacturer: Keenetic Ltd. +! vendor: Keenetic +! series: KN +! model: Sprinter (KN-3710) +! hw_version: 7777777 +! hw_type: router +! hw_id: KN-3710 +! device: Sprinter +! region: EA +! description: Keenetic Sprinter (KN-3710) +! $$$ Agent: http/rci +! $$$ Last change: Fri, 3 Oct 2025 18:37:40 GMT +! $$$ Model: Keenetic Sprinter +! $$$ Username: admin +! $$$ Version: 2.06.1 +system + set net.ipv4.ip_forward 1 + set net.ipv4.neigh.default.gc_thresh1 256 + set net.ipv4.neigh.default.gc_thresh2 1024 + set net.ipv4.neigh.default.gc_thresh3 2048 + set net.ipv4.tcp_fin_timeout 30 + set net.ipv4.tcp_keepalive_time 120 + set net.ipv6.conf.all.forwarding 1 + set net.ipv6.neigh.default.gc_thresh1 256 + set net.ipv6.neigh.default.gc_thresh2 1024 + set net.ipv6.neigh.default.gc_thresh3 2048 + set net.netfilter.nf_conntrack_tcp_timeout_established 1200 + set vm.overcommit_memory 0 + set vm.vfs_cache_pressure 1000 + clock timezone Europe/Berlin + domainname WORKGROUP + hostname test_HW + caption default + description "Keenetic Sprinter (KN-3710)" + ndss dump-report disable +! +dyndns profile _WEBADMIN +! +interface GigabitEthernet0 + up +! +interface GigabitEthernet0/1 + rename 1 + switchport mode access + switchport access vlan 1 + up +! +interface GigabitEthernet0/2 + rename 2 + switchport mode access + switchport access vlan 1 + up +! +interface GigabitEthernet0/3 + rename 3 + switchport mode access + switchport access vlan 1 + up +! +interface GigabitEthernet0/Vlan1 + description "Home VLAN" + ip dhcp client dns-routes + ip name-servers + up +! +interface GigabitEthernet0/Vlan2 + rename ISP + description "\xd0\x9f\xd0\xbe\xd0\xb4\xd0\xba\xd0\xbb\xd1\x8e\xd1\x87\xd0\xb5\xd0\xbd\xd0\xb8\xd0\xb5 Ethernet" + dyndns nobind + mac address factory wan + security-level public + ip address dhcp + ip dhcp client hostname test_HW + ip dhcp client dns-routes + ip mtu 1500 + ip access-group _WEBADMIN_ISP in + ip global 57342 + ip no name-servers + igmp upstream + ipv6 address auto + ipv6 prefix auto + ipv6 no name-servers auto + up +! +interface GigabitEthernet0/0 + rename 0 + role inet for ISP + switchport mode access + switchport access vlan 2 + up +! +interface GigabitEthernet0/Vlan3 + dyndns nobind + ip dhcp client dns-routes + ip name-servers + up +! +interface WifiMaster0 + country-code RU + compatibility BGN+AX + rekey-interval 86400 + up +! +interface WifiMaster0/AccessPoint0 + mac access-list type none + authentication wpa-psk ns3 7777ggggddddsss + encryption enable + encryption wpa2 + ip dhcp client dns-routes + ssid test_HW_2.4G + up +! +interface WifiMaster0/AccessPoint1 + mac access-list type none + security-level private + encryption no enable + ip dhcp client dns-routes + down +! +interface WifiMaster0/AccessPoint2 + mac access-list type none + security-level private + encryption no enable + ip dhcp client dns-routes + down +! +interface WifiMaster0/WifiStation0 + security-level public + encryption no enable + ip dhcp client dns-routes + standby enable + standby timeout 600 + down +! +interface WifiMaster1 + country-code RU + compatibility AN+AC+AX + channel width 40-above/80 + rekey-interval 86400 + up +! +interface WifiMaster1/AccessPoint0 + mac access-list type none + authentication wpa-psk ns3 7777ggggddddsss + encryption enable + encryption wpa2 + ip dhcp client dns-routes + ssid test_HW_5G + up +! +interface WifiMaster1/AccessPoint1 + mac access-list type none + security-level private + encryption no enable + ip dhcp client dns-routes + down +! +interface WifiMaster1/AccessPoint2 + mac access-list type none + security-level private + encryption no enable + ip dhcp client dns-routes + down +! +interface WifiMaster1/WifiStation0 + security-level public + encryption no enable + ip dhcp client dns-routes + standby enable + standby timeout 600 + down +! +interface Bridge0 + rename Home + description "Home network" + dyndns nobind + include GigabitEthernet0/Vlan1 + include WifiMaster0/AccessPoint0 + include WifiMaster1/AccessPoint0 + mac access-list type none + security-level private + ip address 17.36.1.1 255.255.255.0 + ip dhcp client dns-routes + ip access-group _WEBADMIN_Home in + ip name-servers + band-steering + up +! +interface Bridge1 + rename Guest + description "Guest network" + traffic-shape rate 5120 + dyndns nobind + include GigabitEthernet0/Vlan3 + mac access-list type none + peer-isolation + security-level protected + ip address 10.1.30.1 255.255.255.0 + ip dhcp client dns-routes + ip name-servers + down +! +interface Bridge2 + rename Test + mac access-list type none + security-level public + ip dhcp client dns-routes + up +! +interface OpenVPN0 + description test_HW-udp + role misc + security-level public + ip dhcp client dns-routes + ip tcp adjust-mss pmtu + ip name-servers + ipv6 name-servers auto + openvpn accept-routes + openvpn connect + up +! +interface OpenVPN2 + description test_HW-tcp + role misc + dyndns nobind + security-level public + ip dhcp client dns-routes + ip tcp adjust-mss pmtu + openvpn accept-routes + openvpn connect + down +! +interface Wireguard0 + description test_HW + dyndns nobind + security-level public + ip address 10.3.100.1 255.255.255.0 + ip mtu 1324 + ip tcp adjust-mss pmtu + wireguard listen-port 65513 + wireguard peer 7777ggggddddsss= !test_HW + allow-ips 0.0.0.0 0.0.0.0 + connect + ! + up +! +interface Wireguard1 + description test_HW + dyndns nobind + security-level private + ip address 10.1.100.1 255.255.255.0 + ip mtu 1324 + ip access-group _WEBADMIN_Wireguard1 in + ip tcp adjust-mss pmtu + wireguard listen-port 65511 + wireguard peer 7777ggggddddsss= !test_HW + allow-ips 10.1.100.0 255.255.255.0 + allow-ips 17.36.3.0 255.255.255.0 + allow-ips 17.36.1.0 255.255.255.0 + allow-ips 0.0.0.0 0.0.0.0 + connect + ! + up +! +interface Wireguard2 + description test_HW + dyndns nobind + security-level private + ip address 10.2.100.1 255.255.255.0 + ip access-group _WEBADMIN_Wireguard2 in + ip tcp adjust-mss pmtu + wireguard listen-port 65512 + wireguard peer 7777ggggddddsss= !test_HW + allow-ips 0.0.0.0 0.0.0.0 + connect + ! + up +! +ip ssh + port 22 + security-level public + lockout-policy 5 15 3 +! +ip hotspot + policy Home permit + host 7777ggggddddsss permit + host 7777ggggddddsss priority 4 +! +ipv6 subnet Default + bind Home + mode slaac + prefix length 64 + number 0 +! +ppe software +ppe hardware +upnp lan Home +service dhcp +service dns-proxy +service http +service telnet +service ssh +service ntp +service upnp +! +easyconfig disable +components + auto-update disable + auto-update channel stable +! \ No newline at end of file diff --git a/tests/fixtures/keenetic/config.expected.json b/tests/fixtures/keenetic/config.expected.json new file mode 100644 index 0000000..fee724e --- /dev/null +++ b/tests/fixtures/keenetic/config.expected.json @@ -0,0 +1,161 @@ +{ + "system": { + "model": "Sprinter (KN-3710)", + "serial_number": "7777777", + "version": "4.03.C.6.2-7" + }, + "interfaces": [ + { + "interface": "GigabitEthernet0", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "GigabitEthernet0/1", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "GigabitEthernet0/2", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "GigabitEthernet0/3", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "GigabitEthernet0/0", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "WifiMaster0", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "WifiMaster0/AccessPoint0", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "WifiMaster0/AccessPoint1", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "WifiMaster0/AccessPoint2", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "WifiMaster0/WifiStation0", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "WifiMaster1", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "WifiMaster1/AccessPoint0", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "WifiMaster1/AccessPoint1", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "WifiMaster1/AccessPoint2", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "WifiMaster1/WifiStation0", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "Bridge0", + "ip_address": "17.36.1.1", + "mask": 24, + "description": "Home network" + }, + { + "interface": "Bridge1", + "ip_address": "10.1.30.1", + "mask": 24, + "description": "Guest network" + }, + { + "interface": "Bridge2", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "OpenVPN0", + "ip_address": null, + "mask": null, + "description": "test_HW-udp" + }, + { + "interface": "OpenVPN2", + "ip_address": null, + "mask": null, + "description": "test_HW-tcp" + }, + { + "interface": "Wireguard0", + "ip_address": "10.3.100.1", + "mask": 24, + "description": "test_HW" + }, + { + "interface": "Wireguard1", + "ip_address": "10.1.100.1", + "mask": 24, + "description": "test_HW" + }, + { + "interface": "Wireguard2", + "ip_address": "10.2.100.1", + "mask": 24, + "description": "test_HW" + } + ], + "vlans": [ + { + "vlan_id": 1, + "description": "Home VLAN" + }, + { + "vlan_id": 2, + "description": "Подключение Ethernet" + }, + { + "vlan_id": 3, + "description": "Home network" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/mikrotik/config.conf b/tests/fixtures/mikrotik/config.conf new file mode 100644 index 0000000..326f5b3 --- /dev/null +++ b/tests/fixtures/mikrotik/config.conf @@ -0,0 +1,123 @@ +# 2026-02-18 22:32:59 by RouterOS 7.19.3 +# software id = 0V5S-56MC +# version: 7.12 +# model = C52iG-5HaxD2HaxD +# serial number = HE108BBGW0B +/interface bridge +add name=bridge.LAN +/interface ethernet +set [ find default-name=ether1 ] mac-address=C4:AD:32:B2:A1:9A poe-out=off +set [ find default-name=ether4 ] comment=test +/interface vlan +add comment="super test vlan" interface=ether4 name="test vlan" vlan-id=255 +add disabled=yes interface=ether5 name="test test vlan" vlan-id=254 +/interface list +add name=LAN +add name=WAN +/interface wifi channel +add band=2ghz-ax disabled=no name=ch-2ghz width=20/40mhz +add band=5ghz-ax disabled=no name=ch-5ghz width=20/40mhz +/interface wifi security +add authentication-types=wpa2-psk name=common-auth wps=disable +/interface wifi configuration +add name=common-auth security=common-auth ssid=test_HW +/interface wifi +set [ find default-name=wifi1 ] channel=ch-5ghz configuration=common-auth configuration.mode=ap disabled=no +set [ find default-name=wifi2 ] channel=ch-2ghz configuration=common-auth configuration.mode=ap disabled=no +/ip pool +add name=dhcp_pool0 ranges=172.16.3.2-172.16.3.254 +add name=dhcp_pool1 ranges=192.168.3.2-192.168.3.254 +/ip dhcp-server +add address-pool=dhcp_pool0 interface=bridge.LAN lease-time=10h name=dhcp1 +/ppp profile +add name=new_antizapret on-down="/ip dns cache flush\r\ + \n" on-up="/ip dns cache flush\r\ + \n" +add name=robovps on-down="/ip dns cache flush" on-up="/ip dns cache flush" use-ipv6=no +/routing table +add disabled=no fib name=test_HW_table +/interface bridge port +add bridge=bridge.LAN interface=ether2 learn=yes +add bridge=bridge.LAN interface=ether3 +add bridge=bridge.LAN interface=ether4 +add bridge=bridge.LAN interface=ether5 +add bridge=bridge.LAN interface=wifi1 +add bridge=bridge.LAN interface=wifi2 +/ipv6 settings +set disable-ipv6=yes forward=no +/interface detect-internet +set detect-interface-list=all +/interface list member +add interface=bridge.LAN list=LAN +add interface=ether1 list=WAN +/interface ovpn-server server +add mac-address=FE:25:E0:B8:66:01 name=ovpn-server1 +/ip address +add address=172.16.3.1/24 interface=bridge.LAN network=172.16.3.0 +add address=10.38.3.245/24 interface=ether1 network=10.38.3.0 +add address=10.1.100.2/24 interface=wireguard2 network=10.1.100.0 +add address=100.10.10.1/24 disabled=yes interface=ether4 network=100.10.10.0 +/ip dhcp-server lease +add address=172.16.3.20 client-id=1:d8:3a:dd:22:28:1d mac-address=D8:3A:DD:21:28:1D server=dhcp1 +add address=172.16.3.4 client-id=1:2c:cd:29:1a:ea:6d comment=test_HW mac-address=1E:CD:29:8A:EA:6D server=dhcp1 +/ip dhcp-server network +add address=172.16.3.0/24 dns-server=172.16.3.1 gateway=172.16.3.1 +/ip dns +set allow-remote-requests=yes servers=217.10.44.35 +/ip dns static +add address=172.16.3.20 regexp=".*\\.home\$" type=A +add address=172.16.3.20 regexp=".*\\.home.uk\$" type=A +add address=172.16.3.20 disabled=yes regexp=".*\\.home.uk\$" type=A +/ip firewall address-list +add address=172.16.3.0/24 list=test_HW +add address=172.16.2.0/24 list=test_HW +add address=172.16.1.0/24 list=test_HW +add address=255.255.255.255 list=test_HW +/ip firewall filter +add action=drop chain=forward out-interface=ether1 src-address=172.16.3.11 +add action=accept chain=input src-address=255.255.255.255 +add action=accept chain=forward in-interface=wireguard2 +add action=accept chain=forward connection-state=established,related in-interface=ether1 +add action=accept chain=input connection-state=established,related in-interface=ether1 +add action=accept chain=input in-interface=bridge.LAN +add action=drop chain=input in-interface=ether1 +add action=drop chain=forward in-interface=ether1 +/ip firewall mangle +add action=passthrough chain=prerouting connection-mark=test_HW src-address=172.16.3.20 +/ip firewall nat +add action=masquerade chain=srcnat out-interface=ether1 +add action=masquerade chain=srcnat comment=test_HW out-interface=test_HW +/ip firewall service-port +set ftp disabled=yes +set tftp disabled=yes +set h323 disabled=yes +/ip route +add disabled=no distance=1 dst-address=172.16.1.0/24 gateway=10.1.100.1 +add disabled=no distance=1 dst-address=172.16.2.0/24 gateway=10.1.100.1 +add disabled=no distance=1 dst-address=255.255.255.255/32 gateway=10.38.3.1 +add disabled=no distance=3 dst-address=0.0.0.0/0 gateway=10.38.3.1 routing-table=main scope=30 suppress-hw-offload=no target-scope=10 +add disabled=yes distance=2 dst-address=0.0.0.0/0 gateway=10.38.3.1 routing-table=main scope=30 suppress-hw-offload=no target-scope=10 +add comment=test_HW disabled=no distance=1 dst-address=185.255.255.255/32 gateway=10.38.3.1 routing-table=main scope=30 suppress-hw-offload=no target-scope=10 +add comment=test_HW disabled=no distance=1 dst-address=192.168.255.255/32 gateway=10.38.3.1 routing-table=main scope=30 suppress-hw-offload=no target-scope=10 +/ip service +set telnet disabled=yes +/system clock +set time-zone-name=Europe/Berlin +/system identity +set name=test_HW +/system logging +set 0 topics=info,!wireless +/system note +set show-at-login=no +/system ntp client +set enabled=yes +/system ntp client servers +add address=pool.ntp.org +/tool mac-server +set allowed-interface-list=LAN +/tool mac-server mac-winbox +set allowed-interface-list=LAN +/tool mac-server ping +set enabled=no +/user aaa +set default-group=full use-radius=yes \ No newline at end of file diff --git a/tests/fixtures/mikrotik/config.expected.json b/tests/fixtures/mikrotik/config.expected.json new file mode 100644 index 0000000..04427e6 --- /dev/null +++ b/tests/fixtures/mikrotik/config.expected.json @@ -0,0 +1,43 @@ +{ + "system": { + "model": "C52iG-5HaxD2HaxD", + "serial_number": "HE108BBGW0B", + "version": "7.12" + }, + "interfaces": [ + { + "interface": "bridge.LAN", + "ip_address": "172.16.3.1", + "mask": 24, + "description": null + }, + { + "interface": "ether1", + "ip_address": "10.38.3.245", + "mask": 24, + "description": null + }, + { + "interface": "wireguard2", + "ip_address": "10.1.100.2", + "mask": 24, + "description": null + }, + { + "interface": "ether4", + "ip_address": "100.10.10.1", + "mask": 24, + "description": null + } + ], + "vlans": [ + { + "vlan_id": 255, + "description": "test vlan" + }, + { + "vlan_id": 254, + "description": "test test vlan" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/qtech/config_1.conf b/tests/fixtures/qtech/config_1.conf new file mode 100644 index 0000000..df0dcb1 --- /dev/null +++ b/tests/fixtures/qtech/config_1.conf @@ -0,0 +1,42 @@ +! QTECH LLC Internetwork Operating System Software +! QSW-8330-40T-DC Series Software, Version 2.2.0C Build 96279, RELEASE SOFTWARE +! ROM: System Bootstrap, Version 0.4.7,hardware version:A +! Serial num:6060606060606060, ID num:555555555555 +! System image file is "Switch.bin" +! QTECH LLC QSW-8330-40T-DC RISC +! 524288K bytes of memory,16384K bytes of flash +! Base ethernet MAC Address: 08:c6:b3:08:cf:ff +! snmp info: +! vend_ID:27514 product_ID:404 system_ID:1.3.6.1.4.1.27514 +interface GigaEthernet1/0/9 + shutdown + description FREE + switchport pvid 102 + storm-control broadcast threshold 15 + storm-control broadcast action shutdown + storm-control broadcast auto_resume 60s + storm-control multicast threshold 10 + storm-control multicast action shutdown + storm-control multicast auto_resume 60s + qos policy IPP3 ingress +! +interface VLAN1 + ip address 192.168.0.1 255.255.0.0 + ip mtu 1500 + no ip directed-broadcast +! +interface VLAN1002 + description test-1002 + ip address 13.36.8.1 255.255.255.0 + ip mtu 1500 + no ip directed-broadcast +! +vlan 772 + name test +! +vlan 888 + name test_super +! +vlan 1,7,14-15,44,101-102,115,117-124,130-136,139,167,200-205,772 +,1607 +vlan 888,2016,2085-2088 diff --git a/tests/fixtures/qtech/config_1.expected.json b/tests/fixtures/qtech/config_1.expected.json new file mode 100644 index 0000000..e9721f4 --- /dev/null +++ b/tests/fixtures/qtech/config_1.expected.json @@ -0,0 +1,185 @@ +{ + "system": { + "model": "QSW-8330-40T-DC", + "serial_number": "6060606060606060", + "version": "96279" + }, + "interfaces": [ + { + "interface": "GigaEthernet1/0/9", + "ip_address": null, + "mask": null, + "description": "FREE" + }, + { + "interface": "VLAN1", + "ip_address": "192.168.0.1", + "mask": 16, + "description": null + }, + { + "interface": "VLAN1002", + "ip_address": "13.36.8.1", + "mask": 24, + "description": "test-1002" + } + ], + "vlans": [ + { + "vlan_id": 772, + "description": "test" + }, + { + "vlan_id": 888, + "description": "test_super" + }, + { + "vlan_id": 1, + "description": null + }, + { + "vlan_id": 7, + "description": null + }, + { + "vlan_id": 14, + "description": null + }, + { + "vlan_id": 15, + "description": null + }, + { + "vlan_id": 44, + "description": null + }, + { + "vlan_id": 101, + "description": null + }, + { + "vlan_id": 102, + "description": null + }, + { + "vlan_id": 115, + "description": null + }, + { + "vlan_id": 117, + "description": null + }, + { + "vlan_id": 118, + "description": null + }, + { + "vlan_id": 119, + "description": null + }, + { + "vlan_id": 120, + "description": null + }, + { + "vlan_id": 121, + "description": null + }, + { + "vlan_id": 122, + "description": null + }, + { + "vlan_id": 123, + "description": null + }, + { + "vlan_id": 124, + "description": null + }, + { + "vlan_id": 130, + "description": null + }, + { + "vlan_id": 131, + "description": null + }, + { + "vlan_id": 132, + "description": null + }, + { + "vlan_id": 133, + "description": null + }, + { + "vlan_id": 134, + "description": null + }, + { + "vlan_id": 135, + "description": null + }, + { + "vlan_id": 136, + "description": null + }, + { + "vlan_id": 139, + "description": null + }, + { + "vlan_id": 167, + "description": null + }, + { + "vlan_id": 200, + "description": null + }, + { + "vlan_id": 201, + "description": null + }, + { + "vlan_id": 202, + "description": null + }, + { + "vlan_id": 203, + "description": null + }, + { + "vlan_id": 204, + "description": null + }, + { + "vlan_id": 205, + "description": null + }, + { + "vlan_id": 1607, + "description": null + }, + { + "vlan_id": 2016, + "description": null + }, + { + "vlan_id": 2085, + "description": null + }, + { + "vlan_id": 2086, + "description": null + }, + { + "vlan_id": 2087, + "description": null + }, + { + "vlan_id": 2088, + "description": null + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/qtech/config_2.conf b/tests/fixtures/qtech/config_2.conf new file mode 100644 index 0000000..cf22ea4 --- /dev/null +++ b/tests/fixtures/qtech/config_2.conf @@ -0,0 +1,36 @@ +! QTECH LLC Internetwork Operating System Software +! QSW-8330-40T-DC Series Software, Version 2.2.0C Build 96279, RELEASE SOFTWARE +! ROM: System Bootstrap, Version 0.4.7,hardware version:A +! Serial num:6060606060606060, ID num:555555555555 +! System image file is "Switch.bin" +! QTECH LLC QSW-8330-40T-DC RISC +! 524288K bytes of memory,16384K bytes of flash +! Base ethernet MAC Address: 08:c6:b3:08:cf:f7 +! snmp info: +! vend_ID:27514 product_ID:404 system_ID:1.3.6.1.4.1.27514 +interface GigaEthernet1/0/9 + shutdown + description FREE + switchport pvid 102 + storm-control broadcast threshold 15 + storm-control broadcast action shutdown + storm-control broadcast auto_resume 60s + storm-control multicast threshold 10 + storm-control multicast action shutdown + storm-control multicast auto_resume 60s + qos policy IPP3 ingress +! +interface VLAN1 + ip address 192.168.0.1 255.255.0.0 + ip mtu 1500 + no ip directed-broadcast +! +interface VLAN1002 + description test-1002 + ip address 13.36.8.1 255.255.255.0 + ip mtu 1500 + no ip directed-broadcast +! +vlan 1,7,14-15,44,101-102,115,117-124,130-136,139,167,200-205,772 +,1607 +vlan 888,2016,2085-2088 diff --git a/tests/fixtures/qtech/config_2.expected.json b/tests/fixtures/qtech/config_2.expected.json new file mode 100644 index 0000000..e9a4a80 --- /dev/null +++ b/tests/fixtures/qtech/config_2.expected.json @@ -0,0 +1,185 @@ +{ + "system": { + "model": "QSW-8330-40T-DC", + "serial_number": "6060606060606060", + "version": "96279" + }, + "interfaces": [ + { + "interface": "GigaEthernet1/0/9", + "ip_address": null, + "mask": null, + "description": "FREE" + }, + { + "interface": "VLAN1", + "ip_address": "192.168.0.1", + "mask": 16, + "description": null + }, + { + "interface": "VLAN1002", + "ip_address": "13.36.8.1", + "mask": 24, + "description": "test-1002" + } + ], + "vlans": [ + { + "vlan_id": 1, + "description": null + }, + { + "vlan_id": 7, + "description": null + }, + { + "vlan_id": 14, + "description": null + }, + { + "vlan_id": 15, + "description": null + }, + { + "vlan_id": 44, + "description": null + }, + { + "vlan_id": 101, + "description": null + }, + { + "vlan_id": 102, + "description": null + }, + { + "vlan_id": 115, + "description": null + }, + { + "vlan_id": 117, + "description": null + }, + { + "vlan_id": 118, + "description": null + }, + { + "vlan_id": 119, + "description": null + }, + { + "vlan_id": 120, + "description": null + }, + { + "vlan_id": 121, + "description": null + }, + { + "vlan_id": 122, + "description": null + }, + { + "vlan_id": 123, + "description": null + }, + { + "vlan_id": 124, + "description": null + }, + { + "vlan_id": 130, + "description": null + }, + { + "vlan_id": 131, + "description": null + }, + { + "vlan_id": 132, + "description": null + }, + { + "vlan_id": 133, + "description": null + }, + { + "vlan_id": 134, + "description": null + }, + { + "vlan_id": 135, + "description": null + }, + { + "vlan_id": 136, + "description": null + }, + { + "vlan_id": 139, + "description": null + }, + { + "vlan_id": 167, + "description": null + }, + { + "vlan_id": 200, + "description": null + }, + { + "vlan_id": 201, + "description": null + }, + { + "vlan_id": 202, + "description": null + }, + { + "vlan_id": 203, + "description": null + }, + { + "vlan_id": 204, + "description": null + }, + { + "vlan_id": 205, + "description": null + }, + { + "vlan_id": 772, + "description": null + }, + { + "vlan_id": 1607, + "description": null + }, + { + "vlan_id": 888, + "description": null + }, + { + "vlan_id": 2016, + "description": null + }, + { + "vlan_id": 2085, + "description": null + }, + { + "vlan_id": 2086, + "description": null + }, + { + "vlan_id": 2087, + "description": null + }, + { + "vlan_id": 2088, + "description": null + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/quasar/config_1.conf b/tests/fixtures/quasar/config_1.conf new file mode 100644 index 0000000..3a37374 --- /dev/null +++ b/tests/fixtures/quasar/config_1.conf @@ -0,0 +1,41 @@ +# Copyright © 2021-2022, TechArgos LLC +# ----------- ----------------- +# Subsystem Version +# ----------- ----------------- +# Engine 0.2.17.2022-10-21 +# DPlane 0.2.18.2022-05-16 +# BfMonitor 1.1.2.2022-08-25 +# CLI.core 1.0.3.2022-09-30 +# CLI.engine 1.2.2.2022-10-12 +# RConsole 0.3.4.2022-01-12 +# RcAppParams 0.3.1.2022-01-12 +# SNMP 0.0.11.2022-04-26 +# Zabbix 0.2.13.2022-01-26 +# WebUI 1.1.3.2022-10-19 +# BF.core 9.3.1.2021-01-30 +# +# ------------------------- --------------- +# Platform EEPROM field Value +# ------------------------- --------------- +# Product Name Quasar-T-Q-0002 +# Product Number HB4NC011234M +# Local MAC N/A +# Product Serial Number WEE1C1CC0004A +# Product Version 0.0 +# System Manufacturing Date 2026-06-07 + config ethernet ipv4 address 25.25.1.221/24 gateway 25.25.1.254 enable + + config interface 7/4 fec none mode force-up enable + config interface 8/1 fec none mode force-up enable + config interface 8/2 fec none mode force-up enable + config interface 8/3 fec none mode force-up enable + config interface 8/4 fec none mode force-up enable + config interface 9/1 fec none mode force-up enable + config interface 9/2 fec none mode force-up enable + config interface 7/4 description "IN DWDM / OUT TEST_HW_08_N0_p1" + config interface 8/1 description "IN DWDM / OUT TEST_HW_09_N1_p0" + config interface 8/2 description "IN DWDM / OUT TEST_HW_09_N0_p0" + config interface 8/3 description "IN DWDM / OUT TEST_HW_10_N1_p0" + config interface 8/4 description "IN DWDM / OUT TEST_HW_10_N0_p0" + config interface 9/1 description "IN DWDM / OUT TEST_HW_11_N0_p0" + config interface 9/2 description "IN DWDM / OUT TEST_HW_11_N0_p1" diff --git a/tests/fixtures/quasar/config_1.expected.json b/tests/fixtures/quasar/config_1.expected.json new file mode 100644 index 0000000..592ffbd --- /dev/null +++ b/tests/fixtures/quasar/config_1.expected.json @@ -0,0 +1,58 @@ +{ + "system": { + "model": "Quasar-T-Q-0002", + "serial_number": "WEE1C1CC0004A", + "version": "0.2.17.2022-10-21" + }, + "interfaces": [ + { + "interface": "7/4", + "ip_address": null, + "mask": null, + "description": "IN DWDM / OUT TEST_HW_08_N0_p1" + }, + { + "interface": "8/1", + "ip_address": null, + "mask": null, + "description": "IN DWDM / OUT TEST_HW_09_N1_p0" + }, + { + "interface": "8/2", + "ip_address": null, + "mask": null, + "description": "IN DWDM / OUT TEST_HW_09_N0_p0" + }, + { + "interface": "8/3", + "ip_address": null, + "mask": null, + "description": "IN DWDM / OUT TEST_HW_10_N1_p0" + }, + { + "interface": "8/4", + "ip_address": null, + "mask": null, + "description": "IN DWDM / OUT TEST_HW_10_N0_p0" + }, + { + "interface": "9/1", + "ip_address": null, + "mask": null, + "description": "IN DWDM / OUT TEST_HW_11_N0_p0" + }, + { + "interface": "9/2", + "ip_address": null, + "mask": null, + "description": "IN DWDM / OUT TEST_HW_11_N0_p1" + }, + { + "interface": "ethernet", + "ip_address": "25.25.1.221", + "mask": 24, + "description": null + } + ], + "vlans": [] +} \ No newline at end of file diff --git a/tests/fixtures/quasar/config_2.conf b/tests/fixtures/quasar/config_2.conf new file mode 100644 index 0000000..9d21864 --- /dev/null +++ b/tests/fixtures/quasar/config_2.conf @@ -0,0 +1,43 @@ +# Copyright © 2021-2024, TechArgos LLC +# ----------- ------------------ +# Component Version +# ----------- ------------------ +# Assembly 0.2.23_9.9.1_GA +# Engine 2.23.15.2024-08-22 +# DPlane 0.2.20.2023-10-17 +# BfMonitor 1.4.0.2024-08-27 +# CLI.core 1.0.3.2022-09-30 +# CLI.engine 1.3.4.2024-08-22 +# RConsole 1.0.1.2022-12-23 +# RcAppParams 1.0.1.2022-12-23 +# SNMP 0.3.0.2024-07-08 +# Zabbix 0.2.13.2022-01-26 +# WebUI 1.3.34.2024-08-30 +# NMS.agent 1.2.12.2024-08-21 +# SysLog 0.7.12.2024-02-02 +# NTPd 0.1.5.2024-07-15 +# AAA 0.0.8.2023-11-16 +# +# ------------------------- -------------------- +# Platform EEPROM field Value +# ------------------------- -------------------- +# Product Name D5232C-T +# Part Number TA-PB-D5232C-T-AC-PI +# Local MAC N/A +# Product Serial Number WHF1C87123456A +# Product Version 0.0 +# System Manufacturing Date 2026-06-07 + config ethernet ipv4 address 25.25.18.19/24 gateway 25.25.18.254 enable + config interface 1/1 description "TEST_HW_1_1" + config interface 1/3 description "TEST_HW_1_3" + config interface 2/1 description "TEST_HW_2_1" + config interface 2/2 description "TEST_HW_2_2" + config interface 2/3 description "TEST_HW_2_3" + config interface 2/4 description "TEST_HW_2_4" + config interface 3/1 description "TEST_HW_3_1" + config interface 3/3 description "TEST_HW_3_3" + config interface 3/4 description "TEST_HW_3_4" + config interface 4/1 description "TEST_HW_4_1" + config interface 4/2 description "TEST_HW_4_2" + config interface 4/3 description "TEST_HW_4_3" + config interface 4/4 description "TEST_HW_4_4" \ No newline at end of file diff --git a/tests/fixtures/quasar/config_2.expected.json b/tests/fixtures/quasar/config_2.expected.json new file mode 100644 index 0000000..1ddaf5a --- /dev/null +++ b/tests/fixtures/quasar/config_2.expected.json @@ -0,0 +1,94 @@ +{ + "system": { + "model": "D5232C-T", + "serial_number": "WHF1C87123456A", + "version": "0.2.23_9.9.1_GA" + }, + "interfaces": [ + { + "interface": "1/1", + "ip_address": null, + "mask": null, + "description": "TEST_HW_1_1" + }, + { + "interface": "1/3", + "ip_address": null, + "mask": null, + "description": "TEST_HW_1_3" + }, + { + "interface": "2/1", + "ip_address": null, + "mask": null, + "description": "TEST_HW_2_1" + }, + { + "interface": "2/2", + "ip_address": null, + "mask": null, + "description": "TEST_HW_2_2" + }, + { + "interface": "2/3", + "ip_address": null, + "mask": null, + "description": "TEST_HW_2_3" + }, + { + "interface": "2/4", + "ip_address": null, + "mask": null, + "description": "TEST_HW_2_4" + }, + { + "interface": "3/1", + "ip_address": null, + "mask": null, + "description": "TEST_HW_3_1" + }, + { + "interface": "3/3", + "ip_address": null, + "mask": null, + "description": "TEST_HW_3_3" + }, + { + "interface": "3/4", + "ip_address": null, + "mask": null, + "description": "TEST_HW_3_4" + }, + { + "interface": "4/1", + "ip_address": null, + "mask": null, + "description": "TEST_HW_4_1" + }, + { + "interface": "4/2", + "ip_address": null, + "mask": null, + "description": "TEST_HW_4_2" + }, + { + "interface": "4/3", + "ip_address": null, + "mask": null, + "description": "TEST_HW_4_3" + }, + { + "interface": "4/4", + "ip_address": null, + "mask": null, + "description": "TEST_HW_4_4" + }, + { + "interface": "ethernet", + "ip_address": "25.25.18.19", + "mask": 24, + "description": null + } + ], + "vlans": [] +} \ No newline at end of file diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..95f8860 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,39 @@ +import json + +import pytest +from conftest import FIXTURES, load + +from oxi.interfaces import device_registry + +MODEL_CASES = [ + ("mikrotik", "config.conf", "config.expected.json"), + ("keenetic", "config.conf", "config.expected.json"), + ("qtech", "config_1.conf", "config_1.expected.json"), + ("qtech", "config_2.conf", "config_2.expected.json"), + ("huawei", "config.conf", "config.expected.json"), + ("eltex", "config.conf", "config.expected.json"), + ("h3c", "config.conf", "config.expected.json"), + ("quasar", "config_1.conf", "config_1.expected.json"), + ("quasar", "config_2.conf", "config_2.expected.json"), +] + + +@pytest.mark.parametrize("model_key, fixture, expected_file", MODEL_CASES) +def test_parse_matches_golden(model_key, fixture, expected_file): + cls = device_registry[model_key] + raw = load(model_key, fixture) + + parsed = cls(raw).parse().model_dump(by_alias=True, mode="json") + + expected = json.loads((FIXTURES / model_key / expected_file).read_text("utf-8")) + assert parsed == expected + + +@pytest.mark.parametrize("model_key, fixture, _expected", MODEL_CASES) +def test_parse_has_required_sections(model_key, fixture, _expected): + cls = device_registry[model_key] + device = cls(load(model_key, fixture)).parse() + + assert device.system is not None + assert isinstance(device.interfaces, list) + assert isinstance(device.vlans, list) diff --git a/tests/test_network.py b/tests/test_network.py new file mode 100644 index 0000000..92e54c7 --- /dev/null +++ b/tests/test_network.py @@ -0,0 +1,87 @@ +import pytest +import responses +from conftest import load + +from oxi import OxiAPI +from oxi.exception import OxiAPIError + +BASE = "https://oxi.example.com" + +NODE_DATA = { + "name": "HQ", + "full_name": "grp/HQ", + "model": "keenetic", + "ip": "192.168.1.1", + "group": "grp", +} + + +@responses.activate +def test_node_show_returns_view(): + responses.get(f"{BASE}/node/show/HQ.json", json=NODE_DATA) + + api = OxiAPI(url=BASE) + node = api.node("HQ") + + assert node.ip == "192.168.1.1" + assert node.model == "keenetic" + assert node.full_name == "grp/HQ" + assert node.group == "grp" + + +@responses.activate +def test_node_config_fetches_and_parses(): + responses.get(f"{BASE}/node/show/HQ.json", json=NODE_DATA) + responses.get(f"{BASE}/node/fetch/grp/HQ", body=load("keenetic")) + + api = OxiAPI(url=BASE) + config = api.node("HQ").config + + assert config.system.model == "Sprinter (KN-3710)" + assert len(config.interfaces) > 0 + + +@responses.activate +def test_node_not_found_maps_to_404(): + responses.get(f"{BASE}/node/show/missing.json", status=404) + + api = OxiAPI(url=BASE) + with pytest.raises(OxiAPIError) as exc: + api.node("missing") + + assert exc.value.status_code == 404 + + +@responses.activate +def test_500_with_node_not_found_html_maps_to_404(): + responses.get( + f"{BASE}/node/show/ghost.json", + status=500, + content_type="text/html", + body="<html><title>Oxidized::NodeNotFound", + ) + + api = OxiAPI(url=BASE) + with pytest.raises(OxiAPIError) as exc: + api.node("ghost") + + assert exc.value.status_code == 404 + + +@responses.activate +def test_reload_returns_status_code(): + responses.get(f"{BASE}/reload", status=200) + + api = OxiAPI(url=BASE) + assert api.reload() == 200 + + +@responses.activate +def test_unknown_model_raises_value_error(): + data = {**NODE_DATA, "model": "unknown_vendor"} + responses.get(f"{BASE}/node/show/HQ.json", json=data) + responses.get(f"{BASE}/node/fetch/grp/HQ", body="whatever") + + api = OxiAPI(url=BASE) + with pytest.raises(ValueError, match="not found in registry"): + _ = api.node("HQ").config diff --git a/tests/test_units.py b/tests/test_units.py new file mode 100644 index 0000000..846e7fa --- /dev/null +++ b/tests/test_units.py @@ -0,0 +1,65 @@ +import pytest +from conftest import load + +from oxi.exception import OxiAPIError +from oxi.interfaces import device_registry +from oxi.interfaces.base import BaseDevice +from oxi.interfaces.utils import decode_utf, expand_vlan_range + + +class TestExpandVlanRange: + @pytest.mark.parametrize("expand", [expand_vlan_range]) + def test_simple_and_range(self, expand): + assert expand("1,7,14-15") == ["1", "7", "14", "15"] + + @pytest.mark.parametrize("expand", [expand_vlan_range]) + def test_reversed_range_is_normalized(self, expand): + assert expand("15-13") == ["13", "14", "15"] + + @pytest.mark.parametrize("expand", [expand_vlan_range]) + def test_non_numeric_range_kept_verbatim(self, expand): + assert expand("a-b") == ["a-b"] + + @pytest.mark.parametrize("expand", [expand_vlan_range]) + def test_empty(self, expand): + assert expand("") == [] + + @pytest.mark.parametrize("expand", [expand_vlan_range]) + def test_list_input(self, expand): + assert expand(["1", "3-4"]) == ["1", "3", "4"] + + +class TestDecodeUtf: + def test_plain_text_passthrough(self): + assert decode_utf("Plain ASCII") == "Plain ASCII" + + def test_escaped_utf8_is_decoded(self): + assert decode_utf(r'"\xd0\x94\xd0\xbe\xd0\xbc"') == "Дом" + + +class TestTemplateValidation: + def test_missing_required_group_raises(self): + class OnlySystem(BaseDevice): + template = "dummy.ttp" + + def _load_template(self): + return '' + + with pytest.raises(ValueError, match="missing required groups"): + OnlySystem("data") + + def test_missing_template_file_raises(self): + class NoTemplate(BaseDevice): + template = "does_not_exist.ttp" + + with pytest.raises(FileNotFoundError): + NoTemplate("data") + + +class TestNodeNotFound: + def test_not_found_config_raises_on_parse(self): + device = device_registry["eltex"](load("eltex", "not_found.conf"), name="HQ") + assert device.raw is None + with pytest.raises(OxiAPIError) as exc: + device.parse() + assert exc.value.status_code == 404 diff --git a/tests/test_view.py b/tests/test_view.py new file mode 100644 index 0000000..ea38ac8 --- /dev/null +++ b/tests/test_view.py @@ -0,0 +1,72 @@ +import json + +import pytest + +from oxi.conf import ModelView +from oxi.interfaces.contract import Interfaces, System + + +@pytest.fixture +def system_view(): + system = System(model="RB951", serial_number="ABC123", version="7.12") + return ModelView(system) + + +@pytest.fixture +def interfaces_view(): + items = [ + Interfaces(interface="eth0", ip_address="192.168.1.1", mask=24), + Interfaces(interface="eth1", description="uplink"), + ] + return ModelView(items) + + +class TestSingleModelView: + def test_attribute_proxy(self, system_view): + assert system_view.model == "RB951" + assert system_view.serial_number == "ABC123" + + def test_dump(self, system_view): + assert system_view.dump() == { + "model": "RB951", + "serial_number": "ABC123", + "version": "7.12", + } + + def test_dump_json(self, system_view): + assert json.loads(system_view.dump_json())["model"] == "RB951" + + def test_iter_raises(self, system_view): + with pytest.raises(TypeError): + iter(system_view) + + def test_len_raises(self, system_view): + with pytest.raises(TypeError): + len(system_view) + + def test_getitem_raises(self, system_view): + with pytest.raises(TypeError): + system_view[0] + + +class TestListModelView: + def test_len(self, interfaces_view): + assert len(interfaces_view) == 2 + + def test_iter(self, interfaces_view): + names = [iface.name for iface in interfaces_view] + assert names == ["eth0", "eth1"] + + def test_getitem(self, interfaces_view): + assert interfaces_view[0].name == "eth0" + + def test_slice(self, interfaces_view): + assert len(interfaces_view[:1]) == 1 + + def test_dump_uses_aliases(self, interfaces_view): + dumped = interfaces_view.dump() + assert dumped[0]["interface"] == "eth0" + + def test_dump_json_keeps_unicode(self): + view = ModelView([Interfaces(interface="eth0", description="Дом")]) + assert "Дом" in view.dump_json() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..0e2bfb7 --- /dev/null +++ b/uv.lock @@ -0,0 +1,578 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[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 = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "oxipy" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pydantic" }, + { name = "requests" }, + { name = "ttp" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "responses" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "responses" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.12.5" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, + { name = "requests", specifier = ">=2.32.5" }, + { name = "responses", marker = "extra == 'dev'", specifier = ">=0.26.1" }, + { name = "ttp", specifier = ">=0.10.0" }, +] +provides-extras = ["dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.3" }, + { name = "responses", specifier = ">=0.26.1" }, + { name = "ruff", specifier = ">=0.15.17" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +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/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "responses" +version = "0.26.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/58/1fb6de3503428196df78638f991ec8095274f1ee9723e272ee4d9ff0092b/responses-0.26.1.tar.gz", hash = "sha256:2eb3218553cc8f79b57d257bac23af5e1bf381f5b9390b1767816f0843e01dc2", size = 83088, upload-time = "2026-05-21T19:56:39.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/31/6a620b4427d546b9e7cca8b3b8c5f0559d9cef2bb9eedcda7f73c1473c19/responses-0.26.1-py3-none-any.whl", hash = "sha256:8aacc4586eb08fb2208ef64a9eb4258d9b0c6e6f4260845f2f018ab847495345", size = 35502, upload-time = "2026-05-21T19:56:38.046Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, + { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, + { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "ttp" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/e6/9169d35574be82df2a0cdd2546f4f83d0d30964cf0043fc9784df855b024/ttp-0.10.0.tar.gz", hash = "sha256:40f1ca61ee1431f5b1ab5326fb55f852a04749e9574792d45455b62c5e7ac97b", size = 64665, upload-time = "2025-11-02T08:47:50.329Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/c3/60abb45bd8eb973997f133eb76949523478d35dfc551a0dbd8906b6a8075/ttp-0.10.0-py3-none-any.whl", hash = "sha256:9985e0ca414e85d41493a6291a924624b9a08c48c78d2d01477cc60ba2a347c1", size = 84287, upload-time = "2025-11-02T08:47:48.656Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +]