Compare commits
33 Commits
77f539254c
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b92e342e5 | ||
|
|
1cc225917e | ||
|
|
61892d8f51 | ||
|
|
8cebbf743a | ||
|
|
a107662e99 | ||
|
|
1d0f5ed685 | ||
|
|
5b8380aeee | ||
|
|
65c82fbaf5 | ||
|
|
974fff6038 | ||
|
|
586e52282b | ||
|
|
e3392f6c76 | ||
|
|
de0e09af9d | ||
|
|
ca96d2600a | ||
|
|
56eae15e27 | ||
|
|
db79199319 | ||
|
|
2e109db121 | ||
|
|
b9dce8e417 | ||
|
|
d185dc6c7c | ||
|
|
68566a24fb | ||
|
|
08733bd493 | ||
|
|
a1c57733f6 | ||
|
|
c9f6f3472f | ||
|
|
1e84452eb5 | ||
|
|
4ed34227d9 | ||
|
|
2a027ed736 | ||
|
|
bf6dd0c189 | ||
|
|
c382627f9f | ||
|
|
197550558d | ||
|
|
9e4574e869 | ||
|
|
b6630a4d30 | ||
|
|
2c3f5ce354 | ||
|
|
a016db644d | ||
|
|
fe6d0c4ccf |
201
LICENSE
Normal file
201
LICENSE
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
328
README.md
328
README.md
@@ -0,0 +1,328 @@
|
|||||||
|
# oxipy
|
||||||
|
|
||||||
|
Python-клиент для работы с Oxidized API — системой управления конфигурацией сетевых устройств. Предоставляет удобный интерфейс для получения конфигураций узлов, их парсинга и работы с результатами.
|
||||||
|
|
||||||
|
## Содержание
|
||||||
|
|
||||||
|
- [Установка](#установка)
|
||||||
|
- [Быстрый старт](#быстрый-старт)
|
||||||
|
- [API Reference](#api-reference)
|
||||||
|
- [OxiAPI](#oxiapi)
|
||||||
|
- [NodeView](#nodeview)
|
||||||
|
- [NodeConfig](#nodeconfig)
|
||||||
|
- [ModelView](#modelview)
|
||||||
|
- [Поддерживаемые устройства](#поддерживаемые-устройства)
|
||||||
|
- [Дополнительно](#дополнительно)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
> Пакет распространяется через Gitea Package Registry и исходники репозитория.
|
||||||
|
> В PyPI пакет не публикуется.
|
||||||
|
|
||||||
|
**Требования:** Python 3.11+
|
||||||
|
|
||||||
|
### Из 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__:<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
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
```python
|
||||||
|
from oxi import OxiAPI
|
||||||
|
|
||||||
|
api = OxiAPI(url="https://oxi.example.com", verify=False)
|
||||||
|
|
||||||
|
node = api.node("Router_HOME")
|
||||||
|
|
||||||
|
print(node.ip)
|
||||||
|
print(node.model)
|
||||||
|
print(node.full_name)
|
||||||
|
|
||||||
|
>>> 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"}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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-сертификат. `True` по умолчанию |
|
||||||
|
|
||||||
|
|
||||||
|
**Пример:**
|
||||||
|
|
||||||
|
```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("HQ")
|
||||||
|
print(node.ip)
|
||||||
|
|
||||||
|
>>> 192.168.1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `api.node(name)`
|
||||||
|
|
||||||
|
Возвращает `[NodeView](#nodeview)` для указанного узла.
|
||||||
|
|
||||||
|
```python
|
||||||
|
node = api.node("HQ")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### NodeView
|
||||||
|
|
||||||
|
Представление узла сети. Содержит метаданные и ленивый доступ к конфигурации.
|
||||||
|
|
||||||
|
|
||||||
|
| Свойство | Тип | Описание |
|
||||||
|
| ----------- | ------------ | ---------------------------------------------------- |
|
||||||
|
| `ip` | `str` | IP-адрес узла |
|
||||||
|
| `full_name` | `str` | Полное имя узла в Oxi |
|
||||||
|
| `group` | `str` | Группа, к которой принадлежит узел |
|
||||||
|
| `model` | `str` | Модель устройства (используется для парсинга) |
|
||||||
|
| `config` | `NodeConfig` | Конфигурация узла (загружается при первом обращении) |
|
||||||
|
|
||||||
|
|
||||||
|
**Пример:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
node = api.node("HQ")
|
||||||
|
|
||||||
|
print(node.ip)
|
||||||
|
print(node.group)
|
||||||
|
print(node.model)
|
||||||
|
|
||||||
|
>>> 192.168.1.1
|
||||||
|
>>> branch-office
|
||||||
|
>>> keenetic
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
print(cfg.system.serial_number)
|
||||||
|
print(cfg.system.version)
|
||||||
|
|
||||||
|
>>> Mikrotik RB951Ui-2nD
|
||||||
|
>>> B88C0B31117B
|
||||||
|
>>> 7.16.1
|
||||||
|
|
||||||
|
# Итерация по интерфейсам
|
||||||
|
for iface in cfg.interfaces:
|
||||||
|
print(iface.name, iface.ip_address, iface.mask)
|
||||||
|
|
||||||
|
# Индексация
|
||||||
|
first_iface = cfg.interfaces[0]
|
||||||
|
print(first_iface.name)
|
||||||
|
|
||||||
|
# Количество интерфейсов
|
||||||
|
print(len(cfg.interfaces))
|
||||||
|
|
||||||
|
# JSON-дамп любой секции
|
||||||
|
print(cfg.interfaces.json())
|
||||||
|
print(cfg.vlans.json())
|
||||||
|
print(cfg.system.json())
|
||||||
|
|
||||||
|
# Сырая конфигурация текстом
|
||||||
|
print(cfg.text)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ModelView
|
||||||
|
|
||||||
|
Обёртка над Pydantic-моделью или списком моделей. Обеспечивает сериализацию, итерацию и прозрачный доступ к атрибутам.
|
||||||
|
|
||||||
|
|
||||||
|
| Метод / свойство | Применимо к | Описание |
|
||||||
|
| ---------------- | ------------ | ------------------------------------------------- |
|
||||||
|
| `.json()` | оба варианта | Возвращает JSON-строку (с `by_alias=True`) |
|
||||||
|
| `.<attr>` | оба варианта | Проксирует обращение к атрибутам вложенной модели |
|
||||||
|
| `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'
|
||||||
|
|
||||||
|
>>> RB951Ui-2nD
|
||||||
|
>>> B88C0B31117B
|
||||||
|
# Список — interfaces
|
||||||
|
interfaces = node.config.interfaces
|
||||||
|
|
||||||
|
# Итерация
|
||||||
|
for iface in interfaces:
|
||||||
|
print(iface.name, iface.ip_address)
|
||||||
|
|
||||||
|
# Длина
|
||||||
|
print(len(interfaces)) # 5
|
||||||
|
|
||||||
|
# Индексация и срезы
|
||||||
|
first = interfaces[0]
|
||||||
|
top3 = interfaces[:3]
|
||||||
|
|
||||||
|
# JSON всего списка
|
||||||
|
print(interfaces.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Поддерживаемые устройства
|
||||||
|
|
||||||
|
|
||||||
|
| Устройство | Ключи реестра |
|
||||||
|
| ---------- | -------------------------------- |
|
||||||
|
| Keenetic | `ndms`, `keenetic`, `keeneticos` |
|
||||||
|
| MikroTik | `routeros`, `ros`, `mikrotik` |
|
||||||
|
| Qtech | `qtech` |
|
||||||
|
| Huawei | `huawei`, `vrp` |
|
||||||
|
| Eltex | `eltex` |
|
||||||
|
| H3C | `h3c` |
|
||||||
|
| Quasar | `qos`, `quasar` |
|
||||||
|
|
||||||
|
|
||||||
|
Ключи реестра — это значения поля `model`, возвращаемого API для узла. Регистр не учитывается.
|
||||||
|
|
||||||
|
Добавить поддержку нового устройства можно самостоятельно — подробнее в разделе [Расширение моделей](docs/extending-models.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Дополнительно
|
||||||
|
|
||||||
|
- [Написание TTP-шаблонов](docs/templates.md)
|
||||||
|
- [Расширение и переопределение моделей устройств](docs/extending-models.md)
|
||||||
|
|
||||||
|
|||||||
325
docs/extending-models.md
Normal file
325
docs/extending-models.md
Normal file
@@ -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
|
||||||
|
<vars>
|
||||||
|
default_system = {
|
||||||
|
"model": "",
|
||||||
|
"serial_number": "",
|
||||||
|
"version": ""
|
||||||
|
}
|
||||||
|
</vars>
|
||||||
|
|
||||||
|
<group name="system" default="default_system">
|
||||||
|
Cisco IOS Software, {{ ignore }} Version {{ version }},{{ ignore('.*') }}
|
||||||
|
Model Number : {{ model }}
|
||||||
|
System serial number : {{ serial_number }}
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group name="interfaces">
|
||||||
|
interface {{ interface | _start_ }}
|
||||||
|
description {{ description | ORPHRASE }}
|
||||||
|
ip address {{ ip_address }} {{ netmask }}
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group name="vlans">
|
||||||
|
vlan {{ id | _start_ }}
|
||||||
|
name {{ description }}
|
||||||
|
</group>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Класс устройства** (`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`, поэтому принимаются оба варианта.
|
||||||
306
docs/templates.md
Normal file
306
docs/templates.md
Normal file
@@ -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
|
||||||
|
<doc>
|
||||||
|
Описание шаблона (опционально)
|
||||||
|
</doc>
|
||||||
|
|
||||||
|
<vars>
|
||||||
|
<!-- Переменные по умолчанию для групп -->
|
||||||
|
</vars>
|
||||||
|
|
||||||
|
<group name="system">
|
||||||
|
<!-- Правила для системной информации -->
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group name="interfaces">
|
||||||
|
<!-- Правила для интерфейсов -->
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group name="vlans">
|
||||||
|
<!-- Правила для VLAN (опционально) -->
|
||||||
|
</group>
|
||||||
|
```
|
||||||
|
|
||||||
|
Файл-заготовка находится в `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
|
||||||
|
```
|
||||||
|
|
||||||
|
Шаблон:
|
||||||
|
```
|
||||||
|
<group name="system">
|
||||||
|
# version: {{ version }}{{ ignore('.*') }}
|
||||||
|
# model = {{ model }}
|
||||||
|
# serial number = {{ serial_number }}
|
||||||
|
</group>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Пример (Keenetic):**
|
||||||
|
|
||||||
|
Конфигурация:
|
||||||
|
```
|
||||||
|
! release: 4.1.7.1-1
|
||||||
|
! model: Keenetic Extra
|
||||||
|
! hw_version: F02B4E7A1C90
|
||||||
|
```
|
||||||
|
|
||||||
|
Шаблон:
|
||||||
|
```
|
||||||
|
<group name="system">
|
||||||
|
! release: {{ version }}
|
||||||
|
! model: {{ model | ORPHRASE }}
|
||||||
|
! hw_version: {{ serial_number }}
|
||||||
|
</group>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Секция 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Шаблон:
|
||||||
|
```
|
||||||
|
<group name="interfaces">
|
||||||
|
/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 }}
|
||||||
|
</group>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Пример (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
|
||||||
|
```
|
||||||
|
|
||||||
|
Шаблон:
|
||||||
|
```
|
||||||
|
<group name="interfaces">
|
||||||
|
interface {{ name | _start_ | exclude("Vlan") }}
|
||||||
|
description {{ description | ORPHRASE }}
|
||||||
|
ip address {{ ip_address }} {{ netmask }}
|
||||||
|
</group>
|
||||||
|
```
|
||||||
|
|
||||||
|
Здесь переменная называется `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"
|
||||||
|
```
|
||||||
|
|
||||||
|
Шаблон:
|
||||||
|
```
|
||||||
|
<group name="vlans">
|
||||||
|
interface {{ ignore }}/Vlan{{ id }}
|
||||||
|
description {{ description | ORPHRASE | strip('"') }}
|
||||||
|
</group>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 }}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Переменные по умолчанию
|
||||||
|
|
||||||
|
Блок `<vars>` позволяет задавать значения по умолчанию для группы через атрибут `default`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<vars>
|
||||||
|
default_system = {
|
||||||
|
"model": "",
|
||||||
|
"serial_number": ""
|
||||||
|
}
|
||||||
|
</vars>
|
||||||
|
|
||||||
|
<group name="system" default="default_system">
|
||||||
|
# version: {{ version }}
|
||||||
|
# model = {{ model }}
|
||||||
|
# serial number = {{ serial_number }}
|
||||||
|
</group>
|
||||||
|
```
|
||||||
|
|
||||||
|
Если шаблон не нашёл совпадений для группы, будет возвращён словарь из `default_system`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Практические примеры
|
||||||
|
|
||||||
|
### Полный шаблон для нового устройства (пример: Cisco IOS)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<doc>
|
||||||
|
Шаблон для парсинга Cisco IOS running-config
|
||||||
|
</doc>
|
||||||
|
|
||||||
|
<vars>
|
||||||
|
default_system = {
|
||||||
|
"model": "",
|
||||||
|
"serial_number": "",
|
||||||
|
"version": ""
|
||||||
|
}
|
||||||
|
</vars>
|
||||||
|
|
||||||
|
<group name="system" default="default_system">
|
||||||
|
Cisco IOS Software, {{ ignore }} Version {{ version }},{{ ignore('.*') }}
|
||||||
|
Model Number : {{ model }}
|
||||||
|
System serial number : {{ serial_number }}
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group name="interfaces">
|
||||||
|
interface {{ interface | _start_ }}
|
||||||
|
description {{ description | ORPHRASE }}
|
||||||
|
ip address {{ ip_address }} {{ netmask }}
|
||||||
|
shutdown {{ shutdown | set("True") }}
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group name="vlans">
|
||||||
|
vlan {{ id | _start_ }}
|
||||||
|
name {{ description }}
|
||||||
|
</group>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Валидация шаблона
|
||||||
|
|
||||||
|
Фреймворк автоматически выполняет два уровня проверки:
|
||||||
|
|
||||||
|
1. **Валидация структуры шаблона** — при создании объекта устройства парсятся XML-теги `<group>` и проверяется наличие обязательных секций (`system`, `interfaces`).
|
||||||
|
|
||||||
|
2. **Валидация результата парсинга** — после запуска TTP проверяется, что обязательные группы действительно присутствуют в результате (т.е. конфигурация содержала соответствующие строки).
|
||||||
|
|
||||||
|
При нарушении любого условия выбрасывается `ValueError` с подробным описанием проблемы.
|
||||||
21
oxi/adapter.py
Normal file
21
oxi/adapter.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.util import Retry
|
||||||
|
|
||||||
|
|
||||||
|
class OxiAdapter(HTTPAdapter):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
timeout: Optional[int] = None,
|
||||||
|
max_retries: int = 3,
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
self.timeout = timeout
|
||||||
|
retry = Retry(total=max_retries, backoff_factor=0.3)
|
||||||
|
super().__init__(*args, max_retries=retry, **kwargs)
|
||||||
|
|
||||||
|
def send(self, request, **kwargs):
|
||||||
|
if kwargs.get("timeout") is None:
|
||||||
|
kwargs["timeout"] = self.timeout
|
||||||
|
return super().send(request, **kwargs)
|
||||||
14
oxi/conf.py
14
oxi/conf.py
@@ -16,7 +16,7 @@ class ModelView(Generic[TModel]):
|
|||||||
def __init__(self, model: TModel | list[TModel]):
|
def __init__(self, model: TModel | list[TModel]):
|
||||||
self._model = model
|
self._model = model
|
||||||
|
|
||||||
def json(self) -> str:
|
def dump_json(self) -> str:
|
||||||
if isinstance(self._model, list):
|
if isinstance(self._model, list):
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
[item.model_dump(by_alias=True) for item in self._model],
|
[item.model_dump(by_alias=True) for item in self._model],
|
||||||
@@ -24,6 +24,11 @@ class ModelView(Generic[TModel]):
|
|||||||
)
|
)
|
||||||
return self._model.model_dump_json(by_alias=True)
|
return self._model.model_dump_json(by_alias=True)
|
||||||
|
|
||||||
|
def dump(self) -> dict | list:
|
||||||
|
if isinstance(self._model, list):
|
||||||
|
return [item.model_dump(by_alias=True) for item in self._model]
|
||||||
|
return self._model.model_dump(by_alias=True)
|
||||||
|
|
||||||
def __iter__(self) -> Iterator[TModel]:
|
def __iter__(self) -> Iterator[TModel]:
|
||||||
if isinstance(self._model, list):
|
if isinstance(self._model, list):
|
||||||
return iter(self._model)
|
return iter(self._model)
|
||||||
@@ -67,8 +72,11 @@ class NodeConfig:
|
|||||||
def text(self):
|
def text(self):
|
||||||
return self._response.text
|
return self._response.text
|
||||||
|
|
||||||
def json(self):
|
def dump_json(self):
|
||||||
return self._parsed_data.model_dump_json()
|
return self._parsed_data.model_dump_json(by_alias=True)
|
||||||
|
|
||||||
|
def dump(self):
|
||||||
|
return self._parsed_data.model_dump(by_alias=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.text
|
return self.text
|
||||||
|
|||||||
33
oxi/core.py
33
oxi/core.py
@@ -1,5 +1,8 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from requests import Session
|
from requests import HTTPError, Session
|
||||||
|
|
||||||
|
from oxi.adapter import OxiAdapter
|
||||||
|
from oxi.exception import OxiAPIError
|
||||||
from .node import Node
|
from .node import Node
|
||||||
|
|
||||||
|
|
||||||
@@ -12,12 +15,24 @@ class OxiAPI:
|
|||||||
verify: bool = True,
|
verify: bool = True,
|
||||||
):
|
):
|
||||||
self.base_url = url.rstrip("/")
|
self.base_url = url.rstrip("/")
|
||||||
self._session = Session()
|
self._session = self.__create_session(username, password, verify)
|
||||||
self._session.verify = verify
|
|
||||||
if username and password:
|
|
||||||
self._session.auth = (username, password)
|
|
||||||
self.node = Node(self._session, self.base_url)
|
self.node = Node(self._session, self.base_url)
|
||||||
|
|
||||||
|
def __create_session(
|
||||||
|
self,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
password: Optional[str] = None,
|
||||||
|
verify: bool = True,
|
||||||
|
) -> Session:
|
||||||
|
session = Session()
|
||||||
|
adapter = OxiAdapter(timeout=10, max_retries=3)
|
||||||
|
session.mount("https://", adapter)
|
||||||
|
session.mount("http://", adapter)
|
||||||
|
session.verify = verify
|
||||||
|
if username and password:
|
||||||
|
session.auth = (username, password)
|
||||||
|
return session
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@@ -26,3 +41,11 @@ class OxiAPI:
|
|||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
return self._session.close()
|
return self._session.close()
|
||||||
|
|
||||||
|
def reload(self):
|
||||||
|
try:
|
||||||
|
reload_response = self._session.get(f"{self.base_url}/reload")
|
||||||
|
reload_response.raise_for_status()
|
||||||
|
except HTTPError as e:
|
||||||
|
raise OxiAPIError.from_http_error(e, context="Reload Oxidized") from e
|
||||||
|
return reload_response.status_code
|
||||||
|
|||||||
34
oxi/exception.py
Normal file
34
oxi/exception.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from requests import HTTPError
|
||||||
|
|
||||||
|
_STATUS_MESSAGES: dict[int, str] = {
|
||||||
|
401: "Unauthorized",
|
||||||
|
403: "Forbidden",
|
||||||
|
500: "Internal Server Error",
|
||||||
|
502: "Bad Gateway",
|
||||||
|
503: "Service Unavailable",
|
||||||
|
504: "Gateway Timeout",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OxiAPIError(Exception):
|
||||||
|
def __init__(self, message: str, status_code: Optional[int] = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.status_code is not None:
|
||||||
|
return f"OxiAPIError: {self.args[0]} (HTTP {self.status_code})"
|
||||||
|
return f"OxiAPIError: {self.args[0]}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_http_error(cls, e: "HTTPError", context: str = "") -> "OxiAPIError":
|
||||||
|
status = e.response.status_code
|
||||||
|
if status == 404:
|
||||||
|
message = f"{context} not found" if context else "Not found"
|
||||||
|
else:
|
||||||
|
base = _STATUS_MESSAGES.get(status) or e.response.reason or f"HTTP {status}"
|
||||||
|
message = f"{context}: {base}" if context else base
|
||||||
|
return cls(message, status)
|
||||||
@@ -6,7 +6,7 @@ device_registry = {}
|
|||||||
|
|
||||||
|
|
||||||
def register_parser(
|
def register_parser(
|
||||||
name: list[str],
|
name: list[str] | str,
|
||||||
) -> Callable[[Type[BaseDevice]], Type[BaseDevice]]:
|
) -> Callable[[Type[BaseDevice]], Type[BaseDevice]]:
|
||||||
def wrapper(cls):
|
def wrapper(cls):
|
||||||
name_list = []
|
name_list = []
|
||||||
@@ -23,4 +23,4 @@ def register_parser(
|
|||||||
|
|
||||||
from . import models # noqa: E402, F401
|
from . import models # noqa: E402, F401
|
||||||
|
|
||||||
__all__ = ["register_parser", "device_registry"]
|
__all__ = ["register_parser", "device_registry", "BaseDevice"]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class Base(BaseModel):
|
|||||||
|
|
||||||
class System(BaseModel):
|
class System(BaseModel):
|
||||||
"""
|
"""
|
||||||
Requred
|
Required
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model: str
|
model: str
|
||||||
@@ -18,7 +18,7 @@ class System(BaseModel):
|
|||||||
|
|
||||||
class Interfaces(Base):
|
class Interfaces(Base):
|
||||||
"""
|
"""
|
||||||
Requred
|
Required
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str = Field(alias="interface")
|
name: str = Field(alias="interface")
|
||||||
@@ -32,7 +32,7 @@ class Vlans(Base):
|
|||||||
Optional
|
Optional
|
||||||
"""
|
"""
|
||||||
|
|
||||||
vlan_id: int = Field(alias="id")
|
vlan_id: int
|
||||||
name: str | None = Field(default=None, alias="description")
|
name: str | None = Field(default=None, alias="description")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
43
oxi/interfaces/models/eltex.py
Normal file
43
oxi/interfaces/models/eltex.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from oxi.interfaces import register_parser
|
||||||
|
from oxi.interfaces.base import BaseDevice
|
||||||
|
|
||||||
|
|
||||||
|
@register_parser("eltex")
|
||||||
|
class Eltex(BaseDevice):
|
||||||
|
template = "eltex.ttp"
|
||||||
|
|
||||||
|
def system(self) -> dict:
|
||||||
|
system = self.raw["system"]
|
||||||
|
serial_num = self.raw["serial"]
|
||||||
|
if serial_num:
|
||||||
|
if len(serial_num) > 1:
|
||||||
|
serial_num = serial_num[0]
|
||||||
|
system["serial_number"] = serial_num.get("serial_number")
|
||||||
|
return system
|
||||||
|
|
||||||
|
def vlans(self) -> list[dict]:
|
||||||
|
vlans_ttp = self.raw.get("vlans", [])
|
||||||
|
vlans = []
|
||||||
|
named_vlan = set()
|
||||||
|
for item in vlans_ttp:
|
||||||
|
if item.get("vlan_id"):
|
||||||
|
named_vlan.add(item.get("vlan_id"))
|
||||||
|
vlans.append(item)
|
||||||
|
else:
|
||||||
|
ids = item.get("vlan_ids", "")
|
||||||
|
tail = item.get("vlan_tail")
|
||||||
|
if tail:
|
||||||
|
ids = f"{ids},{tail}"
|
||||||
|
for vid in ids:
|
||||||
|
vid = vid.strip()
|
||||||
|
if vid in named_vlan:
|
||||||
|
continue
|
||||||
|
vlans.append({"vlan_id": vid})
|
||||||
|
return vlans
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
with open("./test6.txt") as file:
|
||||||
|
data = file.read()
|
||||||
|
eltex = Eltex(data)
|
||||||
|
print(eltex.parse())
|
||||||
22
oxi/interfaces/models/h3c.py
Normal file
22
oxi/interfaces/models/h3c.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from oxi.interfaces import BaseDevice, register_parser
|
||||||
|
|
||||||
|
|
||||||
|
@register_parser("h3c")
|
||||||
|
class H3C(BaseDevice):
|
||||||
|
template = "h3c.ttp"
|
||||||
|
|
||||||
|
def vlans(self) -> list[dict]:
|
||||||
|
vlan_list = self.raw["vlans"]
|
||||||
|
vlans = []
|
||||||
|
for item in vlan_list:
|
||||||
|
if item.get("vlans_id"):
|
||||||
|
vlans.extend([{'vlan_id': vln }for vln in item.get("vlans_id")])
|
||||||
|
else:
|
||||||
|
vlans.append(item)
|
||||||
|
return vlans
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
with open("./test5.txt") as file:
|
||||||
|
data = file.read()
|
||||||
|
h3c = H3C(data)
|
||||||
|
print(h3c.parse())
|
||||||
18
oxi/interfaces/models/huawei.py
Normal file
18
oxi/interfaces/models/huawei.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from oxi.interfaces import register_parser
|
||||||
|
from oxi.interfaces.base import BaseDevice
|
||||||
|
|
||||||
|
|
||||||
|
@register_parser(["vrp", "huawei"])
|
||||||
|
class Huawei(BaseDevice):
|
||||||
|
template = "huawei.ttp"
|
||||||
|
|
||||||
|
def vlans(self):
|
||||||
|
vlan_ids = self.raw["vlans"].get("vlan_ids")
|
||||||
|
return [{"vlan_id": vlan} for vlan in vlan_ids]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
with open("./test4.txt") as file:
|
||||||
|
data = file.read()
|
||||||
|
huawei = Huawei(data)
|
||||||
|
print(huawei.parse())
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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
|
||||||
|
|
||||||
@@ -5,3 +6,36 @@ from oxi.interfaces.base import BaseDevice
|
|||||||
@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]:
|
||||||
|
vlans_ttp = self.raw["vlans"]
|
||||||
|
vlans = []
|
||||||
|
named_vlan = set()
|
||||||
|
for item in vlans_ttp:
|
||||||
|
if item.get("vlan_id"):
|
||||||
|
named_vlan.add(item.get("vlan_id"))
|
||||||
|
vlans.append(item)
|
||||||
|
else:
|
||||||
|
ids = item.get("vlan_ids", "")
|
||||||
|
tail = item.get("vlan_tail")
|
||||||
|
if tail:
|
||||||
|
ids = f"{ids},{tail}"
|
||||||
|
for vid in ids.split(","):
|
||||||
|
vid = vid.strip()
|
||||||
|
if vid in named_vlan:
|
||||||
|
continue
|
||||||
|
vlans.append({"vlan_id": vid})
|
||||||
|
return vlans
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(os.path.abspath(os.curdir))
|
||||||
|
with open("./test3.txt") as file:
|
||||||
|
data = file.read()
|
||||||
|
qtech = Qtech(data)
|
||||||
|
qt = qtech.parse()
|
||||||
|
print(qt)
|
||||||
|
|||||||
36
oxi/interfaces/models/quasar.py
Normal file
36
oxi/interfaces/models/quasar.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from oxi.interfaces import BaseDevice, register_parser
|
||||||
|
|
||||||
|
|
||||||
|
@register_parser(["quasar", "qos"])
|
||||||
|
class Quasar(BaseDevice):
|
||||||
|
template = "quasar.ttp"
|
||||||
|
|
||||||
|
def interfaces(self) -> list[dict]:
|
||||||
|
ether_interfaces: dict = self.raw["interfaces"]
|
||||||
|
interfaces: list[dict] = []
|
||||||
|
bulk_interfaces: dict = self.raw["bulkinterfaces"]
|
||||||
|
for key, value in bulk_interfaces.items():
|
||||||
|
interfaces.append(
|
||||||
|
{
|
||||||
|
"interface": key,
|
||||||
|
"description": value.get("description"),
|
||||||
|
"ip_address": value.get("ip_address"),
|
||||||
|
"mask": value.get("mask"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
interfaces.append(ether_interfaces)
|
||||||
|
return interfaces
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
with open("./test7.txt") as file:
|
||||||
|
data = file.read()
|
||||||
|
quasar = Quasar(data)
|
||||||
|
qt = quasar.parse()
|
||||||
|
print(qt)
|
||||||
|
print()
|
||||||
|
with open("./test8.txt") as file:
|
||||||
|
data = file.read()
|
||||||
|
quasar = Quasar(data)
|
||||||
|
qt = quasar.parse()
|
||||||
|
print(qt)
|
||||||
@@ -1,9 +1,51 @@
|
|||||||
<doc>
|
<doc>
|
||||||
|
Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте
|
||||||
|
в <vendor>.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
|
||||||
</doc>
|
</doc>
|
||||||
<vars>
|
<vars>
|
||||||
|
default_system = {
|
||||||
|
"model": "",
|
||||||
|
"serial_number": "",
|
||||||
|
"version": ""
|
||||||
|
}
|
||||||
</vars>
|
</vars>
|
||||||
|
|
||||||
<group name="system">
|
<group name="system" default="default_system">
|
||||||
...
|
...
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
|
|||||||
68
oxi/interfaces/models/templates/eltex.ttp
Normal file
68
oxi/interfaces/models/templates/eltex.ttp
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<doc>
|
||||||
|
Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте
|
||||||
|
в <vendor>.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
|
||||||
|
</doc>
|
||||||
|
<vars>
|
||||||
|
default_system = {
|
||||||
|
"model": "",
|
||||||
|
"serial_number": "",
|
||||||
|
"version": ""
|
||||||
|
}
|
||||||
|
</vars>
|
||||||
|
|
||||||
|
<group name="system" default="default_system">
|
||||||
|
Active-image: {{ ignore }} {{ _start_ }}
|
||||||
|
! Version: {{ version }}
|
||||||
|
</group>
|
||||||
|
<group name="serial" method="table">
|
||||||
|
! Unit MAC address Hardware version Serial number
|
||||||
|
! {{ unit | exclude("-") }} {{ mac_address }} {{ hardware_version }} {{ serial_number }}
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group name="interfaces">
|
||||||
|
interface {{ interface | ORPHRASE }}
|
||||||
|
ip address {{ ip_address }} {{ mask | to_cidr }}
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group name="vlans">
|
||||||
|
interface vlan {{ vlan_id | _start_ }}
|
||||||
|
name {{ name }}
|
||||||
|
|
||||||
|
vlan {{ _db_ | _start_ }}
|
||||||
|
vlan {{ vlan_ids | joinmatches(',') | unrange("-", ",") | split(",")}}
|
||||||
|
</group>
|
||||||
66
oxi/interfaces/models/templates/h3c.ttp
Normal file
66
oxi/interfaces/models/templates/h3c.ttp
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<doc>
|
||||||
|
Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте
|
||||||
|
в <vendor>.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
|
||||||
|
</doc>
|
||||||
|
<vars>
|
||||||
|
default_system = {
|
||||||
|
"model": "",
|
||||||
|
"serial_number": "",
|
||||||
|
"version": ""
|
||||||
|
}
|
||||||
|
</vars>
|
||||||
|
|
||||||
|
<group name="system" default="default_system">
|
||||||
|
# Boot image version: {{ version }}, Release {{ release }}
|
||||||
|
# {{ mpu }} Slot {{ slot }}:
|
||||||
|
# BOARD TYPE: {{ model }}
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group name="interfaces">
|
||||||
|
interface {{ interface }}
|
||||||
|
ip address {{ ip_address }} {{ mask | to_cidr }}
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group name="vlans">
|
||||||
|
vlan {{ vlan_id }}
|
||||||
|
name {{ name }}
|
||||||
|
description {{ description }}
|
||||||
|
</group>
|
||||||
|
<group name="vlans">
|
||||||
|
vlan {{ vlans_id | ORPHRASE | contains(" to ") | unrange(" to ", ",") | split(",") }}
|
||||||
|
</group>
|
||||||
58
oxi/interfaces/models/templates/huawei.ttp
Normal file
58
oxi/interfaces/models/templates/huawei.ttp
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<doc>
|
||||||
|
Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте
|
||||||
|
в <vendor>.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
|
||||||
|
</doc>
|
||||||
|
<vars>
|
||||||
|
default_system = {
|
||||||
|
"model": "",
|
||||||
|
"serial_number": "",
|
||||||
|
"version": ""
|
||||||
|
}
|
||||||
|
</vars>
|
||||||
|
|
||||||
|
<group name="system" default="default_system">
|
||||||
|
# VRP (R) software, Version {{ version }} ({{ model }} {{ serial_number }})
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group name="interfaces">
|
||||||
|
interface {{ interface }}
|
||||||
|
description {{ description }}
|
||||||
|
ip address {{ ip_address }} {{ mask | to_cidr }}
|
||||||
|
</group>
|
||||||
|
<group name="vlans">
|
||||||
|
vlan batch {{ vlan_ids | ORPHRASE | unrange(" to ", " ") | split(" ")}}
|
||||||
|
</group>
|
||||||
@@ -3,22 +3,30 @@
|
|||||||
<vars>
|
<vars>
|
||||||
default_system = {
|
default_system = {
|
||||||
"model": "",
|
"model": "",
|
||||||
"serial_number": ""
|
"serial_number": "",
|
||||||
|
"version": ""
|
||||||
}
|
}
|
||||||
</vars>
|
</vars>
|
||||||
|
|
||||||
<group name="system">
|
<group name="system" default="default_system">
|
||||||
! {{ model }} Series Software, Version {{ bootrom_ver }} Build {{ version }}, {{ ignore }}
|
! {{ model | ORPHRASE | _start_ }} Series Software, Version {{ ignore }} Build {{ version | strip(",") }}{{ ignore('.*') }}
|
||||||
! Serial num:{{ serial_number }}, {{ ignore }}
|
! Serial num:{{ serial_number | strip(",") }}{{ ignore('.*') }}
|
||||||
|
|
||||||
|
! System description : {{ description | PHRASE | _start_ }}({{ model }}) By {{ vendor }}
|
||||||
|
! System description : {{ description | PHRASE | _start_ }}({{ model }})
|
||||||
|
! System software version : {{ description | PHRASE }}, Release({{ version }})
|
||||||
|
! System serial number : {{ serial_number }}
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
<group name="interfaces">
|
<group name="interfaces">
|
||||||
interface {{ interface }}
|
interface {{ interface | ORPHRASE }}
|
||||||
description {{ description | ORPHRASE }}
|
description {{ description | ORPHRASE }}
|
||||||
ip address {{ ip_address }} {{ netmask }}
|
ip address {{ ip_address }} {{ mask | to_cidr }}
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
<group name="vlans">
|
<group name="vlans">
|
||||||
vlan {{ vlan_id }}
|
vlan {{ vlan_ids | contains(",", "-") | unrange("-", ",") }}
|
||||||
name {{ name }}
|
,{{ vlan_tail | unrange("-", ",") }}
|
||||||
|
vlan {{ vlan_id | _start_ }}
|
||||||
|
name {{ name | ORPHRASE }}
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
66
oxi/interfaces/models/templates/quasar.ttp
Normal file
66
oxi/interfaces/models/templates/quasar.ttp
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<doc>
|
||||||
|
Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте
|
||||||
|
в <vendor>.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
|
||||||
|
</doc>
|
||||||
|
<vars>
|
||||||
|
default_system = {
|
||||||
|
"model": "",
|
||||||
|
"serial_number": "",
|
||||||
|
"version": ""
|
||||||
|
}
|
||||||
|
</vars>
|
||||||
|
|
||||||
|
<group name="system" default="default_system">
|
||||||
|
# Component Version {{ _start_ }}
|
||||||
|
# Assembly {{ version }}
|
||||||
|
# Product Name {{ model | ORPHRASE }}
|
||||||
|
# Product Serial Number {{ serial_number }}
|
||||||
|
|
||||||
|
# Subsystem Version {{ _start_ }}
|
||||||
|
# Engine {{ version }}
|
||||||
|
# Product Name {{ model | ORPHRASE }}
|
||||||
|
# Product Serial Number {{ serial_number }}
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group name="interfaces">
|
||||||
|
{{ ignore("\\s*") }}config {{ interface }} ipv4 address {{ ip_address }}/{{ mask }} gateway {{ gateway }} {{ ignore }}
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group name="bulkinterfaces.{{ interface }}">
|
||||||
|
{{ ignore("\\s*") }}config interface {{ interface | _start_ }} description {{ description | ORPHRASE | strip('"')}}
|
||||||
|
</group>
|
||||||
19
oxi/node.py
19
oxi/node.py
@@ -1,5 +1,9 @@
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from requests import HTTPError
|
||||||
|
|
||||||
|
from oxi.exception import OxiAPIError
|
||||||
|
|
||||||
from .view import NodeView
|
from .view import NodeView
|
||||||
|
|
||||||
|
|
||||||
@@ -11,15 +15,16 @@ class Node:
|
|||||||
def __init__(self, session: "Session", base_url: str):
|
def __init__(self, session: "Session", base_url: str):
|
||||||
self._session = session
|
self._session = session
|
||||||
self._base_url = base_url
|
self._base_url = base_url
|
||||||
self._data = None
|
|
||||||
|
|
||||||
def __call__(self, name: str) -> NodeView:
|
def __call__(self, name: str) -> NodeView:
|
||||||
url = f"{self._base_url}/node/show/{name}"
|
try:
|
||||||
if not url.endswith(".json"):
|
url = f"{self._base_url}/node/show/{name}"
|
||||||
url += ".json"
|
if not url.endswith(".json"):
|
||||||
response = self._session.get(url)
|
url += ".json"
|
||||||
if response.status_code == 500:
|
response = self._session.get(url)
|
||||||
raise ValueError(f"page {url} not found")
|
response.raise_for_status()
|
||||||
|
except HTTPError as e:
|
||||||
|
raise OxiAPIError.from_http_error(e, context=f"Node {name}") from e
|
||||||
return NodeView(
|
return NodeView(
|
||||||
session=self._session, base_url=self._base_url, data=response.json()
|
session=self._session, base_url=self._base_url, data=response.json()
|
||||||
)
|
)
|
||||||
|
|||||||
35
oxi/view.py
35
oxi/view.py
@@ -14,22 +14,47 @@ class NodeView:
|
|||||||
self._base_url = base_url
|
self._base_url = base_url
|
||||||
self._data = data
|
self._data = data
|
||||||
|
|
||||||
|
def _updater(self) -> int:
|
||||||
|
response = self._session.get(f"{self._base_url}/node/next/{self.full_name}")
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.status_code
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ip(self):
|
def name(self) -> str:
|
||||||
|
return self._data.get("name")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ip(self) -> str:
|
||||||
return self._data.get("ip")
|
return self._data.get("ip")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def full_name(self):
|
def full_name(self) -> str:
|
||||||
return self._data.get("full_name")
|
return self._data.get("full_name")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def group(self):
|
def group(self) -> str:
|
||||||
return self._data.get("group")
|
return self._data.get("group")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def model(self):
|
def model(self) -> str:
|
||||||
return self._data.get("model")
|
return self._data.get("model")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_status(self) -> str:
|
||||||
|
last = self._data.get("last") or {}
|
||||||
|
return last.get("status")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_check(self) -> str:
|
||||||
|
last = self._data.get("last") or {}
|
||||||
|
return last.get("start")
|
||||||
|
|
||||||
|
def refresh(self) -> str:
|
||||||
|
result = self._updater()
|
||||||
|
if result != 200:
|
||||||
|
raise ValueError(f"Failed to refresh node {self.full_name}")
|
||||||
|
return "OK"
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def config(self):
|
def config(self) -> NodeConfig:
|
||||||
return NodeConfig(self._session, self.full_name, self.model, self._base_url)
|
return NodeConfig(self._session, self.full_name, self.model, self._base_url)
|
||||||
|
|||||||
@@ -5,14 +5,27 @@ build-backend = "setuptools.build_meta"
|
|||||||
[project]
|
[project]
|
||||||
name = "oxipy"
|
name = "oxipy"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Oxi API client"
|
description = "Oxidized API client"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
license = { file = "LICENSE" }
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
classifiers = [
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"License :: OSI Approved :: Apache Software License",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pydantic>=2.12.5",
|
"pydantic>=2.12.5",
|
||||||
"requests>=2.32.5",
|
"requests>=2.32.5",
|
||||||
"ttp>=0.10.0",
|
"ttp>=0.10.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["."]
|
where = ["."]
|
||||||
include = ["oxi*"]
|
include = ["oxi*"]
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
include-package-data = true
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
"oxi" = ["**/*.ttp"]
|
||||||
78
uv.lock
generated
78
uv.lock
generated
@@ -1,6 +1,6 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "annotated-types"
|
name = "annotated-types"
|
||||||
@@ -26,6 +26,38 @@ 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/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
|
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
|
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
|
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
|
||||||
@@ -111,6 +143,34 @@ 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/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
|
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
|
||||||
@@ -153,6 +213,22 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
|
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user