Compare commits

...

3 Commits

Author SHA1 Message Date
IluaAir
2a03240414 Update setuptools version and modify license information in pyproject.toml
- Updated the required setuptools version from 61 to 77 to ensure compatibility with the latest features and improvements.
- Changed the license format in `pyproject.toml` to specify "Apache-2.0" directly and added a reference to the license file for clarity.
2026-05-28 15:55:20 +03:00
IluaAir
41c4cc48e9 Update project description and enhance documentation for clarity
- Revised the project description in `pyproject.toml` to better reflect the functionality of the `oxipy` client.
- Improved the README.md by adding detailed explanations of the project structure, installation instructions, and usage examples.
- Updated documentation files to enhance clarity and organization, including sections on extending models and writing TTP templates.
- Adjusted various TTP templates to ensure consistency and accuracy in the parsing of device configurations.
2026-05-25 16:01:38 +03:00
IluaAir
e8c33b0e64 Add VLAN range expansion utility and improve VLAN processing in Qtech model
- Introduced a new helper function `_expand_vlan_range` to convert VLAN range strings into a list of individual VLAN IDs.
- Enhanced the `vlans` method in the `Qtech` class to utilize the new function, improving the handling of VLAN IDs and ensuring proper processing of both individual and range-based VLAN inputs.
2026-05-25 15:41:50 +03:00
17 changed files with 568 additions and 656 deletions

268
README.md
View File

@@ -1,38 +1,42 @@
# oxipy
Python-клиент для работы с Oxidized API — системой управления конфигурацией сетевых устройств. Предоставляет удобный интерфейс для получения конфигураций узлов, их парсинга и работы с результатами.
`oxipy` is a Python client for the [Oxidized](https://github.com/ytti/oxidized) API.
It fetches device configurations from Oxidized and parses them into structured
Pydantic models using bundled TTP templates.
## Содержание
Oxidized remains responsible for collecting and storing configuration backups.
`oxipy` focuses on consuming those backups from Python code and exposing common
configuration sections such as system data, interfaces, and VLANs.
- [Установка](#установка)
- [Быстрый старт](#быстрый-старт)
## Contents
- [Installation](#installation)
- [Quick Start](#quick-start)
- [API Reference](#api-reference)
- [OxiAPI](#oxiapi)
- [NodeView](#nodeview)
- [NodeConfig](#nodeconfig)
- [ModelView](#modelview)
- [Поддерживаемые устройства](#поддерживаемые-устройства)
- [Дополнительно](#дополнительно)
- [Supported Devices](#supported-devices)
- [Additional Documentation](#additional-documentation)
---
## Installation
## Установка
The package is distributed through a private Gitea Package Registry and from the
source repository. It is not published to PyPI.
> Пакет распространяется через Gitea Package Registry и исходники репозитория.
> В PyPI пакет не публикуется.
**Requirements:** Python 3.10+
**Требования:** Python 3.11+
### From Gitea Package Registry
### Из Gitea Package Registry
Добавьте registry в конфигурацию pip и установите пакет:
Install the package by pointing `pip` to the private registry:
```bash
pip install oxipy \
--index-url https://gitea.imbastark.ru/api/packages/Netbox/pypi/simple/
```
Или пропишите registry постоянно в `pip.conf` / `pip.ini`, чтобы не указывать `--index-url` каждый раз:
You can also configure the registry permanently in `pip.conf` or `pip.ini`:
```ini
# ~/.config/pip/pip.conf (Linux/macOS)
@@ -42,35 +46,35 @@ pip install oxipy \
extra-index-url = https://gitea.imbastark.ru/api/packages/Netbox/pypi/simple/
```
После этого достаточно:
After that, install normally:
```bash
pip install oxipy
```
Если registry требует аутентификации, передайте токен:
If the registry requires authentication, pass a token in the index URL:
```bash
pip install oxipy \
--index-url https://__token__:<your_token>@gitea.imbastark.ru/api/packages/Netbox/pypi/simple/
```
### Из репозитория Gitea
### From Gitea Source
Установка напрямую через pip без клонирования:
Install directly from the repository:
```bash
pip install git+https://gitea.imbastark.ru/Netbox/oxipy.git
```
Конкретный тег или ветка:
Install a specific tag or branch:
```bash
pip install git+https://gitea.imbastark.ru/Netbox/oxipy.git@v0.1.0
pip install git+https://gitea.imbastark.ru/Netbox/oxipy.git@dev
```
Для разработки (editable install):
For local development:
```bash
git clone https://gitea.imbastark.ru/Netbox/oxipy
@@ -78,9 +82,7 @@ cd oxipy
pip install -e .
```
---
## Быстрый старт
## Quick Start
```python
from oxi import OxiAPI
@@ -93,35 +95,35 @@ print(node.ip)
print(node.model)
print(node.full_name)
>>> 192.168.1.1
>>> keenetic
>>> router/HQ
print(node.config.system.model)
print(node.config.interfaces.json())
print(node.config.vlans.json())
>>> Sprinter (KN-3710)
>>>
[
{"name":"Bridge1","ip_address":"192.168.1.1","mask":24,"description":"\"Guest network\""},
{"name":"Bridge0","ip_address":"172.16.1.1","mask":24,"description":"\"Home network\""}
]
>>>
[
{"vlan_id":1,"name":"Home VLAN"},
{"vlan_id":2,"name":"Подключение Ethernet"},
{"vlan_id":3,"name":"Home network"}
]
print(node.config.interfaces.dump_json())
print(node.config.vlans.dump_json())
```
---
Example output:
```text
192.168.1.1
keenetic
router/HQ
Sprinter (KN-3710)
[
{"interface": "Bridge1", "ip_address": "192.168.1.1", "mask": 24, "description": "Guest network"},
{"interface": "Bridge0", "ip_address": "172.16.1.1", "mask": 24, "description": "Home network"}
]
[
{"vlan_id": 1, "description": "Home VLAN"},
{"vlan_id": 2, "description": "Ethernet uplink"},
{"vlan_id": 3, "description": "Home network"}
]
```
## API Reference
### OxiAPI
Точка входа. Управляет HTTP-сессией и предоставляет доступ к узлам.
`OxiAPI` is the entry point. It manages the HTTP session and provides access to
Oxidized nodes.
```python
OxiAPI(
@@ -132,61 +134,54 @@ OxiAPI(
)
```
| Parameter | Type | Description |
| --- | --- | --- |
| `url` | `str` | Base URL of the Oxidized API, for example `https://oxi.example.com`. |
| `username` | `str | None` | Optional username for HTTP basic authentication. |
| `password` | `str | None` | Optional password for HTTP basic authentication. |
| `verify` | `bool` | Whether to verify TLS certificates. Defaults to `True`. |
| Параметр | Тип | Описание |
| ---------- | ------ | --------------------------------------------------------- |
| `url` | `str` | Базовый URL Oxi API, например `https://oxi.example.com` |
| `username` | `str` | Имя пользователя для базовой аутентификации (опционально) |
| `password` | `str` | Пароль для базовой аутентификации (опционально) |
| `verify` | `bool` | Проверять SSL-сертификат. `True` по умолчанию |
**Пример:**
Example:
```python
# Без аутентификации
# Without authentication
api = OxiAPI(url="https://oxi.example.com")
# С базовой аутентификацией
# With HTTP basic authentication
api = OxiAPI(
url="https://oxi.example.com",
username="admin",
password="secret",
)
# Использование как контекстного менеджера (автоматически закрывает сессию)
# As a context manager. The HTTP session is closed automatically.
with OxiAPI(url="https://oxi.example.com") as api:
node = api.node("HQ")
print(node.ip)
>>> 192.168.1.1
```
#### `api.node(name)`
Возвращает `[NodeView](#nodeview)` для указанного узла.
Returns a `NodeView` for the requested Oxidized node.
```python
node = api.node("HQ")
```
---
### NodeView
Представление узла сети. Содержит метаданные и ленивый доступ к конфигурации.
`NodeView` represents one network device. It contains metadata returned by
Oxidized and lazy access to the fetched configuration.
| Property | Type | Description |
| --- | --- | --- |
| `ip` | `str` | Node IP address. |
| `full_name` | `str` | Full node name in Oxidized. |
| `group` | `str` | Oxidized group the node belongs to. |
| `model` | `str` | Device model key used to select a parser. |
| `config` | `NodeConfig` | Device configuration, fetched and parsed on first access. |
| Свойство | Тип | Описание |
| ----------- | ------------ | ---------------------------------------------------- |
| `ip` | `str` | IP-адрес узла |
| `full_name` | `str` | Полное имя узла в Oxi |
| `group` | `str` | Группа, к которой принадлежит узел |
| `model` | `str` | Модель устройства (используется для парсинга) |
| `config` | `NodeConfig` | Конфигурация узла (загружается при первом обращении) |
**Пример:**
Example:
```python
node = api.node("HQ")
@@ -194,118 +189,94 @@ node = api.node("HQ")
print(node.ip)
print(node.group)
print(node.model)
>>> 192.168.1.1
>>> branch-office
>>> keenetic
```
---
### NodeConfig
Загружает и парсит конфигурацию устройства. Использует TTP-шаблоны, соответствующие модели устройства.
`NodeConfig` fetches and parses a device configuration. The parser is selected
from the device registry by the node `model` value returned by Oxidized.
Доступ к секциям конфигурации осуществляется через свойства, возвращающие `[ModelView](#modelview)`.
Configuration sections are exposed through properties that return `ModelView`
objects.
| Property | Returns | Description |
| --- | --- | --- |
| `system` | `ModelView[System]` | System information. |
| `interfaces` | `ModelView[list[Interfaces]]` | Parsed interface list. |
| `vlans` | `ModelView[list[Vlans]]` | Parsed VLAN list, if the template provides VLAN data. |
| `text` | `str` | Raw configuration text fetched from Oxidized. |
| Свойство | Возвращает | Описание |
| ------------ | ----------------------------- | ---------------------------------- |
| `system` | `ModelView[System]` | Системная информация об устройстве |
| `interfaces` | `ModelView[list[Interfaces]]` | Список интерфейсов |
| `vlans` | `ModelView[list[Vlans]]` | Список VLAN (если есть) |
| `text` | `str` | Сырой текст конфигурации |
**Пример:**
Example:
```python
cfg = node.config
# Системная информация
print(cfg.system.model)
print(cfg.system.serial_number)
print(cfg.system.version)
>>> Mikrotik RB951Ui-2nD
>>> B88C0B31117B
>>> 7.16.1
# Итерация по интерфейсам
for iface in cfg.interfaces:
print(iface.name, iface.ip_address, iface.mask)
# Индексация
first_iface = cfg.interfaces[0]
print(first_iface.name)
# Количество интерфейсов
print(len(cfg.interfaces))
# JSON-дамп любой секции
print(cfg.interfaces.json())
print(cfg.vlans.json())
print(cfg.system.json())
print(cfg.interfaces.dump_json())
print(cfg.vlans.dump_json())
print(cfg.system.dump_json())
# Сырая конфигурация текстом
print(cfg.text)
```
---
`NodeConfig` also provides `dump()` and `dump_json()` methods for the whole
parsed device object.
### ModelView
Обёртка над Pydantic-моделью или списком моделей. Обеспечивает сериализацию, итерацию и прозрачный доступ к атрибутам.
`ModelView` wraps either a single Pydantic model or a list of Pydantic models.
It provides serialization, iteration for list sections, and transparent access
to model attributes.
| Method / operation | Applies to | Description |
| --- | --- | --- |
| `.dump()` | single model and list | Returns a Python `dict` or `list` using aliases. |
| `.dump_json()` | single model and list | Returns a JSON string using aliases. |
| `.<attr>` | single model and list | Proxies attribute access to the wrapped model. |
| `iter(view)` | list only | Iterates over wrapped models. |
| `len(view)` | list only | Returns the number of wrapped models. |
| `view[i]` | list only | Returns an item or slice. |
| Метод / свойство | Применимо к | Описание |
| ---------------- | ------------ | ------------------------------------------------- |
| `.json()` | оба варианта | Возвращает JSON-строку (с `by_alias=True`) |
| `.<attr>` | оба варианта | Проксирует обращение к атрибутам вложенной модели |
| `iter(view)` | список | Итерация по элементам списка моделей |
| `len(view)` | список | Количество элементов в списке |
| `view[i]` | список | Получение элемента по индексу или срез |
`__iter__`, `__len__`, and `__getitem__` are available only for list-backed
sections such as `interfaces` and `vlans`. Calling them on `system` raises
`TypeError`.
> `__iter__`, `__len__` и `__getitem__` доступны только для `interfaces` и `vlans` (они оборачивают список). Вызов этих методов на `system` (одиночная модель) вызовет `TypeError`.
**Примеры:**
Examples:
```python
# Одиночная модель — system
view = node.config.system
print(view.json())
>>> '{"model":"RB951Ui-2nD","serial_number":"B88C0B31117B","version":"7.12.1"}'
print(view.model) # 'RB951Ui-2nD'
print(view.serial_number) # 'B88C0B31117B'
system = node.config.system
print(system.dump_json())
print(system.model)
print(system.serial_number)
>>> RB951Ui-2nD
>>> B88C0B31117B
# Список — interfaces
interfaces = node.config.interfaces
# Итерация
for iface in interfaces:
print(iface.name, iface.ip_address)
# Длина
print(len(interfaces)) # 5
# Индексация и срезы
first = interfaces[0]
top3 = interfaces[:3]
# JSON всего списка
print(interfaces.json())
print(len(interfaces))
print(interfaces[0])
print(interfaces[:3])
print(interfaces.dump())
```
---
## Supported Devices
## Поддерживаемые устройства
Registry keys are compared with the Oxidized node `model` value
case-insensitively.
| Устройство | Ключи реестра |
| ---------- | -------------------------------- |
| Device | Registry keys |
| --- | --- |
| Keenetic | `ndms`, `keenetic`, `keeneticos` |
| MikroTik | `routeros`, `ros`, `mikrotik` |
| Qtech | `qtech` |
@@ -314,15 +285,10 @@ print(interfaces.json())
| H3C | `h3c` |
| Quasar | `qos`, `quasar` |
You can add support for another device family by creating a new device model
and TTP template. See [Extending Device Models](docs/extending-models.md).
Ключи реестра — это значения поля `model`, возвращаемого API для узла. Регистр не учитывается.
Добавить поддержку нового устройства можно самостоятельно — подробнее в разделе [Расширение моделей](docs/extending-models.md).
---
## Дополнительно
- [Написание TTP-шаблонов](docs/templates.md)
- [Расширение и переопределение моделей устройств](docs/extending-models.md)
## Additional Documentation
- [Writing TTP Templates](docs/templates.md)
- [Extending Device Models](docs/extending-models.md)

View File

@@ -1,46 +1,49 @@
# Расширение и переопределение моделей устройств
# Extending Device Models
oxipy предоставляет гибкий механизм расширения через наследование от `BaseDevice`. После того как TTP-шаблон разобрал конфигурацию в сырой словарь `self.raw`, данные проходят через три метода экземпляра — `system()`, `interfaces()`, `vlans()` — перед тем как попасть в контракт. Переопределяя эти методы, можно трансформировать, фильтровать и обогащать данные без изменения шаблона или контракта.
`oxipy` parses an Oxidized configuration in two stages. A TTP template first
extracts raw dictionaries from the text, then a device model normalizes those
dictionaries before Pydantic validates them against the public contract.
## Содержание
Device models extend `BaseDevice`. Override `system()`, `interfaces()`, or
`vlans()` when the raw TTP result needs vendor-specific cleanup.
- [Архитектура: путь данных](#архитектура-путь-данных)
- [Регистрация нового устройства](#регистрация-нового-устройства)
- [Переопределение методов (monkey patching)](#переопределение-методов-monkey-patching)
## Contents
- [Data Flow](#data-flow)
- [Registering a Device](#registering-a-device)
- [Method Overrides](#method-overrides)
- [interfaces()](#interfaces)
- [vlans()](#vlans)
- [system()](#system)
- [Полный пример: новое устройство](#полный-пример-новое-устройство)
- [Контракт: ожидаемые структуры](#контракт-ожидаемые-структуры)
- [Complete Example](#complete-example)
- [Expected Contract](#expected-contract)
---
## Data Flow
## Архитектура: путь данных
```
текст конфигурации
TTP-шаблон (.ttp)
│ парсит в сырой словарь
```text
configuration text
|
v
TTP template (.ttp)
|
v
self.raw: dict
├──► system() dict
├──► interfaces() list[dict]
└──► vlans() list[dict]
_validate_contract()
│ создаёт Pydantic-модели
|
+--> system() -> dict
+--> interfaces() -> list[dict]
+--> vlans() -> list[dict]
|
v
Pydantic validation
|
v
Device(system, interfaces, vlans)
```
Методы `system()`, `interfaces()`, `vlans()` — это точки расширения. Базовая реализация просто возвращает данные из `self.raw`:
The extension methods are intentionally small. The base implementation returns
data directly from `self.raw`:
```python
# BaseDevice (упрощённо)
def interfaces(self) -> list[dict]:
return self.raw.get("interfaces", [])
@@ -51,18 +54,16 @@ def system(self) -> dict:
return self.raw.get("system", None)
```
---
## Registering a Device
## Регистрация нового устройства
To add support for a new vendor:
Чтобы добавить поддержку нового вендора:
1. Создайте файл в `oxi/interfaces/models/`, например `cisco.py`.
2. Создайте шаблон `oxi/interfaces/models/templates/cisco.ttp`.
3. Унаследуйте класс от `BaseDevice` и зарегистрируйте его декоратором `@register_parser`.
1. Create a Python file in `oxi/interfaces/models/`, for example `cisco.py`.
2. Create a template in `oxi/interfaces/models/templates/`, for example
`cisco.ttp`.
3. Subclass `BaseDevice` and register it with `@register_parser`.
```python
# oxi/interfaces/models/cisco.py
from oxi.interfaces import register_parser
from oxi.interfaces.base import BaseDevice
@@ -72,26 +73,26 @@ class CiscoIOS(BaseDevice):
template = "cisco.ttp"
```
Декоратор `@register_parser` принимает список строк — это ключи, по которым устройство ищется в реестре. Поле `model` от API сравнивается с этими ключами без учёта регистра.
`@register_parser` accepts a string or a list of strings. These values are the
registry keys used to match the Oxidized node `model` field. Matching is
case-insensitive.
После добавления файла он автоматически импортируется через `pkgutil` при старте приложения — явно импортировать не нужно.
Model modules are imported automatically through `pkgutil` when
`oxi.interfaces` is loaded, so you do not need to import your model class
manually.
---
## Переопределение методов (monkey patching)
## Method Overrides
### interfaces()
Используйте переопределение, когда нужно:
Override `interfaces()` when you need to:
- Преобразовать формат IP-адреса (например, `netmask``prefix_length`).
- Декодировать escape-последовательности в описаниях.
- Переименовать ключи, не совпадающие с контрактом.
- Фильтровать служебные интерфейсы.
- Convert dotted decimal netmasks to prefix lengths.
- Decode escaped descriptions.
- Rename keys that do not match the contract.
- Filter service-only interfaces.
**Пример: конвертация маски подсети в префикс**
TTP возвращает `netmask` как `255.255.255.0`, а контракт `Interfaces` ожидает `mask` как целое число (prefix length):
Example: convert a netmask to a prefix length.
```python
from ipaddress import ip_interface
@@ -114,19 +115,17 @@ class MyVendor(BaseDevice):
return result
```
**Пример: фильтрация служебных интерфейсов**
Example: filter management interfaces.
```python
def interfaces(self) -> list[dict]:
return [
item for item in self.raw.get("interfaces", [])
if not item.get("name", "").startswith("lo")
if not item.get("interface", "").startswith("Mgmt")
]
```
**Пример: декодирование Unicode escape-последовательностей**
Некоторые устройства (например, Keenetic) хранят кириллические описания как `\xd0\xb8\xd0\xbc\xd1\x8f`:
Example: decode escaped UTF-8 descriptions.
```python
def _decode_utf(self, text: str) -> str:
@@ -140,6 +139,7 @@ def _decode_utf(self, text: str) -> str:
)
return text
def interfaces(self) -> list[dict]:
interfaces = self.raw.get("interfaces", [])
for item in interfaces:
@@ -148,75 +148,83 @@ def interfaces(self) -> list[dict]:
return interfaces
```
---
### vlans()
Аналогично `interfaces()`. Используйте для нормализации ID, декодирования названий, обогащения данными из других секций.
Override `vlans()` to normalize VLAN IDs, expand compressed ranges, decode
names, or merge details from multiple template groups.
**Пример: добавление префикса к имени VLAN**
Example: add a generated VLAN name.
```python
def vlans(self) -> list[dict]:
result = []
for item in self.raw.get("vlans", []):
item["description"] = f"VLAN_{item.get('id', '?')}"
item["description"] = f"VLAN_{item.get('vlan_id', '?')}"
result.append(item)
return result
```
**Пример: объединение данных из нескольких секций**
Example: merge data from another raw group.
```python
def vlans(self) -> list[dict]:
vlans = {v["id"]: v for v in self.raw.get("vlans", [])}
# обогащаем данными из другой секции, если она есть
vlans = {item["vlan_id"]: item for item in self.raw.get("vlans", [])}
for extra in self.raw.get("vlan_details", []):
vlan_id = extra.get("id")
vlan_id = extra.get("vlan_id")
if vlan_id in vlans:
vlans[vlan_id].update(extra)
return list(vlans.values())
```
---
Example: expand a comma-separated VLAN range.
```python
def _expand_vlan_range(value: str) -> list[str]:
result = []
for part in value.split(","):
if "-" not in part:
result.append(part.strip())
continue
start, end = (int(item) for item in part.split("-", 1))
result.extend(str(vlan_id) for vlan_id in range(start, end + 1))
return result
```
### system()
Переопределяйте, если структура системной секции отличается от ожидаемой контрактом, или нужно вычислить поля:
Override `system()` when the system section needs computed fields or data from
another raw group.
**Пример: собрать серийный номер из нескольких полей**
Example: assemble a serial number from two fields.
```python
def system(self) -> dict:
raw_system = self.raw.get("system", {})
# Устройство возвращает серийный номер в двух частях
part1 = raw_system.get("serial_part1", "")
part2 = raw_system.get("serial_part2", "")
raw_system["serial_number"] = f"{part1}-{part2}"
return raw_system
```
**Пример: нормализация строки версии**
Example: normalize a version string.
```python
def system(self) -> dict:
raw_system = self.raw.get("system", {})
# Убираем лишнее из "7.12.1 (stable)" → "7.12.1"
version = raw_system.get("version", "")
raw_system["version"] = version.split()[0] if version else version
return raw_system
```
---
## Complete Example
## Полный пример: новое устройство
Assume a Cisco IOS-like device where:
Допустим, нужно добавить поддержку Cisco IOS, где:
- IP-адрес и маска разделены пробелом в конфигурации (`ip address 10.0.0.1 255.255.255.0`).
- Описание интерфейса может содержать несколько слов.
- Серийный номер разделён дефисом в двух строках.
- IP address and netmask are separated by a space.
- Interface descriptions can contain several words.
- System fields are present in separate lines.
**Шаблон** (`oxi/interfaces/models/templates/cisco.ttp`):
Template: `oxi/interfaces/models/templates/cisco.ttp`
```xml
<vars>
@@ -240,12 +248,12 @@ interface {{ interface | _start_ }}
</group>
<group name="vlans">
vlan {{ id | _start_ }}
name {{ description }}
vlan {{ vlan_id | _start_ }}
name {{ name | ORPHRASE }}
</group>
```
**Класс устройства** (`oxi/interfaces/models/cisco.py`):
Device model: `oxi/interfaces/models/cisco.py`
```python
from ipaddress import ip_interface
@@ -260,12 +268,10 @@ class CiscoIOS(BaseDevice):
def interfaces(self) -> list[dict]:
result = []
for item in self.raw.get("interfaces", []):
# Конвертируем маску подсети в длину префикса
if item.get("ip_address") and item.get("netmask"):
iface = ip_interface(f"{item['ip_address']}/{item['netmask']}")
item["mask"] = iface.network.prefixlen
item.pop("netmask", None)
# Фильтруем интерфейсы управления
if item.get("interface", "").startswith("Mgmt"):
continue
result.append(item)
@@ -273,53 +279,48 @@ class CiscoIOS(BaseDevice):
def system(self) -> dict:
raw_system = self.raw.get("system", {})
# Нормализуем версию: "15.2(4)M3" → оставляем как есть
# Убираем лишние пробелы в модели
if raw_system.get("model"):
raw_system["model"] = raw_system["model"].strip()
return raw_system
```
---
## Expected Contract
## Контракт: ожидаемые структуры
Methods must return structures accepted by `oxi.interfaces.contract`.
Методы должны возвращать данные в следующем формате. Контракт жёстко проверяется Pydantic.
### `system()` → `dict`
### `system() -> dict`
```python
{
"model": "RB951Ui-2nD", # str, обязательно
"serial_number": "B88C0B31117B", # str, обязательно
"version": "7.12.1", # str, обязательно
"model": "RB951Ui-2nD",
"serial_number": "B88C0B31117B",
"version": "7.12.1",
}
```
### `interfaces()` → `list[dict]`
### `interfaces() -> list[dict]`
```python
[
{
"interface": "ether1", # str, обязательно (alias для поля name)
"ip_address": "192.168.1.1", # str | None
"mask": 24, # int | None (длина префикса)
"description": "LAN", # str | None
"interface": "ether1",
"ip_address": "192.168.1.1",
"mask": 24,
"description": "LAN",
},
...
]
```
### `vlans()` → `list[dict]`
### `vlans() -> list[dict]`
```python
[
{
"id": 10, # int, обязательно (alias для поля vlan_id)
"description": "MGMT", # str | None (alias для поля name)
"vlan_id": 10,
"description": "MGMT",
},
...
]
```
> Если имя ключа в словаре совпадает с **alias** поля Pydantic-модели, а не с именем атрибута — используйте alias. Модели сконфигурированы с `populate_by_name=True`, поэтому принимаются оба варианта.
The Pydantic models use `populate_by_name=True` for aliased models, so both
field names and aliases are accepted where aliases exist.

View File

@@ -1,86 +1,86 @@
# Написание TTP-шаблонов
# Writing TTP Templates
oxipy использует библиотеку [TTP (Template Text Parser)](https://ttp.readthedocs.io/) для парсинга конфигураций сетевых устройств в структурированные данные. Шаблоны хранятся в директории `oxi/interfaces/models/templates/`.
`oxipy` uses [TTP (Template Text Parser)](https://ttp.readthedocs.io/) to turn
network device configurations fetched from Oxidized into structured data.
Templates are stored in `oxi/interfaces/models/templates/`.
## Содержание
## Contents
- [Структура шаблона](#структура-шаблона)
- [Обязательные группы](#обязательные-группы)
- [Секция system](#секция-system)
- [Секция interfaces](#секция-interfaces)
- [Секция vlans](#секция-vlans)
- [TTP: основные возможности](#ttp-основные-возможности)
- [Переменные по умолчанию](#переменные-по-умолчанию)
- [Практические примеры](#практические-примеры)
- [Валидация шаблона](#валидация-шаблона)
- [Template Structure](#template-structure)
- [Required Groups](#required-groups)
- [The system Group](#the-system-group)
- [The interfaces Group](#the-interfaces-group)
- [The vlans Group](#the-vlans-group)
- [Useful TTP Features](#useful-ttp-features)
- [Default Variables](#default-variables)
- [Full Example](#full-example)
- [Validation](#validation)
---
## Template Structure
## Структура шаблона
Каждый шаблон — это `.ttp`-файл, состоящий из следующих блоков:
Each template is a `.ttp` file with a small set of conventional blocks:
```xml
<doc>
Описание шаблона (опционально)
Optional template documentation.
</doc>
<vars>
<!-- Переменные по умолчанию для групп -->
<!-- Default values for groups. -->
</vars>
<group name="system">
<!-- Правила для системной информации -->
<!-- Rules for system information. -->
</group>
<group name="interfaces">
<!-- Правила для интерфейсов -->
<!-- Rules for interfaces. -->
</group>
<group name="vlans">
<!-- Правила для VLAN (опционально) -->
<!-- Optional rules for VLANs. -->
</group>
```
Файл-заготовка находится в `oxi/interfaces/models/templates/_template.ttp`.
Use `oxi/interfaces/models/templates/_template.ttp` as the starting point for a
new parser.
---
## Required Groups
## Обязательные группы
The framework requires two groups in every template:
Фреймворк требует наличия в шаблоне **двух обязательных групп**:
| Group | Required | Description |
| --- | --- | --- |
| `system` | Yes | Device system information. |
| `interfaces` | Yes | Interface configuration. |
| `vlans` | No | VLAN configuration. |
| Группа | Обязательна | Описание |
|--------------|-------------|-------------------------------|
| `system` | Да | Системная информация |
| `interfaces` | Да | Конфигурация интерфейсов |
| `vlans` | Нет | Конфигурация VLAN |
If a required group is missing from the template or from the TTP result,
`BaseDevice` raises `ValueError`.
Если обязательная группа отсутствует в шаблоне или TTP не вернул её данные, будет выброшено `ValueError`.
If a template declares an optional `vlans` group, `oxipy` expects TTP to return
that group. Omit the group completely for devices where VLAN parsing is not
implemented.
---
## The system Group
## Секция system
The `system` group must return one dictionary with these fields:
Должна возвращать словарь со следующими полями:
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | `str` | Yes | Device model. |
| `serial_number` | `str` | Yes | Device serial number. |
| `version` | `str` | Yes | Firmware, software, or build version chosen by the parser. |
| Поле | Тип | Обязательное | Описание |
|-----------------|------|--------------|---------------------|
| `model` | str | Да | Модель устройства |
| `serial_number` | str | Да | Серийный номер |
| `version` | str | Да | Версия прошивки |
Example for MikroTik:
**Пример (MikroTik):**
Конфигурация:
```
```text
# version: 7.12.1 (stable)
# model = RB951Ui-2nD
# serial number = B88C0B31117B
```
Шаблон:
```
```xml
<group name="system">
# version: {{ version }}{{ ignore('.*') }}
# model = {{ model }}
@@ -88,17 +88,15 @@ oxipy использует библиотеку [TTP (Template Text Parser)](htt
</group>
```
**Пример (Keenetic):**
Example for Keenetic:
Конфигурация:
```
```text
! release: 4.1.7.1-1
! model: Keenetic Extra
! hw_version: F02B4E7A1C90
```
Шаблон:
```
```xml
<group name="system">
! release: {{ version }}
! model: {{ model | ORPHRASE }}
@@ -106,34 +104,34 @@ oxipy использует библиотеку [TTP (Template Text Parser)](htt
</group>
```
---
## The interfaces Group
## Секция interfaces
The `interfaces` group must return a list of dictionaries. Each dictionary
describes one interface.
Должна возвращать список словарей. Каждый словарь описывает один интерфейс.
The `Interfaces` contract expects these fields:
Поля, которые ожидает контракт `Interfaces`:
| Contract field | TTP name / alias | Type | Required |
| --- | --- | --- | --- |
| `name` | `interface` | `str` | Yes |
| `ip_address` | `ip_address` | `IPv4Address | None` | No |
| `mask` | `mask` | `int | None` | No |
| `description` | `description` | `str | None` | No |
| Поле | TTP-имя / alias | Тип | Обязательное |
|---------------|-----------------|------------------|--------------|
| `name` | `interface` | str | Да |
| `ip_address` | `ip_address` | IPv4Address | Нет |
| `mask` | `mask` | int (prefix len) | Нет |
| `description` | `description` | str | Нет |
The Pydantic field `name` has the alias `interface`, so templates should usually
emit `interface`. You can also emit `name` because the models allow population
by field name, or you can normalize keys in the device class by overriding
`interfaces()`.
> **Важно:** поле `name` в Pydantic-модели имеет алиас `interface`, поэтому в шаблоне переменную нужно называть именно `interface` **или** переопределить метод `interfaces()` в классе модели (см. [Расширение моделей](extending-models.md)).
Example for MikroTik:
**Пример (MikroTik):**
Конфигурация:
```
```text
/ip address
add address=192.168.1.1/24 interface=ether1 network=192.168.1.0
add address=10.0.0.1/30 comment="WAN link" interface=ether2 network=10.0.0.0
```
Шаблон:
```
```xml
<group name="interfaces">
/ip address
add address={{ ip_address | _start_ }}/{{ mask }} interface={{ interface }} network={{ network }}
@@ -141,108 +139,104 @@ add address={{ ip_address | _start_ }}/{{ mask }} comment={{ description | ORPHR
</group>
```
**Пример (Keenetic):**
Example for CLI-style devices:
Конфигурация:
```
interface GigabitEthernet0/0
description "WAN"
ip address 10.0.0.2 255.255.255.252
interface GigabitEthernet0/1
ip address 192.168.1.1 255.255.255.0
```text
interface Vlanif120
description SSH
ip address 10.26.196.254 255.255.255.0
```
Шаблон:
```
```xml
<group name="interfaces">
interface {{ name | _start_ | exclude("Vlan") }}
interface {{ interface | _start_ }}
description {{ description | ORPHRASE }}
ip address {{ ip_address }} {{ netmask }}
ip address {{ ip_address }} {{ mask | to_cidr }}
</group>
```
Здесь переменная называется `name`, а не `interface` — это покрывается переопределением метода `interfaces()` в классе `Keenetic`.
Use TTP's `to_cidr` formatter when the device uses dotted decimal masks.
---
## The vlans Group
## Секция vlans
The `vlans` group is optional. If it is declared, it must return a list of VLAN
dictionaries.
Необязательная группа. Если объявлена в шаблоне, фреймворк ожидает её наличия в результате TTP.
The `Vlans` contract expects these fields:
Поля контракта `Vlans`:
| Contract field | Alias | Type | Required |
| --- | --- | --- | --- |
| `vlan_id` | none | `int` | Yes |
| `name` | `description` | `str | None` | No |
| Поле | TTP-имя / alias | Тип | Обязательное |
|-----------|-----------------|------|--------------|
| `vlan_id` | `id` | int | Да |
| `name` | `description` | str | Нет |
`name` has the alias `description`, so either key is accepted. Existing parsers
use both forms depending on the vendor format.
> `vlan_id` имеет алиас `id`, поэтому в шаблоне переменная должна называться `id` либо переименовываться в методе `vlans()`.
Example:
**Пример (Keenetic):**
Конфигурация:
```
interface Bridge0/Vlan10
description "MGMT"
interface Bridge0/Vlan20
description "SERVERS"
```text
vlan 10
name MGMT
```
Шаблон:
```
```xml
<group name="vlans">
interface {{ ignore }}/Vlan{{ id }}
description {{ description | ORPHRASE | strip('"') }}
vlan {{ vlan_id | _start_ }}
name {{ name | ORPHRASE }}
</group>
```
---
For compressed vendor syntax such as `vlan batch 101 to 103 110`, parse the raw
range in the template and normalize it in the device class when needed.
## TTP: основные возможности
## Useful TTP Features
### Маркеры строк
### Line markers
| Маркер | Описание |
|-------------|---------------------------------------------------------------|
| `_start_` | Строка с этой переменной считается началом нового совпадения |
| `_end_` | Строка с этой переменной завершает совпадение группы |
| Marker | Description |
| --- | --- |
| `_start_` | Starts a new group match from the current line. |
| `_end_` | Ends the current group match. |
```
add address={{ ip_address | _start_ }}/{{ mask }} interface={{ name }}
```xml
interface {{ interface | _start_ }}
```
### Модификаторы переменных
### Variable modifiers
| Модификатор | Описание |
|------------------------|-----------------------------------------------------------|
| `ORPHRASE` | Захватывает одно слово или фразу (до конца строки) |
| `exclude("pattern")` | Пропускает строку, если захваченное значение содержит паттерн |
| `strip('"')` | Удаляет символ из начала и конца захваченного значения |
| `replace("old","new")` | Заменяет подстроку в захваченном значении |
| `re("pattern")` | Принимает значение, только если оно соответствует regex |
| `ignore` | Захватывает, но игнорирует значение (не включает в результат) |
| `ignore('.*')` | Игнорирует всё до конца строки |
| Modifier | Description |
| --- | --- |
| `ORPHRASE` | Captures a word or phrase to the end of the line. |
| `exclude("pattern")` | Skips the match when the captured value contains the pattern. |
| `strip('"')` | Removes a character from both ends of the captured value. |
| `replace("old","new")` | Replaces text inside the captured value. |
| `re("pattern")` | Accepts the value only if it matches the regex. |
| `ignore` | Captures and discards the value. |
| `ignore('.*')` | Discards the rest of the line. |
| `to_cidr` | Converts a dotted decimal netmask to a prefix length. |
| `unrange("-", ",")` | Expands ranges such as `10-12` using a comma separator. |
| `split(",")` | Splits a captured string into a list. |
### Комментарии в шаблоне
### Template comments
Строки, начинающиеся с `##`, — это комментарии TTP и не влияют на парсинг:
Lines beginning with `##` are TTP comments:
```
```xml
## disabled no comment
add address={{ ip_address | _start_ }}/{{ mask }} interface={{ name }}
add address={{ ip_address | _start_ }}/{{ mask }} interface={{ interface }}
```
---
## Default Variables
## Переменные по умолчанию
Блок `<vars>` позволяет задавать значения по умолчанию для группы через атрибут `default`:
The `<vars>` block can define default values for a group through the group's
`default` attribute:
```xml
<vars>
default_system = {
"model": "",
"serial_number": ""
"serial_number": "",
"version": ""
}
</vars>
@@ -253,17 +247,16 @@ default_system = {
</group>
```
Если шаблон не нашёл совпадений для группы, будет возвращён словарь из `default_system`.
If the group does not match anything, TTP returns the default dictionary.
---
## Full Example
## Практические примеры
### Полный шаблон для нового устройства (пример: Cisco IOS)
This simplified Cisco IOS-style example shows the expected shape of a complete
template:
```xml
<doc>
Шаблон для парсинга Cisco IOS running-config
Cisco IOS running-config parser.
</doc>
<vars>
@@ -283,24 +276,24 @@ System serial number : {{ serial_number }}
<group name="interfaces">
interface {{ interface | _start_ }}
description {{ description | ORPHRASE }}
ip address {{ ip_address }} {{ netmask }}
shutdown {{ shutdown | set("True") }}
ip address {{ ip_address }} {{ mask | to_cidr }}
</group>
<group name="vlans">
vlan {{ id | _start_ }}
name {{ description }}
vlan {{ vlan_id | _start_ }}
name {{ name | ORPHRASE }}
</group>
```
---
## Validation
## Валидация шаблона
`BaseDevice` performs two validation passes:
Фреймворк автоматически выполняет два уровня проверки:
1. Template structure validation checks that the template declares the required
`system` and `interfaces` groups.
2. Parse result validation checks that TTP actually returned the required groups
for the given configuration.
1. **Валидация структуры шаблона** — при создании объекта устройства парсятся XML-теги `<group>` и проверяется наличие обязательных секций (`system`, `interfaces`).
2. **Валидация результата парсинга** — после запуска TTP проверяется, что обязательные группы действительно присутствуют в результате (т.е. конфигурация содержала соответствующие строки).
При нарушении любого условия выбрасывается `ValueError` с подробным описанием проблемы.
After that, parsed data is validated by Pydantic models from
`oxi.interfaces.contract`. Invalid structures raise the original Pydantic
validation error.

View File

@@ -24,10 +24,7 @@ class BaseDevice(ABC):
@abstractmethod
def template(self) -> str:
"""
Expected structure:
Название файла с парсером ttp
Returns:
None
Name of the TTP template file used by this device parser.
"""
def vlans(self) -> list[dict]:
@@ -35,14 +32,14 @@ class BaseDevice(ABC):
Parse VLAN configuration from self.raw['vlans'].
Expected structure:
[{"id": 10, "description": "MGMT"}, {"id": 15, "name": "SSH"}, ...]
[{"vlan_id": 10, "description": "MGMT"}, {"vlan_id": 15, "name": "SSH"}, ...]
Returns:
list[Vlans]: список VLAN из секции vlans,
пустой список если секция отсутствует.
list[Vlans]: VLANs from the vlans section, or an empty list
when the section is absent.
Raises:
ValueError: если raw содержит некорректные данные.
ValueError: if raw data cannot be validated by the contract.
"""
return self.raw.get("vlans", [])
@@ -51,10 +48,10 @@ class BaseDevice(ABC):
Parse Interface configuration from self.raw['interfaces'].
Expected raw structure:
[{"name": "GEthernet1/0/1", "ip_address": "192.168.1.1", "mask": "24", "description": "IPBB interface"}]
[{"interface": "GEthernet1/0/1", "ip_address": "192.168.1.1", "mask": "24", "description": "IPBB interface"}]
Raises:
ValueError: если raw содержит некорректные данные.
ValueError: if raw data cannot be validated by the contract.
"""
return self.raw.get("interfaces", [])
@@ -66,7 +63,7 @@ class BaseDevice(ABC):
{"model":"RB951Ui-2nD", serial_number: "B88C0B31117B", "version": "7.12.1"}
Raises:
ValueError: если raw содержит некорректные данные.
ValueError: if raw data cannot be validated by the contract.
"""
return self.raw.get("system", None)
@@ -97,14 +94,14 @@ class BaseDevice(ABC):
return result
def _load_template(self):
"""Подгрузка темплейтов из папки models/templates"""
"""Load the device TTP template from models/templates."""
path = Path(__file__).parent / "models" / "templates" / self.template
if not path.exists():
raise FileNotFoundError(f"Template {self.template} not found")
return path.read_text(encoding="utf-8")
def _validate_template_groups(self) -> None:
"""Проверяем только обязательные группы в template."""
"""Validate that the template declares all required groups."""
try:
root = ET.fromstring(self._loaded_template)
except ET.ParseError:
@@ -122,7 +119,7 @@ class BaseDevice(ABC):
)
def _run_ttp(self) -> dict:
"""Основной парсер"""
"""Run the node-not-found check and then parse the config with TTP."""
pattern = """node not {{found}}"""
parser = ttp(data=self.config, template=pattern)
parser.parse()

View File

@@ -2,6 +2,30 @@ 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
@register_parser("eltex")
class Eltex(BaseDevice):
template = "eltex.ttp"
@@ -17,19 +41,20 @@ class Eltex(BaseDevice):
def vlans(self) -> list[dict]:
vlans_ttp = self.raw.get("vlans", [])
vlans = []
named_vlan = set()
vlans: list[dict] = []
named_vlan: set[str] = set()
for item in vlans_ttp:
if item.get("vlan_id"):
named_vlan.add(item.get("vlan_id"))
vlan_id = item.get("vlan_id")
if vlan_id:
named_vlan.add(str(vlan_id))
vlans.append(item)
else:
continue
ids = item.get("vlan_ids", "")
tail = item.get("vlan_tail")
if tail:
ids = f"{ids},{tail}"
for vid in ids:
vid = vid.strip()
ids = [*ids, tail] if isinstance(ids, list) else f"{ids},{tail}"
for vid in _expand_vlan_range(ids):
if vid in named_vlan:
continue
vlans.append({"vlan_id": vid})

View File

@@ -6,15 +6,17 @@ class H3C(BaseDevice):
template = "h3c.ttp"
def vlans(self) -> list[dict]:
vlan_list = self.raw["vlans"]
vlans = []
vlan_list = self.raw.get("vlans", [])
vlans: list[dict] = []
for item in vlan_list:
if item.get("vlans_id"):
vlans.extend([{'vlan_id': vln }for vln in item.get("vlans_id")])
else:
vlan_ids = item.get("vlans_id")
if not vlan_ids:
vlans.append(item)
continue
vlans.extend({"vlan_id": vlan_id} for vlan_id in vlan_ids)
return vlans
if __name__ == "__main__":
with open("./test5.txt") as file:
data = file.read()

View File

@@ -6,8 +6,8 @@ from oxi.interfaces.base import BaseDevice
class Huawei(BaseDevice):
template = "huawei.ttp"
def vlans(self):
vlan_ids = self.raw["vlans"].get("vlan_ids")
def vlans(self) -> list[dict]:
vlan_ids = self.raw.get("vlans", {}).get("vlan_ids", [])
return [{"vlan_id": vlan} for vlan in vlan_ids]

View File

@@ -1,31 +1,54 @@
import os
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
@register_parser(["QTECH"])
class Qtech(BaseDevice):
template = "qtech.ttp"
def system(self) -> dict:
system = self.raw["system"]
return system
def vlans(self) -> list[dict]:
vlans_ttp = self.raw["vlans"]
vlans = []
named_vlan = set()
vlans_ttp = self.raw.get("vlans", [])
vlans: list[dict] = []
named_vlan: set[str] = set()
for item in vlans_ttp:
if item.get("vlan_id"):
named_vlan.add(item.get("vlan_id"))
vlan_id = item.get("vlan_id")
if vlan_id and "," not in vlan_id and "-" not in vlan_id:
named_vlan.add(vlan_id)
vlans.append(item)
else:
ids = item.get("vlan_ids", "")
continue
ids = item.get("vlan_ids") or vlan_id or ""
tail = item.get("vlan_tail")
if tail:
ids = f"{ids},{tail}"
for vid in ids.split(","):
vid = vid.strip()
ids = [*ids, tail] if isinstance(ids, list) else f"{ids},{tail}"
for vid in _expand_vlan_range(ids):
if vid in named_vlan:
continue
vlans.append({"vlan_id": vid})
@@ -33,9 +56,13 @@ class Qtech(BaseDevice):
if __name__ == "__main__":
print(os.path.abspath(os.curdir))
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

@@ -6,9 +6,9 @@ class Quasar(BaseDevice):
template = "quasar.ttp"
def interfaces(self) -> list[dict]:
ether_interfaces: dict = self.raw["interfaces"]
ether_interface: dict = self.raw.get("interfaces", {})
interfaces: list[dict] = []
bulk_interfaces: dict = self.raw["bulkinterfaces"]
bulk_interfaces: dict = self.raw.get("bulkinterfaces", {})
for key, value in bulk_interfaces.items():
interfaces.append(
{
@@ -18,7 +18,8 @@ class Quasar(BaseDevice):
"mask": value.get("mask"),
}
)
interfaces.append(ether_interfaces)
if ether_interface:
interfaces.append(ether_interface)
return interfaces

View File

@@ -1,41 +1,20 @@
<doc>
Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте
в &lt;vendor&gt;.ttp и заполните группы под формат конфигурации вашего устройства.
Base template for a new device parser. Copy this file, rename it to
&lt;vendor&gt;.ttp, and fill the groups for the target configuration format.
Обязательные группы: system, interfaces.
Опциональная группа: vlans — добавляйте только если устройство поддерживает VLAN.
Required groups: system, interfaces.
Optional group: vlans. Add it only when VLAN parsing is implemented.
--- Группа system ---
Должна возвращать одиночный словарь с полями:
model (str) — модель устройства
serial_number (str) — серийный номер
version (str) — версия прошивки
system must return one dictionary with: model, serial_number, version.
interfaces must return a list of dictionaries with: interface, ip_address,
mask, description. Use a prefix length for mask; convert dotted decimal masks
with `to_cidr` or in the device class.
vlans must return dictionaries with vlan_id and optional name/description.
--- Группа interfaces ---
Должна возвращать список словарей. Каждый элемент:
interface (str) — имя интерфейса (alias поля name)
ip_address (str|None) — IPv4-адрес
mask (int|None) — длина префикса (напр. 24)
description (str|None) — описание интерфейса
Useful TTP modifiers: ORPHRASE, _start_, strip(), replace(), exclude(),
ignore, ignore('.*'), to_cidr, unrange(), split().
Если устройство возвращает маску в виде 255.255.255.0, конвертируйте
её в prefix length в методе interfaces() класса устройства.
--- Группа vlans ---
Должна возвращать список словарей. Каждый элемент:
id (int) — номер VLAN (alias поля vlan_id)
description (str|None) — название VLAN (alias поля name)
--- Полезные модификаторы TTP ---
{{ field | ORPHRASE }} — одно слово или фраза до конца строки
{{ field | _start_ }} — начало новой записи группы
{{ field | strip('"') }} — убрать кавычки
{{ field | replace("yes","True") }} — замена подстроки
{{ field | exclude("pattern") }} — пропустить строку при совпадении
{{ ignore }} — захватить и выбросить значение
{{ ignore('.*') }} — выбросить всё до конца строки
Подробнее: docs/templates.md
See docs/templates.md for details.
</doc>
<vars>
default_system = {

View File

@@ -1,41 +1,10 @@
<doc>
Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте
в &lt;vendor&gt;.ttp и заполните группы под формат конфигурации вашего устройства.
Eltex configuration parser.
Обязательные группы: system, interfaces.
Опциональная группа: vlans — добавляйте только если устройство поддерживает VLAN.
--- Группа system ---
Должна возвращать одиночный словарь с полями:
model (str) — модель устройства
serial_number (str) — серийный номер
version (str) — версия прошивки
--- Группа interfaces ---
Должна возвращать список словарей. Каждый элемент:
interface (str) — имя интерфейса (alias поля name)
ip_address (str|None) — IPv4-адрес
mask (int|None) — длина префикса (напр. 24)
description (str|None) — описание интерфейса
Если устройство возвращает маску в виде 255.255.255.0, конвертируйте
её в prefix length в методе interfaces() класса устройства.
--- Группа vlans ---
Должна возвращать список словарей. Каждый элемент:
id (int) — номер VLAN (alias поля vlan_id)
description (str|None) — название VLAN (alias поля name)
--- Полезные модификаторы TTP ---
{{ field | ORPHRASE }} — одно слово или фраза до конца строки
{{ field | _start_ }} — начало новой записи группы
{{ field | strip('"') }} — убрать кавычки
{{ field | replace("yes","True") }} — замена подстроки
{{ field | exclude("pattern") }} — пропустить строку при совпадении
{{ ignore }} — захватить и выбросить значение
{{ ignore('.*') }} — выбросить всё до конца строки
Подробнее: docs/templates.md
The system group reads software version data and the serial group extracts
serial numbers from the unit table. The interfaces group parses interface IP
settings. The vlans group supports named VLAN interfaces and compressed VLAN
lists.
</doc>
<vars>
default_system = {

View File

@@ -1,41 +1,9 @@
<doc>
Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте
в &lt;vendor&gt;.ttp и заполните группы под формат конфигурации вашего устройства.
H3C configuration parser.
Обязательные группы: system, interfaces.
Опциональная группа: vlans — добавляйте только если устройство поддерживает VLAN.
--- Группа system ---
Должна возвращать одиночный словарь с полями:
model (str) — модель устройства
serial_number (str) — серийный номер
version (str) — версия прошивки
--- Группа interfaces ---
Должна возвращать список словарей. Каждый элемент:
interface (str) — имя интерфейса (alias поля name)
ip_address (str|None) — IPv4-адрес
mask (int|None) — длина префикса (напр. 24)
description (str|None) — описание интерфейса
Если устройство возвращает маску в виде 255.255.255.0, конвертируйте
её в prefix length в методе interfaces() класса устройства.
--- Группа vlans ---
Должна возвращать список словарей. Каждый элемент:
id (int) — номер VLAN (alias поля vlan_id)
description (str|None) — название VLAN (alias поля name)
--- Полезные модификаторы TTP ---
{{ field | ORPHRASE }} — одно слово или фраза до конца строки
{{ field | _start_ }} — начало новой записи группы
{{ field | strip('"') }} — убрать кавычки
{{ field | replace("yes","True") }} — замена подстроки
{{ field | exclude("pattern") }} — пропустить строку при совпадении
{{ ignore }} — захватить и выбросить значение
{{ ignore('.*') }} — выбросить всё до конца строки
Подробнее: docs/templates.md
The system group reads boot image version and board model data. The interfaces
group parses interface IP settings. The vlans groups parse both named VLANs and
range-style VLAN declarations.
</doc>
<vars>
default_system = {

View File

@@ -1,40 +1,9 @@
<doc>
Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте
в &lt;vendor&gt;.ttp и заполните группы под формат конфигурации вашего устройства.
Huawei VRP configuration parser.
Обязательные группы: system, interfaces.
Опциональная группа: vlans — добавляйте только если устройство поддерживает VLAN.
--- Группа system ---
Должна возвращать одиночный словарь с полями:
model (str) — модель устройства
serial_number (str) — серийный номер
version (str) — версия прошивки
--- Группа interfaces ---
Должна возвращать список словарей. Каждый элемент:
interface (str) — имя интерфейса (alias поля name)
ip_address (str|None) — IPv4-адрес
mask (int|None) — длина префикса (напр. 24)
description (str|None) — описание интерфейса
Если устройство возвращает маску в виде 255.255.255.0, конвертируйте
её в prefix length в методе interfaces() класса устройства.
--- Группа vlans ---
Должна возвращать список словарей. Каждый элемент:
id (int) — номер VLAN (alias поля vlan_id)
description (str|None) — название VLAN (alias поля name)
--- Полезные модификаторы TTP ---
{{ field | ORPHRASE }} — одно слово или фраза до конца строки
{{ field | _start_ }} — начало новой записи группы
{{ field | strip('"') }} — убрать кавычки
{{ field | replace("yes","True") }} — замена подстроки
{{ field | exclude("pattern") }} — пропустить строку при совпадении
{{ ignore }} — захватить и выбросить значение
{{ ignore('.*') }} — выбросить всё до конца строки
Подробнее: docs/templates.md
The system group reads VRP version and slot ESN data. The interfaces group
parses interface blocks and converts dotted decimal masks to prefix lengths.
The vlans group parses `vlan batch` declarations and emits VLAN IDs.
</doc>
<vars>
default_system = {

View File

@@ -1,4 +1,13 @@
<doc>
Qtech switch configuration parser.
The system group reads the model, serial number, and build number. For Qtech,
system.version intentionally stores the build number from lines like
`Version 2.2.0C Build 96279`.
The interfaces group parses CLI interface blocks and converts dotted decimal
masks to prefix lengths. The vlans group supports named VLANs, comma-separated
VLAN lists, ranges, and continuation lines.
</doc>
<vars>
default_system = {

View File

@@ -1,41 +1,10 @@
<doc>
Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте
в &lt;vendor&gt;.ttp и заполните группы под формат конфигурации вашего устройства.
Quasar configuration parser.
Обязательные группы: system, interfaces.
Опциональная группа: vlans — добавляйте только если устройство поддерживает VLAN.
--- Группа system ---
Должна возвращать одиночный словарь с полями:
model (str) — модель устройства
serial_number (str) — серийный номер
version (str) — версия прошивки
--- Группа interfaces ---
Должна возвращать список словарей. Каждый элемент:
interface (str) — имя интерфейса (alias поля name)
ip_address (str|None) — IPv4-адрес
mask (int|None) — длина префикса (напр. 24)
description (str|None) — описание интерфейса
Если устройство возвращает маску в виде 255.255.255.0, конвертируйте
её в prefix length в методе interfaces() класса устройства.
--- Группа vlans ---
Должна возвращать список словарей. Каждый элемент:
id (int) — номер VLAN (alias поля vlan_id)
description (str|None) — название VLAN (alias поля name)
--- Полезные модификаторы TTP ---
{{ field | ORPHRASE }} — одно слово или фраза до конца строки
{{ field | _start_ }} — начало новой записи группы
{{ field | strip('"') }} — убрать кавычки
{{ field | replace("yes","True") }} — замена подстроки
{{ field | exclude("pattern") }} — пропустить строку при совпадении
{{ ignore }} — захватить и выбросить значение
{{ ignore('.*') }} — выбросить всё до конца строки
Подробнее: docs/templates.md
The system group supports Assembly-based and Engine-based firmware blocks. The
interfaces group parses the management Ethernet address, while bulkinterfaces
collects per-port descriptions that the Python model merges into interface
records.
</doc>
<vars>
default_system = {

View File

@@ -1,17 +1,17 @@
[build-system]
requires = ["setuptools>=61"]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "oxipy"
version = "0.1.0"
description = "Oxidized API client"
description = "Python client for Oxidized API with TTP-based config parsing"
readme = "README.md"
license = { file = "LICENSE" }
license = "Apache-2.0"
license-files = ["LICENSE"]
requires-python = ">=3.10"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
]
dependencies = [

39
uv.lock generated
View File

@@ -1,6 +1,6 @@
version = 1
revision = 3
requires-python = ">=3.11"
requires-python = ">=3.10"
[[package]]
name = "annotated-types"
@@ -26,6 +26,22 @@ version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" },
{ url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" },
{ url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" },
{ url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" },
{ url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" },
{ url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" },
{ url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" },
{ url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" },
{ url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" },
{ url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" },
{ url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" },
{ url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" },
{ url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" },
{ url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" },
{ url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" },
{ url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" },
{ url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" },
@@ -143,6 +159,19 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" },
{ url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" },
{ url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" },
{ url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" },
{ url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" },
{ url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" },
{ url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" },
{ url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" },
{ url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" },
{ url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" },
{ url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" },
{ url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" },
{ url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" },
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
@@ -221,6 +250,14 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" },
{ url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" },
{ url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" },
{ url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" },
{ url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" },
{ url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" },
{ url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" },
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },