Merge pull request 'documentation' (#1) from documentation into dev

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-05-29 15:55:32 +03:00
17 changed files with 570 additions and 687 deletions

298
README.md
View File

@@ -1,86 +1,56 @@
# oxipy # 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) - [API Reference](#api-reference)
- [OxiAPI](#oxiapi) - [OxiAPI](#oxiapi)
- [NodeView](#nodeview) - [NodeView](#nodeview)
- [NodeConfig](#nodeconfig) - [NodeConfig](#nodeconfig)
- [ModelView](#modelview) - [ModelView](#modelview)
- [Поддерживаемые устройства](#поддерживаемые-устройства) - [Supported Devices](#supported-devices)
- [Дополнительно](#дополнительно) - [Additional Documentation](#additional-documentation)
--- ## Installation
## Установка The package is distributed from the source repository. It is not published to
PyPI yet.
> Пакет распространяется через Gitea Package Registry и исходники репозитория. **Requirements:** Python 3.10+
> В PyPI пакет не публикуется.
**Требования:** Python 3.11+ ### From GitHub Source
### Из Gitea Package Registry Install directly from the repository:
Добавьте registry в конфигурацию pip и установите пакет:
```bash ```bash
pip install oxipy \ pip install git+https://github.com/sttarsky/oxipy.git
--index-url https://gitea.imbastark.ru/api/packages/Netbox/pypi/simple/
``` ```
Или пропишите registry постоянно в `pip.conf` / `pip.ini`, чтобы не указывать `--index-url` каждый раз: Install a specific tag or branch:
```ini
# ~/.config/pip/pip.conf (Linux/macOS)
# %APPDATA%\pip\pip.ini (Windows)
[global]
extra-index-url = https://gitea.imbastark.ru/api/packages/Netbox/pypi/simple/
```
После этого достаточно:
```bash ```bash
pip install oxipy pip install git+https://github.com/sttarsky/oxipy.git@v0.1.0
pip install git+https://github.com/sttarsky/oxipy.git@dev
``` ```
Если registry требует аутентификации, передайте токен: For local development:
```bash ```bash
pip install oxipy \ git clone https://github.com/sttarsky/oxipy
--index-url https://__token__:<your_token>@gitea.imbastark.ru/api/packages/Netbox/pypi/simple/
```
### Из репозитория Gitea
Установка напрямую через pip без клонирования:
```bash
pip install git+https://gitea.imbastark.ru/Netbox/oxipy.git
```
Конкретный тег или ветка:
```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):
```bash
git clone https://gitea.imbastark.ru/Netbox/oxipy
cd oxipy cd oxipy
pip install -e . pip install -e .
``` ```
--- ## Quick Start
## Быстрый старт
```python ```python
from oxi import OxiAPI from oxi import OxiAPI
@@ -93,35 +63,35 @@ print(node.ip)
print(node.model) print(node.model)
print(node.full_name) print(node.full_name)
>>> 192.168.1.1
>>> keenetic
>>> router/HQ
print(node.config.system.model) print(node.config.system.model)
print(node.config.interfaces.json()) print(node.config.interfaces.dump_json())
print(node.config.vlans.json()) print(node.config.vlans.dump_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"}
]
``` ```
--- 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 ## API Reference
### OxiAPI ### OxiAPI
Точка входа. Управляет HTTP-сессией и предоставляет доступ к узлам. `OxiAPI` is the entry point. It manages the HTTP session and provides access to
Oxidized nodes.
```python ```python
OxiAPI( OxiAPI(
@@ -132,61 +102,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`. |
| Параметр | Тип | Описание | Example:
| ---------- | ------ | --------------------------------------------------------- |
| `url` | `str` | Базовый URL Oxi API, например `https://oxi.example.com` |
| `username` | `str` | Имя пользователя для базовой аутентификации (опционально) |
| `password` | `str` | Пароль для базовой аутентификации (опционально) |
| `verify` | `bool` | Проверять SSL-сертификат. `True` по умолчанию |
**Пример:**
```python ```python
# Без аутентификации # Without authentication
api = OxiAPI(url="https://oxi.example.com") api = OxiAPI(url="https://oxi.example.com")
# С базовой аутентификацией # With HTTP basic authentication
api = OxiAPI( api = OxiAPI(
url="https://oxi.example.com", url="https://oxi.example.com",
username="admin", username="admin",
password="secret", password="secret",
) )
# Использование как контекстного менеджера (автоматически закрывает сессию) # As a context manager. The HTTP session is closed automatically.
with OxiAPI(url="https://oxi.example.com") as api: with OxiAPI(url="https://oxi.example.com") as api:
node = api.node("HQ") node = api.node("HQ")
print(node.ip) print(node.ip)
>>> 192.168.1.1
``` ```
#### `api.node(name)` #### `api.node(name)`
Возвращает `[NodeView](#nodeview)` для указанного узла. Returns a `NodeView` for the requested Oxidized node.
```python ```python
node = api.node("HQ") node = api.node("HQ")
``` ```
---
### NodeView ### NodeView
Представление узла сети. Содержит метаданные и ленивый доступ к конфигурации. `NodeView` represents one network device. It contains metadata returned by
Oxidized and lazy access to the fetched configuration.
| Property | Type | Description |
| --- | --- | --- |
| `ip` | `str` | Node IP address. |
| `full_name` | `str` | Full node name in Oxidized. |
| `group` | `str` | Oxidized group the node belongs to. |
| `model` | `str` | Device model key used to select a parser. |
| `config` | `NodeConfig` | Device configuration, fetched and parsed on first access. |
| Свойство | Тип | Описание | Example:
| ----------- | ------------ | ---------------------------------------------------- |
| `ip` | `str` | IP-адрес узла |
| `full_name` | `str` | Полное имя узла в Oxi |
| `group` | `str` | Группа, к которой принадлежит узел |
| `model` | `str` | Модель устройства (используется для парсинга) |
| `config` | `NodeConfig` | Конфигурация узла (загружается при первом обращении) |
**Пример:**
```python ```python
node = api.node("HQ") node = api.node("HQ")
@@ -194,118 +157,94 @@ node = api.node("HQ")
print(node.ip) print(node.ip)
print(node.group) print(node.group)
print(node.model) print(node.model)
>>> 192.168.1.1
>>> branch-office
>>> keenetic
``` ```
---
### NodeConfig ### 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. |
| Свойство | Возвращает | Описание | Example:
| ------------ | ----------------------------- | ---------------------------------- |
| `system` | `ModelView[System]` | Системная информация об устройстве |
| `interfaces` | `ModelView[list[Interfaces]]` | Список интерфейсов |
| `vlans` | `ModelView[list[Vlans]]` | Список VLAN (если есть) |
| `text` | `str` | Сырой текст конфигурации |
**Пример:**
```python ```python
cfg = node.config cfg = node.config
# Системная информация
print(cfg.system.model) print(cfg.system.model)
print(cfg.system.serial_number) print(cfg.system.serial_number)
print(cfg.system.version) print(cfg.system.version)
>>> Mikrotik RB951Ui-2nD
>>> B88C0B31117B
>>> 7.16.1
# Итерация по интерфейсам
for iface in cfg.interfaces: for iface in cfg.interfaces:
print(iface.name, iface.ip_address, iface.mask) print(iface.name, iface.ip_address, iface.mask)
# Индексация
first_iface = cfg.interfaces[0] first_iface = cfg.interfaces[0]
print(first_iface.name) print(first_iface.name)
# Количество интерфейсов
print(len(cfg.interfaces)) print(len(cfg.interfaces))
# JSON-дамп любой секции print(cfg.interfaces.dump_json())
print(cfg.interfaces.json()) print(cfg.vlans.dump_json())
print(cfg.vlans.json()) print(cfg.system.dump_json())
print(cfg.system.json())
# Сырая конфигурация текстом
print(cfg.text) print(cfg.text)
``` ```
--- `NodeConfig` also provides `dump()` and `dump_json()` methods for the whole
parsed device object.
### ModelView ### 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. |
| Метод / свойство | Применимо к | Описание | `__iter__`, `__len__`, and `__getitem__` are available only for list-backed
| ---------------- | ------------ | ------------------------------------------------- | sections such as `interfaces` and `vlans`. Calling them on `system` raises
| `.json()` | оба варианта | Возвращает JSON-строку (с `by_alias=True`) | `TypeError`.
| `.<attr>` | оба варианта | Проксирует обращение к атрибутам вложенной модели |
| `iter(view)` | список | Итерация по элементам списка моделей |
| `len(view)` | список | Количество элементов в списке |
| `view[i]` | список | Получение элемента по индексу или срез |
Examples:
> `__iter__`, `__len__` и `__getitem__` доступны только для `interfaces` и `vlans` (они оборачивают список). Вызов этих методов на `system` (одиночная модель) вызовет `TypeError`.
**Примеры:**
```python ```python
# Одиночная модель — system system = node.config.system
view = node.config.system print(system.dump_json())
print(view.json()) print(system.model)
>>> '{"model":"RB951Ui-2nD","serial_number":"B88C0B31117B","version":"7.12.1"}' print(system.serial_number)
print(view.model) # 'RB951Ui-2nD'
print(view.serial_number) # 'B88C0B31117B'
>>> RB951Ui-2nD
>>> B88C0B31117B
# Список — interfaces
interfaces = node.config.interfaces interfaces = node.config.interfaces
# Итерация
for iface in interfaces: for iface in interfaces:
print(iface.name, iface.ip_address) print(iface.name, iface.ip_address)
# Длина print(len(interfaces))
print(len(interfaces)) # 5 print(interfaces[0])
print(interfaces[:3])
# Индексация и срезы print(interfaces.dump())
first = interfaces[0]
top3 = interfaces[:3]
# JSON всего списка
print(interfaces.json())
``` ```
--- ## Supported Devices
## Поддерживаемые устройства Registry keys are compared with the Oxidized node `model` value
case-insensitively.
| Device | Registry keys |
| Устройство | Ключи реестра | | --- | --- |
| ---------- | -------------------------------- |
| Keenetic | `ndms`, `keenetic`, `keeneticos` | | Keenetic | `ndms`, `keenetic`, `keeneticos` |
| MikroTik | `routeros`, `ros`, `mikrotik` | | MikroTik | `routeros`, `ros`, `mikrotik` |
| Qtech | `qtech` | | Qtech | `qtech` |
@@ -314,15 +253,10 @@ print(interfaces.json())
| H3C | `h3c` | | H3C | `h3c` |
| Quasar | `qos`, `quasar` | | 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 для узла. Регистр не учитывается. ## Additional Documentation
Добавить поддержку нового устройства можно самостоятельно — подробнее в разделе [Расширение моделей](docs/extending-models.md).
---
## Дополнительно
- [Написание TTP-шаблонов](docs/templates.md)
- [Расширение и переопределение моделей устройств](docs/extending-models.md)
- [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.
- [Архитектура: путь данных](#архитектура-путь-данных) ## Contents
- [Регистрация нового устройства](#регистрация-нового-устройства)
- [Переопределение методов (monkey patching)](#переопределение-методов-monkey-patching) - [Data Flow](#data-flow)
- [Registering a Device](#registering-a-device)
- [Method Overrides](#method-overrides)
- [interfaces()](#interfaces) - [interfaces()](#interfaces)
- [vlans()](#vlans) - [vlans()](#vlans)
- [system()](#system) - [system()](#system)
- [Полный пример: новое устройство](#полный-пример-новое-устройство) - [Complete Example](#complete-example)
- [Контракт: ожидаемые структуры](#контракт-ожидаемые-структуры) - [Expected Contract](#expected-contract)
--- ## Data Flow
## Архитектура: путь данных ```text
configuration text
``` |
текст конфигурации v
TTP template (.ttp)
|
TTP-шаблон (.ttp) v
│ парсит в сырой словарь
self.raw: dict self.raw: dict
|
├──► system() dict +--> system() -> dict
├──► interfaces() list[dict] +--> interfaces() -> list[dict]
└──► vlans() list[dict] +--> vlans() -> list[dict]
|
v
_validate_contract() Pydantic validation
│ создаёт Pydantic-модели |
v
Device(system, interfaces, vlans) 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 ```python
# BaseDevice (упрощённо)
def interfaces(self) -> list[dict]: def interfaces(self) -> list[dict]:
return self.raw.get("interfaces", []) return self.raw.get("interfaces", [])
@@ -51,18 +54,16 @@ def system(self) -> dict:
return self.raw.get("system", None) return self.raw.get("system", None)
``` ```
--- ## Registering a Device
## Регистрация нового устройства To add support for a new vendor:
Чтобы добавить поддержку нового вендора: 1. Create a Python file in `oxi/interfaces/models/`, for example `cisco.py`.
2. Create a template in `oxi/interfaces/models/templates/`, for example
1. Создайте файл в `oxi/interfaces/models/`, например `cisco.py`. `cisco.ttp`.
2. Создайте шаблон `oxi/interfaces/models/templates/cisco.ttp`. 3. Subclass `BaseDevice` and register it with `@register_parser`.
3. Унаследуйте класс от `BaseDevice` и зарегистрируйте его декоратором `@register_parser`.
```python ```python
# oxi/interfaces/models/cisco.py
from oxi.interfaces import register_parser from oxi.interfaces import register_parser
from oxi.interfaces.base import BaseDevice from oxi.interfaces.base import BaseDevice
@@ -72,26 +73,26 @@ class CiscoIOS(BaseDevice):
template = "cisco.ttp" 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.
--- ## Method Overrides
## Переопределение методов (monkey patching)
### interfaces() ### interfaces()
Используйте переопределение, когда нужно: Override `interfaces()` when you need to:
- Преобразовать формат IP-адреса (например, `netmask``prefix_length`). - Convert dotted decimal netmasks to prefix lengths.
- Декодировать escape-последовательности в описаниях. - Decode escaped descriptions.
- Переименовать ключи, не совпадающие с контрактом. - Rename keys that do not match the contract.
- Фильтровать служебные интерфейсы. - Filter service-only interfaces.
**Пример: конвертация маски подсети в префикс** Example: convert a netmask to a prefix length.
TTP возвращает `netmask` как `255.255.255.0`, а контракт `Interfaces` ожидает `mask` как целое число (prefix length):
```python ```python
from ipaddress import ip_interface from ipaddress import ip_interface
@@ -114,19 +115,17 @@ class MyVendor(BaseDevice):
return result return result
``` ```
**Пример: фильтрация служебных интерфейсов** Example: filter management interfaces.
```python ```python
def interfaces(self) -> list[dict]: def interfaces(self) -> list[dict]:
return [ return [
item for item in self.raw.get("interfaces", []) item for item in self.raw.get("interfaces", [])
if not item.get("name", "").startswith("lo") if not item.get("interface", "").startswith("Mgmt")
] ]
``` ```
**Пример: декодирование Unicode escape-последовательностей** Example: decode escaped UTF-8 descriptions.
Некоторые устройства (например, Keenetic) хранят кириллические описания как `\xd0\xb8\xd0\xbc\xd1\x8f`:
```python ```python
def _decode_utf(self, text: str) -> str: def _decode_utf(self, text: str) -> str:
@@ -140,6 +139,7 @@ def _decode_utf(self, text: str) -> str:
) )
return text return text
def interfaces(self) -> list[dict]: def interfaces(self) -> list[dict]:
interfaces = self.raw.get("interfaces", []) interfaces = self.raw.get("interfaces", [])
for item in interfaces: for item in interfaces:
@@ -148,75 +148,83 @@ def interfaces(self) -> list[dict]:
return interfaces return interfaces
``` ```
---
### vlans() ### 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 ```python
def vlans(self) -> list[dict]: def vlans(self) -> list[dict]:
result = [] result = []
for item in self.raw.get("vlans", []): 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) result.append(item)
return result return result
``` ```
**Пример: объединение данных из нескольких секций** Example: merge data from another raw group.
```python ```python
def vlans(self) -> list[dict]: 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", []): for extra in self.raw.get("vlan_details", []):
vlan_id = extra.get("id") vlan_id = extra.get("vlan_id")
if vlan_id in vlans: if vlan_id in vlans:
vlans[vlan_id].update(extra) vlans[vlan_id].update(extra)
return list(vlans.values()) 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() ### 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 ```python
def system(self) -> dict: def system(self) -> dict:
raw_system = self.raw.get("system", {}) raw_system = self.raw.get("system", {})
# Устройство возвращает серийный номер в двух частях
part1 = raw_system.get("serial_part1", "") part1 = raw_system.get("serial_part1", "")
part2 = raw_system.get("serial_part2", "") part2 = raw_system.get("serial_part2", "")
raw_system["serial_number"] = f"{part1}-{part2}" raw_system["serial_number"] = f"{part1}-{part2}"
return raw_system return raw_system
``` ```
**Пример: нормализация строки версии** Example: normalize a version string.
```python ```python
def system(self) -> dict: def system(self) -> dict:
raw_system = self.raw.get("system", {}) raw_system = self.raw.get("system", {})
# Убираем лишнее из "7.12.1 (stable)" → "7.12.1"
version = raw_system.get("version", "") version = raw_system.get("version", "")
raw_system["version"] = version.split()[0] if version else version raw_system["version"] = version.split()[0] if version else version
return raw_system return raw_system
``` ```
--- ## Complete Example
## Полный пример: новое устройство Assume a Cisco IOS-like device where:
Допустим, нужно добавить поддержку Cisco IOS, где: - IP address and netmask are separated by a space.
- IP-адрес и маска разделены пробелом в конфигурации (`ip address 10.0.0.1 255.255.255.0`). - 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 ```xml
<vars> <vars>
@@ -240,12 +248,12 @@ interface {{ interface | _start_ }}
</group> </group>
<group name="vlans"> <group name="vlans">
vlan {{ id | _start_ }} vlan {{ vlan_id | _start_ }}
name {{ description }} name {{ name | ORPHRASE }}
</group> </group>
``` ```
**Класс устройства** (`oxi/interfaces/models/cisco.py`): Device model: `oxi/interfaces/models/cisco.py`
```python ```python
from ipaddress import ip_interface from ipaddress import ip_interface
@@ -260,12 +268,10 @@ class CiscoIOS(BaseDevice):
def interfaces(self) -> list[dict]: def interfaces(self) -> list[dict]:
result = [] result = []
for item in self.raw.get("interfaces", []): for item in self.raw.get("interfaces", []):
# Конвертируем маску подсети в длину префикса
if item.get("ip_address") and item.get("netmask"): if item.get("ip_address") and item.get("netmask"):
iface = ip_interface(f"{item['ip_address']}/{item['netmask']}") iface = ip_interface(f"{item['ip_address']}/{item['netmask']}")
item["mask"] = iface.network.prefixlen item["mask"] = iface.network.prefixlen
item.pop("netmask", None) item.pop("netmask", None)
# Фильтруем интерфейсы управления
if item.get("interface", "").startswith("Mgmt"): if item.get("interface", "").startswith("Mgmt"):
continue continue
result.append(item) result.append(item)
@@ -273,53 +279,48 @@ class CiscoIOS(BaseDevice):
def system(self) -> dict: def system(self) -> dict:
raw_system = self.raw.get("system", {}) raw_system = self.raw.get("system", {})
# Нормализуем версию: "15.2(4)M3" → оставляем как есть
# Убираем лишние пробелы в модели
if raw_system.get("model"): if raw_system.get("model"):
raw_system["model"] = raw_system["model"].strip() raw_system["model"] = raw_system["model"].strip()
return raw_system return raw_system
``` ```
--- ## Expected Contract
## Контракт: ожидаемые структуры Methods must return structures accepted by `oxi.interfaces.contract`.
Методы должны возвращать данные в следующем формате. Контракт жёстко проверяется Pydantic. ### `system() -> dict`
### `system()` → `dict`
```python ```python
{ {
"model": "RB951Ui-2nD", # str, обязательно "model": "RB951Ui-2nD",
"serial_number": "B88C0B31117B", # str, обязательно "serial_number": "B88C0B31117B",
"version": "7.12.1", # str, обязательно "version": "7.12.1",
} }
``` ```
### `interfaces()` → `list[dict]` ### `interfaces() -> list[dict]`
```python ```python
[ [
{ {
"interface": "ether1", # str, обязательно (alias для поля name) "interface": "ether1",
"ip_address": "192.168.1.1", # str | None "ip_address": "192.168.1.1",
"mask": 24, # int | None (длина префикса) "mask": 24,
"description": "LAN", # str | None "description": "LAN",
}, },
...
] ]
``` ```
### `vlans()` → `list[dict]` ### `vlans() -> list[dict]`
```python ```python
[ [
{ {
"id": 10, # int, обязательно (alias для поля vlan_id) "vlan_id": 10,
"description": "MGMT", # str | None (alias для поля name) "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
- [Структура шаблона](#структура-шаблона) - [Template Structure](#template-structure)
- [Обязательные группы](#обязательные-группы) - [Required Groups](#required-groups)
- [Секция system](#секция-system) - [The system Group](#the-system-group)
- [Секция interfaces](#секция-interfaces) - [The interfaces Group](#the-interfaces-group)
- [Секция vlans](#секция-vlans) - [The vlans Group](#the-vlans-group)
- [TTP: основные возможности](#ttp-основные-возможности) - [Useful TTP Features](#useful-ttp-features)
- [Переменные по умолчанию](#переменные-по-умолчанию) - [Default Variables](#default-variables)
- [Практические примеры](#практические-примеры) - [Full Example](#full-example)
- [Валидация шаблона](#валидация-шаблона) - [Validation](#validation)
--- ## Template Structure
## Структура шаблона Each template is a `.ttp` file with a small set of conventional blocks:
Каждый шаблон — это `.ttp`-файл, состоящий из следующих блоков:
```xml ```xml
<doc> <doc>
Описание шаблона (опционально) Optional template documentation.
</doc> </doc>
<vars> <vars>
<!-- Переменные по умолчанию для групп --> <!-- Default values for groups. -->
</vars> </vars>
<group name="system"> <group name="system">
<!-- Правила для системной информации --> <!-- Rules for system information. -->
</group> </group>
<group name="interfaces"> <group name="interfaces">
<!-- Правила для интерфейсов --> <!-- Rules for interfaces. -->
</group> </group>
<group name="vlans"> <group name="vlans">
<!-- Правила для VLAN (опционально) --> <!-- Optional rules for VLANs. -->
</group> </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. |
| Группа | Обязательна | Описание | If a required group is missing from the template or from the TTP result,
|--------------|-------------|-------------------------------| `BaseDevice` raises `ValueError`.
| `system` | Да | Системная информация |
| `interfaces` | Да | Конфигурация интерфейсов |
| `vlans` | Нет | Конфигурация VLAN |
Если обязательная группа отсутствует в шаблоне или 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. |
| Поле | Тип | Обязательное | Описание | Example for MikroTik:
|-----------------|------|--------------|---------------------|
| `model` | str | Да | Модель устройства |
| `serial_number` | str | Да | Серийный номер |
| `version` | str | Да | Версия прошивки |
**Пример (MikroTik):** ```text
Конфигурация:
```
# version: 7.12.1 (stable) # version: 7.12.1 (stable)
# model = RB951Ui-2nD # model = RB951Ui-2nD
# serial number = B88C0B31117B # serial number = B88C0B31117B
``` ```
Шаблон: ```xml
```
<group name="system"> <group name="system">
# version: {{ version }}{{ ignore('.*') }} # version: {{ version }}{{ ignore('.*') }}
# model = {{ model }} # model = {{ model }}
@@ -88,17 +88,15 @@ oxipy использует библиотеку [TTP (Template Text Parser)](htt
</group> </group>
``` ```
**Пример (Keenetic):** Example for Keenetic:
Конфигурация: ```text
```
! release: 4.1.7.1-1 ! release: 4.1.7.1-1
! model: Keenetic Extra ! model: Keenetic Extra
! hw_version: F02B4E7A1C90 ! hw_version: F02B4E7A1C90
``` ```
Шаблон: ```xml
```
<group name="system"> <group name="system">
! release: {{ version }} ! release: {{ version }}
! model: {{ model | ORPHRASE }} ! model: {{ model | ORPHRASE }}
@@ -106,34 +104,34 @@ oxipy использует библиотеку [TTP (Template Text Parser)](htt
</group> </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 | Тип | Обязательное | The Pydantic field `name` has the alias `interface`, so templates should usually
|---------------|-----------------|------------------|--------------| emit `interface`. You can also emit `name` because the models allow population
| `name` | `interface` | str | Да | by field name, or you can normalize keys in the device class by overriding
| `ip_address` | `ip_address` | IPv4Address | Нет | `interfaces()`.
| `mask` | `mask` | int (prefix len) | Нет |
| `description` | `description` | str | Нет |
> **Важно:** поле `name` в Pydantic-модели имеет алиас `interface`, поэтому в шаблоне переменную нужно называть именно `interface` **или** переопределить метод `interfaces()` в классе модели (см. [Расширение моделей](extending-models.md)). Example for MikroTik:
**Пример (MikroTik):** ```text
Конфигурация:
```
/ip address /ip address
add address=192.168.1.1/24 interface=ether1 network=192.168.1.0 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 add address=10.0.0.1/30 comment="WAN link" interface=ether2 network=10.0.0.0
``` ```
Шаблон: ```xml
```
<group name="interfaces"> <group name="interfaces">
/ip address /ip address
add address={{ ip_address | _start_ }}/{{ mask }} interface={{ interface }} network={{ network }} add address={{ ip_address | _start_ }}/{{ mask }} interface={{ interface }} network={{ network }}
@@ -141,108 +139,104 @@ add address={{ ip_address | _start_ }}/{{ mask }} comment={{ description | ORPHR
</group> </group>
``` ```
**Пример (Keenetic):** Example for CLI-style devices:
Конфигурация: ```text
``` interface Vlanif120
interface GigabitEthernet0/0 description SSH
description "WAN" ip address 10.26.196.254 255.255.255.0
ip address 10.0.0.2 255.255.255.252
interface GigabitEthernet0/1
ip address 192.168.1.1 255.255.255.0
``` ```
Шаблон: ```xml
```
<group name="interfaces"> <group name="interfaces">
interface {{ name | _start_ | exclude("Vlan") }} interface {{ interface | _start_ }}
description {{ description | ORPHRASE }} description {{ description | ORPHRASE }}
ip address {{ ip_address }} {{ netmask }} ip address {{ ip_address }} {{ mask | to_cidr }}
</group> </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 | Тип | Обязательное | `name` has the alias `description`, so either key is accepted. Existing parsers
|-----------|-----------------|------|--------------| use both forms depending on the vendor format.
| `vlan_id` | `id` | int | Да |
| `name` | `description` | str | Нет |
> `vlan_id` имеет алиас `id`, поэтому в шаблоне переменная должна называться `id` либо переименовываться в методе `vlans()`. Example:
**Пример (Keenetic):** ```text
vlan 10
Конфигурация: name MGMT
```
interface Bridge0/Vlan10
description "MGMT"
interface Bridge0/Vlan20
description "SERVERS"
``` ```
Шаблон: ```xml
```
<group name="vlans"> <group name="vlans">
interface {{ ignore }}/Vlan{{ id }} vlan {{ vlan_id | _start_ }}
description {{ description | ORPHRASE | strip('"') }} name {{ name | ORPHRASE }}
</group> </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
| Маркер | Описание | | Marker | Description |
|-------------|---------------------------------------------------------------| | --- | --- |
| `_start_` | Строка с этой переменной считается началом нового совпадения | | `_start_` | Starts a new group match from the current line. |
| `_end_` | Строка с этой переменной завершает совпадение группы | | `_end_` | Ends the current group match. |
``` ```xml
add address={{ ip_address | _start_ }}/{{ mask }} interface={{ name }} interface {{ interface | _start_ }}
``` ```
### Модификаторы переменных ### Variable modifiers
| Модификатор | Описание | | Modifier | Description |
|------------------------|-----------------------------------------------------------| | --- | --- |
| `ORPHRASE` | Захватывает одно слово или фразу (до конца строки) | | `ORPHRASE` | Captures a word or phrase to the end of the line. |
| `exclude("pattern")` | Пропускает строку, если захваченное значение содержит паттерн | | `exclude("pattern")` | Skips the match when the captured value contains the pattern. |
| `strip('"')` | Удаляет символ из начала и конца захваченного значения | | `strip('"')` | Removes a character from both ends of the captured value. |
| `replace("old","new")` | Заменяет подстроку в захваченном значении | | `replace("old","new")` | Replaces text inside the captured value. |
| `re("pattern")` | Принимает значение, только если оно соответствует regex | | `re("pattern")` | Accepts the value only if it matches the regex. |
| `ignore` | Захватывает, но игнорирует значение (не включает в результат) | | `ignore` | Captures and discards the value. |
| `ignore('.*')` | Игнорирует всё до конца строки | | `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 ## disabled no comment
add address={{ ip_address | _start_ }}/{{ mask }} interface={{ name }} add address={{ ip_address | _start_ }}/{{ mask }} interface={{ interface }}
``` ```
--- ## Default Variables
## Переменные по умолчанию The `<vars>` block can define default values for a group through the group's
`default` attribute:
Блок `<vars>` позволяет задавать значения по умолчанию для группы через атрибут `default`:
```xml ```xml
<vars> <vars>
default_system = { default_system = {
"model": "", "model": "",
"serial_number": "" "serial_number": "",
"version": ""
} }
</vars> </vars>
@@ -253,17 +247,16 @@ default_system = {
</group> </group>
``` ```
Если шаблон не нашёл совпадений для группы, будет возвращён словарь из `default_system`. If the group does not match anything, TTP returns the default dictionary.
--- ## Full Example
## Практические примеры This simplified Cisco IOS-style example shows the expected shape of a complete
template:
### Полный шаблон для нового устройства (пример: Cisco IOS)
```xml ```xml
<doc> <doc>
Шаблон для парсинга Cisco IOS running-config Cisco IOS running-config parser.
</doc> </doc>
<vars> <vars>
@@ -283,24 +276,24 @@ System serial number : {{ serial_number }}
<group name="interfaces"> <group name="interfaces">
interface {{ interface | _start_ }} interface {{ interface | _start_ }}
description {{ description | ORPHRASE }} description {{ description | ORPHRASE }}
ip address {{ ip_address }} {{ netmask }} ip address {{ ip_address }} {{ mask | to_cidr }}
shutdown {{ shutdown | set("True") }}
</group> </group>
<group name="vlans"> <group name="vlans">
vlan {{ id | _start_ }} vlan {{ vlan_id | _start_ }}
name {{ description }} name {{ name | ORPHRASE }}
</group> </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`). After that, parsed data is validated by Pydantic models from
`oxi.interfaces.contract`. Invalid structures raise the original Pydantic
2. **Валидация результата парсинга** — после запуска TTP проверяется, что обязательные группы действительно присутствуют в результате (т.е. конфигурация содержала соответствующие строки). validation error.
При нарушении любого условия выбрасывается `ValueError` с подробным описанием проблемы.

View File

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

View File

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

View File

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

View File

@@ -6,8 +6,8 @@ from oxi.interfaces.base import BaseDevice
class Huawei(BaseDevice): class Huawei(BaseDevice):
template = "huawei.ttp" template = "huawei.ttp"
def vlans(self): def vlans(self) -> list[dict]:
vlan_ids = self.raw["vlans"].get("vlan_ids") vlan_ids = self.raw.get("vlans", {}).get("vlan_ids", [])
return [{"vlan_id": vlan} for vlan in 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 import register_parser
from oxi.interfaces.base import BaseDevice 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"]) @register_parser(["QTECH"])
class Qtech(BaseDevice): class Qtech(BaseDevice):
template = "qtech.ttp" template = "qtech.ttp"
def system(self) -> dict:
system = self.raw["system"]
return system
def vlans(self) -> list[dict]: def vlans(self) -> list[dict]:
vlans_ttp = self.raw["vlans"] vlans_ttp = self.raw.get("vlans", [])
vlans = [] vlans: list[dict] = []
named_vlan = set() named_vlan: set[str] = set()
for item in vlans_ttp: for item in vlans_ttp:
if item.get("vlan_id"): vlan_id = item.get("vlan_id")
named_vlan.add(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) vlans.append(item)
else: continue
ids = item.get("vlan_ids", "")
ids = item.get("vlan_ids") or vlan_id or ""
tail = item.get("vlan_tail") tail = item.get("vlan_tail")
if tail: if tail:
ids = f"{ids},{tail}" ids = [*ids, tail] if isinstance(ids, list) else f"{ids},{tail}"
for vid in ids.split(","): for vid in _expand_vlan_range(ids):
vid = vid.strip()
if vid in named_vlan: if vid in named_vlan:
continue continue
vlans.append({"vlan_id": vid}) vlans.append({"vlan_id": vid})
@@ -33,9 +56,13 @@ class Qtech(BaseDevice):
if __name__ == "__main__": if __name__ == "__main__":
print(os.path.abspath(os.curdir))
with open("./test3.txt") as file: with open("./test3.txt") as file:
data = file.read() data = file.read()
qtech = Qtech(data) qtech = Qtech(data)
qt = qtech.parse() qt = qtech.parse()
print(qt) 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" template = "quasar.ttp"
def interfaces(self) -> list[dict]: def interfaces(self) -> list[dict]:
ether_interfaces: dict = self.raw["interfaces"] ether_interface: dict = self.raw.get("interfaces", {})
interfaces: list[dict] = [] interfaces: list[dict] = []
bulk_interfaces: dict = self.raw["bulkinterfaces"] bulk_interfaces: dict = self.raw.get("bulkinterfaces", {})
for key, value in bulk_interfaces.items(): for key, value in bulk_interfaces.items():
interfaces.append( interfaces.append(
{ {
@@ -18,7 +18,8 @@ class Quasar(BaseDevice):
"mask": value.get("mask"), "mask": value.get("mask"),
} }
) )
interfaces.append(ether_interfaces) if ether_interface:
interfaces.append(ether_interface)
return interfaces return interfaces

View File

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

View File

@@ -1,41 +1,10 @@
<doc> <doc>
Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте Eltex configuration parser.
в &lt;vendor&gt;.ttp и заполните группы под формат конфигурации вашего устройства.
Обязательные группы: system, interfaces. The system group reads software version data and the serial group extracts
Опциональная группа: vlans — добавляйте только если устройство поддерживает VLAN. serial numbers from the unit table. The interfaces group parses interface IP
settings. The vlans group supports named VLAN interfaces and compressed VLAN
--- Группа system --- lists.
Должна возвращать одиночный словарь с полями:
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
</doc> </doc>
<vars> <vars>
default_system = { default_system = {

View File

@@ -1,41 +1,9 @@
<doc> <doc>
Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте H3C configuration parser.
в &lt;vendor&gt;.ttp и заполните группы под формат конфигурации вашего устройства.
Обязательные группы: system, interfaces. The system group reads boot image version and board model data. The interfaces
Опциональная группа: vlans — добавляйте только если устройство поддерживает VLAN. group parses interface IP settings. The vlans groups parse both named VLANs and
range-style VLAN declarations.
--- Группа 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
</doc> </doc>
<vars> <vars>
default_system = { default_system = {

View File

@@ -1,40 +1,9 @@
<doc> <doc>
Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте Huawei VRP configuration parser.
в &lt;vendor&gt;.ttp и заполните группы под формат конфигурации вашего устройства.
Обязательные группы: system, interfaces. The system group reads VRP version and slot ESN data. The interfaces group
Опциональная группа: vlans — добавляйте только если устройство поддерживает VLAN. parses interface blocks and converts dotted decimal masks to prefix lengths.
The vlans group parses `vlan batch` declarations and emits VLAN IDs.
--- Группа 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
</doc> </doc>
<vars> <vars>
default_system = { default_system = {

View File

@@ -1,4 +1,13 @@
<doc> <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> </doc>
<vars> <vars>
default_system = { default_system = {

View File

@@ -1,41 +1,10 @@
<doc> <doc>
Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте Quasar configuration parser.
в &lt;vendor&gt;.ttp и заполните группы под формат конфигурации вашего устройства.
Обязательные группы: system, interfaces. The system group supports Assembly-based and Engine-based firmware blocks. The
Опциональная группа: vlans — добавляйте только если устройство поддерживает VLAN. interfaces group parses the management Ethernet address, while bulkinterfaces
collects per-port descriptions that the Python model merges into interface
--- Группа system --- records.
Должна возвращать одиночный словарь с полями:
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
</doc> </doc>
<vars> <vars>
default_system = { default_system = {

View File

@@ -1,17 +1,17 @@
[build-system] [build-system]
requires = ["setuptools>=61"] requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "oxipy" name = "oxipy"
version = "0.1.0" version = "0.1.0"
description = "Oxidized API client" description = "Python client for Oxidized API with TTP-based config parsing"
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = "Apache-2.0"
license-files = ["LICENSE"]
requires-python = ">=3.10" requires-python = ">=3.10"
classifiers = [ classifiers = [
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent", "Operating System :: OS Independent",
] ]
dependencies = [ dependencies = [
@@ -20,6 +20,9 @@ dependencies = [
"ttp>=0.10.0", "ttp>=0.10.0",
] ]
[project.urls]
Repository = "https://github.com/sttarsky/oxipy"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["."]
include = ["oxi*"] include = ["oxi*"]

39
uv.lock generated
View File

@@ -1,6 +1,6 @@
version = 1 version = 1
revision = 3 revision = 3
requires-python = ">=3.11" requires-python = ">=3.10"
[[package]] [[package]]
name = "annotated-types" name = "annotated-types"
@@ -26,6 +26,22 @@ version = "3.4.4"
source = { registry = "https://pypi.org/simple" } 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" } 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 = [ 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/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/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" },
{ url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, { url = "https://files.pythonhosted.org/packages/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" } 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 = [ 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/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/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, { url = "https://files.pythonhosted.org/packages/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/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/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/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/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/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, { url = "https://files.pythonhosted.org/packages/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" },