Compare commits

..

22 Commits

Author SHA1 Message Date
53b79ce04f Merge pull request 'dev' (#3) from dev into main
All checks were successful
CI / lint (push) Successful in 29s
CI / test (3.10) (push) Successful in 28s
CI / test (3.11) (push) Successful in 43s
CI / test (3.12) (push) Successful in 41s
CI / test (3.13) (push) Successful in 43s
Reviewed-on: #3
2026-06-12 15:59:57 +03:00
IluaAir
074f2e9340 Update .gitignore and remove .python-version file
All checks were successful
CI / lint (pull_request) Successful in 45s
CI / test (3.10) (pull_request) Successful in 28s
CI / test (3.11) (pull_request) Successful in 42s
CI / test (3.12) (pull_request) Successful in 47s
CI / test (3.13) (pull_request) Successful in 39s
- Added .python-version to .gitignore to prevent tracking of Python version files.
- Deleted the .python-version file as it is no longer needed for the project.
2026-06-12 00:03:55 +03:00
IluaAir
dd7f117380 Add CI workflows for Gitea and GitHub
- Created a CI workflow for Gitea with linting and testing jobs using Python versions 3.10 to 3.13.
- Established a CI workflow for GitHub with similar linting and testing jobs, ensuring consistency across platforms.
- Both workflows utilize the `uv` tool for dependency management and testing, enhancing the project's CI/CD capabilities.
2026-06-11 23:57:11 +03:00
IluaAir
3c0e70b320 Update project configuration and dependencies
- Added `.vscode` to `.gitignore` to exclude Visual Studio Code settings.
- Updated `pyproject.toml` to include `ruff` for linting and configured its settings.
- Modified `uv.lock` to include `ruff` in both optional and development dependencies.
- Refactored type hints in several files to use `str | None` for optional parameters.
- Cleaned up unused imports and whitespace in various modules for improved code clarity.
2026-06-11 23:47:39 +03:00
IluaAir
c40cae1561 Update dependencies and clean up Huawei model code
- Updated `pytest` to version 9.0.3 and `responses` to version 0.26.1 in `pyproject.toml`.
- Removed the main execution block from the Huawei model for cleaner code structure.
2026-06-11 23:22:45 +03:00
7b09612313 Merge pull request 'test' (#2) from test into dev
Reviewed-on: #2
2026-06-11 16:22:05 +03:00
IluaAir
8edd1a296c Add H3C device support with configuration and expected output files
- Introduced support for H3C devices by adding a new configuration file `config.conf` containing interface and VLAN settings.
- Created an expected output JSON file `config.expected.json` to validate the parsing of H3C configurations.
- Updated the test model cases to include H3C for comprehensive testing of device parsing functionality.
2026-06-11 16:16:14 +03:00
IluaAir
686cd6d715 Remove main execution block from Quasar model and add configuration files for testing
- Eliminated the main execution block from the Quasar model for cleaner code.
- Introduced new configuration files `config_1.conf` and `config_2.conf` for Quasar devices, detailing interface settings and IP configurations.
- Added expected output JSON files `config_1.expected.json` and `config_2.expected.json` to validate the parsing of Quasar configurations against expected results.
2026-06-07 09:12:47 +03:00
IluaAir
acb3a6291c Add expected configuration output for Mikrotik devices
- Introduced a new JSON file `config.expected.json` containing expected system and interface configurations for Mikrotik devices, including model, serial number, IP addresses, and VLAN details.
- This addition facilitates testing and validation of the parsing functionality for Mikrotik configurations.
2026-06-07 09:07:17 +03:00
IluaAir
9c90279868 Add normalization method for TTP group results in BaseDevice class
- Introduced a static method `_as_list` to normalize TTP group results, ensuring consistent list output regardless of input type (dict or list).
- Updated the `_validate_contract` method to utilize `_as_list` for processing interfaces and VLANs, improving code clarity and reliability.
2026-06-07 09:06:52 +03:00
IluaAir
2ea056aa17 Refactor Mikrotik model and update TTP template for improved configuration handling
- Removed unused imports and main execution block from the Mikrotik model for cleaner code.
- Updated the Mikrotik TTP template to adjust the order of parameters in the 'add' command, enhancing clarity in the generated configurations.
- Added a new configuration file `config.conf` for Mikrotik devices to facilitate testing and validation of parsing functionality.
2026-06-07 09:06:24 +03:00
IluaAir
a617bd6ecd Add description fields to Eltex interface template
- Updated the Eltex TTP template to include 'name' and 'description' fields for interfaces, enhancing clarity and detail in the generated configurations.
2026-06-07 08:47:51 +03:00
IluaAir
0ef5e7798a Refactor Qtech model to utilize centralized VLAN range expansion utility
- Replaced the internal `_expand_vlan_range` function in the `Qtech` class with the new `expand_vlan_range` utility from `utils.py` for improved code maintainability.
- Added new configuration files `config_1.conf` and `config_2.conf` for Qtech devices to facilitate testing.
- Introduced expected output JSON files `config_1.expected.json` and `config_2.expected.json` to validate the parsing of Qtech configurations against expected results.
2026-06-07 08:47:08 +03:00
IluaAir
1bc01c9c1b Add Huawei configuration files for testing
- Introduced a new configuration file `config.conf` for Huawei devices, detailing interface settings and VLAN configurations.
- Added an expected output JSON file `config.expected.json` to validate the parsing of Huawei configurations against expected results, including system model, serial number, and interface details.
2026-06-07 08:44:35 +03:00
IluaAir
170a2ebf85 Refactor Eltex model to use centralized VLAN range expansion utility
- Replaced the internal `_expand_vlan_range` function in the `Eltex` class with the new `expand_vlan_range` utility from `utils.py` for improved code maintainability.
- Added a new configuration file `config.conf` for Eltex devices to facilitate testing.
- Introduced an expected output JSON file `config.expected.json` to validate the parsing of Eltex configurations against expected results.
2026-06-07 08:42:58 +03:00
IluaAir
168111e23c Refactor Keenetic model to utilize centralized UTF-8 decoding utility
- Removed the internal `_decode_utf` method from the `Keenetic` class and replaced its usage with the new `decode_utf` utility function for decoding interface descriptions.
- Added a new configuration file `config.conf` for Keenetic devices to facilitate testing.
- Introduced an expected output JSON file `config.expected.json` to validate the parsing of Keenetic configurations against expected results.
2026-06-07 08:41:59 +03:00
IluaAir
d329ddc4ad Add unit tests for device registry model parsing
- Introduced a new test file `test_models.py` to implement unit tests for various device models in the `device_registry`.
- Added parameterized tests to validate the parsing of device configurations against expected JSON outputs and to ensure required sections are present in the parsed models.
- Enhanced test coverage for multiple device types including Mikrotik, Keenetic, Qtech, Huawei, Eltex, and Quasar.
2026-06-06 13:55:51 +03:00
IluaAir
229bef99f6 Add UTF-8 decoding utility and corresponding unit tests
- Introduced a new utility function `decode_utf` in `utils.py` to decode escaped UTF-8 descriptions.
- Updated unit tests in `test_units.py` to include tests for the `decode_utf` function, covering plain text and escaped UTF-8 scenarios.
- Refactored existing tests to streamline the usage of the `expand_vlan_range` function.
2026-06-06 13:55:01 +03:00
IluaAir
f446ae52e7 Add VLAN range expansion utility and corresponding unit tests
- Introduced a new utility function `expand_vlan_range` in `utils.py` to expand VLAN range strings into individual VLAN IDs.
- Created a new test file `test_units.py` with unit tests for the `expand_vlan_range` function, covering various scenarios including simple ranges, reversed ranges, non-numeric inputs, and list inputs.
- Enhanced test coverage for other functionalities in the `device_registry` and template validation classes.
2026-06-06 13:49:55 +03:00
IluaAir
c4f20d3241 Update Huawei TTP template to include model information in version display
- Modified the Huawei TTP template to append the model identifier to the version string, enhancing the clarity of system information output for diagnostics.
2026-06-06 11:13:50 +03:00
IluaAir
bebbe78163 Add unit tests for OxiAPI node functionality
- Introduced a new test file `test_network.py` to implement unit tests for the `OxiAPI` class.
- Added tests for node retrieval, configuration fetching, error handling for missing nodes, and response status checks.
- Utilized the `responses` library to mock API responses for comprehensive testing of various scenarios, including successful retrieval and error cases.
2026-06-06 11:13:32 +03:00
IluaAir
a55fc938f0 Add optional dependencies for development and update .gitignore
- Added `.DS_Store` to `.gitignore` to prevent macOS system files from being tracked.
- Introduced optional development dependencies in `pyproject.toml`, including `pytest` and `responses`, to facilitate testing and development.
- Updated `uv.lock` with new package dependencies and versions for improved compatibility and functionality.
- Created new test files `conftest.py` and `test_view.py` to establish testing fixtures and implement unit tests for the `ModelView` class.
2026-06-06 11:06:03 +03:00
53 changed files with 2727 additions and 169 deletions

40
.gitea/workflows/ci.yml Normal file
View File

@@ -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

38
.github/workflows/ci.yml vendored Normal file
View File

@@ -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

4
.gitignore vendored
View File

@@ -8,7 +8,9 @@ wheels/
main.py
# Virtual environments
.venv
.python-version
.idea
.DS_Store
.vscode
# etc files
*.txt

View File

@@ -1 +0,0 @@
3.13

View File

@@ -1,6 +1,5 @@
from .core import OxiAPI
__all__ = [
"OxiAPI",
]

View File

@@ -1,4 +1,3 @@
from typing import Optional
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
@@ -6,7 +5,7 @@ from urllib3.util import Retry
class OxiAdapter(HTTPAdapter):
def __init__(
self,
timeout: Optional[int] = None,
timeout: int | None = None,
max_retries: int = 3,
*args,
**kwargs,

View File

@@ -1,6 +1,7 @@
from functools import cached_property
import json
from typing import TYPE_CHECKING, Generic, Iterator, TypeVar
from collections.abc import Iterator
from functools import cached_property
from typing import TYPE_CHECKING, Generic, TypeVar
from pydantic import BaseModel

View File

@@ -1,8 +1,8 @@
from typing import Optional
from requests import HTTPError, Session
from oxi.adapter import OxiAdapter
from oxi.exception import OxiAPIError
from .node import Node
@@ -10,8 +10,8 @@ class OxiAPI:
def __init__(
self,
url: str,
username: Optional[str] = None,
password: Optional[str] = None,
username: str | None = None,
password: str | None = None,
verify: bool = True,
):
self.base_url = url.rstrip("/")
@@ -20,8 +20,8 @@ class OxiAPI:
def __create_session(
self,
username: Optional[str] = None,
password: Optional[str] = None,
username: str | None = None,
password: str | None = None,
verify: bool = True,
) -> Session:
session = Session()

View File

@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from requests import HTTPError
@@ -35,7 +35,7 @@ def _looks_like_node_not_found_html(e: "HTTPError") -> bool:
class OxiAPIError(Exception):
def __init__(self, message: str, status_code: Optional[int] = None):
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
self.message = message

View File

@@ -1,4 +1,4 @@
from typing import Callable, Type
from collections.abc import Callable
from .base import BaseDevice
@@ -7,7 +7,7 @@ device_registry = {}
def register_parser(
name: list[str] | str,
) -> Callable[[Type[BaseDevice]], Type[BaseDevice]]:
) -> Callable[[type[BaseDevice]], type[BaseDevice]]:
def wrapper(cls):
name_list = []
if isinstance(name, str):

View File

@@ -1,10 +1,11 @@
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
import xml.etree.ElementTree as ET
from oxi.interfaces.contract import Interfaces, System, Vlans
from oxi.interfaces.contract import Device, Interfaces, System, Vlans
class BaseDevice(ABC):
@@ -40,7 +41,7 @@ class BaseDevice(ABC):
Raises:
ValueError: if raw data cannot be validated by the contract.
"""
""" # noqa: E501
return self.raw.get("vlans", [])
def interfaces(self) -> list[dict]:
@@ -52,7 +53,7 @@ class BaseDevice(ABC):
Raises:
ValueError: if raw data cannot be validated by the contract.
"""
""" # noqa: E501
return self.raw.get("interfaces", [])
def system(self) -> dict:
@@ -67,16 +68,25 @@ class BaseDevice(ABC):
"""
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"
)
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.interfaces() or []
interfaces_data = self._as_list(self.interfaces())
result = {
"system": System(**system_data),
"interfaces": [Interfaces(**item) for item in interfaces_data],
@@ -86,10 +96,10 @@ class BaseDevice(ABC):
if "vlans" in self._declared_sections:
if "vlans" not in self.raw:
raise ValueError(
f"{self.__class__.__name__}: template '{self.template}' declares optional group "
f"'vlans', but TTP did not return it."
f"{self.__class__.__name__}: template '{self.template}' "
f"declares optional group 'vlans', but TTP did not return it."
)
vlans_data = self.vlans() or []
vlans_data = self._as_list(self.vlans())
result["vlans"] = [Vlans(**item) for item in vlans_data]
return result

View File

@@ -1,4 +1,5 @@
from ipaddress import IPv4Address
from pydantic import BaseModel, ConfigDict, Field

View File

@@ -3,5 +3,5 @@ import pkgutil
package = __package__
for loader, module_name, is_pkg in pkgutil.iter_modules(__path__):
for _, module_name, _ in pkgutil.iter_modules(__path__):
importlib.import_module(f"{package}.{module_name}")

View File

@@ -1,29 +1,6 @@
from oxi.interfaces import register_parser
from oxi.interfaces.base import BaseDevice
def _expand_vlan_range(value: str | list[str]) -> list[str]:
if isinstance(value, list):
value = ",".join(str(item) for item in value)
result: list[str] = []
for part in value.split(","):
part = part.strip()
if not part:
continue
if "-" not in part:
result.append(part)
continue
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(vlan_id) for vlan_id in range(start, end + 1))
return result
from oxi.interfaces.utils import expand_vlan_range
@register_parser("eltex")
@@ -54,15 +31,8 @@ class Eltex(BaseDevice):
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):
for vid in expand_vlan_range(ids):
if vid in named_vlan:
continue
vlans.append({"vlan_id": vid})
return vlans
if __name__ == "__main__":
with open("./test_not_found.txt") as file:
data = file.read()
eltex = Eltex(data)
print(eltex.parse())

View File

@@ -15,10 +15,3 @@ class H3C(BaseDevice):
continue
vlans.extend({"vlan_id": vlan_id} for vlan_id in vlan_ids)
return vlans
if __name__ == "__main__":
with open("./test5.txt") as file:
data = file.read()
h3c = H3C(data)
print(h3c.parse())

View File

@@ -9,10 +9,3 @@ class Huawei(BaseDevice):
def vlans(self) -> list[dict]:
vlan_ids = self.raw.get("vlans", {}).get("vlan_ids", [])
return [{"vlan_id": vlan} for vlan in vlan_ids]
if __name__ == "__main__":
with open("./test4.txt") as file:
data = file.read()
huawei = Huawei(data)
print(huawei.parse())

View File

@@ -1,24 +1,14 @@
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 _decode_utf(self, text: str):
if "\\x" in text:
desc = text.strip('"')
decoded = (
desc.encode("utf-8")
.decode("unicode_escape")
.encode("latin1")
.decode("utf-8")
)
return decoded
return text
def interfaces(self):
interfaces: list[dict] = self.raw["interfaces"]
for item in interfaces:
@@ -29,7 +19,7 @@ class Keenetic(BaseDevice):
item["mask"] = ipaddress.network.prefixlen
item.pop("netmask", "Key not found")
if item.get("description"):
decoded = self._decode_utf(item.get("description", ""))
decoded = decode_utf(item.get("description", ""))
item["description"] = decoded
return interfaces
@@ -37,13 +27,6 @@ class Keenetic(BaseDevice):
vlans = self.raw["vlans"]
for item in vlans:
if item.get("description"):
decoded = self._decode_utf(item.get("description", ""))
decoded = decode_utf(item.get("description", ""))
item["description"] = decoded
return vlans
if __name__ == "__main__":
with open("./test2.txt") as file:
data = file.read()
mikr = Keenetic(data)
print(mikr.parse().model_dump_json())

View File

@@ -1,4 +1,3 @@
import os
from oxi.interfaces import register_parser
from oxi.interfaces.base import BaseDevice
@@ -6,11 +5,3 @@ from oxi.interfaces.base import BaseDevice
@register_parser(["routeros", "ros", "mikrotik"])
class Mikrotik(BaseDevice):
template = "mikrotik.ttp"
if __name__ == "__main__":
print(os.path.abspath(os.curdir))
with open("./test.txt") as file:
data = file.read()
mikr = Mikrotik(data)
print(mikr.parse().json())

View File

@@ -1,32 +1,6 @@
from oxi.interfaces import register_parser
from oxi.interfaces.base import BaseDevice
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
from oxi.interfaces.utils import expand_vlan_range
@register_parser(["QTECH"])
@@ -48,21 +22,8 @@ class Qtech(BaseDevice):
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):
for vid in expand_vlan_range(ids):
if vid in named_vlan:
continue
vlans.append({"vlan_id": vid})
return vlans
if __name__ == "__main__":
with open("./test3.txt") as file:
data = file.read()
qtech = Qtech(data)
qt = qtech.parse()
print(qt)
with open("./test3-1.txt") as file:
data = file.read()
qtech = Qtech(data)
qt = qtech.parse()
print(qt)

View File

@@ -21,17 +21,3 @@ class Quasar(BaseDevice):
if ether_interface:
interfaces.append(ether_interface)
return interfaces
if __name__ == "__main__":
with open("./test7.txt") as file:
data = file.read()
quasar = Quasar(data)
qt = quasar.parse()
print(qt)
print()
with open("./test8.txt") as file:
data = file.read()
quasar = Quasar(data)
qt = quasar.parse()
print(qt)

View File

@@ -26,11 +26,13 @@ Active-image: {{ ignore }} {{ _start_ }}
<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 }}
name {{ name | ORPHRASE }}
vlan {{ _db_ | _start_ }}
vlan {{ vlan_ids | joinmatches(',') | unrange("-", ",") | split(",")}}

View File

@@ -21,6 +21,7 @@ default_system = {
<group name="interfaces">
interface {{ interface }}
description {{ description | ORPHRASE }}
ip address {{ ip_address }} {{ mask | to_cidr }}
</group>

View File

@@ -14,13 +14,13 @@ default_system = {
</vars>
<group name="system" default="default_system">
# VRP (R) software, Version {{ version }} {{ _line_ }}
# VRP (R) software, Version {{ version }} ({{ model }} {{ _line_ }}
# ESN of slot {{ slot_number }}: {{ serial_number }}
</group>
<group name="interfaces">
interface {{ interface }}
description {{ description }}
description {{ description | ORPHRASE }}
ip address {{ ip_address }} {{ mask | to_cidr }}
</group>
<group name="vlans">

View File

@@ -42,6 +42,6 @@ add comment={{ comment | _start_ | ORPHRASE | exclude("disabled=") | strip('"')}
## 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 interface={{ interface | _start_ }} name={{ name | ORPHRASE | strip('"') }} vlan-id={{ vlan_id }} disabled={{ disabled | replace("yes","True") | strip('"') }}
add disabled={{ disabled | _start_ | replace("yes","True") | strip('"') }} interface={{ interface }} name={{ name | ORPHRASE | strip('"') }} vlan-id={{ vlan_id }}
</group>

39
oxi/interfaces/utils.py Normal file
View File

@@ -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

View File

@@ -6,7 +6,6 @@ from oxi.exception import OxiAPIError
from .view import NodeView
if TYPE_CHECKING:
from requests import Session

View File

@@ -3,7 +3,6 @@ from typing import TYPE_CHECKING
from .conf import NodeConfig
if TYPE_CHECKING:
from requests import Session

View File

@@ -20,9 +20,19 @@ dependencies = [
"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*"]
@@ -32,3 +42,17 @@ 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"]

8
tests/conftest.py Normal file
View File

@@ -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")

55
tests/fixtures/eltex/config.conf vendored Normal file
View File

@@ -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
!

View File

@@ -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"
}
]
}

1
tests/fixtures/eltex/not_found.conf vendored Normal file
View File

@@ -0,0 +1 @@
node not found

169
tests/fixtures/h3c/config.conf vendored Normal file
View File

@@ -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
#

155
tests/fixtures/h3c/config.expected.json vendored Normal file
View File

@@ -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
}
]
}

38
tests/fixtures/huawei/config.conf vendored Normal file
View File

@@ -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
#

View File

@@ -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
}
]
}

341
tests/fixtures/keenetic/config.conf vendored Normal file
View File

@@ -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
!

View File

@@ -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"
}
]
}

123
tests/fixtures/mikrotik/config.conf vendored Normal file
View File

@@ -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

View File

@@ -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"
}
]
}

42
tests/fixtures/qtech/config_1.conf vendored Normal file
View File

@@ -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

View File

@@ -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
}
]
}

36
tests/fixtures/qtech/config_2.conf vendored Normal file
View File

@@ -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

View File

@@ -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
}
]
}

41
tests/fixtures/quasar/config_1.conf vendored Normal file
View File

@@ -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"

View File

@@ -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": []
}

43
tests/fixtures/quasar/config_2.conf vendored Normal file
View File

@@ -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"

View File

@@ -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": []
}

39
tests/test_models.py Normal file
View File

@@ -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)

87
tests/test_network.py Normal file
View File

@@ -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</title></html>",
)
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

65
tests/test_units.py Normal file
View File

@@ -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 '<group name="system"></group>'
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

72
tests/test_view.py Normal file
View File

@@ -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()

255
uv.lock generated
View File

@@ -109,6 +109,27 @@ wheels = [
{ 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"
@@ -118,6 +139,15 @@ 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"
@@ -128,12 +158,53 @@ dependencies = [
{ 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"
@@ -268,6 +339,97 @@ wheels = [
{ 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"
@@ -283,6 +445,99 @@ 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"