From fe6d0c4ccf065cf99846cd68d0abef80faf78986 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Tue, 24 Feb 2026 23:27:14 +0300 Subject: [PATCH 1/3] Add README and documentation for Oxi API client - Introduced a comprehensive README file detailing the installation, quick start guide, and API reference for the oxipy Python client. - Added documentation on extending and overriding device models, including architecture, registration of new devices, and method overriding examples. - Created a template guide for writing TTP templates, outlining structure, mandatory groups, and practical examples for new device support. - Included a base template for device configuration parsing, enhancing usability for developers integrating with the Oxi API. --- README.md | 240 +++++++++++++ docs/extending-models.md | 325 ++++++++++++++++++ docs/templates.md | 306 +++++++++++++++++ oxi/interfaces/models/templates/_template.ttp | 44 ++- 4 files changed, 914 insertions(+), 1 deletion(-) create mode 100644 docs/extending-models.md create mode 100644 docs/templates.md diff --git a/README.md b/README.md index e69de29..446ec46 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,240 @@ +# oxipy + +Python-клиент для работы с Oxi API — системой мониторинга и управления сетевыми устройствами. Предоставляет удобный интерфейс для получения конфигураций узлов, их парсинга и работы с результатами через типизированные Pydantic-модели. + +## Содержание + +- [Установка](#установка) +- [Быстрый старт](#быстрый-старт) +- [API Reference](#api-reference) + - [OxiAPI](#oxiapi) + - [NodeView](#nodeview) + - [NodeConfig](#nodeconfig) + - [ModelView](#modelview) +- [Поддерживаемые устройства](#поддерживаемые-устройства) +- [Дополнительно](#дополнительно) + +--- + +## Установка + +```bash +pip install oxipy +``` + +Или из исходников: + +```bash +git clone https://gitea.imbastark.ru/Netbox/oxipy +cd oxipy +pip install -e . +``` + +**Требования:** Python 3.13+ + +--- + +## Быстрый старт + +```python +from oxi import OxiAPI + +api = OxiAPI(url="https://oxi.example.com", verify=False) + +node = api.node("Router_HOME") + +print(node.ip) # '192.168.1.1' +print(node.model) # 'keenetic' +print(node.full_name) # 'Router_HOME' + +print(node.config.system.model) +print(node.config.interfaces.json()) +print(node.config.vlans.json()) +``` + +--- + +## API Reference + +### OxiAPI + +Точка входа в библиотеку. Управляет HTTP-сессией и предоставляет доступ к узлам. + +```python +OxiAPI( + url: str, + username: str | None = None, + password: str | None = None, + verify: bool = True, +) +``` + +| Параметр | Тип | Описание | +|------------|--------|----------------------------------------------------------------| +| `url` | `str` | Базовый URL Oxi API, например `https://oxi.example.com` | +| `username` | `str` | Имя пользователя для базовой аутентификации (опционально) | +| `password` | `str` | Пароль для базовой аутентификации (опционально) | +| `verify` | `bool` | Проверять SSL-сертификат. `False` — отключить проверку | + +**Пример:** + +```python +# Без аутентификации +api = OxiAPI(url="https://oxi.example.com") + +# С базовой аутентификацией +api = OxiAPI( + url="https://oxi.example.com", + username="admin", + password="secret", +) + +# Использование как контекстного менеджера (автоматически закрывает сессию) +with OxiAPI(url="https://oxi.example.com") as api: + node = api.node("Router_HOME") + print(node.ip) +``` + +#### `api.node(name)` + +Возвращает [`NodeView`](#nodeview) для указанного узла. + +```python +node = api.node("Router_HOME") +``` + +--- + +### NodeView + +Представление узла сети. Содержит метаданные и ленивый доступ к конфигурации. + +| Свойство | Тип | Описание | +|-------------|--------------|-----------------------------------------------| +| `ip` | `str` | IP-адрес узла | +| `full_name` | `str` | Полное имя узла в Oxi | +| `group` | `str` | Группа, к которой принадлежит узел | +| `model` | `str` | Модель устройства (используется для парсинга) | +| `config` | `NodeConfig` | Конфигурация узла (загружается при первом обращении) | + +**Пример:** + +```python +node = api.node("Router_HOME") + +print(node.ip) # '10.0.0.1' +print(node.group) # 'branch-office' +print(node.model) # 'keenetic' + +# Конфигурация загружается один раз (cached_property) +cfg = node.config +``` + +--- + +### NodeConfig + +Загружает и парсит конфигурацию устройства. Использует TTP-шаблоны, соответствующие модели устройства. + +Доступ к секциям конфигурации осуществляется через свойства, возвращающие [`ModelView`](#modelview). + +| Свойство | Возвращает | Описание | +|--------------|-------------------------|-----------------------------------| +| `system` | `ModelView[System]` | Системная информация об устройстве | +| `interfaces` | `ModelView[list[Interfaces]]` | Список интерфейсов | +| `vlans` | `ModelView[list[Vlans]]` | Список VLAN (если есть) | +| `text` | `str` | Сырой текст конфигурации | + +**Пример:** + +```python +cfg = node.config + +# Системная информация +print(cfg.system.model) # 'RB951Ui-2nD' +print(cfg.system.serial_number) # 'B88C0B31117B' +print(cfg.system.version) # '7.12.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.text) +``` + +--- + +### ModelView + +Обёртка над Pydantic-моделью или списком моделей. Обеспечивает сериализацию, итерацию и прозрачный доступ к атрибутам. + +| Метод / свойство | Применимо к | Описание | +|------------------|----------------|---------------------------------------------------------------| +| `.json()` | оба варианта | Возвращает JSON-строку (с `by_alias=True`) | +| `.` | оба варианта | Проксирует обращение к атрибутам вложенной модели | +| `iter(view)` | список | Итерация по элементам списка моделей | +| `len(view)` | список | Количество элементов в списке | +| `view[i]` | список | Получение элемента по индексу или срез | + +> `__iter__`, `__len__` и `__getitem__` доступны только для `interfaces` и `vlans` (они оборачивают список). Вызов этих методов на `system` (одиночная модель) вызовет `TypeError`. + +**Примеры:** + +```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' + +# Список — 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()) +``` + +--- + +## Поддерживаемые устройства + +| Устройство | Ключи реестра | +|-------------|----------------------------------------| +| Keenetic | `ndms`, `keenetic`, `keeneticos` | +| MikroTik | `routeros`, `ros`, `mikrotik` | + +Ключи реестра — это значения поля `model`, возвращаемого API для узла. Регистр не учитывается. + +Добавить поддержку нового устройства можно самостоятельно — подробнее в разделе [Расширение моделей](docs/extending-models.md). + +--- + +## Дополнительно + +- [Написание TTP-шаблонов](docs/templates.md) +- [Расширение и переопределение моделей устройств](docs/extending-models.md) diff --git a/docs/extending-models.md b/docs/extending-models.md new file mode 100644 index 0000000..dfe30c8 --- /dev/null +++ b/docs/extending-models.md @@ -0,0 +1,325 @@ +# Расширение и переопределение моделей устройств + +oxipy предоставляет гибкий механизм расширения через наследование от `BaseDevice`. После того как TTP-шаблон разобрал конфигурацию в сырой словарь `self.raw`, данные проходят через три метода экземпляра — `system()`, `interfaces()`, `vlans()` — перед тем как попасть в контракт. Переопределяя эти методы, можно трансформировать, фильтровать и обогащать данные без изменения шаблона или контракта. + +## Содержание + +- [Архитектура: путь данных](#архитектура-путь-данных) +- [Регистрация нового устройства](#регистрация-нового-устройства) +- [Переопределение методов (monkey patching)](#переопределение-методов-monkey-patching) + - [interfaces()](#interfaces) + - [vlans()](#vlans) + - [system()](#system) +- [Полный пример: новое устройство](#полный-пример-новое-устройство) +- [Контракт: ожидаемые структуры](#контракт-ожидаемые-структуры) + +--- + +## Архитектура: путь данных + +``` +текст конфигурации + │ + ▼ + TTP-шаблон (.ttp) + │ парсит в сырой словарь + ▼ + self.raw: dict + │ + ├──► system() → dict + ├──► interfaces() → list[dict] + └──► vlans() → list[dict] + │ + ▼ + _validate_contract() + │ создаёт Pydantic-модели + ▼ + Device(system, interfaces, vlans) +``` + +Методы `system()`, `interfaces()`, `vlans()` — это точки расширения. Базовая реализация просто возвращает данные из `self.raw`: + +```python +# BaseDevice (упрощённо) +def interfaces(self) -> list[dict]: + return self.raw.get("interfaces", []) + +def vlans(self) -> list[dict]: + return self.raw.get("vlans", []) + +def system(self) -> dict: + return self.raw.get("system", None) +``` + +--- + +## Регистрация нового устройства + +Чтобы добавить поддержку нового вендора: + +1. Создайте файл в `oxi/interfaces/models/`, например `cisco.py`. +2. Создайте шаблон `oxi/interfaces/models/templates/cisco.ttp`. +3. Унаследуйте класс от `BaseDevice` и зарегистрируйте его декоратором `@register_parser`. + +```python +# oxi/interfaces/models/cisco.py +from oxi.interfaces import register_parser +from oxi.interfaces.base import BaseDevice + + +@register_parser(["ios", "cisco", "cisco_ios"]) +class CiscoIOS(BaseDevice): + template = "cisco.ttp" +``` + +Декоратор `@register_parser` принимает список строк — это ключи, по которым устройство ищется в реестре. Поле `model` от API сравнивается с этими ключами без учёта регистра. + +После добавления файла он автоматически импортируется через `pkgutil` при старте приложения — явно импортировать не нужно. + +--- + +## Переопределение методов (monkey patching) + +### interfaces() + +Используйте переопределение, когда нужно: + +- Преобразовать формат IP-адреса (например, `netmask` → `prefix_length`). +- Декодировать escape-последовательности в описаниях. +- Переименовать ключи, не совпадающие с контрактом. +- Фильтровать служебные интерфейсы. + +**Пример: конвертация маски подсети в префикс** + +TTP возвращает `netmask` как `255.255.255.0`, а контракт `Interfaces` ожидает `mask` как целое число (prefix length): + +```python +from ipaddress import ip_interface +from oxi.interfaces import register_parser +from oxi.interfaces.base import BaseDevice + + +@register_parser(["myvendor"]) +class MyVendor(BaseDevice): + template = "myvendor.ttp" + + def interfaces(self) -> list[dict]: + result = [] + for item in self.raw.get("interfaces", []): + if item.get("ip_address") and item.get("netmask"): + iface = ip_interface(f"{item['ip_address']}/{item['netmask']}") + item["mask"] = iface.network.prefixlen + item.pop("netmask", None) + result.append(item) + return result +``` + +**Пример: фильтрация служебных интерфейсов** + +```python +def interfaces(self) -> list[dict]: + return [ + item for item in self.raw.get("interfaces", []) + if not item.get("name", "").startswith("lo") + ] +``` + +**Пример: декодирование Unicode escape-последовательностей** + +Некоторые устройства (например, Keenetic) хранят кириллические описания как `\xd0\xb8\xd0\xbc\xd1\x8f`: + +```python +def _decode_utf(self, text: str) -> str: + if "\\x" in text: + return ( + text.strip('"') + .encode("utf-8") + .decode("unicode_escape") + .encode("latin1") + .decode("utf-8") + ) + return text + +def interfaces(self) -> list[dict]: + interfaces = self.raw.get("interfaces", []) + for item in interfaces: + if item.get("description"): + item["description"] = self._decode_utf(item["description"]) + return interfaces +``` + +--- + +### vlans() + +Аналогично `interfaces()`. Используйте для нормализации ID, декодирования названий, обогащения данными из других секций. + +**Пример: добавление префикса к имени VLAN** + +```python +def vlans(self) -> list[dict]: + result = [] + for item in self.raw.get("vlans", []): + item["description"] = f"VLAN_{item.get('id', '?')}" + result.append(item) + return result +``` + +**Пример: объединение данных из нескольких секций** + +```python +def vlans(self) -> list[dict]: + vlans = {v["id"]: v for v in self.raw.get("vlans", [])} + # обогащаем данными из другой секции, если она есть + for extra in self.raw.get("vlan_details", []): + vlan_id = extra.get("id") + if vlan_id in vlans: + vlans[vlan_id].update(extra) + return list(vlans.values()) +``` + +--- + +### system() + +Переопределяйте, если структура системной секции отличается от ожидаемой контрактом, или нужно вычислить поля: + +**Пример: собрать серийный номер из нескольких полей** + +```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 +``` + +**Пример: нормализация строки версии** + +```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 +``` + +--- + +## Полный пример: новое устройство + +Допустим, нужно добавить поддержку Cisco IOS, где: +- IP-адрес и маска разделены пробелом в конфигурации (`ip address 10.0.0.1 255.255.255.0`). +- Описание интерфейса может содержать несколько слов. +- Серийный номер разделён дефисом в двух строках. + +**Шаблон** (`oxi/interfaces/models/templates/cisco.ttp`): + +```xml + +default_system = { + "model": "", + "serial_number": "", + "version": "" +} + + + +Cisco IOS Software, {{ ignore }} Version {{ version }},{{ ignore('.*') }} +Model Number : {{ model }} +System serial number : {{ serial_number }} + + + +interface {{ interface | _start_ }} + description {{ description | ORPHRASE }} + ip address {{ ip_address }} {{ netmask }} + + + +vlan {{ id | _start_ }} + name {{ description }} + +``` + +**Класс устройства** (`oxi/interfaces/models/cisco.py`): + +```python +from ipaddress import ip_interface +from oxi.interfaces import register_parser +from oxi.interfaces.base import BaseDevice + + +@register_parser(["ios", "cisco", "cisco_ios"]) +class CiscoIOS(BaseDevice): + template = "cisco.ttp" + + def interfaces(self) -> list[dict]: + result = [] + for item in self.raw.get("interfaces", []): + # Конвертируем маску подсети в длину префикса + if item.get("ip_address") and item.get("netmask"): + iface = ip_interface(f"{item['ip_address']}/{item['netmask']}") + item["mask"] = iface.network.prefixlen + item.pop("netmask", None) + # Фильтруем интерфейсы управления + if item.get("interface", "").startswith("Mgmt"): + continue + result.append(item) + return result + + def system(self) -> dict: + raw_system = self.raw.get("system", {}) + # Нормализуем версию: "15.2(4)M3" → оставляем как есть + # Убираем лишние пробелы в модели + if raw_system.get("model"): + raw_system["model"] = raw_system["model"].strip() + return raw_system +``` + +--- + +## Контракт: ожидаемые структуры + +Методы должны возвращать данные в следующем формате. Контракт жёстко проверяется Pydantic. + +### `system()` → `dict` + +```python +{ + "model": "RB951Ui-2nD", # str, обязательно + "serial_number": "B88C0B31117B", # str, обязательно + "version": "7.12.1", # str, обязательно +} +``` + +### `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 + }, + ... +] +``` + +### `vlans()` → `list[dict]` + +```python +[ + { + "id": 10, # int, обязательно (alias для поля vlan_id) + "description": "MGMT", # str | None (alias для поля name) + }, + ... +] +``` + +> Если имя ключа в словаре совпадает с **alias** поля Pydantic-модели, а не с именем атрибута — используйте alias. Модели сконфигурированы с `populate_by_name=True`, поэтому принимаются оба варианта. diff --git a/docs/templates.md b/docs/templates.md new file mode 100644 index 0000000..86cf78d --- /dev/null +++ b/docs/templates.md @@ -0,0 +1,306 @@ +# Написание TTP-шаблонов + +oxipy использует библиотеку [TTP (Template Text Parser)](https://ttp.readthedocs.io/) для парсинга конфигураций сетевых устройств в структурированные данные. Шаблоны хранятся в директории `oxi/interfaces/models/templates/`. + +## Содержание + +- [Структура шаблона](#структура-шаблона) +- [Обязательные группы](#обязательные-группы) +- [Секция system](#секция-system) +- [Секция interfaces](#секция-interfaces) +- [Секция vlans](#секция-vlans) +- [TTP: основные возможности](#ttp-основные-возможности) +- [Переменные по умолчанию](#переменные-по-умолчанию) +- [Практические примеры](#практические-примеры) +- [Валидация шаблона](#валидация-шаблона) + +--- + +## Структура шаблона + +Каждый шаблон — это `.ttp`-файл, состоящий из следующих блоков: + +```xml + + Описание шаблона (опционально) + + + + + + + + + + + + + + + + + +``` + +Файл-заготовка находится в `oxi/interfaces/models/templates/_template.ttp`. + +--- + +## Обязательные группы + +Фреймворк требует наличия в шаблоне **двух обязательных групп**: + +| Группа | Обязательна | Описание | +|--------------|-------------|-------------------------------| +| `system` | Да | Системная информация | +| `interfaces` | Да | Конфигурация интерфейсов | +| `vlans` | Нет | Конфигурация VLAN | + +Если обязательная группа отсутствует в шаблоне или TTP не вернул её данные, будет выброшено `ValueError`. + +--- + +## Секция system + +Должна возвращать словарь со следующими полями: + +| Поле | Тип | Обязательное | Описание | +|-----------------|------|--------------|---------------------| +| `model` | str | Да | Модель устройства | +| `serial_number` | str | Да | Серийный номер | +| `version` | str | Да | Версия прошивки | + +**Пример (MikroTik):** + +Конфигурация: +``` +# version: 7.12.1 (stable) +# model = RB951Ui-2nD +# serial number = B88C0B31117B +``` + +Шаблон: +``` + +# version: {{ version }}{{ ignore('.*') }} +# model = {{ model }} +# serial number = {{ serial_number }} + +``` + +**Пример (Keenetic):** + +Конфигурация: +``` +! release: 4.1.7.1-1 +! model: Keenetic Extra +! hw_version: F02B4E7A1C90 +``` + +Шаблон: +``` + +! release: {{ version }} +! model: {{ model | ORPHRASE }} +! hw_version: {{ serial_number }} + +``` + +--- + +## Секция interfaces + +Должна возвращать список словарей. Каждый словарь описывает один интерфейс. + +Поля, которые ожидает контракт `Interfaces`: + +| Поле | TTP-имя / alias | Тип | Обязательное | +|---------------|-----------------|------------------|--------------| +| `name` | `interface` | str | Да | +| `ip_address` | `ip_address` | IPv4Address | Нет | +| `mask` | `mask` | int (prefix len) | Нет | +| `description` | `description` | str | Нет | + +> **Важно:** поле `name` в Pydantic-модели имеет алиас `interface`, поэтому в шаблоне переменную нужно называть именно `interface` **или** переопределить метод `interfaces()` в классе модели (см. [Расширение моделей](extending-models.md)). + +**Пример (MikroTik):** + +Конфигурация: +``` +/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 +``` + +Шаблон: +``` + +/ip address +add address={{ ip_address | _start_ }}/{{ mask }} interface={{ interface }} network={{ network }} +add address={{ ip_address | _start_ }}/{{ mask }} comment={{ description | ORPHRASE | strip('"') }} interface={{ interface }} network={{ network }} + +``` + +**Пример (Keenetic):** + +Конфигурация: +``` +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 +``` + +Шаблон: +``` + +interface {{ name | _start_ | exclude("Vlan") }} + description {{ description | ORPHRASE }} + ip address {{ ip_address }} {{ netmask }} + +``` + +Здесь переменная называется `name`, а не `interface` — это покрывается переопределением метода `interfaces()` в классе `Keenetic`. + +--- + +## Секция vlans + +Необязательная группа. Если объявлена в шаблоне, фреймворк ожидает её наличия в результате TTP. + +Поля контракта `Vlans`: + +| Поле | TTP-имя / alias | Тип | Обязательное | +|-----------|-----------------|------|--------------| +| `vlan_id` | `id` | int | Да | +| `name` | `description` | str | Нет | + +> `vlan_id` имеет алиас `id`, поэтому в шаблоне переменная должна называться `id` либо переименовываться в методе `vlans()`. + +**Пример (Keenetic):** + +Конфигурация: +``` +interface Bridge0/Vlan10 + description "MGMT" +interface Bridge0/Vlan20 + description "SERVERS" +``` + +Шаблон: +``` + +interface {{ ignore }}/Vlan{{ id }} + description {{ description | ORPHRASE | strip('"') }} + +``` + +--- + +## TTP: основные возможности + +### Маркеры строк + +| Маркер | Описание | +|-------------|---------------------------------------------------------------| +| `_start_` | Строка с этой переменной считается началом нового совпадения | +| `_end_` | Строка с этой переменной завершает совпадение группы | + +``` +add address={{ ip_address | _start_ }}/{{ mask }} interface={{ name }} +``` + +### Модификаторы переменных + +| Модификатор | Описание | +|------------------------|-----------------------------------------------------------| +| `ORPHRASE` | Захватывает одно слово или фразу (до конца строки) | +| `exclude("pattern")` | Пропускает строку, если захваченное значение содержит паттерн | +| `strip('"')` | Удаляет символ из начала и конца захваченного значения | +| `replace("old","new")` | Заменяет подстроку в захваченном значении | +| `re("pattern")` | Принимает значение, только если оно соответствует regex | +| `ignore` | Захватывает, но игнорирует значение (не включает в результат) | +| `ignore('.*')` | Игнорирует всё до конца строки | + +### Комментарии в шаблоне + +Строки, начинающиеся с `##`, — это комментарии TTP и не влияют на парсинг: + +``` +## disabled no comment +add address={{ ip_address | _start_ }}/{{ mask }} interface={{ name }} +``` + +--- + +## Переменные по умолчанию + +Блок `` позволяет задавать значения по умолчанию для группы через атрибут `default`: + +```xml + +default_system = { + "model": "", + "serial_number": "" +} + + + +# version: {{ version }} +# model = {{ model }} +# serial number = {{ serial_number }} + +``` + +Если шаблон не нашёл совпадений для группы, будет возвращён словарь из `default_system`. + +--- + +## Практические примеры + +### Полный шаблон для нового устройства (пример: Cisco IOS) + +```xml + + Шаблон для парсинга Cisco IOS running-config + + + +default_system = { + "model": "", + "serial_number": "", + "version": "" +} + + + +Cisco IOS Software, {{ ignore }} Version {{ version }},{{ ignore('.*') }} +Model Number : {{ model }} +System serial number : {{ serial_number }} + + + +interface {{ interface | _start_ }} + description {{ description | ORPHRASE }} + ip address {{ ip_address }} {{ netmask }} + shutdown {{ shutdown | set("True") }} + + + +vlan {{ id | _start_ }} + name {{ description }} + +``` + +--- + +## Валидация шаблона + +Фреймворк автоматически выполняет два уровня проверки: + +1. **Валидация структуры шаблона** — при создании объекта устройства парсятся XML-теги `` и проверяется наличие обязательных секций (`system`, `interfaces`). + +2. **Валидация результата парсинга** — после запуска TTP проверяется, что обязательные группы действительно присутствуют в результате (т.е. конфигурация содержала соответствующие строки). + +При нарушении любого условия выбрасывается `ValueError` с подробным описанием проблемы. diff --git a/oxi/interfaces/models/templates/_template.ttp b/oxi/interfaces/models/templates/_template.ttp index 67cb94d..3e6593d 100644 --- a/oxi/interfaces/models/templates/_template.ttp +++ b/oxi/interfaces/models/templates/_template.ttp @@ -1,9 +1,51 @@ +Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте +в .ttp и заполните группы под формат конфигурации вашего устройства. + +Обязательные группы: 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 +default_system = { + "model": "", + "serial_number": "", + "version": "" +} - + ... From a016db644d46c6d6a164bd707d25f8104ecbae06 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Wed, 25 Feb 2026 13:34:19 +0300 Subject: [PATCH 2/3] Update README to reflect API name change and enhance installation instructions - Changed references from "Oxi API" to "Oxidized API" for accuracy. - Added detailed installation instructions for using the Gitea Package Registry, including authentication steps and editable install options. - Improved clarity in the quick start section with formatted output examples. --- README.md | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 70 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 446ec46..74c5bec 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # oxipy -Python-клиент для работы с Oxi API — системой мониторинга и управления сетевыми устройствами. Предоставляет удобный интерфейс для получения конфигураций узлов, их парсинга и работы с результатами через типизированные Pydantic-модели. +Python-клиент для работы с Oxidized API — системой управления конфигурацией сетевых устройств. Предоставляет удобный интерфейс для получения конфигураций узлов, их парсинга и работы с результатами. ## Содержание @@ -18,11 +18,59 @@ Python-клиент для работы с Oxi API — системой мони ## Установка +> Пакет распространяется через Gitea Package Registry и исходники репозитория. +> В PyPI пакет не публикуется. + +**Требования:** Python 3.13+ + +### Из Gitea Package Registry + +Добавьте registry в конфигурацию pip и установите пакет: + +```bash +pip install oxipy \ + --index-url https://gitea.imbastark.ru/api/packages/Netbox/pypi/simple/ +``` + +Или пропишите registry постоянно в `pip.conf` / `pip.ini`, чтобы не указывать `--index-url` каждый раз: + +```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 pip install oxipy ``` -Или из исходников: +Если registry требует аутентификации, передайте токен: + +```bash +pip install oxipy \ + --index-url https://__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 @@ -30,8 +78,6 @@ cd oxipy pip install -e . ``` -**Требования:** Python 3.13+ - --- ## Быстрый старт @@ -43,13 +89,30 @@ api = OxiAPI(url="https://oxi.example.com", verify=False) node = api.node("Router_HOME") -print(node.ip) # '192.168.1.1' -print(node.model) # 'keenetic' -print(node.full_name) # 'Router_HOME' +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"} +] ``` --- From b6630a4d301e9cf107e2a08bd5836a90634c4740 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Wed, 25 Feb 2026 13:46:04 +0300 Subject: [PATCH 3/3] Update README for clarity and consistency - Simplified the description of the OxiAPI entry point. - Improved formatting of parameter and property tables for better readability. - Updated example outputs to reflect changes in node names and configurations. --- README.md | 110 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 65 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 74c5bec..4dabb57 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Python-клиент для работы с Oxidized API — системой у > Пакет распространяется через Gitea Package Registry и исходники репозитория. > В PyPI пакет не публикуется. -**Требования:** Python 3.13+ +**Требования:** Python 3.11+ ### Из Gitea Package Registry @@ -121,7 +121,7 @@ print(node.config.vlans.json()) ### OxiAPI -Точка входа в библиотеку. Управляет HTTP-сессией и предоставляет доступ к узлам. +Точка входа. Управляет HTTP-сессией и предоставляет доступ к узлам. ```python OxiAPI( @@ -132,12 +132,14 @@ OxiAPI( ) ``` -| Параметр | Тип | Описание | -|------------|--------|----------------------------------------------------------------| -| `url` | `str` | Базовый URL Oxi API, например `https://oxi.example.com` | -| `username` | `str` | Имя пользователя для базовой аутентификации (опционально) | -| `password` | `str` | Пароль для базовой аутентификации (опционально) | -| `verify` | `bool` | Проверять SSL-сертификат. `False` — отключить проверку | + +| Параметр | Тип | Описание | +| ---------- | ------ | --------------------------------------------------------- | +| `url` | `str` | Базовый URL Oxi API, например `https://oxi.example.com` | +| `username` | `str` | Имя пользователя для базовой аутентификации (опционально) | +| `password` | `str` | Пароль для базовой аутентификации (опционально) | +| `verify` | `bool` | Проверять SSL-сертификат. `True` по умолчанию | + **Пример:** @@ -154,16 +156,18 @@ api = OxiAPI( # Использование как контекстного менеджера (автоматически закрывает сессию) with OxiAPI(url="https://oxi.example.com") as api: - node = api.node("Router_HOME") + node = api.node("HQ") print(node.ip) + +>>> 192.168.1.1 ``` #### `api.node(name)` -Возвращает [`NodeView`](#nodeview) для указанного узла. +Возвращает `[NodeView](#nodeview)` для указанного узла. ```python -node = api.node("Router_HOME") +node = api.node("HQ") ``` --- @@ -172,25 +176,28 @@ node = api.node("Router_HOME") Представление узла сети. Содержит метаданные и ленивый доступ к конфигурации. -| Свойство | Тип | Описание | -|-------------|--------------|-----------------------------------------------| -| `ip` | `str` | IP-адрес узла | -| `full_name` | `str` | Полное имя узла в Oxi | -| `group` | `str` | Группа, к которой принадлежит узел | -| `model` | `str` | Модель устройства (используется для парсинга) | + +| Свойство | Тип | Описание | +| ----------- | ------------ | ---------------------------------------------------- | +| `ip` | `str` | IP-адрес узла | +| `full_name` | `str` | Полное имя узла в Oxi | +| `group` | `str` | Группа, к которой принадлежит узел | +| `model` | `str` | Модель устройства (используется для парсинга) | | `config` | `NodeConfig` | Конфигурация узла (загружается при первом обращении) | + **Пример:** ```python -node = api.node("Router_HOME") +node = api.node("HQ") -print(node.ip) # '10.0.0.1' -print(node.group) # 'branch-office' -print(node.model) # 'keenetic' +print(node.ip) +print(node.group) +print(node.model) -# Конфигурация загружается один раз (cached_property) -cfg = node.config +>>> 192.168.1.1 +>>> branch-office +>>> keenetic ``` --- @@ -199,14 +206,16 @@ cfg = node.config Загружает и парсит конфигурацию устройства. Использует TTP-шаблоны, соответствующие модели устройства. -Доступ к секциям конфигурации осуществляется через свойства, возвращающие [`ModelView`](#modelview). +Доступ к секциям конфигурации осуществляется через свойства, возвращающие `[ModelView](#modelview)`. + + +| Свойство | Возвращает | Описание | +| ------------ | ----------------------------- | ---------------------------------- | +| `system` | `ModelView[System]` | Системная информация об устройстве | +| `interfaces` | `ModelView[list[Interfaces]]` | Список интерфейсов | +| `vlans` | `ModelView[list[Vlans]]` | Список VLAN (если есть) | +| `text` | `str` | Сырой текст конфигурации | -| Свойство | Возвращает | Описание | -|--------------|-------------------------|-----------------------------------| -| `system` | `ModelView[System]` | Системная информация об устройстве | -| `interfaces` | `ModelView[list[Interfaces]]` | Список интерфейсов | -| `vlans` | `ModelView[list[Vlans]]` | Список VLAN (если есть) | -| `text` | `str` | Сырой текст конфигурации | **Пример:** @@ -214,9 +223,13 @@ cfg = node.config cfg = node.config # Системная информация -print(cfg.system.model) # 'RB951Ui-2nD' -print(cfg.system.serial_number) # 'B88C0B31117B' -print(cfg.system.version) # '7.12.1' +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: @@ -244,13 +257,15 @@ print(cfg.text) Обёртка над Pydantic-моделью или списком моделей. Обеспечивает сериализацию, итерацию и прозрачный доступ к атрибутам. -| Метод / свойство | Применимо к | Описание | -|------------------|----------------|---------------------------------------------------------------| -| `.json()` | оба варианта | Возвращает JSON-строку (с `by_alias=True`) | -| `.` | оба варианта | Проксирует обращение к атрибутам вложенной модели | -| `iter(view)` | список | Итерация по элементам списка моделей | -| `len(view)` | список | Количество элементов в списке | -| `view[i]` | список | Получение элемента по индексу или срез | + +| Метод / свойство | Применимо к | Описание | +| ---------------- | ------------ | ------------------------------------------------- | +| `.json()` | оба варианта | Возвращает JSON-строку (с `by_alias=True`) | +| `.` | оба варианта | Проксирует обращение к атрибутам вложенной модели | +| `iter(view)` | список | Итерация по элементам списка моделей | +| `len(view)` | список | Количество элементов в списке | +| `view[i]` | список | Получение элемента по индексу или срез | + > `__iter__`, `__len__` и `__getitem__` доступны только для `interfaces` и `vlans` (они оборачивают список). Вызов этих методов на `system` (одиночная модель) вызовет `TypeError`. @@ -260,10 +275,12 @@ print(cfg.text) # Одиночная модель — system view = node.config.system print(view.json()) -# '{"model":"RB951Ui-2nD","serial_number":"B88C0B31117B","version":"7.12.1"}' +>>> '{"model":"RB951Ui-2nD","serial_number":"B88C0B31117B","version":"7.12.1"}' print(view.model) # 'RB951Ui-2nD' print(view.serial_number) # 'B88C0B31117B' +>>> RB951Ui-2nD +>>> B88C0B31117B # Список — interfaces interfaces = node.config.interfaces @@ -286,10 +303,12 @@ print(interfaces.json()) ## Поддерживаемые устройства -| Устройство | Ключи реестра | -|-------------|----------------------------------------| -| Keenetic | `ndms`, `keenetic`, `keeneticos` | -| MikroTik | `routeros`, `ros`, `mikrotik` | + +| Устройство | Ключи реестра | +| ---------- | -------------------------------- | +| Keenetic | `ndms`, `keenetic`, `keeneticos` | +| MikroTik | `routeros`, `ros`, `mikrotik` | + Ключи реестра — это значения поля `model`, возвращаемого API для узла. Регистр не учитывается. @@ -301,3 +320,4 @@ print(interfaces.json()) - [Написание TTP-шаблонов](docs/templates.md) - [Расширение и переопределение моделей устройств](docs/extending-models.md) +