From 8e85086d98b9e18e2c08be0692a7c8ea615a410d Mon Sep 17 00:00:00 2001 From: IluaAir Date: Sat, 14 Feb 2026 01:07:26 +0300 Subject: [PATCH 01/80] base structure --- .gitignore | 10 +++++ .python-version | 1 + oxi/__init__.py | 6 +++ oxi/conf.py | 45 +++++++++++++++++++++++ oxi/core.py | 35 ++++++++++++++++++ oxi/node.py | 28 ++++++++++++++ oxi/view.py | 35 ++++++++++++++++++ uv.lock | 97 +++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 257 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 oxi/__init__.py create mode 100644 oxi/conf.py create mode 100644 oxi/core.py create mode 100644 oxi/node.py create mode 100644 oxi/view.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c72c75c --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info +main.py +# Virtual environments +.venv diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/oxi/__init__.py b/oxi/__init__.py new file mode 100644 index 0000000..c6ef94b --- /dev/null +++ b/oxi/__init__.py @@ -0,0 +1,6 @@ +from .core import OxiAPI + + +__all__ = [ + "OxiAPI", +] diff --git a/oxi/conf.py b/oxi/conf.py new file mode 100644 index 0000000..c55c69c --- /dev/null +++ b/oxi/conf.py @@ -0,0 +1,45 @@ +from functools import cached_property +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from requests import Session + + +class NodeConfig: + def __init__(self, session: "Session", full_name: str, model: str, base_url: str): + self._session = session + self._full_name = full_name + self._model = model + self._url = f"{base_url}/node/fetch/{full_name}" + self._device: type[BaseDevice] = device_registry.get(self._model.lower()) + if self._device is None: + raise ValueError(f"Device model '{self._model}' not found in registry") + self._parsed_data = self._device(self.text).parse_config() + + @cached_property + def _response(self): + log.debug(f"Fetching config from {self._url}") + response = self._session.get(self._url) + response.raise_for_status() + return response + + @property + def text(self): + return self._response.text + + @property + def json(self): + return self._response.json() + + def __str__(self): + return self.text + + def vlans(self): + return self._parsed_data.vlans + + def l3interfaces(self): + return self._parsed_data.l3interfaces + + def vlaninterfaces(self): + return self._parsed_data.vlaninterfaces diff --git a/oxi/core.py b/oxi/core.py new file mode 100644 index 0000000..392a02d --- /dev/null +++ b/oxi/core.py @@ -0,0 +1,35 @@ +from typing import Optional +from requests import Session +from .node import Node + + +class OxiAPI: + def __init__( + self, + url: str, + session: Optional[Session] = None, + username: Optional[str] = None, + password: Optional[str] = None, + verify: bool = True, + ): + self.base_url = url.rstrip("/") + self._session = session or Session() + self._session.verify = verify + if username and password: + self._session.auth = (username, password) + self.node = Node(self._session, self.base_url) + + def __enter__(self): + return self + + def __exit__(self, *args): + self._session.close() + + def get(self, endpoint: str, **kwargs) -> dict: + url = f"{self.base_url}/{endpoint.lstrip('/')}" + if not url.endswith(".json"): + url += ".json" + result = self._session.get(url, **kwargs) + if result.status_code == 500: + raise ValueError(f"page {url} not found") + return result.json() diff --git a/oxi/node.py b/oxi/node.py new file mode 100644 index 0000000..fe90e17 --- /dev/null +++ b/oxi/node.py @@ -0,0 +1,28 @@ +from typing import TYPE_CHECKING + +from oxi.view import NodeView + + +if TYPE_CHECKING: + from requests import Session + + +class Node: + def __init__(self, session: "Session", base_url: str): + self._session = session + self._base_url = base_url + self._data = None + + def __call__(self, name: str) -> NodeView: + url = f"{self._base_url}/node/show/{name}" + if not url.endswith(".json"): + url += ".json" + response = self._session.get(url) + if response.status_code == 500: + log.warning( + "Oxidized response: %r , %r not found", response.status_code, url + ) + raise ValueError(f"page {url} not found") + return NodeView( + session=self._session, base_url=self._base_url, data=response.json() + ) diff --git a/oxi/view.py b/oxi/view.py new file mode 100644 index 0000000..da8adc2 --- /dev/null +++ b/oxi/view.py @@ -0,0 +1,35 @@ +from functools import cached_property +from typing import TYPE_CHECKING + +from .conf import NodeConfig + + +if TYPE_CHECKING: + from requests import Session + + +class NodeView: + def __init__(self, session: "Session", base_url: str, data: dict): + self._session = session + self._base_url = base_url + self._data = data + + @property + def ip(self): + return self._data.get("ip") + + @property + def full_name(self): + return self._data.get("full_name") + + @property + def group(self): + return self._data.get("group") + + @property + def model(self): + return self._data.get("model") + + @cached_property + def config(self): + return NodeConfig(self._session, self.full_name, self.model, self._base_url) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..56a475b --- /dev/null +++ b/uv.lock @@ -0,0 +1,97 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/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/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/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "oxipy" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "requests" }, +] + +[package.metadata] +requires-dist = [{ name = "requests", specifier = ">=2.32.5" }] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] -- 2.53.0 From b60182ef3cb6094159196828230e6eb8cd0bcb4c Mon Sep 17 00:00:00 2001 From: IluaAir Date: Sat, 14 Feb 2026 21:31:02 +0300 Subject: [PATCH 02/80] Add ttp dependency and refactor OxiAPI and NodeConfig classes - Added `ttp` as a dependency in `pyproject.toml` and `uv.lock`. - Updated `NodeConfig` to store model names in lowercase. - Refactored `OxiAPI` to always create a new session and added a `close` method. - Removed unnecessary logging in `Node` class. - Introduced interfaces for device registration with a new `BaseDevice` class and a `register_parser` function. - Created initial structure for device models, including a `Mikrotik` parser. --- oxi/conf.py | 4 ++-- oxi/core.py | 15 ++++----------- oxi/interfaces/__init__.py | 21 +++++++++++++++++++++ oxi/interfaces/base.py | 1 + oxi/interfaces/models/__init__.py | 7 +++++++ oxi/interfaces/models/mikrotik.py | 5 +++++ oxi/node.py | 5 +---- pyproject.toml | 3 ++- uv.lock | 15 ++++++++++++++- 9 files changed, 57 insertions(+), 19 deletions(-) create mode 100644 oxi/interfaces/__init__.py create mode 100644 oxi/interfaces/base.py create mode 100644 oxi/interfaces/models/__init__.py create mode 100644 oxi/interfaces/models/mikrotik.py diff --git a/oxi/conf.py b/oxi/conf.py index c55c69c..e39a6b4 100644 --- a/oxi/conf.py +++ b/oxi/conf.py @@ -1,6 +1,7 @@ from functools import cached_property from typing import TYPE_CHECKING +from .interfaces import BaseDevice, device_registry if TYPE_CHECKING: from requests import Session @@ -10,7 +11,7 @@ class NodeConfig: def __init__(self, session: "Session", full_name: str, model: str, base_url: str): self._session = session self._full_name = full_name - self._model = model + self._model = model.lower() self._url = f"{base_url}/node/fetch/{full_name}" self._device: type[BaseDevice] = device_registry.get(self._model.lower()) if self._device is None: @@ -19,7 +20,6 @@ class NodeConfig: @cached_property def _response(self): - log.debug(f"Fetching config from {self._url}") response = self._session.get(self._url) response.raise_for_status() return response diff --git a/oxi/core.py b/oxi/core.py index 392a02d..c5c0386 100644 --- a/oxi/core.py +++ b/oxi/core.py @@ -7,13 +7,12 @@ class OxiAPI: def __init__( self, url: str, - session: Optional[Session] = None, username: Optional[str] = None, password: Optional[str] = None, verify: bool = True, ): self.base_url = url.rstrip("/") - self._session = session or Session() + self._session = Session() self._session.verify = verify if username and password: self._session.auth = (username, password) @@ -23,13 +22,7 @@ class OxiAPI: return self def __exit__(self, *args): - self._session.close() + self.close() - def get(self, endpoint: str, **kwargs) -> dict: - url = f"{self.base_url}/{endpoint.lstrip('/')}" - if not url.endswith(".json"): - url += ".json" - result = self._session.get(url, **kwargs) - if result.status_code == 500: - raise ValueError(f"page {url} not found") - return result.json() + def close(self): + return self._session.close() diff --git a/oxi/interfaces/__init__.py b/oxi/interfaces/__init__.py new file mode 100644 index 0000000..928ea16 --- /dev/null +++ b/oxi/interfaces/__init__.py @@ -0,0 +1,21 @@ +from typing import Callable, Type + +from .base import BaseDevice + +device_registry = {} + + +def register_parser( + name: list[str], +) -> Callable[[Type[BaseDevice]], Type[BaseDevice]]: + def wrapper(cls): + for item in name: + device_registry[item.lower()] = cls + return cls + + return wrapper + + +from . import models # noqa: E402, F401 + +__all__ = ["register_parser", "device_registry"] diff --git a/oxi/interfaces/base.py b/oxi/interfaces/base.py new file mode 100644 index 0000000..c572181 --- /dev/null +++ b/oxi/interfaces/base.py @@ -0,0 +1 @@ +class BaseDevice: ... diff --git a/oxi/interfaces/models/__init__.py b/oxi/interfaces/models/__init__.py new file mode 100644 index 0000000..a3a9678 --- /dev/null +++ b/oxi/interfaces/models/__init__.py @@ -0,0 +1,7 @@ +import importlib +import pkgutil + +package = __package__ + +for loader, module_name, is_pkg in pkgutil.iter_modules(__path__): + importlib.import_module(f"{package}.{module_name}") diff --git a/oxi/interfaces/models/mikrotik.py b/oxi/interfaces/models/mikrotik.py new file mode 100644 index 0000000..cd7ef7f --- /dev/null +++ b/oxi/interfaces/models/mikrotik.py @@ -0,0 +1,5 @@ +from oxi.interfaces import register_parser + + +@register_parser(["routeros", "ros", "mikrotik"]) +class Mikrotik: ... diff --git a/oxi/node.py b/oxi/node.py index fe90e17..f84ca7c 100644 --- a/oxi/node.py +++ b/oxi/node.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING -from oxi.view import NodeView +from .view import NodeView if TYPE_CHECKING: @@ -19,9 +19,6 @@ class Node: url += ".json" response = self._session.get(url) if response.status_code == 500: - log.warning( - "Oxidized response: %r , %r not found", response.status_code, url - ) raise ValueError(f"page {url} not found") return NodeView( session=self._session, base_url=self._base_url, data=response.json() diff --git a/pyproject.toml b/pyproject.toml index 7096929..676ac3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,8 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "requests>=2.32.5", + "ttp>=0.10.0", ] [tool.setuptools.packages.find] where = ["."] -include = ["oxi*"] \ No newline at end of file +include = ["oxi*"] diff --git a/uv.lock b/uv.lock index 56a475b..358d04d 100644 --- a/uv.lock +++ b/uv.lock @@ -67,10 +67,14 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "requests" }, + { name = "ttp" }, ] [package.metadata] -requires-dist = [{ name = "requests", specifier = ">=2.32.5" }] +requires-dist = [ + { name = "requests", specifier = ">=2.32.5" }, + { name = "ttp", specifier = ">=0.10.0" }, +] [[package]] name = "requests" @@ -87,6 +91,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "ttp" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/e6/9169d35574be82df2a0cdd2546f4f83d0d30964cf0043fc9784df855b024/ttp-0.10.0.tar.gz", hash = "sha256:40f1ca61ee1431f5b1ab5326fb55f852a04749e9574792d45455b62c5e7ac97b", size = 64665, upload-time = "2025-11-02T08:47:50.329Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/c3/60abb45bd8eb973997f133eb76949523478d35dfc551a0dbd8906b6a8075/ttp-0.10.0-py3-none-any.whl", hash = "sha256:9985e0ca414e85d41493a6291a924624b9a08c48c78d2d01477cc60ba2a347c1", size = 84287, upload-time = "2025-11-02T08:47:48.656Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" -- 2.53.0 From 544688dae1970b87591a002497c312b4b8f13531 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Sun, 15 Feb 2026 02:44:29 +0300 Subject: [PATCH 03/80] Update .gitignore and refactor Mikrotik model to inherit from BaseDevice - Added *.txt files to .gitignore to exclude etc files. - Refactored Mikrotik class to inherit from BaseDevice for improved structure and functionality. --- .gitignore | 3 +++ oxi/interfaces/models/mikrotik.py | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c72c75c..4794f8d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ wheels/ main.py # Virtual environments .venv + +# etc files +*.txt \ No newline at end of file diff --git a/oxi/interfaces/models/mikrotik.py b/oxi/interfaces/models/mikrotik.py index cd7ef7f..5a3507b 100644 --- a/oxi/interfaces/models/mikrotik.py +++ b/oxi/interfaces/models/mikrotik.py @@ -1,5 +1,6 @@ from oxi.interfaces import register_parser +from oxi.interfaces.base import BaseDevice @register_parser(["routeros", "ros", "mikrotik"]) -class Mikrotik: ... +class Mikrotik(BaseDevice): ... -- 2.53.0 From 2cfcc41e5854e3c47795757d719f8b70992a8c20 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Mon, 16 Feb 2026 00:57:09 +0300 Subject: [PATCH 04/80] Enhance device interface structure and update dependencies - Added `.idea` to `.gitignore` to exclude IDE configuration files. - Introduced `pydantic` version `2.12.5` as a dependency in `pyproject.toml` and `uv.lock`. - Added `annotated-types` as a new dependency in `uv.lock`. - Refactored `BaseDevice` class to include abstract methods for device properties and loading templates. - Created `Interfaces`, `System`, `Vlans`, and `Device` models in a new `contract.py` file for structured data handling. - Updated `Mikrotik` class to implement the new `BaseDevice` structure and added a template loading method. --- .gitignore | 1 + oxi/interfaces/__init__.py | 7 ++- oxi/interfaces/base.py | 38 +++++++++++- oxi/interfaces/contract.py | 25 ++++++++ oxi/interfaces/models/mikrotik.py | 9 ++- pyproject.toml | 1 + uv.lock | 100 ++++++++++++++++++++++++++++++ 7 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 oxi/interfaces/contract.py diff --git a/.gitignore b/.gitignore index 4794f8d..964223f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ wheels/ main.py # Virtual environments .venv +.idea # etc files *.txt \ No newline at end of file diff --git a/oxi/interfaces/__init__.py b/oxi/interfaces/__init__.py index 928ea16..4edb8b3 100644 --- a/oxi/interfaces/__init__.py +++ b/oxi/interfaces/__init__.py @@ -9,7 +9,12 @@ def register_parser( name: list[str], ) -> Callable[[Type[BaseDevice]], Type[BaseDevice]]: def wrapper(cls): - for item in name: + name_list = [] + if isinstance(name, str): + name_list.append(name) + else: + name_list.extend(name) + for item in name_list: device_registry[item.lower()] = cls return cls diff --git a/oxi/interfaces/base.py b/oxi/interfaces/base.py index c572181..639bd83 100644 --- a/oxi/interfaces/base.py +++ b/oxi/interfaces/base.py @@ -1 +1,37 @@ -class BaseDevice: ... +from abc import ABC, abstractmethod +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from oxi.interfaces.contract import Interfaces, System, Vlans + + +class BaseDevice(ABC): + @property + @abstractmethod + def template(self) -> str: + """ + :return: + """ + + @abstractmethod + def vlans(self) -> "Vlans": ... + + @abstractmethod + def interfaces(self) -> "Interfaces": ... + + @abstractmethod + def system(self) -> "System": ... + + def __load_template(self): + path = Path(__file__).parent / "template" / self.template + if not path.exists(): + raise FileNotFoundError(f"Template {self.template} not found") + return path.read_text(encoding="utf-8") + + def run(self): + self.load = self.__load_template() + return self.load + + +BaseDevice() diff --git a/oxi/interfaces/contract.py b/oxi/interfaces/contract.py new file mode 100644 index 0000000..a910e28 --- /dev/null +++ b/oxi/interfaces/contract.py @@ -0,0 +1,25 @@ +from ipaddress import IPv4Address +from pydantic import BaseModel, Field + + +class Interfaces(BaseModel): + ip_address: IPv4Address + mask: int + description: str + + +class System(BaseModel): + model: str + serial_number: str + version: str + + +class Vlans(BaseModel): + id: int + name: str = Field(alias="description") + + +class Device(BaseModel): + system: System + interfaces: Interfaces + vlans: Vlans diff --git a/oxi/interfaces/models/mikrotik.py b/oxi/interfaces/models/mikrotik.py index 5a3507b..b06f949 100644 --- a/oxi/interfaces/models/mikrotik.py +++ b/oxi/interfaces/models/mikrotik.py @@ -3,4 +3,11 @@ from oxi.interfaces.base import BaseDevice @register_parser(["routeros", "ros", "mikrotik"]) -class Mikrotik(BaseDevice): ... +class Mikrotik(BaseDevice): + template = "mikrotik.ttp" + + +if __name__ == "__main__": + mikr = Mikrotik() + mikr.run() + print(mikr.load) diff --git a/pyproject.toml b/pyproject.toml index 676ac3a..46d9832 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ description = "Oxi API client" readme = "README.md" requires-python = ">=3.13" dependencies = [ + "pydantic>=2.12.5", "requests>=2.32.5", "ttp>=0.10.0", ] diff --git a/uv.lock b/uv.lock index 358d04d..84f8506 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.13" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -66,16 +75,86 @@ name = "oxipy" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "pydantic" }, { name = "requests" }, { name = "ttp" }, ] [package.metadata] requires-dist = [ + { name = "pydantic", specifier = ">=2.12.5" }, { name = "requests", specifier = ">=2.32.5" }, { name = "ttp", specifier = ">=0.10.0" }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/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/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/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { 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/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" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -100,6 +179,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/c3/60abb45bd8eb973997f133eb76949523478d35dfc551a0dbd8906b6a8075/ttp-0.10.0-py3-none-any.whl", hash = "sha256:9985e0ca414e85d41493a6291a924624b9a08c48c78d2d01477cc60ba2a347c1", size = 84287, upload-time = "2025-11-02T08:47:48.656Z" }, ] +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" -- 2.53.0 From e82bc253466ade3f3f51404befcbec215bb57e88 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Wed, 18 Feb 2026 00:06:48 +0300 Subject: [PATCH 05/80] Refactor BaseDevice and update Device model structure - Enhanced the `BaseDevice` class with methods for loading templates and parsing configuration data using TTP. - Updated the `Device` model to use lists for `interfaces` and `vlans`, allowing for multiple entries. - Introduced new TTP template files for structured data parsing. --- oxi/interfaces/base.py | 40 +++++++++++++++---- oxi/interfaces/contract.py | 6 +-- oxi/interfaces/models/templates/_template.ttp | 7 ++++ oxi/interfaces/models/templates/mikrotik.ttp | 7 ++++ 4 files changed, 49 insertions(+), 11 deletions(-) create mode 100644 oxi/interfaces/models/templates/_template.ttp create mode 100644 oxi/interfaces/models/templates/mikrotik.ttp diff --git a/oxi/interfaces/base.py b/oxi/interfaces/base.py index 639bd83..a543084 100644 --- a/oxi/interfaces/base.py +++ b/oxi/interfaces/base.py @@ -2,11 +2,21 @@ from abc import ABC, abstractmethod from pathlib import Path from typing import TYPE_CHECKING +from ttp import ttp +from oxi.interfaces.contract import Device + if TYPE_CHECKING: from oxi.interfaces.contract import Interfaces, System, Vlans class BaseDevice(ABC): + _REQUIRED_SECTIONS: frozenset[str] = frozenset({"system", "interfaces", "vlans"}) + + def __init__(self, config: str): + self.config: str = config + self._loaded_template = self._load_template() + self._raw: dict = self._run_ttp() + @property @abstractmethod def template(self) -> str: @@ -15,23 +25,37 @@ class BaseDevice(ABC): """ @abstractmethod - def vlans(self) -> "Vlans": ... + def vlans(self) -> list["Vlans"]: ... @abstractmethod - def interfaces(self) -> "Interfaces": ... + def interfaces(self) -> list["Interfaces"]: ... @abstractmethod def system(self) -> "System": ... - def __load_template(self): + def _load_template(self): path = Path(__file__).parent / "template" / self.template if not path.exists(): raise FileNotFoundError(f"Template {self.template} not found") return path.read_text(encoding="utf-8") - def run(self): - self.load = self.__load_template() - return self.load + def _run_ttp(self) -> dict: + p = ttp(data=self.config, template=self._loaded_template) + p.parse() + raw: dict = p.result()[0][0] + missing = self._REQUIRED_SECTIONS - raw.keys() + if missing: + raise ValueError( + f"{self.__class__.__name__}: TTP template '{self.template}' " + f"did not produce required sections: {sorted(missing)}. " + f"Got: {sorted(raw.keys())}" + ) + return raw - -BaseDevice() + def parse(self) -> Device: + data = { + "system": self.system(), + "interfaces": self.interfaces(), + "vlans": self.vlans(), + } + return Device(**data) diff --git a/oxi/interfaces/contract.py b/oxi/interfaces/contract.py index a910e28..9869500 100644 --- a/oxi/interfaces/contract.py +++ b/oxi/interfaces/contract.py @@ -16,10 +16,10 @@ class System(BaseModel): class Vlans(BaseModel): id: int - name: str = Field(alias="description") + name: str | None = Field(default=None, alias="description") class Device(BaseModel): system: System - interfaces: Interfaces - vlans: Vlans + interfaces: list[Interfaces] = [] + vlans: list[Vlans] = [] diff --git a/oxi/interfaces/models/templates/_template.ttp b/oxi/interfaces/models/templates/_template.ttp new file mode 100644 index 0000000..02d8a0c --- /dev/null +++ b/oxi/interfaces/models/templates/_template.ttp @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/oxi/interfaces/models/templates/mikrotik.ttp b/oxi/interfaces/models/templates/mikrotik.ttp new file mode 100644 index 0000000..02d8a0c --- /dev/null +++ b/oxi/interfaces/models/templates/mikrotik.ttp @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file -- 2.53.0 From 72cd7968039bd951bc37f5db47db2986ddb8c0bc Mon Sep 17 00:00:00 2001 From: IluaAir Date: Wed, 18 Feb 2026 00:11:14 +0300 Subject: [PATCH 06/80] Refactor parse method in BaseDevice for improved readability - Simplified the `parse` method in the `BaseDevice` class by directly returning a `Device` instance with keyword arguments, enhancing code clarity and maintainability. --- oxi/interfaces/base.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/oxi/interfaces/base.py b/oxi/interfaces/base.py index a543084..5b40741 100644 --- a/oxi/interfaces/base.py +++ b/oxi/interfaces/base.py @@ -53,9 +53,8 @@ class BaseDevice(ABC): return raw def parse(self) -> Device: - data = { - "system": self.system(), - "interfaces": self.interfaces(), - "vlans": self.vlans(), - } - return Device(**data) + return Device( + system=self.system(), + interfaces=self.interfaces(), + vlans=self.vlans(), + ) -- 2.53.0 From 91b6606e3f10d72e991213a6ef70955e9a1f241f Mon Sep 17 00:00:00 2001 From: IluaAir Date: Wed, 18 Feb 2026 00:55:43 +0300 Subject: [PATCH 07/80] Enhance BaseDevice methods with detailed parsing documentation - Updated the `vlans`, `interfaces`, and `system` methods in the `BaseDevice` class to include comprehensive docstrings outlining expected raw data structures and error handling. - Modified the `Interfaces` model in `contract.py` to allow optional fields for `ip_address` and `mask`, improving flexibility in interface definitions. --- oxi/interfaces/base.py | 40 +++++++++++++++++++++++++++++++++++--- oxi/interfaces/contract.py | 5 +++-- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/oxi/interfaces/base.py b/oxi/interfaces/base.py index 5b40741..05adeeb 100644 --- a/oxi/interfaces/base.py +++ b/oxi/interfaces/base.py @@ -25,13 +25,47 @@ class BaseDevice(ABC): """ @abstractmethod - def vlans(self) -> list["Vlans"]: ... + def vlans(self) -> list["Vlans"]: + f""" + Parse VLAN configuration from self._raw['vlans']. + + Expected raw structure: + [{"id": 10, "description": "MGMT"}, {"id": 15, "name": "SSH"}, ...] + + Returns: + list[Vlans]: список VLAN из секции vlans, + пустой список если секция отсутствует. + + Raises: + ValueError: если _raw содержит некорректные данные. + """ + ... @abstractmethod - def interfaces(self) -> list["Interfaces"]: ... + def interfaces(self) -> list["Interfaces"]: + f""" + Parse Interface configuration from self._raw['interfaces']. + + Expected raw structure: + [{"name": "GEthernet1/0/1", "ip_address": "192.168.1.1", "mask": "24", "description": "IPBB interface"}] + + Raises: + ValueError: если _raw содержит некорректные данные. + """ + ... @abstractmethod - def system(self) -> "System": ... + def system(self) -> "System": + """ + Parse System configuration from self._raw['system']. + + Expected raw structure: + {"model":"RB951Ui-2nD", serial_number: "B88C0B31117B", "version": "7.12.1"} + + Raises: + ValueError: если _raw содержит некорректные данные. + """ + ... def _load_template(self): path = Path(__file__).parent / "template" / self.template diff --git a/oxi/interfaces/contract.py b/oxi/interfaces/contract.py index 9869500..34b36df 100644 --- a/oxi/interfaces/contract.py +++ b/oxi/interfaces/contract.py @@ -3,8 +3,9 @@ from pydantic import BaseModel, Field class Interfaces(BaseModel): - ip_address: IPv4Address - mask: int + name: str + ip_address: IPv4Address | None = None + mask: int | None = None description: str -- 2.53.0 From c434712309a19b23afb869c823b41730a6e5525e Mon Sep 17 00:00:00 2001 From: IluaAir Date: Wed, 18 Feb 2026 15:45:17 +0300 Subject: [PATCH 08/80] Refactor Mikrotik model and update template structure - Changed the parsing method in `NodeConfig` to use `parse()` instead of `parse_config()`. - Updated the template loading path in `BaseDevice` to reflect the new directory structure. - Enhanced the `Mikrotik` class with new methods for `system`, `interfaces`, and `vlans`, including debug print statements. - Expanded the TTP template for Mikrotik to include structured variable definitions and groups for system, interfaces, and VLANs. --- oxi/conf.py | 2 +- oxi/interfaces/base.py | 4 ++- oxi/interfaces/models/mikrotik.py | 16 ++++++++++ oxi/interfaces/models/templates/mikrotik.ttp | 32 +++++++++++++++++--- 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/oxi/conf.py b/oxi/conf.py index e39a6b4..1047b2a 100644 --- a/oxi/conf.py +++ b/oxi/conf.py @@ -16,7 +16,7 @@ class NodeConfig: self._device: type[BaseDevice] = device_registry.get(self._model.lower()) if self._device is None: raise ValueError(f"Device model '{self._model}' not found in registry") - self._parsed_data = self._device(self.text).parse_config() + self._parsed_data = self._device(self.text).parse() @cached_property def _response(self): diff --git a/oxi/interfaces/base.py b/oxi/interfaces/base.py index 05adeeb..a159243 100644 --- a/oxi/interfaces/base.py +++ b/oxi/interfaces/base.py @@ -68,8 +68,10 @@ class BaseDevice(ABC): ... def _load_template(self): - path = Path(__file__).parent / "template" / self.template + path = Path(__file__).parent / "models" / "templates" / self.template if not path.exists(): + print("-" * 12) + print(path) raise FileNotFoundError(f"Template {self.template} not found") return path.read_text(encoding="utf-8") diff --git a/oxi/interfaces/models/mikrotik.py b/oxi/interfaces/models/mikrotik.py index b06f949..c84260d 100644 --- a/oxi/interfaces/models/mikrotik.py +++ b/oxi/interfaces/models/mikrotik.py @@ -1,11 +1,27 @@ +from typing import TYPE_CHECKING from oxi.interfaces import register_parser from oxi.interfaces.base import BaseDevice +if TYPE_CHECKING: + from oxi.interfaces.contract import Interfaces, System, Vlans + @register_parser(["routeros", "ros", "mikrotik"]) class Mikrotik(BaseDevice): template = "mikrotik.ttp" + def system(self) -> "System": + print(self._raw["system"]) + print("-" * 12) + + def interfaces(self) -> "Interfaces": + print(f"{self._raw["interfaces"]=}") + print("-" * 12) + + def vlans(self) -> list["Vlans"]: + print(f"{self._raw["vlans"]=}") + print("-" * 12) + if __name__ == "__main__": mikr = Mikrotik() diff --git a/oxi/interfaces/models/templates/mikrotik.ttp b/oxi/interfaces/models/templates/mikrotik.ttp index 02d8a0c..a535494 100644 --- a/oxi/interfaces/models/templates/mikrotik.ttp +++ b/oxi/interfaces/models/templates/mikrotik.ttp @@ -1,7 +1,31 @@ - - + \ No newline at end of file -- 2.53.0 From 685ff19d2f10e33bbbd11f769ab408b0252190d4 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Thu, 19 Feb 2026 00:16:37 +0300 Subject: [PATCH 09/80] Enhance BaseDevice validation and update Mikrotik model - Introduced a new method `_validate_template_groups` in `BaseDevice` to ensure TTP templates declare all required and optional sections. - Updated `_REQUIRED_SECTIONS` and added `_OPTIONAL_SECTIONS` to improve template validation. - Modified the `vlans` method in `Mikrotik` to streamline raw data handling and added debug print statements for clarity. - Revised the Mikrotik TTP template to include structured variable definitions and improved group handling for interfaces and VLANs. --- oxi/interfaces/base.py | 24 +++++++++++-- oxi/interfaces/models/mikrotik.py | 11 +++--- oxi/interfaces/models/templates/mikrotik.ttp | 38 ++++++++++++++------ 3 files changed, 56 insertions(+), 17 deletions(-) diff --git a/oxi/interfaces/base.py b/oxi/interfaces/base.py index a159243..6955b72 100644 --- a/oxi/interfaces/base.py +++ b/oxi/interfaces/base.py @@ -1,20 +1,22 @@ from abc import ABC, abstractmethod from pathlib import Path from typing import TYPE_CHECKING - from ttp import ttp from oxi.interfaces.contract import Device +import xml.etree.ElementTree as ET if TYPE_CHECKING: from oxi.interfaces.contract import Interfaces, System, Vlans class BaseDevice(ABC): - _REQUIRED_SECTIONS: frozenset[str] = frozenset({"system", "interfaces", "vlans"}) + _REQUIRED_SECTIONS: frozenset[str] = frozenset({"system", "interfaces"}) + _OPTIONAL_SECTIONS: frozenset[str] = frozenset({"vlans"}) def __init__(self, config: str): self.config: str = config self._loaded_template = self._load_template() + self._validate_template_groups() self._raw: dict = self._run_ttp() @property @@ -75,6 +77,24 @@ class BaseDevice(ABC): raise FileNotFoundError(f"Template {self.template} not found") return path.read_text(encoding="utf-8") + def _validate_template_groups(self) -> None: + """Проверка что TTP темлпейт имеет декларированные группы для всех требуемых и опциональных секций""" + try: + root = ET.fromstring(self._loaded_template) + except ET.ParseError: + root = ET.fromstring(f"") + + declared = {g.get("name") for g in root.iter("group") if g.get("name")} + expected = self._REQUIRED_SECTIONS | self._OPTIONAL_SECTIONS + missing = expected - declared + + if missing: + raise ValueError( + f"{self.__class__.__name__}: template '{self.template}' " + f"missing group declarations: {sorted(missing)}. " + f"Declared groups: {sorted(declared)}" + ) + def _run_ttp(self) -> dict: p = ttp(data=self.config, template=self._loaded_template) p.parse() diff --git a/oxi/interfaces/models/mikrotik.py b/oxi/interfaces/models/mikrotik.py index c84260d..6b6f6387 100644 --- a/oxi/interfaces/models/mikrotik.py +++ b/oxi/interfaces/models/mikrotik.py @@ -1,3 +1,4 @@ +import re from typing import TYPE_CHECKING from oxi.interfaces import register_parser from oxi.interfaces.base import BaseDevice @@ -19,11 +20,13 @@ class Mikrotik(BaseDevice): print("-" * 12) def vlans(self) -> list["Vlans"]: - print(f"{self._raw["vlans"]=}") - print("-" * 12) + raw = self._raw.get("vlans", []) + print(raw) if __name__ == "__main__": - mikr = Mikrotik() - mikr.run() + with open("../../test.txt") as file: + data = file.read() + mikr = Mikrotik(data) + mikr.parse() print(mikr.load) diff --git a/oxi/interfaces/models/templates/mikrotik.ttp b/oxi/interfaces/models/templates/mikrotik.ttp index a535494..7cc958f 100644 --- a/oxi/interfaces/models/templates/mikrotik.ttp +++ b/oxi/interfaces/models/templates/mikrotik.ttp @@ -1,31 +1,47 @@ + some templates - \ No newline at end of file -- 2.53.0 From a938fe2d475099ababc3052c23154ebec093f3a0 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Thu, 19 Feb 2026 00:31:54 +0300 Subject: [PATCH 10/80] Update Interfaces model and Mikrotik template for improved data handling - Made the `description` field in the `Interfaces` model optional to enhance flexibility. - Renamed `id` to `vlan_id` in the `Vlans` model for clarity. - Refactored the `Mikrotik` class methods to streamline raw data access and removed unnecessary print statements. - Updated the Mikrotik TTP template to reflect changes in variable names and improve overall structure for interface and VLAN configurations. --- oxi/interfaces/contract.py | 4 ++-- oxi/interfaces/models/mikrotik.py | 20 ++++++++------------ oxi/interfaces/models/templates/mikrotik.ttp | 18 +++++++++--------- 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/oxi/interfaces/contract.py b/oxi/interfaces/contract.py index 34b36df..fdd7f1e 100644 --- a/oxi/interfaces/contract.py +++ b/oxi/interfaces/contract.py @@ -6,7 +6,7 @@ class Interfaces(BaseModel): name: str ip_address: IPv4Address | None = None mask: int | None = None - description: str + description: str | None = None class System(BaseModel): @@ -16,7 +16,7 @@ class System(BaseModel): class Vlans(BaseModel): - id: int + vlan_id: int name: str | None = Field(default=None, alias="description") diff --git a/oxi/interfaces/models/mikrotik.py b/oxi/interfaces/models/mikrotik.py index 6b6f6387..721905d 100644 --- a/oxi/interfaces/models/mikrotik.py +++ b/oxi/interfaces/models/mikrotik.py @@ -1,10 +1,6 @@ -import re -from typing import TYPE_CHECKING from oxi.interfaces import register_parser from oxi.interfaces.base import BaseDevice - -if TYPE_CHECKING: - from oxi.interfaces.contract import Interfaces, System, Vlans +from oxi.interfaces.contract import Interfaces, System, Vlans @register_parser(["routeros", "ros", "mikrotik"]) @@ -12,16 +8,16 @@ class Mikrotik(BaseDevice): template = "mikrotik.ttp" def system(self) -> "System": - print(self._raw["system"]) - print("-" * 12) + systems = self._raw.get("system") + return System(**systems) def interfaces(self) -> "Interfaces": - print(f"{self._raw["interfaces"]=}") - print("-" * 12) + print(self._raw.get("interfaces")) + return [Interfaces(**item) for item in self._raw.get("interfaces")] def vlans(self) -> list["Vlans"]: - raw = self._raw.get("vlans", []) - print(raw) + print(self._raw.get("vlans")) + return [Vlans(**item) for item in self._raw.get("vlans")] if __name__ == "__main__": @@ -29,4 +25,4 @@ if __name__ == "__main__": data = file.read() mikr = Mikrotik(data) mikr.parse() - print(mikr.load) + print(mikr) diff --git a/oxi/interfaces/models/templates/mikrotik.ttp b/oxi/interfaces/models/templates/mikrotik.ttp index 7cc958f..a6cbace 100644 --- a/oxi/interfaces/models/templates/mikrotik.ttp +++ b/oxi/interfaces/models/templates/mikrotik.ttp @@ -24,24 +24,24 @@ default_vlans = { /ip address ## not disabled and no comment -add address={{ ip | _start_ }}/{{ mask }} interface={{ interface }} network={{ network }} +add address={{ ip_address | _start_ }}/{{ mask }} interface={{ name }} network={{ network }} ## not disabled and comment with/without quotes -add address={{ ip | _start_ }}/{{ mask }} comment={{ comment | ORPHRASE | exclude("disabled=") | strip('"')}} interface={{ interface }} network={{ network }} +add address={{ ip_address | _start_ }}/{{ mask }} comment={{ description | ORPHRASE | exclude("disabled=") | strip('"')}} interface={{ name }} network={{ network }} ## disabled no comment -add address={{ ip | _start_ }}/{{ mask }} disabled={{ disabled | replace("yes","True") | strip('"')}} interface={{ interface }} network={{ network }} +add address={{ ip_address | _start_ }}/{{ mask }} disabled={{ disabled | replace("yes","True") | strip('"')}} interface={{ name }} network={{ network }} ## disabled with comment with/without quotes -add address={{ ip | _start_ }}/{{ mask }} comment={{ comment | ORPHRASE | exclude("disabled=") | strip('"') }} disabled={{ disabled }} interface={{ interface }} network={{ network }} +add address={{ ip_address | _start_ }}/{{ mask }} comment={{ description | ORPHRASE | exclude("disabled=") | strip('"') }} disabled={{ disabled }} interface={{ name }} network={{ network }} -/interface vlan {{ _start_ }} +/interface vlan ## not disabled and no comment -add interface={{ interface }} name={{ name | ORPHRASE }} vlan-id={{ vlan_id }} +add interface={{ interface | _start_ }} name={{ name | ORPHRASE | strip('"') }} vlan-id={{ vlan_id }} ## not disabled and comment with/without quotes -add comment={{ comment | ORPHRASE | exclude("disabled=") | strip('"')}} interface={{ interface }} name={{ name | ORPHRASE }} vlan-id={{ vlan_id }} +add comment={{ comment | _start_ | ORPHRASE | exclude("disabled=") | strip('"')}} interface={{ interface }} name={{ name | ORPHRASE | strip('"') }} vlan-id={{ vlan_id }} ## disabled with comment with/without quotes -add comment={{ comment | ORPHRASE | exclude("disabled=") | strip('"')}} disabled={{ disabled }} interface={{ interface }} name={{ name | ORPHRASE }} vlan-id={{ vlan_id }} +add comment={{ comment | _start_ | ORPHRASE | exclude("disabled=") | strip('"')}} disabled={{ disabled | replace("yes","True") | strip('"') }} interface={{ interface }} name={{ name | ORPHRASE | strip('"') }} vlan-id={{ vlan_id }} ## disabled no comment -add interface={{ interface }} name={{ name | ORPHRASE }} vlan-id={{ vlan_id }} disabled={{ disabled }} +add interface={{ interface | _start_ }} name={{ name | ORPHRASE | strip('"') }} vlan-id={{ vlan_id }} disabled={{ disabled | replace("yes","True") | strip('"') }} -- 2.53.0 From a41cb1f7ec0012ffcad8866808a8034873ce02fe Mon Sep 17 00:00:00 2001 From: IluaAir Date: Thu, 19 Feb 2026 00:49:10 +0300 Subject: [PATCH 11/80] """ Implement Keenetic model and enhance BaseDevice documentation - Added a new `Keenetic` model that registers a parser for KeeneticOS, extending the `BaseDevice` class. - Updated docstrings in the `BaseDevice` class methods to provide clearer descriptions of their functionality and expected data structures. - Introduced `model_config` in the `Vlans` model to enable name-based population. - Removed unnecessary print statements from the `Mikrotik` model methods to streamline output. """ --- oxi/interfaces/base.py | 6 ++++-- oxi/interfaces/contract.py | 4 +++- oxi/interfaces/models/keenetic.py | 20 ++++++++++++++++++++ oxi/interfaces/models/mikrotik.py | 5 +---- 4 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 oxi/interfaces/models/keenetic.py diff --git a/oxi/interfaces/base.py b/oxi/interfaces/base.py index 6955b72..29efea8 100644 --- a/oxi/interfaces/base.py +++ b/oxi/interfaces/base.py @@ -28,7 +28,7 @@ class BaseDevice(ABC): @abstractmethod def vlans(self) -> list["Vlans"]: - f""" + """ Parse VLAN configuration from self._raw['vlans']. Expected raw structure: @@ -45,7 +45,7 @@ class BaseDevice(ABC): @abstractmethod def interfaces(self) -> list["Interfaces"]: - f""" + """ Parse Interface configuration from self._raw['interfaces']. Expected raw structure: @@ -70,6 +70,7 @@ class BaseDevice(ABC): ... def _load_template(self): + """Подгрузка темплейтов из папки models/templates""" path = Path(__file__).parent / "models" / "templates" / self.template if not path.exists(): print("-" * 12) @@ -96,6 +97,7 @@ class BaseDevice(ABC): ) def _run_ttp(self) -> dict: + """ Основной парсер """ p = ttp(data=self.config, template=self._loaded_template) p.parse() raw: dict = p.result()[0][0] diff --git a/oxi/interfaces/contract.py b/oxi/interfaces/contract.py index fdd7f1e..21f0f9b 100644 --- a/oxi/interfaces/contract.py +++ b/oxi/interfaces/contract.py @@ -1,5 +1,5 @@ from ipaddress import IPv4Address -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class Interfaces(BaseModel): @@ -16,6 +16,8 @@ class System(BaseModel): class Vlans(BaseModel): + model_config = ConfigDict(populate_by_name=True) + vlan_id: int name: str | None = Field(default=None, alias="description") diff --git a/oxi/interfaces/models/keenetic.py b/oxi/interfaces/models/keenetic.py new file mode 100644 index 0000000..42df4f4 --- /dev/null +++ b/oxi/interfaces/models/keenetic.py @@ -0,0 +1,20 @@ +from oxi.interfaces import register_parser +from oxi.interfaces.base import BaseDevice + + +@register_parser(["NDMS", "keenetic", "KeeneticOS"]) +class Keenetic(BaseDevice): + template = "keenetic.ttp" + + def system(self): ... + + def interfaces(self): ... + + def vlans(self): ... + + +if __name__ == "__main__": + with open("../../test2.txt") as file: + data = file.read() + mikr = Keenetic(data) + print(mikr.parse().json()) diff --git a/oxi/interfaces/models/mikrotik.py b/oxi/interfaces/models/mikrotik.py index 721905d..4a49095 100644 --- a/oxi/interfaces/models/mikrotik.py +++ b/oxi/interfaces/models/mikrotik.py @@ -12,11 +12,9 @@ class Mikrotik(BaseDevice): return System(**systems) def interfaces(self) -> "Interfaces": - print(self._raw.get("interfaces")) return [Interfaces(**item) for item in self._raw.get("interfaces")] def vlans(self) -> list["Vlans"]: - print(self._raw.get("vlans")) return [Vlans(**item) for item in self._raw.get("vlans")] @@ -24,5 +22,4 @@ if __name__ == "__main__": with open("../../test.txt") as file: data = file.read() mikr = Mikrotik(data) - mikr.parse() - print(mikr) + print(mikr.parse().json()) -- 2.53.0 From 2394296f5b00ebbe098f8b30f736ec92fe4ae693 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Sun, 22 Feb 2026 00:21:55 +0300 Subject: [PATCH 12/80] Enhance Keenetic model and update templates for improved data handling - Implemented the `interfaces` and `vlans` methods in the `Keenetic` model to process and decode interface and VLAN data. - Added a `_decode_utf` method to handle UTF-8 encoded descriptions. - Updated the Keenetic TTP template to define structured groups for system, interfaces, and VLANs. - Refactored file paths in the `Mikrotik` model for consistency and clarity. --- oxi/interfaces/base.py | 2 +- oxi/interfaces/models/keenetic.py | 44 ++++++++++++++++--- oxi/interfaces/models/mikrotik.py | 4 +- oxi/interfaces/models/templates/_template.ttp | 17 +++++-- oxi/interfaces/models/templates/keenetic.ttp | 29 ++++++++++++ 5 files changed, 85 insertions(+), 11 deletions(-) create mode 100644 oxi/interfaces/models/templates/keenetic.ttp diff --git a/oxi/interfaces/base.py b/oxi/interfaces/base.py index 29efea8..c1d7073 100644 --- a/oxi/interfaces/base.py +++ b/oxi/interfaces/base.py @@ -106,7 +106,7 @@ class BaseDevice(ABC): raise ValueError( f"{self.__class__.__name__}: TTP template '{self.template}' " f"did not produce required sections: {sorted(missing)}. " - f"Got: {sorted(raw.keys())}" + f"Got: {(raw.keys())}" ) return raw diff --git a/oxi/interfaces/models/keenetic.py b/oxi/interfaces/models/keenetic.py index 42df4f4..b994e95 100644 --- a/oxi/interfaces/models/keenetic.py +++ b/oxi/interfaces/models/keenetic.py @@ -1,20 +1,54 @@ +from ipaddress import ip_interface +from pprint import pprint from oxi.interfaces import register_parser from oxi.interfaces.base import BaseDevice +from oxi.interfaces.contract import Interfaces, System, Vlans @register_parser(["NDMS", "keenetic", "KeeneticOS"]) class Keenetic(BaseDevice): template = "keenetic.ttp" - def system(self): ... + def system(self): + return System(**self._raw["system"]) - def interfaces(self): ... + def _decode_utf(self, text: str): + if "\\x" in text: + desc = text.strip('"') + decoded = ( + desc.encode("utf-8") + .decode("unicode_escape") + .encode("latin1") + .decode("utf-8") + ) + return decoded + return text - def vlans(self): ... + def interfaces(self): + interfaces: list[dict] = self._raw["interfaces"] + for item in interfaces: + if item.get("ip_address") and item.get("netmask"): + ipaddress = ip_interface( + f"{item.get('ip_address')}/{item.get('netmask')}" + ) + item["mask"] = ipaddress.network.prefixlen + item.pop("netmask", "Key not found") + if item.get("description"): + decoded = self._decode_utf(item.get("description", "")) + item["description"] = decoded + return [Interfaces(**item) for item in interfaces] + + def vlans(self): + vlans = self._raw["vlans"] + for item in vlans: + if item.get("description"): + decoded = self._decode_utf(item.get("description", "")) + item["description"] = decoded + return [Vlans(**item) for item in vlans] if __name__ == "__main__": - with open("../../test2.txt") as file: + with open("./test2.txt") as file: data = file.read() mikr = Keenetic(data) - print(mikr.parse().json()) + print(mikr.parse().model_dump_json()) diff --git a/oxi/interfaces/models/mikrotik.py b/oxi/interfaces/models/mikrotik.py index 4a49095..1fc3691 100644 --- a/oxi/interfaces/models/mikrotik.py +++ b/oxi/interfaces/models/mikrotik.py @@ -1,3 +1,4 @@ +import os from oxi.interfaces import register_parser from oxi.interfaces.base import BaseDevice from oxi.interfaces.contract import Interfaces, System, Vlans @@ -19,7 +20,8 @@ class Mikrotik(BaseDevice): if __name__ == "__main__": - with open("../../test.txt") as file: + print(os.path.abspath(os.curdir)) + with open("./test.txt") as file: data = file.read() mikr = Mikrotik(data) print(mikr.parse().json()) diff --git a/oxi/interfaces/models/templates/_template.ttp b/oxi/interfaces/models/templates/_template.ttp index 02d8a0c..67cb94d 100644 --- a/oxi/interfaces/models/templates/_template.ttp +++ b/oxi/interfaces/models/templates/_template.ttp @@ -1,7 +1,16 @@ - - + + - - \ No newline at end of file + + ... + + + + ... + + + + ... + diff --git a/oxi/interfaces/models/templates/keenetic.ttp b/oxi/interfaces/models/templates/keenetic.ttp new file mode 100644 index 0000000..6f9dfc7 --- /dev/null +++ b/oxi/interfaces/models/templates/keenetic.ttp @@ -0,0 +1,29 @@ + + + +default_system = { + "model": "", + "serial_number": "" +} +default_interfaces = {} + + + +! release: {{ version }} +! model: {{ model | ORPHRASE }} +! hw_version: {{ serial_number }} + + + +interface {{ name | _start_ | exclude("Vlan") }} + rename {{ rename }} + description {{ description | ORPHRASE }} + ip address {{ ip_address }} {{ netmask }} + {{ shutdown | re("up") | replace("up","False") | strip('"') }} + {{ shutdown | re("down") | replace("down","True") | strip('"') }} + + + +interface {{ ignore }}/Vlan{{ vlan_id }} + description {{ description | ORPHRASE | strip('"') }} + \ No newline at end of file -- 2.53.0 From 3635a07b27bc19125552c17037296d4a37ea6f66 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Sun, 22 Feb 2026 09:52:17 +0300 Subject: [PATCH 13/80] Refactor BaseDevice methods for improved data handling and validation - Updated the `BaseDevice` class to replace `_raw` with `raw` for consistency in data access. - Enhanced the `vlans`, `interfaces`, and `system` methods to utilize the new `raw` attribute. - Introduced a `_validate_contract` method to streamline the validation of parsed data into structured models. - Adjusted the `Keenetic` and `Mikrotik` models to align with the updated data handling approach, ensuring proper parsing and decoding of interface and VLAN data. --- oxi/conf.py | 2 +- oxi/interfaces/base.py | 63 +++++++++++++++---------------- oxi/interfaces/models/keenetic.py | 14 +++---- oxi/interfaces/models/mikrotik.py | 15 ++++---- 4 files changed, 44 insertions(+), 50 deletions(-) diff --git a/oxi/conf.py b/oxi/conf.py index 1047b2a..8a1ade8 100644 --- a/oxi/conf.py +++ b/oxi/conf.py @@ -30,7 +30,7 @@ class NodeConfig: @property def json(self): - return self._response.json() + return self._parsed_data.json() def __str__(self): return self.text diff --git a/oxi/interfaces/base.py b/oxi/interfaces/base.py index c1d7073..f3c287b 100644 --- a/oxi/interfaces/base.py +++ b/oxi/interfaces/base.py @@ -1,12 +1,9 @@ from abc import ABC, abstractmethod from pathlib import Path -from typing import TYPE_CHECKING from ttp import ttp from oxi.interfaces.contract import Device import xml.etree.ElementTree as ET - -if TYPE_CHECKING: - from oxi.interfaces.contract import Interfaces, System, Vlans +from oxi.interfaces.contract import Interfaces, System, Vlans class BaseDevice(ABC): @@ -17,19 +14,19 @@ class BaseDevice(ABC): self.config: str = config self._loaded_template = self._load_template() self._validate_template_groups() - self._raw: dict = self._run_ttp() + self.raw: dict = self._run_ttp() @property @abstractmethod def template(self) -> str: """ - :return: + Returns: + Название файла с парсером ttp """ - @abstractmethod - def vlans(self) -> list["Vlans"]: + def vlans(self) -> list[dict]: """ - Parse VLAN configuration from self._raw['vlans']. + Parse VLAN configuration from self.raw['vlans']. Expected raw structure: [{"id": 10, "description": "MGMT"}, {"id": 15, "name": "SSH"}, ...] @@ -39,42 +36,48 @@ class BaseDevice(ABC): пустой список если секция отсутствует. Raises: - ValueError: если _raw содержит некорректные данные. + ValueError: если raw содержит некорректные данные. """ - ... + return self.raw.get("vlans", []) - @abstractmethod - def interfaces(self) -> list["Interfaces"]: + def interfaces(self) -> list[dict]: """ - Parse Interface configuration from self._raw['interfaces']. + Parse Interface configuration from self.raw['interfaces']. Expected raw structure: [{"name": "GEthernet1/0/1", "ip_address": "192.168.1.1", "mask": "24", "description": "IPBB interface"}] - - Raises: - ValueError: если _raw содержит некорректные данные. - """ - ... - @abstractmethod - def system(self) -> "System": + Raises: + ValueError: если raw содержит некорректные данные. """ - Parse System configuration from self._raw['system']. + return self.raw.get("interfaces", []) + + def system(self) -> dict: + """ + Parse System configuration from self.raw['system']. Expected raw structure: {"model":"RB951Ui-2nD", serial_number: "B88C0B31117B", "version": "7.12.1"} Raises: - ValueError: если _raw содержит некорректные данные. + ValueError: если raw содержит некорректные данные. """ - ... + return self.raw.get("system", None) + + def _validate_contract(self): + optional_vlans = self.vlans() + if optional_vlans: + optional_vlans = [Vlans(**item) for item in optional_vlans] + return { + "system": System(**self.system()), + "interfaces": [Interfaces(**items) for items in self.interfaces()], + "vlans": optional_vlans, + } def _load_template(self): """Подгрузка темплейтов из папки models/templates""" path = Path(__file__).parent / "models" / "templates" / self.template if not path.exists(): - print("-" * 12) - print(path) raise FileNotFoundError(f"Template {self.template} not found") return path.read_text(encoding="utf-8") @@ -97,7 +100,7 @@ class BaseDevice(ABC): ) def _run_ttp(self) -> dict: - """ Основной парсер """ + """Основной парсер""" p = ttp(data=self.config, template=self._loaded_template) p.parse() raw: dict = p.result()[0][0] @@ -111,8 +114,4 @@ class BaseDevice(ABC): return raw def parse(self) -> Device: - return Device( - system=self.system(), - interfaces=self.interfaces(), - vlans=self.vlans(), - ) + return Device(**self._validate_contract()) diff --git a/oxi/interfaces/models/keenetic.py b/oxi/interfaces/models/keenetic.py index b994e95..a550a49 100644 --- a/oxi/interfaces/models/keenetic.py +++ b/oxi/interfaces/models/keenetic.py @@ -1,17 +1,13 @@ from ipaddress import ip_interface -from pprint import pprint from oxi.interfaces import register_parser from oxi.interfaces.base import BaseDevice -from oxi.interfaces.contract import Interfaces, System, Vlans +from oxi.interfaces.contract import Interfaces, Vlans @register_parser(["NDMS", "keenetic", "KeeneticOS"]) class Keenetic(BaseDevice): template = "keenetic.ttp" - def system(self): - return System(**self._raw["system"]) - def _decode_utf(self, text: str): if "\\x" in text: desc = text.strip('"') @@ -25,7 +21,7 @@ class Keenetic(BaseDevice): return text def interfaces(self): - interfaces: list[dict] = self._raw["interfaces"] + interfaces: list[dict] = self.raw["interfaces"] for item in interfaces: if item.get("ip_address") and item.get("netmask"): ipaddress = ip_interface( @@ -36,15 +32,15 @@ class Keenetic(BaseDevice): if item.get("description"): decoded = self._decode_utf(item.get("description", "")) item["description"] = decoded - return [Interfaces(**item) for item in interfaces] + return interfaces def vlans(self): - vlans = self._raw["vlans"] + vlans = self.raw["vlans"] for item in vlans: if item.get("description"): decoded = self._decode_utf(item.get("description", "")) item["description"] = decoded - return [Vlans(**item) for item in vlans] + return vlans if __name__ == "__main__": diff --git a/oxi/interfaces/models/mikrotik.py b/oxi/interfaces/models/mikrotik.py index 1fc3691..d912718 100644 --- a/oxi/interfaces/models/mikrotik.py +++ b/oxi/interfaces/models/mikrotik.py @@ -1,22 +1,21 @@ import os from oxi.interfaces import register_parser from oxi.interfaces.base import BaseDevice -from oxi.interfaces.contract import Interfaces, System, Vlans @register_parser(["routeros", "ros", "mikrotik"]) class Mikrotik(BaseDevice): template = "mikrotik.ttp" - def system(self) -> "System": - systems = self._raw.get("system") - return System(**systems) + # def system(self) -> "System": + # systems = self._raw.get("system") + # return System(**systems) - def interfaces(self) -> "Interfaces": - return [Interfaces(**item) for item in self._raw.get("interfaces")] + # def interfaces(self) -> "Interfaces": + # return [Interfaces(**item) for item in self._raw.get("interfaces")] - def vlans(self) -> list["Vlans"]: - return [Vlans(**item) for item in self._raw.get("vlans")] + # def vlans(self) -> list["Vlans"]: + # return [Vlans(**item) for item in self._raw.get("vlans")] if __name__ == "__main__": -- 2.53.0 From 753268a381c0e3e624613fc5e601f008f638b252 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Sun, 22 Feb 2026 15:52:45 +0300 Subject: [PATCH 14/80] Refactor BaseDevice and Interfaces models for improved validation and structure - Introduced `_declared_sections` in `BaseDevice` to track declared template groups. - Enhanced `_validate_contract` method to conditionally validate VLANs based on declared sections. - Updated docstrings in `BaseDevice` and `Interfaces` models for clarity on expected structures. - Refactored `Interfaces` and `Vlans` models to improve field definitions and aliases. - Commented out unused `vlans` method in `Keenetic` model for future reference. --- oxi/interfaces/base.py | 42 ++++++++++++++++++------------- oxi/interfaces/contract.py | 32 ++++++++++++++++------- oxi/interfaces/models/keenetic.py | 15 ++++++----- 3 files changed, 55 insertions(+), 34 deletions(-) diff --git a/oxi/interfaces/base.py b/oxi/interfaces/base.py index f3c287b..4077b92 100644 --- a/oxi/interfaces/base.py +++ b/oxi/interfaces/base.py @@ -12,7 +12,9 @@ class BaseDevice(ABC): def __init__(self, config: str): self.config: str = config + self._loaded_template = self._load_template() + self._declared_sections = None self._validate_template_groups() self.raw: dict = self._run_ttp() @@ -20,15 +22,17 @@ class BaseDevice(ABC): @abstractmethod def template(self) -> str: """ - Returns: + Expected structure: Название файла с парсером ttp + Returns: + None """ def vlans(self) -> list[dict]: """ Parse VLAN configuration from self.raw['vlans']. - Expected raw structure: + Expected structure: [{"id": 10, "description": "MGMT"}, {"id": 15, "name": "SSH"}, ...] Returns: @@ -64,16 +68,20 @@ class BaseDevice(ABC): """ return self.raw.get("system", None) - def _validate_contract(self): - optional_vlans = self.vlans() - if optional_vlans: - optional_vlans = [Vlans(**item) for item in optional_vlans] - return { - "system": System(**self.system()), - "interfaces": [Interfaces(**items) for items in self.interfaces()], - "vlans": optional_vlans, + def _validate_contract(self) -> dict: + system_data = self.system() + interfaces_data = self.interfaces() or [] + result = { + "system": System(**system_data), + "interfaces": [Interfaces(**item) for item in interfaces_data], + "vlans": [], } + if "vlans" in self._declared_sections: + vlans_data = self.vlans() or [] + result["vlans"] = [Vlans(**item) for item in vlans_data] + return result + def _load_template(self): """Подгрузка темплейтов из папки models/templates""" path = Path(__file__).parent / "models" / "templates" / self.template @@ -82,20 +90,20 @@ class BaseDevice(ABC): return path.read_text(encoding="utf-8") def _validate_template_groups(self) -> None: - """Проверка что TTP темлпейт имеет декларированные группы для всех требуемых и опциональных секций""" + """Проверяем только обязательные группы в template.""" try: root = ET.fromstring(self._loaded_template) except ET.ParseError: root = ET.fromstring(f"") declared = {g.get("name") for g in root.iter("group") if g.get("name")} - expected = self._REQUIRED_SECTIONS | self._OPTIONAL_SECTIONS - missing = expected - declared + self._declared_sections = declared - if missing: + missing_required = self._REQUIRED_SECTIONS - declared + if missing_required: raise ValueError( f"{self.__class__.__name__}: template '{self.template}' " - f"missing group declarations: {sorted(missing)}. " + f"missing required groups: {sorted(missing_required)}. " f"Declared groups: {sorted(declared)}" ) @@ -108,8 +116,8 @@ class BaseDevice(ABC): if missing: raise ValueError( f"{self.__class__.__name__}: TTP template '{self.template}' " - f"did not produce required sections: {sorted(missing)}. " - f"Got: {(raw.keys())}" + f"did not produce required groups: {sorted(missing)}. " + f"Return only: {(raw.keys())}" ) return raw diff --git a/oxi/interfaces/contract.py b/oxi/interfaces/contract.py index 21f0f9b..834c7a4 100644 --- a/oxi/interfaces/contract.py +++ b/oxi/interfaces/contract.py @@ -2,27 +2,41 @@ from ipaddress import IPv4Address from pydantic import BaseModel, ConfigDict, Field -class Interfaces(BaseModel): - name: str - ip_address: IPv4Address | None = None - mask: int | None = None - description: str | None = None +class Base(BaseModel): + model_config = ConfigDict(populate_by_name=True) class System(BaseModel): + """ + Requred + """ + model: str serial_number: str version: str -class Vlans(BaseModel): - model_config = ConfigDict(populate_by_name=True) +class Interfaces(Base): + """ + Requred + """ - vlan_id: int + name: str = Field(alias="interface") + ip_address: IPv4Address | None = None + mask: int | None = None + description: str | None = None + + +class Vlans(Base): + """ + Optional + """ + + vlan_id: int = Field(alias="id") name: str | None = Field(default=None, alias="description") class Device(BaseModel): system: System - interfaces: list[Interfaces] = [] + interfaces: list[Interfaces] vlans: list[Vlans] = [] diff --git a/oxi/interfaces/models/keenetic.py b/oxi/interfaces/models/keenetic.py index a550a49..2a692d8 100644 --- a/oxi/interfaces/models/keenetic.py +++ b/oxi/interfaces/models/keenetic.py @@ -1,7 +1,6 @@ from ipaddress import ip_interface from oxi.interfaces import register_parser from oxi.interfaces.base import BaseDevice -from oxi.interfaces.contract import Interfaces, Vlans @register_parser(["NDMS", "keenetic", "KeeneticOS"]) @@ -34,13 +33,13 @@ class Keenetic(BaseDevice): item["description"] = decoded return interfaces - def vlans(self): - vlans = self.raw["vlans"] - for item in vlans: - if item.get("description"): - decoded = self._decode_utf(item.get("description", "")) - item["description"] = decoded - return vlans + # def vlans(self): + # vlans = self.raw["vlans"] + # for item in vlans: + # if item.get("description"): + # decoded = self._decode_utf(item.get("description", "")) + # item["description"] = decoded + # return vlans if __name__ == "__main__": -- 2.53.0 From 3fdff33e2e6966d7f963618f4e57ffd5b3bddad1 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Tue, 24 Feb 2026 22:28:36 +0300 Subject: [PATCH 15/80] Implement validation for optional VLANs in BaseDevice - Added a check in the BaseDevice class to raise a ValueError if the 'vlans' section is declared in the template but not present in the raw data returned by TTP. - This enhancement improves data integrity by ensuring that optional groups are properly validated before processing. --- oxi/interfaces/base.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/oxi/interfaces/base.py b/oxi/interfaces/base.py index 4077b92..bdd4308 100644 --- a/oxi/interfaces/base.py +++ b/oxi/interfaces/base.py @@ -78,6 +78,11 @@ class BaseDevice(ABC): } if "vlans" in self._declared_sections: + if "vlans" not in self.raw: + raise ValueError( + f"{self.__class__.__name__}: template '{self.template}' declares optional group " + f"'vlans', but TTP did not return it." + ) vlans_data = self.vlans() or [] result["vlans"] = [Vlans(**item) for item in vlans_data] return result -- 2.53.0 From 16e97692fc5de5875718e44d6352c841ea5b894d Mon Sep 17 00:00:00 2001 From: IluaAir Date: Tue, 24 Feb 2026 22:31:18 +0300 Subject: [PATCH 16/80] Refactor NodeConfig methods for consistency in data access - Renamed `l3interfaces` to `interfaces` and `vlaninterfaces` to `system` in the `NodeConfig` class to align with updated data structure. - This change enhances clarity and consistency in method naming, reflecting the underlying data model more accurately. --- oxi/conf.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/oxi/conf.py b/oxi/conf.py index 8a1ade8..95ddf10 100644 --- a/oxi/conf.py +++ b/oxi/conf.py @@ -38,8 +38,8 @@ class NodeConfig: def vlans(self): return self._parsed_data.vlans - def l3interfaces(self): - return self._parsed_data.l3interfaces + def interfaces(self): + return self._parsed_data.interfaces - def vlaninterfaces(self): - return self._parsed_data.vlaninterfaces + def system(self): + return self._parsed_data.system -- 2.53.0 From 3159570e2713218161f9203e73433ed7d0d18cb8 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Tue, 24 Feb 2026 22:50:55 +0300 Subject: [PATCH 17/80] Add ModelView class for enhanced JSON serialization in NodeConfig - Introduced a new `ModelView` class to facilitate JSON serialization of model data, supporting both single models and lists. - Updated `NodeConfig` methods to utilize `ModelView` for `vlans`, `interfaces`, and `system` properties, improving data handling consistency. - Refactored the `json` method to ensure proper serialization of parsed data. --- oxi/conf.py | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/oxi/conf.py b/oxi/conf.py index 95ddf10..bfcc0d9 100644 --- a/oxi/conf.py +++ b/oxi/conf.py @@ -1,11 +1,35 @@ from functools import cached_property -from typing import TYPE_CHECKING +import json +from typing import TYPE_CHECKING, Generic, TypeVar + +from pydantic import BaseModel from .interfaces import BaseDevice, device_registry if TYPE_CHECKING: from requests import Session +TModel = TypeVar("TModel", bound=BaseModel) + + +class ModelView(Generic[TModel]): + def __init__(self, model: TModel | list[TModel]): + self._model = model + + def json(self) -> str: + if isinstance(self._model, list): + return json.dumps( + [item.model_dump(by_alias=True) for item in self._model], + ensure_ascii=False, + ) + return self._model.model_dump_json(by_alias=True) + + def __getattr__(self, item): + return getattr(self._model, item) + + def __repr__(self) -> str: + return repr(self._model) + class NodeConfig: def __init__(self, session: "Session", full_name: str, model: str, base_url: str): @@ -28,18 +52,20 @@ class NodeConfig: def text(self): return self._response.text - @property def json(self): - return self._parsed_data.json() + return self._parsed_data.model_dump_json() def __str__(self): return self.text + @property def vlans(self): - return self._parsed_data.vlans + return ModelView(self._parsed_data.vlans) + @property def interfaces(self): - return self._parsed_data.interfaces + return ModelView(self._parsed_data.interfaces) + @property def system(self): - return self._parsed_data.system + return ModelView(self._parsed_data.system) -- 2.53.0 From ac835d6b56d70f8f249298be7ecc00d1231253bb Mon Sep 17 00:00:00 2001 From: IluaAir Date: Tue, 24 Feb 2026 23:19:57 +0300 Subject: [PATCH 18/80] Enhance ModelView class with iterable and indexing support - Added iterator, length, and item access methods to the `ModelView` class, allowing it to handle single models and lists more effectively. - Refactored the `vlans` method in the `Keenetic` model to restore functionality for decoding VLAN descriptions, improving data processing consistency. --- oxi/conf.py | 17 ++++++++++++++++- oxi/interfaces/models/keenetic.py | 14 +++++++------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/oxi/conf.py b/oxi/conf.py index bfcc0d9..6c930f1 100644 --- a/oxi/conf.py +++ b/oxi/conf.py @@ -1,6 +1,6 @@ from functools import cached_property import json -from typing import TYPE_CHECKING, Generic, TypeVar +from typing import TYPE_CHECKING, Generic, Iterator, Type, TypeVar from pydantic import BaseModel @@ -24,6 +24,21 @@ class ModelView(Generic[TModel]): ) return self._model.model_dump_json(by_alias=True) + def __iter__(self) -> Iterator[TModel]: + if isinstance(self._model, list): + return iter(self._model) + raise TypeError("This view wraps a single model, not a list") + + def __len__(self) -> int: + if isinstance(self._model, list): + return len(self._model) + raise TypeError("This view wraps a single model, not a list") + + def __getitem__(self, item): + if isinstance(self._model, list): + return self._model[item] + raise TypeError("This view wraps a single model, not a list") + def __getattr__(self, item): return getattr(self._model, item) diff --git a/oxi/interfaces/models/keenetic.py b/oxi/interfaces/models/keenetic.py index 2a692d8..89ea3c7 100644 --- a/oxi/interfaces/models/keenetic.py +++ b/oxi/interfaces/models/keenetic.py @@ -33,13 +33,13 @@ class Keenetic(BaseDevice): item["description"] = decoded return interfaces - # def vlans(self): - # vlans = self.raw["vlans"] - # for item in vlans: - # if item.get("description"): - # decoded = self._decode_utf(item.get("description", "")) - # item["description"] = decoded - # return vlans + def vlans(self): + vlans = self.raw["vlans"] + for item in vlans: + if item.get("description"): + decoded = self._decode_utf(item.get("description", "")) + item["description"] = decoded + return vlans if __name__ == "__main__": -- 2.53.0 From fe6d0c4ccf065cf99846cd68d0abef80faf78986 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Tue, 24 Feb 2026 23:27:14 +0300 Subject: [PATCH 19/80] Add README and documentation for Oxi API client - Introduced a comprehensive README file detailing the installation, quick start guide, and API reference for the oxipy Python client. - Added documentation on extending and overriding device models, including architecture, registration of new devices, and method overriding examples. - Created a template guide for writing TTP templates, outlining structure, mandatory groups, and practical examples for new device support. - Included a base template for device configuration parsing, enhancing usability for developers integrating with the Oxi API. --- README.md | 240 +++++++++++++ docs/extending-models.md | 325 ++++++++++++++++++ docs/templates.md | 306 +++++++++++++++++ oxi/interfaces/models/templates/_template.ttp | 44 ++- 4 files changed, 914 insertions(+), 1 deletion(-) create mode 100644 docs/extending-models.md create mode 100644 docs/templates.md diff --git a/README.md b/README.md index e69de29..446ec46 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,240 @@ +# oxipy + +Python-клиент для работы с Oxi API — системой мониторинга и управления сетевыми устройствами. Предоставляет удобный интерфейс для получения конфигураций узлов, их парсинга и работы с результатами через типизированные Pydantic-модели. + +## Содержание + +- [Установка](#установка) +- [Быстрый старт](#быстрый-старт) +- [API Reference](#api-reference) + - [OxiAPI](#oxiapi) + - [NodeView](#nodeview) + - [NodeConfig](#nodeconfig) + - [ModelView](#modelview) +- [Поддерживаемые устройства](#поддерживаемые-устройства) +- [Дополнительно](#дополнительно) + +--- + +## Установка + +```bash +pip install oxipy +``` + +Или из исходников: + +```bash +git clone https://gitea.imbastark.ru/Netbox/oxipy +cd oxipy +pip install -e . +``` + +**Требования:** Python 3.13+ + +--- + +## Быстрый старт + +```python +from oxi import OxiAPI + +api = OxiAPI(url="https://oxi.example.com", verify=False) + +node = api.node("Router_HOME") + +print(node.ip) # '192.168.1.1' +print(node.model) # 'keenetic' +print(node.full_name) # 'Router_HOME' + +print(node.config.system.model) +print(node.config.interfaces.json()) +print(node.config.vlans.json()) +``` + +--- + +## API Reference + +### OxiAPI + +Точка входа в библиотеку. Управляет HTTP-сессией и предоставляет доступ к узлам. + +```python +OxiAPI( + url: str, + username: str | None = None, + password: str | None = None, + verify: bool = True, +) +``` + +| Параметр | Тип | Описание | +|------------|--------|----------------------------------------------------------------| +| `url` | `str` | Базовый URL Oxi API, например `https://oxi.example.com` | +| `username` | `str` | Имя пользователя для базовой аутентификации (опционально) | +| `password` | `str` | Пароль для базовой аутентификации (опционально) | +| `verify` | `bool` | Проверять SSL-сертификат. `False` — отключить проверку | + +**Пример:** + +```python +# Без аутентификации +api = OxiAPI(url="https://oxi.example.com") + +# С базовой аутентификацией +api = OxiAPI( + url="https://oxi.example.com", + username="admin", + password="secret", +) + +# Использование как контекстного менеджера (автоматически закрывает сессию) +with OxiAPI(url="https://oxi.example.com") as api: + node = api.node("Router_HOME") + print(node.ip) +``` + +#### `api.node(name)` + +Возвращает [`NodeView`](#nodeview) для указанного узла. + +```python +node = api.node("Router_HOME") +``` + +--- + +### NodeView + +Представление узла сети. Содержит метаданные и ленивый доступ к конфигурации. + +| Свойство | Тип | Описание | +|-------------|--------------|-----------------------------------------------| +| `ip` | `str` | IP-адрес узла | +| `full_name` | `str` | Полное имя узла в Oxi | +| `group` | `str` | Группа, к которой принадлежит узел | +| `model` | `str` | Модель устройства (используется для парсинга) | +| `config` | `NodeConfig` | Конфигурация узла (загружается при первом обращении) | + +**Пример:** + +```python +node = api.node("Router_HOME") + +print(node.ip) # '10.0.0.1' +print(node.group) # 'branch-office' +print(node.model) # 'keenetic' + +# Конфигурация загружается один раз (cached_property) +cfg = node.config +``` + +--- + +### NodeConfig + +Загружает и парсит конфигурацию устройства. Использует TTP-шаблоны, соответствующие модели устройства. + +Доступ к секциям конфигурации осуществляется через свойства, возвращающие [`ModelView`](#modelview). + +| Свойство | Возвращает | Описание | +|--------------|-------------------------|-----------------------------------| +| `system` | `ModelView[System]` | Системная информация об устройстве | +| `interfaces` | `ModelView[list[Interfaces]]` | Список интерфейсов | +| `vlans` | `ModelView[list[Vlans]]` | Список VLAN (если есть) | +| `text` | `str` | Сырой текст конфигурации | + +**Пример:** + +```python +cfg = node.config + +# Системная информация +print(cfg.system.model) # 'RB951Ui-2nD' +print(cfg.system.serial_number) # 'B88C0B31117B' +print(cfg.system.version) # '7.12.1' + +# Итерация по интерфейсам +for iface in cfg.interfaces: + print(iface.name, iface.ip_address, iface.mask) + +# Индексация +first_iface = cfg.interfaces[0] +print(first_iface.name) + +# Количество интерфейсов +print(len(cfg.interfaces)) + +# JSON-дамп любой секции +print(cfg.interfaces.json()) +print(cfg.vlans.json()) +print(cfg.system.json()) + +# Сырая конфигурация текстом +print(cfg.text) +``` + +--- + +### ModelView + +Обёртка над Pydantic-моделью или списком моделей. Обеспечивает сериализацию, итерацию и прозрачный доступ к атрибутам. + +| Метод / свойство | Применимо к | Описание | +|------------------|----------------|---------------------------------------------------------------| +| `.json()` | оба варианта | Возвращает JSON-строку (с `by_alias=True`) | +| `.` | оба варианта | Проксирует обращение к атрибутам вложенной модели | +| `iter(view)` | список | Итерация по элементам списка моделей | +| `len(view)` | список | Количество элементов в списке | +| `view[i]` | список | Получение элемента по индексу или срез | + +> `__iter__`, `__len__` и `__getitem__` доступны только для `interfaces` и `vlans` (они оборачивают список). Вызов этих методов на `system` (одиночная модель) вызовет `TypeError`. + +**Примеры:** + +```python +# Одиночная модель — system +view = node.config.system +print(view.json()) +# '{"model":"RB951Ui-2nD","serial_number":"B88C0B31117B","version":"7.12.1"}' +print(view.model) # 'RB951Ui-2nD' +print(view.serial_number) # 'B88C0B31117B' + +# Список — interfaces +interfaces = node.config.interfaces + +# Итерация +for iface in interfaces: + print(iface.name, iface.ip_address) + +# Длина +print(len(interfaces)) # 5 + +# Индексация и срезы +first = interfaces[0] +top3 = interfaces[:3] + +# JSON всего списка +print(interfaces.json()) +``` + +--- + +## Поддерживаемые устройства + +| Устройство | Ключи реестра | +|-------------|----------------------------------------| +| Keenetic | `ndms`, `keenetic`, `keeneticos` | +| MikroTik | `routeros`, `ros`, `mikrotik` | + +Ключи реестра — это значения поля `model`, возвращаемого API для узла. Регистр не учитывается. + +Добавить поддержку нового устройства можно самостоятельно — подробнее в разделе [Расширение моделей](docs/extending-models.md). + +--- + +## Дополнительно + +- [Написание TTP-шаблонов](docs/templates.md) +- [Расширение и переопределение моделей устройств](docs/extending-models.md) diff --git a/docs/extending-models.md b/docs/extending-models.md new file mode 100644 index 0000000..dfe30c8 --- /dev/null +++ b/docs/extending-models.md @@ -0,0 +1,325 @@ +# Расширение и переопределение моделей устройств + +oxipy предоставляет гибкий механизм расширения через наследование от `BaseDevice`. После того как TTP-шаблон разобрал конфигурацию в сырой словарь `self.raw`, данные проходят через три метода экземпляра — `system()`, `interfaces()`, `vlans()` — перед тем как попасть в контракт. Переопределяя эти методы, можно трансформировать, фильтровать и обогащать данные без изменения шаблона или контракта. + +## Содержание + +- [Архитектура: путь данных](#архитектура-путь-данных) +- [Регистрация нового устройства](#регистрация-нового-устройства) +- [Переопределение методов (monkey patching)](#переопределение-методов-monkey-patching) + - [interfaces()](#interfaces) + - [vlans()](#vlans) + - [system()](#system) +- [Полный пример: новое устройство](#полный-пример-новое-устройство) +- [Контракт: ожидаемые структуры](#контракт-ожидаемые-структуры) + +--- + +## Архитектура: путь данных + +``` +текст конфигурации + │ + ▼ + TTP-шаблон (.ttp) + │ парсит в сырой словарь + ▼ + self.raw: dict + │ + ├──► system() → dict + ├──► interfaces() → list[dict] + └──► vlans() → list[dict] + │ + ▼ + _validate_contract() + │ создаёт Pydantic-модели + ▼ + Device(system, interfaces, vlans) +``` + +Методы `system()`, `interfaces()`, `vlans()` — это точки расширения. Базовая реализация просто возвращает данные из `self.raw`: + +```python +# BaseDevice (упрощённо) +def interfaces(self) -> list[dict]: + return self.raw.get("interfaces", []) + +def vlans(self) -> list[dict]: + return self.raw.get("vlans", []) + +def system(self) -> dict: + return self.raw.get("system", None) +``` + +--- + +## Регистрация нового устройства + +Чтобы добавить поддержку нового вендора: + +1. Создайте файл в `oxi/interfaces/models/`, например `cisco.py`. +2. Создайте шаблон `oxi/interfaces/models/templates/cisco.ttp`. +3. Унаследуйте класс от `BaseDevice` и зарегистрируйте его декоратором `@register_parser`. + +```python +# oxi/interfaces/models/cisco.py +from oxi.interfaces import register_parser +from oxi.interfaces.base import BaseDevice + + +@register_parser(["ios", "cisco", "cisco_ios"]) +class CiscoIOS(BaseDevice): + template = "cisco.ttp" +``` + +Декоратор `@register_parser` принимает список строк — это ключи, по которым устройство ищется в реестре. Поле `model` от API сравнивается с этими ключами без учёта регистра. + +После добавления файла он автоматически импортируется через `pkgutil` при старте приложения — явно импортировать не нужно. + +--- + +## Переопределение методов (monkey patching) + +### interfaces() + +Используйте переопределение, когда нужно: + +- Преобразовать формат IP-адреса (например, `netmask` → `prefix_length`). +- Декодировать escape-последовательности в описаниях. +- Переименовать ключи, не совпадающие с контрактом. +- Фильтровать служебные интерфейсы. + +**Пример: конвертация маски подсети в префикс** + +TTP возвращает `netmask` как `255.255.255.0`, а контракт `Interfaces` ожидает `mask` как целое число (prefix length): + +```python +from ipaddress import ip_interface +from oxi.interfaces import register_parser +from oxi.interfaces.base import BaseDevice + + +@register_parser(["myvendor"]) +class MyVendor(BaseDevice): + template = "myvendor.ttp" + + def interfaces(self) -> list[dict]: + result = [] + for item in self.raw.get("interfaces", []): + if item.get("ip_address") and item.get("netmask"): + iface = ip_interface(f"{item['ip_address']}/{item['netmask']}") + item["mask"] = iface.network.prefixlen + item.pop("netmask", None) + result.append(item) + return result +``` + +**Пример: фильтрация служебных интерфейсов** + +```python +def interfaces(self) -> list[dict]: + return [ + item for item in self.raw.get("interfaces", []) + if not item.get("name", "").startswith("lo") + ] +``` + +**Пример: декодирование Unicode escape-последовательностей** + +Некоторые устройства (например, Keenetic) хранят кириллические описания как `\xd0\xb8\xd0\xbc\xd1\x8f`: + +```python +def _decode_utf(self, text: str) -> str: + if "\\x" in text: + return ( + text.strip('"') + .encode("utf-8") + .decode("unicode_escape") + .encode("latin1") + .decode("utf-8") + ) + return text + +def interfaces(self) -> list[dict]: + interfaces = self.raw.get("interfaces", []) + for item in interfaces: + if item.get("description"): + item["description"] = self._decode_utf(item["description"]) + return interfaces +``` + +--- + +### vlans() + +Аналогично `interfaces()`. Используйте для нормализации ID, декодирования названий, обогащения данными из других секций. + +**Пример: добавление префикса к имени VLAN** + +```python +def vlans(self) -> list[dict]: + result = [] + for item in self.raw.get("vlans", []): + item["description"] = f"VLAN_{item.get('id', '?')}" + result.append(item) + return result +``` + +**Пример: объединение данных из нескольких секций** + +```python +def vlans(self) -> list[dict]: + vlans = {v["id"]: v for v in self.raw.get("vlans", [])} + # обогащаем данными из другой секции, если она есть + for extra in self.raw.get("vlan_details", []): + vlan_id = extra.get("id") + if vlan_id in vlans: + vlans[vlan_id].update(extra) + return list(vlans.values()) +``` + +--- + +### system() + +Переопределяйте, если структура системной секции отличается от ожидаемой контрактом, или нужно вычислить поля: + +**Пример: собрать серийный номер из нескольких полей** + +```python +def system(self) -> dict: + raw_system = self.raw.get("system", {}) + # Устройство возвращает серийный номер в двух частях + part1 = raw_system.get("serial_part1", "") + part2 = raw_system.get("serial_part2", "") + raw_system["serial_number"] = f"{part1}-{part2}" + return raw_system +``` + +**Пример: нормализация строки версии** + +```python +def system(self) -> dict: + raw_system = self.raw.get("system", {}) + # Убираем лишнее из "7.12.1 (stable)" → "7.12.1" + version = raw_system.get("version", "") + raw_system["version"] = version.split()[0] if version else version + return raw_system +``` + +--- + +## Полный пример: новое устройство + +Допустим, нужно добавить поддержку Cisco IOS, где: +- IP-адрес и маска разделены пробелом в конфигурации (`ip address 10.0.0.1 255.255.255.0`). +- Описание интерфейса может содержать несколько слов. +- Серийный номер разделён дефисом в двух строках. + +**Шаблон** (`oxi/interfaces/models/templates/cisco.ttp`): + +```xml + +default_system = { + "model": "", + "serial_number": "", + "version": "" +} + + + +Cisco IOS Software, {{ ignore }} Version {{ version }},{{ ignore('.*') }} +Model Number : {{ model }} +System serial number : {{ serial_number }} + + + +interface {{ interface | _start_ }} + description {{ description | ORPHRASE }} + ip address {{ ip_address }} {{ netmask }} + + + +vlan {{ id | _start_ }} + name {{ description }} + +``` + +**Класс устройства** (`oxi/interfaces/models/cisco.py`): + +```python +from ipaddress import ip_interface +from oxi.interfaces import register_parser +from oxi.interfaces.base import BaseDevice + + +@register_parser(["ios", "cisco", "cisco_ios"]) +class CiscoIOS(BaseDevice): + template = "cisco.ttp" + + def interfaces(self) -> list[dict]: + result = [] + for item in self.raw.get("interfaces", []): + # Конвертируем маску подсети в длину префикса + if item.get("ip_address") and item.get("netmask"): + iface = ip_interface(f"{item['ip_address']}/{item['netmask']}") + item["mask"] = iface.network.prefixlen + item.pop("netmask", None) + # Фильтруем интерфейсы управления + if item.get("interface", "").startswith("Mgmt"): + continue + result.append(item) + return result + + def system(self) -> dict: + raw_system = self.raw.get("system", {}) + # Нормализуем версию: "15.2(4)M3" → оставляем как есть + # Убираем лишние пробелы в модели + if raw_system.get("model"): + raw_system["model"] = raw_system["model"].strip() + return raw_system +``` + +--- + +## Контракт: ожидаемые структуры + +Методы должны возвращать данные в следующем формате. Контракт жёстко проверяется Pydantic. + +### `system()` → `dict` + +```python +{ + "model": "RB951Ui-2nD", # str, обязательно + "serial_number": "B88C0B31117B", # str, обязательно + "version": "7.12.1", # str, обязательно +} +``` + +### `interfaces()` → `list[dict]` + +```python +[ + { + "interface": "ether1", # str, обязательно (alias для поля name) + "ip_address": "192.168.1.1", # str | None + "mask": 24, # int | None (длина префикса) + "description": "LAN", # str | None + }, + ... +] +``` + +### `vlans()` → `list[dict]` + +```python +[ + { + "id": 10, # int, обязательно (alias для поля vlan_id) + "description": "MGMT", # str | None (alias для поля name) + }, + ... +] +``` + +> Если имя ключа в словаре совпадает с **alias** поля Pydantic-модели, а не с именем атрибута — используйте alias. Модели сконфигурированы с `populate_by_name=True`, поэтому принимаются оба варианта. diff --git a/docs/templates.md b/docs/templates.md new file mode 100644 index 0000000..86cf78d --- /dev/null +++ b/docs/templates.md @@ -0,0 +1,306 @@ +# Написание TTP-шаблонов + +oxipy использует библиотеку [TTP (Template Text Parser)](https://ttp.readthedocs.io/) для парсинга конфигураций сетевых устройств в структурированные данные. Шаблоны хранятся в директории `oxi/interfaces/models/templates/`. + +## Содержание + +- [Структура шаблона](#структура-шаблона) +- [Обязательные группы](#обязательные-группы) +- [Секция system](#секция-system) +- [Секция interfaces](#секция-interfaces) +- [Секция vlans](#секция-vlans) +- [TTP: основные возможности](#ttp-основные-возможности) +- [Переменные по умолчанию](#переменные-по-умолчанию) +- [Практические примеры](#практические-примеры) +- [Валидация шаблона](#валидация-шаблона) + +--- + +## Структура шаблона + +Каждый шаблон — это `.ttp`-файл, состоящий из следующих блоков: + +```xml + + Описание шаблона (опционально) + + + + + + + + + + + + + + + + + +``` + +Файл-заготовка находится в `oxi/interfaces/models/templates/_template.ttp`. + +--- + +## Обязательные группы + +Фреймворк требует наличия в шаблоне **двух обязательных групп**: + +| Группа | Обязательна | Описание | +|--------------|-------------|-------------------------------| +| `system` | Да | Системная информация | +| `interfaces` | Да | Конфигурация интерфейсов | +| `vlans` | Нет | Конфигурация VLAN | + +Если обязательная группа отсутствует в шаблоне или TTP не вернул её данные, будет выброшено `ValueError`. + +--- + +## Секция system + +Должна возвращать словарь со следующими полями: + +| Поле | Тип | Обязательное | Описание | +|-----------------|------|--------------|---------------------| +| `model` | str | Да | Модель устройства | +| `serial_number` | str | Да | Серийный номер | +| `version` | str | Да | Версия прошивки | + +**Пример (MikroTik):** + +Конфигурация: +``` +# version: 7.12.1 (stable) +# model = RB951Ui-2nD +# serial number = B88C0B31117B +``` + +Шаблон: +``` + +# version: {{ version }}{{ ignore('.*') }} +# model = {{ model }} +# serial number = {{ serial_number }} + +``` + +**Пример (Keenetic):** + +Конфигурация: +``` +! release: 4.1.7.1-1 +! model: Keenetic Extra +! hw_version: F02B4E7A1C90 +``` + +Шаблон: +``` + +! release: {{ version }} +! model: {{ model | ORPHRASE }} +! hw_version: {{ serial_number }} + +``` + +--- + +## Секция interfaces + +Должна возвращать список словарей. Каждый словарь описывает один интерфейс. + +Поля, которые ожидает контракт `Interfaces`: + +| Поле | TTP-имя / alias | Тип | Обязательное | +|---------------|-----------------|------------------|--------------| +| `name` | `interface` | str | Да | +| `ip_address` | `ip_address` | IPv4Address | Нет | +| `mask` | `mask` | int (prefix len) | Нет | +| `description` | `description` | str | Нет | + +> **Важно:** поле `name` в Pydantic-модели имеет алиас `interface`, поэтому в шаблоне переменную нужно называть именно `interface` **или** переопределить метод `interfaces()` в классе модели (см. [Расширение моделей](extending-models.md)). + +**Пример (MikroTik):** + +Конфигурация: +``` +/ip address +add address=192.168.1.1/24 interface=ether1 network=192.168.1.0 +add address=10.0.0.1/30 comment="WAN link" interface=ether2 network=10.0.0.0 +``` + +Шаблон: +``` + +/ip address +add address={{ ip_address | _start_ }}/{{ mask }} interface={{ interface }} network={{ network }} +add address={{ ip_address | _start_ }}/{{ mask }} comment={{ description | ORPHRASE | strip('"') }} interface={{ interface }} network={{ network }} + +``` + +**Пример (Keenetic):** + +Конфигурация: +``` +interface GigabitEthernet0/0 + description "WAN" + ip address 10.0.0.2 255.255.255.252 +interface GigabitEthernet0/1 + ip address 192.168.1.1 255.255.255.0 +``` + +Шаблон: +``` + +interface {{ name | _start_ | exclude("Vlan") }} + description {{ description | ORPHRASE }} + ip address {{ ip_address }} {{ netmask }} + +``` + +Здесь переменная называется `name`, а не `interface` — это покрывается переопределением метода `interfaces()` в классе `Keenetic`. + +--- + +## Секция vlans + +Необязательная группа. Если объявлена в шаблоне, фреймворк ожидает её наличия в результате TTP. + +Поля контракта `Vlans`: + +| Поле | TTP-имя / alias | Тип | Обязательное | +|-----------|-----------------|------|--------------| +| `vlan_id` | `id` | int | Да | +| `name` | `description` | str | Нет | + +> `vlan_id` имеет алиас `id`, поэтому в шаблоне переменная должна называться `id` либо переименовываться в методе `vlans()`. + +**Пример (Keenetic):** + +Конфигурация: +``` +interface Bridge0/Vlan10 + description "MGMT" +interface Bridge0/Vlan20 + description "SERVERS" +``` + +Шаблон: +``` + +interface {{ ignore }}/Vlan{{ id }} + description {{ description | ORPHRASE | strip('"') }} + +``` + +--- + +## TTP: основные возможности + +### Маркеры строк + +| Маркер | Описание | +|-------------|---------------------------------------------------------------| +| `_start_` | Строка с этой переменной считается началом нового совпадения | +| `_end_` | Строка с этой переменной завершает совпадение группы | + +``` +add address={{ ip_address | _start_ }}/{{ mask }} interface={{ name }} +``` + +### Модификаторы переменных + +| Модификатор | Описание | +|------------------------|-----------------------------------------------------------| +| `ORPHRASE` | Захватывает одно слово или фразу (до конца строки) | +| `exclude("pattern")` | Пропускает строку, если захваченное значение содержит паттерн | +| `strip('"')` | Удаляет символ из начала и конца захваченного значения | +| `replace("old","new")` | Заменяет подстроку в захваченном значении | +| `re("pattern")` | Принимает значение, только если оно соответствует regex | +| `ignore` | Захватывает, но игнорирует значение (не включает в результат) | +| `ignore('.*')` | Игнорирует всё до конца строки | + +### Комментарии в шаблоне + +Строки, начинающиеся с `##`, — это комментарии TTP и не влияют на парсинг: + +``` +## disabled no comment +add address={{ ip_address | _start_ }}/{{ mask }} interface={{ name }} +``` + +--- + +## Переменные по умолчанию + +Блок `` позволяет задавать значения по умолчанию для группы через атрибут `default`: + +```xml + +default_system = { + "model": "", + "serial_number": "" +} + + + +# version: {{ version }} +# model = {{ model }} +# serial number = {{ serial_number }} + +``` + +Если шаблон не нашёл совпадений для группы, будет возвращён словарь из `default_system`. + +--- + +## Практические примеры + +### Полный шаблон для нового устройства (пример: Cisco IOS) + +```xml + + Шаблон для парсинга Cisco IOS running-config + + + +default_system = { + "model": "", + "serial_number": "", + "version": "" +} + + + +Cisco IOS Software, {{ ignore }} Version {{ version }},{{ ignore('.*') }} +Model Number : {{ model }} +System serial number : {{ serial_number }} + + + +interface {{ interface | _start_ }} + description {{ description | ORPHRASE }} + ip address {{ ip_address }} {{ netmask }} + shutdown {{ shutdown | set("True") }} + + + +vlan {{ id | _start_ }} + name {{ description }} + +``` + +--- + +## Валидация шаблона + +Фреймворк автоматически выполняет два уровня проверки: + +1. **Валидация структуры шаблона** — при создании объекта устройства парсятся XML-теги `` и проверяется наличие обязательных секций (`system`, `interfaces`). + +2. **Валидация результата парсинга** — после запуска TTP проверяется, что обязательные группы действительно присутствуют в результате (т.е. конфигурация содержала соответствующие строки). + +При нарушении любого условия выбрасывается `ValueError` с подробным описанием проблемы. diff --git a/oxi/interfaces/models/templates/_template.ttp b/oxi/interfaces/models/templates/_template.ttp index 67cb94d..3e6593d 100644 --- a/oxi/interfaces/models/templates/_template.ttp +++ b/oxi/interfaces/models/templates/_template.ttp @@ -1,9 +1,51 @@ +Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте +в .ttp и заполните группы под формат конфигурации вашего устройства. + +Обязательные группы: system, interfaces. +Опциональная группа: vlans — добавляйте только если устройство поддерживает VLAN. + +--- Группа system --- +Должна возвращать одиночный словарь с полями: + model (str) — модель устройства + serial_number (str) — серийный номер + version (str) — версия прошивки + +--- Группа interfaces --- +Должна возвращать список словарей. Каждый элемент: + interface (str) — имя интерфейса (alias поля name) + ip_address (str|None) — IPv4-адрес + mask (int|None) — длина префикса (напр. 24) + description (str|None) — описание интерфейса + + Если устройство возвращает маску в виде 255.255.255.0, конвертируйте + её в prefix length в методе interfaces() класса устройства. + +--- Группа vlans --- +Должна возвращать список словарей. Каждый элемент: + id (int) — номер VLAN (alias поля vlan_id) + description (str|None) — название VLAN (alias поля name) + +--- Полезные модификаторы TTP --- + {{ field | ORPHRASE }} — одно слово или фраза до конца строки + {{ field | _start_ }} — начало новой записи группы + {{ field | strip('"') }} — убрать кавычки + {{ field | replace("yes","True") }} — замена подстроки + {{ field | exclude("pattern") }} — пропустить строку при совпадении + {{ ignore }} — захватить и выбросить значение + {{ ignore('.*') }} — выбросить всё до конца строки + +Подробнее: docs/templates.md +default_system = { + "model": "", + "serial_number": "", + "version": "" +} - + ... -- 2.53.0 From a016db644d46c6d6a164bd707d25f8104ecbae06 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Wed, 25 Feb 2026 13:34:19 +0300 Subject: [PATCH 20/80] Update README to reflect API name change and enhance installation instructions - Changed references from "Oxi API" to "Oxidized API" for accuracy. - Added detailed installation instructions for using the Gitea Package Registry, including authentication steps and editable install options. - Improved clarity in the quick start section with formatted output examples. --- README.md | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 70 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 446ec46..74c5bec 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # oxipy -Python-клиент для работы с Oxi API — системой мониторинга и управления сетевыми устройствами. Предоставляет удобный интерфейс для получения конфигураций узлов, их парсинга и работы с результатами через типизированные Pydantic-модели. +Python-клиент для работы с Oxidized API — системой управления конфигурацией сетевых устройств. Предоставляет удобный интерфейс для получения конфигураций узлов, их парсинга и работы с результатами. ## Содержание @@ -18,11 +18,59 @@ Python-клиент для работы с Oxi API — системой мони ## Установка +> Пакет распространяется через Gitea Package Registry и исходники репозитория. +> В PyPI пакет не публикуется. + +**Требования:** Python 3.13+ + +### Из Gitea Package Registry + +Добавьте registry в конфигурацию pip и установите пакет: + +```bash +pip install oxipy \ + --index-url https://gitea.imbastark.ru/api/packages/Netbox/pypi/simple/ +``` + +Или пропишите registry постоянно в `pip.conf` / `pip.ini`, чтобы не указывать `--index-url` каждый раз: + +```ini +# ~/.config/pip/pip.conf (Linux/macOS) +# %APPDATA%\pip\pip.ini (Windows) + +[global] +extra-index-url = https://gitea.imbastark.ru/api/packages/Netbox/pypi/simple/ +``` + +После этого достаточно: + ```bash pip install oxipy ``` -Или из исходников: +Если registry требует аутентификации, передайте токен: + +```bash +pip install oxipy \ + --index-url https://__token__:@gitea.imbastark.ru/api/packages/Netbox/pypi/simple/ +``` + +### Из репозитория Gitea + +Установка напрямую через pip без клонирования: + +```bash +pip install git+https://gitea.imbastark.ru/Netbox/oxipy.git +``` + +Конкретный тег или ветка: + +```bash +pip install git+https://gitea.imbastark.ru/Netbox/oxipy.git@v0.1.0 +pip install git+https://gitea.imbastark.ru/Netbox/oxipy.git@dev +``` + +Для разработки (editable install): ```bash git clone https://gitea.imbastark.ru/Netbox/oxipy @@ -30,8 +78,6 @@ cd oxipy pip install -e . ``` -**Требования:** Python 3.13+ - --- ## Быстрый старт @@ -43,13 +89,30 @@ api = OxiAPI(url="https://oxi.example.com", verify=False) node = api.node("Router_HOME") -print(node.ip) # '192.168.1.1' -print(node.model) # 'keenetic' -print(node.full_name) # 'Router_HOME' +print(node.ip) +print(node.model) +print(node.full_name) + +>>> 192.168.1.1 +>>> keenetic +>>> router/HQ print(node.config.system.model) print(node.config.interfaces.json()) print(node.config.vlans.json()) + +>>> Sprinter (KN-3710) +>>> +[ + {"name":"Bridge1","ip_address":"192.168.1.1","mask":24,"description":"\"Guest network\""}, + {"name":"Bridge0","ip_address":"172.16.1.1","mask":24,"description":"\"Home network\""} +] +>>> +[ + {"vlan_id":1,"name":"Home VLAN"}, + {"vlan_id":2,"name":"Подключение Ethernet"}, + {"vlan_id":3,"name":"Home network"} +] ``` --- -- 2.53.0 From c9d171e2fae1dc0bdcdceced6387fb356e244d0f Mon Sep 17 00:00:00 2001 From: IluaAir Date: Wed, 25 Feb 2026 13:47:06 +0300 Subject: [PATCH 21/80] Update Keenetic TTP template - Modified the `description` field in the Keenetic TTP template to strip surrounding quotes. --- oxi/interfaces/models/templates/keenetic.ttp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oxi/interfaces/models/templates/keenetic.ttp b/oxi/interfaces/models/templates/keenetic.ttp index 6f9dfc7..85eb929 100644 --- a/oxi/interfaces/models/templates/keenetic.ttp +++ b/oxi/interfaces/models/templates/keenetic.ttp @@ -17,7 +17,7 @@ default_interfaces = {} interface {{ name | _start_ | exclude("Vlan") }} rename {{ rename }} - description {{ description | ORPHRASE }} + description {{ description | ORPHRASE | strip('"') }} ip address {{ ip_address }} {{ netmask }} {{ shutdown | re("up") | replace("up","False") | strip('"') }} {{ shutdown | re("down") | replace("down","True") | strip('"') }} -- 2.53.0 From 8a126d88060ab8cfb65e535cbd5e13af3417100c Mon Sep 17 00:00:00 2001 From: IluaAir Date: Wed, 25 Feb 2026 14:16:00 +0300 Subject: [PATCH 22/80] Update pyproject.toml - Changed the `requires-python` field from ">=3.13" to ">=3.11" . --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 46d9832..cba5323 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "oxipy" version = "0.1.0" description = "Oxi API client" readme = "README.md" -requires-python = ">=3.13" +requires-python = ">=3.11" dependencies = [ "pydantic>=2.12.5", "requests>=2.32.5", -- 2.53.0 From 77f539254c7c661c72c3a5dcfc9ee4c2e49f0937 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Wed, 25 Feb 2026 17:00:36 +0300 Subject: [PATCH 23/80] Add Qtech model and template; clean up Mikrotik model - Introduced a new `Qtech` model with a corresponding TTP template for parsing QTECH devices. - Removed commented-out methods in the `Mikrotik` model to improve code clarity and maintainability. --- oxi/interfaces/models/mikrotik.py | 10 ---------- oxi/interfaces/models/qtech.py | 7 +++++++ oxi/interfaces/models/templates/qtech.ttp | 24 +++++++++++++++++++++++ 3 files changed, 31 insertions(+), 10 deletions(-) create mode 100644 oxi/interfaces/models/qtech.py create mode 100644 oxi/interfaces/models/templates/qtech.ttp diff --git a/oxi/interfaces/models/mikrotik.py b/oxi/interfaces/models/mikrotik.py index d912718..25a6eae 100644 --- a/oxi/interfaces/models/mikrotik.py +++ b/oxi/interfaces/models/mikrotik.py @@ -7,16 +7,6 @@ from oxi.interfaces.base import BaseDevice class Mikrotik(BaseDevice): template = "mikrotik.ttp" - # def system(self) -> "System": - # systems = self._raw.get("system") - # return System(**systems) - - # def interfaces(self) -> "Interfaces": - # return [Interfaces(**item) for item in self._raw.get("interfaces")] - - # def vlans(self) -> list["Vlans"]: - # return [Vlans(**item) for item in self._raw.get("vlans")] - if __name__ == "__main__": print(os.path.abspath(os.curdir)) diff --git a/oxi/interfaces/models/qtech.py b/oxi/interfaces/models/qtech.py new file mode 100644 index 0000000..9f7eec5 --- /dev/null +++ b/oxi/interfaces/models/qtech.py @@ -0,0 +1,7 @@ +from oxi.interfaces import register_parser +from oxi.interfaces.base import BaseDevice + + +@register_parser(["QTECH"]) +class Qtech(BaseDevice): + template = "qtech.ttp" diff --git a/oxi/interfaces/models/templates/qtech.ttp b/oxi/interfaces/models/templates/qtech.ttp new file mode 100644 index 0000000..83eaf62 --- /dev/null +++ b/oxi/interfaces/models/templates/qtech.ttp @@ -0,0 +1,24 @@ + + + +default_system = { + "model": "", + "serial_number": "" +} + + + +! {{ model }} Series Software, Version {{ bootrom_ver }} Build {{ version }}, {{ ignore }} +! Serial num:{{ serial_number }}, {{ ignore }} + + + +interface {{ interface }} + description {{ description | ORPHRASE }} + ip address {{ ip_address }} {{ netmask }} + + + +vlan {{ vlan_id }} + name {{ name }} + -- 2.53.0 From 2c3f5ce3547b236cfa8b2ffc68a9c129861fa38e Mon Sep 17 00:00:00 2001 From: IluaAir Date: Wed, 25 Feb 2026 19:11:06 +0300 Subject: [PATCH 24/80] Add interfaces and vlans methods to Qtech model; update TTP template - Implemented the `interfaces` method to process IP addresses and netmasks, adding a calculated mask to the output. - Added the `vlans` method to handle VLAN data, supporting both individual and grouped VLAN IDs. - Updated the QTECH TTP template to include a new version field and improved formatting for VLAN definitions. --- oxi/interfaces/models/qtech.py | 42 +++++++++++++++++++++++ oxi/interfaces/models/templates/qtech.ttp | 19 +++++----- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/oxi/interfaces/models/qtech.py b/oxi/interfaces/models/qtech.py index 9f7eec5..793713f 100644 --- a/oxi/interfaces/models/qtech.py +++ b/oxi/interfaces/models/qtech.py @@ -1,3 +1,5 @@ +from ipaddress import ip_interface +import os from oxi.interfaces import register_parser from oxi.interfaces.base import BaseDevice @@ -5,3 +7,43 @@ from oxi.interfaces.base import BaseDevice @register_parser(["QTECH"]) class Qtech(BaseDevice): template = "qtech.ttp" + + def interfaces(self) -> list[dict]: + interfaces_ttp = self.raw["interfaces"] + for item in interfaces_ttp: + if item.get("ip_address") and item.get("netmask"): + ipaddress = ip_interface( + f"{item.get('ip_address')}/{item.get('netmask')}" + ) + item["mask"] = ipaddress.network.prefixlen + item.pop("netmask", "Key not found") + return interfaces_ttp + + 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) diff --git a/oxi/interfaces/models/templates/qtech.ttp b/oxi/interfaces/models/templates/qtech.ttp index 83eaf62..49c7d91 100644 --- a/oxi/interfaces/models/templates/qtech.ttp +++ b/oxi/interfaces/models/templates/qtech.ttp @@ -3,22 +3,25 @@ default_system = { "model": "", - "serial_number": "" + "serial_number": "", + "version": "" } - -! {{ model }} Series Software, Version {{ bootrom_ver }} Build {{ version }}, {{ ignore }} -! Serial num:{{ serial_number }}, {{ ignore }} + +! {{ model | ORPHRASE }} Series Software, Version {{ ignore }} Build {{ version | strip(",") }}{{ ignore('.*') }} +! Serial num:{{ serial_number | strip(",") }}{{ ignore('.*') }} interface {{ interface }} - description {{ description | ORPHRASE }} - ip address {{ ip_address }} {{ netmask }} + description {{ description | ORPHRASE }} + ip address {{ ip_address }} {{ netmask }} -vlan {{ vlan_id }} - name {{ name }} +vlan {{ vlan_ids | contains(",", "-") | unrange("-", ",") | _start_ }} +,{{ vlan_tail | unrange("-", ",") }} +vlan {{ vlan_id | _start_ }} + name {{ name | ORPHRASE }} -- 2.53.0 From b6630a4d301e9cf107e2a08bd5836a90634c4740 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Wed, 25 Feb 2026 13:46:04 +0300 Subject: [PATCH 25/80] Update README for clarity and consistency - Simplified the description of the OxiAPI entry point. - Improved formatting of parameter and property tables for better readability. - Updated example outputs to reflect changes in node names and configurations. --- README.md | 110 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 65 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 74c5bec..4dabb57 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Python-клиент для работы с Oxidized API — системой у > Пакет распространяется через Gitea Package Registry и исходники репозитория. > В PyPI пакет не публикуется. -**Требования:** Python 3.13+ +**Требования:** Python 3.11+ ### Из Gitea Package Registry @@ -121,7 +121,7 @@ print(node.config.vlans.json()) ### OxiAPI -Точка входа в библиотеку. Управляет HTTP-сессией и предоставляет доступ к узлам. +Точка входа. Управляет HTTP-сессией и предоставляет доступ к узлам. ```python OxiAPI( @@ -132,12 +132,14 @@ OxiAPI( ) ``` -| Параметр | Тип | Описание | -|------------|--------|----------------------------------------------------------------| -| `url` | `str` | Базовый URL Oxi API, например `https://oxi.example.com` | -| `username` | `str` | Имя пользователя для базовой аутентификации (опционально) | -| `password` | `str` | Пароль для базовой аутентификации (опционально) | -| `verify` | `bool` | Проверять SSL-сертификат. `False` — отключить проверку | + +| Параметр | Тип | Описание | +| ---------- | ------ | --------------------------------------------------------- | +| `url` | `str` | Базовый URL Oxi API, например `https://oxi.example.com` | +| `username` | `str` | Имя пользователя для базовой аутентификации (опционально) | +| `password` | `str` | Пароль для базовой аутентификации (опционально) | +| `verify` | `bool` | Проверять SSL-сертификат. `True` по умолчанию | + **Пример:** @@ -154,16 +156,18 @@ api = OxiAPI( # Использование как контекстного менеджера (автоматически закрывает сессию) with OxiAPI(url="https://oxi.example.com") as api: - node = api.node("Router_HOME") + node = api.node("HQ") print(node.ip) + +>>> 192.168.1.1 ``` #### `api.node(name)` -Возвращает [`NodeView`](#nodeview) для указанного узла. +Возвращает `[NodeView](#nodeview)` для указанного узла. ```python -node = api.node("Router_HOME") +node = api.node("HQ") ``` --- @@ -172,25 +176,28 @@ node = api.node("Router_HOME") Представление узла сети. Содержит метаданные и ленивый доступ к конфигурации. -| Свойство | Тип | Описание | -|-------------|--------------|-----------------------------------------------| -| `ip` | `str` | IP-адрес узла | -| `full_name` | `str` | Полное имя узла в Oxi | -| `group` | `str` | Группа, к которой принадлежит узел | -| `model` | `str` | Модель устройства (используется для парсинга) | + +| Свойство | Тип | Описание | +| ----------- | ------------ | ---------------------------------------------------- | +| `ip` | `str` | IP-адрес узла | +| `full_name` | `str` | Полное имя узла в Oxi | +| `group` | `str` | Группа, к которой принадлежит узел | +| `model` | `str` | Модель устройства (используется для парсинга) | | `config` | `NodeConfig` | Конфигурация узла (загружается при первом обращении) | + **Пример:** ```python -node = api.node("Router_HOME") +node = api.node("HQ") -print(node.ip) # '10.0.0.1' -print(node.group) # 'branch-office' -print(node.model) # 'keenetic' +print(node.ip) +print(node.group) +print(node.model) -# Конфигурация загружается один раз (cached_property) -cfg = node.config +>>> 192.168.1.1 +>>> branch-office +>>> keenetic ``` --- @@ -199,14 +206,16 @@ cfg = node.config Загружает и парсит конфигурацию устройства. Использует TTP-шаблоны, соответствующие модели устройства. -Доступ к секциям конфигурации осуществляется через свойства, возвращающие [`ModelView`](#modelview). +Доступ к секциям конфигурации осуществляется через свойства, возвращающие `[ModelView](#modelview)`. + + +| Свойство | Возвращает | Описание | +| ------------ | ----------------------------- | ---------------------------------- | +| `system` | `ModelView[System]` | Системная информация об устройстве | +| `interfaces` | `ModelView[list[Interfaces]]` | Список интерфейсов | +| `vlans` | `ModelView[list[Vlans]]` | Список VLAN (если есть) | +| `text` | `str` | Сырой текст конфигурации | -| Свойство | Возвращает | Описание | -|--------------|-------------------------|-----------------------------------| -| `system` | `ModelView[System]` | Системная информация об устройстве | -| `interfaces` | `ModelView[list[Interfaces]]` | Список интерфейсов | -| `vlans` | `ModelView[list[Vlans]]` | Список VLAN (если есть) | -| `text` | `str` | Сырой текст конфигурации | **Пример:** @@ -214,9 +223,13 @@ cfg = node.config cfg = node.config # Системная информация -print(cfg.system.model) # 'RB951Ui-2nD' -print(cfg.system.serial_number) # 'B88C0B31117B' -print(cfg.system.version) # '7.12.1' +print(cfg.system.model) +print(cfg.system.serial_number) +print(cfg.system.version) + +>>> Mikrotik RB951Ui-2nD +>>> B88C0B31117B +>>> 7.16.1 # Итерация по интерфейсам for iface in cfg.interfaces: @@ -244,13 +257,15 @@ print(cfg.text) Обёртка над Pydantic-моделью или списком моделей. Обеспечивает сериализацию, итерацию и прозрачный доступ к атрибутам. -| Метод / свойство | Применимо к | Описание | -|------------------|----------------|---------------------------------------------------------------| -| `.json()` | оба варианта | Возвращает JSON-строку (с `by_alias=True`) | -| `.` | оба варианта | Проксирует обращение к атрибутам вложенной модели | -| `iter(view)` | список | Итерация по элементам списка моделей | -| `len(view)` | список | Количество элементов в списке | -| `view[i]` | список | Получение элемента по индексу или срез | + +| Метод / свойство | Применимо к | Описание | +| ---------------- | ------------ | ------------------------------------------------- | +| `.json()` | оба варианта | Возвращает JSON-строку (с `by_alias=True`) | +| `.` | оба варианта | Проксирует обращение к атрибутам вложенной модели | +| `iter(view)` | список | Итерация по элементам списка моделей | +| `len(view)` | список | Количество элементов в списке | +| `view[i]` | список | Получение элемента по индексу или срез | + > `__iter__`, `__len__` и `__getitem__` доступны только для `interfaces` и `vlans` (они оборачивают список). Вызов этих методов на `system` (одиночная модель) вызовет `TypeError`. @@ -260,10 +275,12 @@ print(cfg.text) # Одиночная модель — system view = node.config.system print(view.json()) -# '{"model":"RB951Ui-2nD","serial_number":"B88C0B31117B","version":"7.12.1"}' +>>> '{"model":"RB951Ui-2nD","serial_number":"B88C0B31117B","version":"7.12.1"}' print(view.model) # 'RB951Ui-2nD' print(view.serial_number) # 'B88C0B31117B' +>>> RB951Ui-2nD +>>> B88C0B31117B # Список — interfaces interfaces = node.config.interfaces @@ -286,10 +303,12 @@ print(interfaces.json()) ## Поддерживаемые устройства -| Устройство | Ключи реестра | -|-------------|----------------------------------------| -| Keenetic | `ndms`, `keenetic`, `keeneticos` | -| MikroTik | `routeros`, `ros`, `mikrotik` | + +| Устройство | Ключи реестра | +| ---------- | -------------------------------- | +| Keenetic | `ndms`, `keenetic`, `keeneticos` | +| MikroTik | `routeros`, `ros`, `mikrotik` | + Ключи реестра — это значения поля `model`, возвращаемого API для узла. Регистр не учитывается. @@ -301,3 +320,4 @@ print(interfaces.json()) - [Написание TTP-шаблонов](docs/templates.md) - [Расширение и переопределение моделей устройств](docs/extending-models.md) + -- 2.53.0 From 197550558de864986e6720145129d8ae3781c326 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Sat, 28 Feb 2026 10:39:03 +0300 Subject: [PATCH 26/80] Add Huawei model and TTP template - Introduced a new `Huawei` model - Created a corresponding TTP template for Huawei devices. - Updated the base template to correct HTML entity encoding for vendor placeholders. --- oxi/interfaces/models/huawei.py | 18 ++++++ oxi/interfaces/models/templates/_template.ttp | 2 +- oxi/interfaces/models/templates/huawei.ttp | 58 +++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 oxi/interfaces/models/huawei.py create mode 100644 oxi/interfaces/models/templates/huawei.ttp diff --git a/oxi/interfaces/models/huawei.py b/oxi/interfaces/models/huawei.py new file mode 100644 index 0000000..4d53dbb --- /dev/null +++ b/oxi/interfaces/models/huawei.py @@ -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()) diff --git a/oxi/interfaces/models/templates/_template.ttp b/oxi/interfaces/models/templates/_template.ttp index 3e6593d..2362b98 100644 --- a/oxi/interfaces/models/templates/_template.ttp +++ b/oxi/interfaces/models/templates/_template.ttp @@ -1,6 +1,6 @@ Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте -в .ttp и заполните группы под формат конфигурации вашего устройства. +в <vendor>.ttp и заполните группы под формат конфигурации вашего устройства. Обязательные группы: system, interfaces. Опциональная группа: vlans — добавляйте только если устройство поддерживает VLAN. diff --git a/oxi/interfaces/models/templates/huawei.ttp b/oxi/interfaces/models/templates/huawei.ttp new file mode 100644 index 0000000..22f6283 --- /dev/null +++ b/oxi/interfaces/models/templates/huawei.ttp @@ -0,0 +1,58 @@ + +Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте +в <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 + + +default_system = { + "model": "", + "serial_number": "", + "version": "" +} + + + +# VRP (R) software, Version {{ version }} ({{ model }} {{ serial_number }}) + + + +interface {{ interface }} + description {{ description }} + ip address {{ ip_address }} {{ mask | to_cidr }} + + +vlan batch {{ vlan_ids | ORPHRASE | unrange(" to ", " ") | split(" ")}} + -- 2.53.0 From c382627f9f5249ce19c2c94f73cfe0466bd4f02a Mon Sep 17 00:00:00 2001 From: IluaAir Date: Sat, 28 Feb 2026 10:41:49 +0300 Subject: [PATCH 27/80] Update README - Added `Qtech` and `Huawei` entries to the interfaces table in the README. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 4dabb57..2ac7955 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,8 @@ print(interfaces.json()) | ---------- | -------------------------------- | | Keenetic | `ndms`, `keenetic`, `keeneticos` | | MikroTik | `routeros`, `ros`, `mikrotik` | +| Qtech | `qtech` | +| Huawei | `huawei`, `vrp` | Ключи реестра — это значения поля `model`, возвращаемого API для узла. Регистр не учитывается. -- 2.53.0 From bf6dd0c189602ab51b7e55c238bc5ee8ccbdbf67 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Sat, 28 Feb 2026 10:44:20 +0300 Subject: [PATCH 28/80] Refactor Qtech model and update TTP template - Removed the `interfaces` method from the `Qtech` model, simplifying the interface processing. - Updated the TTP template to use `mask` instead of `netmask` for IP address configuration, enhancing clarity in CIDR notation. --- oxi/interfaces/models/qtech.py | 13 +------------ oxi/interfaces/models/templates/qtech.ttp | 2 +- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/oxi/interfaces/models/qtech.py b/oxi/interfaces/models/qtech.py index 793713f..e2e9749 100644 --- a/oxi/interfaces/models/qtech.py +++ b/oxi/interfaces/models/qtech.py @@ -8,17 +8,6 @@ from oxi.interfaces.base import BaseDevice class Qtech(BaseDevice): template = "qtech.ttp" - def interfaces(self) -> list[dict]: - interfaces_ttp = self.raw["interfaces"] - for item in interfaces_ttp: - if item.get("ip_address") and item.get("netmask"): - ipaddress = ip_interface( - f"{item.get('ip_address')}/{item.get('netmask')}" - ) - item["mask"] = ipaddress.network.prefixlen - item.pop("netmask", "Key not found") - return interfaces_ttp - def vlans(self) -> list[dict]: vlans_ttp = self.raw["vlans"] vlans = [] @@ -46,4 +35,4 @@ if __name__ == "__main__": data = file.read() qtech = Qtech(data) qt = qtech.parse() - # print(qt) + print(qt) diff --git a/oxi/interfaces/models/templates/qtech.ttp b/oxi/interfaces/models/templates/qtech.ttp index 49c7d91..6d4d86e 100644 --- a/oxi/interfaces/models/templates/qtech.ttp +++ b/oxi/interfaces/models/templates/qtech.ttp @@ -16,7 +16,7 @@ default_system = { interface {{ interface }} description {{ description | ORPHRASE }} - ip address {{ ip_address }} {{ netmask }} + ip address {{ ip_address }} {{ mask | to_cidr }} -- 2.53.0 From 2a027ed73643d41d679638bb517e692e571d83d3 Mon Sep 17 00:00:00 2001 From: "i.shramko" Date: Tue, 3 Mar 2026 09:31:54 +0300 Subject: [PATCH 29/80] Update uv.lock for windows support --- uv.lock | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 84f8506..4ecb60b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.13" +requires-python = ">=3.11" [[package]] name = "annotated-types" @@ -26,6 +26,38 @@ version = "3.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/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/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" }, @@ -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" } 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/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" }, @@ -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/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/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]] -- 2.53.0 From 4ed34227d9b728292ba5c5d702c28086c4cc3969 Mon Sep 17 00:00:00 2001 From: "i.shramko" Date: Tue, 3 Mar 2026 09:52:28 +0300 Subject: [PATCH 30/80] Update __init__.py: - Add BaseDevice to __all__ - Add type str for register_parser --- oxi/interfaces/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/oxi/interfaces/__init__.py b/oxi/interfaces/__init__.py index 4edb8b3..c5f11ea 100644 --- a/oxi/interfaces/__init__.py +++ b/oxi/interfaces/__init__.py @@ -6,7 +6,7 @@ device_registry = {} def register_parser( - name: list[str], + name: list[str] | str, ) -> Callable[[Type[BaseDevice]], Type[BaseDevice]]: def wrapper(cls): name_list = [] @@ -23,4 +23,4 @@ def register_parser( from . import models # noqa: E402, F401 -__all__ = ["register_parser", "device_registry"] +__all__ = ["register_parser", "device_registry", "BaseDevice"] -- 2.53.0 From 1e84452eb5467a7e843830837352c30f2f29c91c Mon Sep 17 00:00:00 2001 From: "i.shramko" Date: Wed, 4 Mar 2026 15:23:55 +0300 Subject: [PATCH 31/80] Add H3C parse: - Add h3c.py with basemodel parser - Add ttp template for h3c --- oxi/interfaces/models/h3c.py | 22 +++++++++ oxi/interfaces/models/templates/h3c.ttp | 66 +++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 oxi/interfaces/models/h3c.py create mode 100644 oxi/interfaces/models/templates/h3c.ttp diff --git a/oxi/interfaces/models/h3c.py b/oxi/interfaces/models/h3c.py new file mode 100644 index 0000000..f97b209 --- /dev/null +++ b/oxi/interfaces/models/h3c.py @@ -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()) \ No newline at end of file diff --git a/oxi/interfaces/models/templates/h3c.ttp b/oxi/interfaces/models/templates/h3c.ttp new file mode 100644 index 0000000..8c3abe6 --- /dev/null +++ b/oxi/interfaces/models/templates/h3c.ttp @@ -0,0 +1,66 @@ + +Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте +в <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 + + +default_system = { + "model": "", + "serial_number": "", + "version": "" +} + + + +# Boot image version: {{ version }}, Release {{ release }} +# {{ mpu }} Slot {{ slot }}: +# BOARD TYPE: {{ model }} + + + +interface {{ interface }} + ip address {{ ip_address }} {{ mask | to_cidr }} + + + +vlan {{ vlan_id }} + name {{ name }} + description {{ description }} + + +vlan {{ vlans_id | ORPHRASE | contains(" to ") | unrange(" to ", ",") | split(",") }} + \ No newline at end of file -- 2.53.0 From c9f6f3472f061cc577169cf8452050d57a4518eb Mon Sep 17 00:00:00 2001 From: IluaAir Date: Wed, 4 Mar 2026 15:50:47 +0300 Subject: [PATCH 32/80] Add Eltex model and TTP template - Introduced a new `Eltex` model for parsing Eltex devices. - Created a corresponding TTP template for Eltex devices, defining required and optional groups for configuration parsing. --- oxi/interfaces/models/eltex.py | 43 ++++++++++++++ oxi/interfaces/models/qtech.py | 1 - oxi/interfaces/models/templates/eltex.ttp | 68 +++++++++++++++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 oxi/interfaces/models/eltex.py create mode 100644 oxi/interfaces/models/templates/eltex.ttp diff --git a/oxi/interfaces/models/eltex.py b/oxi/interfaces/models/eltex.py new file mode 100644 index 0000000..1d9f3de --- /dev/null +++ b/oxi/interfaces/models/eltex.py @@ -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()) diff --git a/oxi/interfaces/models/qtech.py b/oxi/interfaces/models/qtech.py index e2e9749..2b6e077 100644 --- a/oxi/interfaces/models/qtech.py +++ b/oxi/interfaces/models/qtech.py @@ -1,4 +1,3 @@ -from ipaddress import ip_interface import os from oxi.interfaces import register_parser from oxi.interfaces.base import BaseDevice diff --git a/oxi/interfaces/models/templates/eltex.ttp b/oxi/interfaces/models/templates/eltex.ttp new file mode 100644 index 0000000..2d8660c --- /dev/null +++ b/oxi/interfaces/models/templates/eltex.ttp @@ -0,0 +1,68 @@ + +Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте +в <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 + + +default_system = { + "model": "", + "serial_number": "", + "version": "" +} + + + +Active-image: {{ ignore }} {{ _start_ }} +! Version: {{ version }} + + +! Unit MAC address Hardware version Serial number +! {{ unit | exclude("-") }} {{ mac_address }} {{ hardware_version }} {{ serial_number }} + + + +interface {{ interface | ORPHRASE }} + ip address {{ ip_address }} {{ mask | to_cidr }} + + + +interface vlan {{ vlan_id | _start_ }} + name {{ name }} + +vlan {{ _db_ | _start_ }} + vlan {{ vlan_ids | joinmatches(',') | unrange("-", ",") | split(",")}} + -- 2.53.0 From a1c57733f6c2e570f0428f7539ccea392a7cccf1 Mon Sep 17 00:00:00 2001 From: "i.shramko" Date: Tue, 10 Mar 2026 15:52:38 +0300 Subject: [PATCH 33/80] Update pyproject.toml: - Add package-data with resource .ttp --- pyproject.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index cba5323..d2239eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,13 @@ dependencies = [ "requests>=2.32.5", "ttp>=0.10.0", ] + [tool.setuptools.packages.find] where = ["."] include = ["oxi*"] + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.package-data] +"oxi" = ["**/*.ttp"] \ No newline at end of file -- 2.53.0 From 68566a24fbd3b0b8c038ebba9a31bca461bd3ccb Mon Sep 17 00:00:00 2001 From: "i.shramko" Date: Tue, 10 Mar 2026 16:00:57 +0300 Subject: [PATCH 34/80] Update qtech.ttp: - add interface name ORPHRASE --- oxi/interfaces/models/templates/qtech.ttp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oxi/interfaces/models/templates/qtech.ttp b/oxi/interfaces/models/templates/qtech.ttp index 6d4d86e..111f1ae 100644 --- a/oxi/interfaces/models/templates/qtech.ttp +++ b/oxi/interfaces/models/templates/qtech.ttp @@ -14,7 +14,7 @@ default_system = { -interface {{ interface }} +interface {{ interface | ORPHRASE }} description {{ description | ORPHRASE }} ip address {{ ip_address }} {{ mask | to_cidr }} -- 2.53.0 From d185dc6c7cfdde81358469baf51bf6264c88a8d3 Mon Sep 17 00:00:00 2001 From: "i.shramko" Date: Tue, 10 Mar 2026 16:19:40 +0300 Subject: [PATCH 35/80] Update config: - add dump() for dict| list overview - del qtech.ttp _start_ method --- oxi/conf.py | 11 +++++++++-- oxi/interfaces/models/templates/qtech.ttp | 4 ++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/oxi/conf.py b/oxi/conf.py index 6c930f1..6b7e832 100644 --- a/oxi/conf.py +++ b/oxi/conf.py @@ -16,7 +16,7 @@ class ModelView(Generic[TModel]): def __init__(self, model: TModel | list[TModel]): self._model = model - def json(self) -> str: + def dump_json(self) -> str: if isinstance(self._model, list): return json.dumps( [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) + 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]: if isinstance(self._model, list): return iter(self._model) @@ -67,9 +72,11 @@ class NodeConfig: def text(self): return self._response.text - def json(self): + def dump_json(self): return self._parsed_data.model_dump_json() + def dump(self): + return self._parsed_data.model_dump() def __str__(self): return self.text diff --git a/oxi/interfaces/models/templates/qtech.ttp b/oxi/interfaces/models/templates/qtech.ttp index 111f1ae..b2a3789 100644 --- a/oxi/interfaces/models/templates/qtech.ttp +++ b/oxi/interfaces/models/templates/qtech.ttp @@ -20,8 +20,8 @@ interface {{ interface | ORPHRASE }} -vlan {{ vlan_ids | contains(",", "-") | unrange("-", ",") | _start_ }} +vlan {{ vlan_ids | contains(",", "-") | unrange("-", ",") }} ,{{ vlan_tail | unrange("-", ",") }} -vlan {{ vlan_id | _start_ }} +vlan {{ vlan_id }} name {{ name | ORPHRASE }} -- 2.53.0 From b9dce8e4172d4c58ce643ecf100b94463bd6c89f Mon Sep 17 00:00:00 2001 From: "i.shramko" Date: Tue, 10 Mar 2026 17:53:51 +0300 Subject: [PATCH 36/80] Update config: - add by_alias attr Update contract: - del aliase for Vlans vlan_id Update qtech.ttp: - fix vla_id _start_ method --- oxi/conf.py | 5 +++-- oxi/interfaces/contract.py | 2 +- oxi/interfaces/models/templates/qtech.ttp | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/oxi/conf.py b/oxi/conf.py index 6b7e832..cd89be4 100644 --- a/oxi/conf.py +++ b/oxi/conf.py @@ -73,10 +73,11 @@ class NodeConfig: return self._response.text 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() + return self._parsed_data.model_dump(by_alias=True) + def __str__(self): return self.text diff --git a/oxi/interfaces/contract.py b/oxi/interfaces/contract.py index 834c7a4..091e663 100644 --- a/oxi/interfaces/contract.py +++ b/oxi/interfaces/contract.py @@ -32,7 +32,7 @@ class Vlans(Base): Optional """ - vlan_id: int = Field(alias="id") + vlan_id: int name: str | None = Field(default=None, alias="description") diff --git a/oxi/interfaces/models/templates/qtech.ttp b/oxi/interfaces/models/templates/qtech.ttp index b2a3789..aa14025 100644 --- a/oxi/interfaces/models/templates/qtech.ttp +++ b/oxi/interfaces/models/templates/qtech.ttp @@ -22,6 +22,6 @@ interface {{ interface | ORPHRASE }} vlan {{ vlan_ids | contains(",", "-") | unrange("-", ",") }} ,{{ vlan_tail | unrange("-", ",") }} -vlan {{ vlan_id }} +vlan {{ vlan_id | _start_ }} name {{ name | ORPHRASE }} -- 2.53.0 From 2e109db1215bed3c8411e3a85f7d8f49d91f6f84 Mon Sep 17 00:00:00 2001 From: "i.shramko" Date: Tue, 10 Mar 2026 18:41:43 +0300 Subject: [PATCH 37/80] Update Qtech: - Add full support of diff types of switches - Fix default system parser --- oxi/interfaces/models/qtech.py | 4 ++++ oxi/interfaces/models/templates/qtech.ttp | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/oxi/interfaces/models/qtech.py b/oxi/interfaces/models/qtech.py index 2b6e077..ab62ba8 100644 --- a/oxi/interfaces/models/qtech.py +++ b/oxi/interfaces/models/qtech.py @@ -7,6 +7,10 @@ from oxi.interfaces.base import BaseDevice class Qtech(BaseDevice): template = "qtech.ttp" + def system(self) -> dict: + system = self.raw["system"] + return system + def vlans(self) -> list[dict]: vlans_ttp = self.raw["vlans"] vlans = [] diff --git a/oxi/interfaces/models/templates/qtech.ttp b/oxi/interfaces/models/templates/qtech.ttp index aa14025..f606893 100644 --- a/oxi/interfaces/models/templates/qtech.ttp +++ b/oxi/interfaces/models/templates/qtech.ttp @@ -9,8 +9,13 @@ default_system = { -! {{ model | ORPHRASE }} Series Software, Version {{ ignore }} Build {{ version | strip(",") }}{{ ignore('.*') }} +! {{ model | ORPHRASE | _start_ }} Series Software, Version {{ ignore }} Build {{ version | strip(",") }}{{ 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 }} -- 2.53.0 From db791993194b99e055592e57bc01459c5b8ec90f Mon Sep 17 00:00:00 2001 From: IluaAir Date: Wed, 11 Mar 2026 23:23:23 +0300 Subject: [PATCH 38/80] Add LICENSE file and update pyproject.toml - Added Apache License 2.0 to the project. - Updated project description in pyproject.toml to "Oxidized API client". - Specified the LICENSE file in pyproject.toml. - Added classifiers for Python version and license type. --- LICENSE | 201 +++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 8 +- 2 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d2a37d3 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/pyproject.toml b/pyproject.toml index d2239eb..d30eb06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,9 +5,15 @@ build-backend = "setuptools.build_meta" [project] name = "oxipy" version = "0.1.0" -description = "Oxi API client" +description = "Oxidized API client" readme = "README.md" +license = { file = "LICENSE" } requires-python = ">=3.11" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", +] dependencies = [ "pydantic>=2.12.5", "requests>=2.32.5", -- 2.53.0 From 56eae15e27f0cd74bfd96164fa1a509f6927d070 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Wed, 11 Mar 2026 23:26:35 +0300 Subject: [PATCH 39/80] Update README.md to include new device models - Added Eltex, H3C, and Quasar to the list of supported device models with their corresponding identifiers. --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 2ac7955..92028b5 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,9 @@ print(interfaces.json()) | MikroTik | `routeros`, `ros`, `mikrotik` | | Qtech | `qtech` | | Huawei | `huawei`, `vrp` | +| Eltex | `eltex` | +| H3C | `h3c` | +| Quasar | `qos`, `quasar` | Ключи реестра — это значения поля `model`, возвращаемого API для узла. Регистр не учитывается. -- 2.53.0 From ca96d2600a9e82a7c871d107df56b96283b18c81 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Wed, 11 Mar 2026 23:29:08 +0300 Subject: [PATCH 40/80] Add Quasar model and TTP template - Introduced a new `Quasar` model for parsing Quasar devices. - Created a corresponding TTP template defining required and optional groups for configuration parsing. --- oxi/interfaces/models/quasar.py | 21 ++++++++ oxi/interfaces/models/templates/quasar.ttp | 61 ++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 oxi/interfaces/models/quasar.py create mode 100644 oxi/interfaces/models/templates/quasar.ttp diff --git a/oxi/interfaces/models/quasar.py b/oxi/interfaces/models/quasar.py new file mode 100644 index 0000000..f01a005 --- /dev/null +++ b/oxi/interfaces/models/quasar.py @@ -0,0 +1,21 @@ +from oxi.interfaces import BaseDevice, register_parser + + +@register_parser(["quasar", "qos"]) +class Quasar(BaseDevice): + template = "quasar.ttp" + + def interfaces(self) -> list[dict]: + inter = self.raw["interfaces"] + # test = self.raw["mass"] + print(inter) + # print(test) + return inter + + +if __name__ == "__main__": + with open("./test7.txt") as file: + data = file.read() + quasar = Quasar(data) + qt = quasar.parse() + print(qt) diff --git a/oxi/interfaces/models/templates/quasar.ttp b/oxi/interfaces/models/templates/quasar.ttp new file mode 100644 index 0000000..45ded7a --- /dev/null +++ b/oxi/interfaces/models/templates/quasar.ttp @@ -0,0 +1,61 @@ + +Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте +в <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 + + +default_system = { + "model": "", + "serial_number": "", + "version": "" +} + + + +# Engine {{ version }} +# Product Name {{ model }} +# Product Serial Number {{ serial_number }} + + + +{{ ignore("\s*") }}config {{ interface }} ipv4 address {{ ip_address }}/{{ mask }} gateway {{ gateway }} {{ ignore }} + + + +{{ ignore("\s*") }}config interface {{ interface | _start_ }} fec none mode force-up enable +{{ ignore("\s*") }}config interface {{ interface | _start_ }} description {{ description | ORPHRASE | strip('"')}} + \ No newline at end of file -- 2.53.0 From de0e09af9da8fa6c10f632abdf9437d5f997bba9 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Thu, 12 Mar 2026 20:13:02 +0300 Subject: [PATCH 41/80] Add node refresh functionality to NodeView - Implemented a private `_updater` method to fetch the next node's status. - Added `last_status` and `last_check` properties to retrieve the latest node status and check time. - Introduced a `refresh` property to update the node status and handle errors appropriately. --- oxi/view.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/oxi/view.py b/oxi/view.py index da8adc2..b42e5ed 100644 --- a/oxi/view.py +++ b/oxi/view.py @@ -14,6 +14,11 @@ class NodeView: self._base_url = base_url self._data = data + def _updater(self) -> None: + response = self._session.get(f"{self._base_url}/node/next/{self.full_name}") + response.raise_for_status() + return response.status_code + @property def ip(self): return self._data.get("ip") @@ -30,6 +35,21 @@ class NodeView: def model(self): return self._data.get("model") + @property + def last_status(self): + return self._data.get("last").get("status") + + @property + def last_check(self): + return self._data.get("last").get("start") + + @property + def refresh(self): + result = self._updater() + if result != 200: + raise ValueError(f"Failed to refresh node {self.full_name}") + return "OK" + @cached_property def config(self): return NodeConfig(self._session, self.full_name, self.model, self._base_url) -- 2.53.0 From e3392f6c76535084b4fbdd0ef13b75831d6d58a9 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Thu, 12 Mar 2026 20:15:52 +0300 Subject: [PATCH 42/80] Add reload method to OxiAPI class - Implemented a new `reload` method to fetch the reload status from the API. - The method raises an error for unsuccessful responses and returns the status code on success. --- oxi/core.py | 5 +++++ oxi/view.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/oxi/core.py b/oxi/core.py index c5c0386..9a618f2 100644 --- a/oxi/core.py +++ b/oxi/core.py @@ -26,3 +26,8 @@ class OxiAPI: def close(self): return self._session.close() + + def reload(self): + reload_response = self._session.get(f"{self.base_url}/reload") + reload_response.raise_for_status() + return reload_response.status_code diff --git a/oxi/view.py b/oxi/view.py index b42e5ed..0e12cf2 100644 --- a/oxi/view.py +++ b/oxi/view.py @@ -14,7 +14,7 @@ class NodeView: self._base_url = base_url self._data = data - def _updater(self) -> None: + 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 -- 2.53.0 From 586e52282b536f7f0f6358e9a2b9cb88ff1c094b Mon Sep 17 00:00:00 2001 From: IluaAir Date: Thu, 12 Mar 2026 23:39:47 +0300 Subject: [PATCH 43/80] Refactor Quasar interface handling and update TTP template - Enhanced the `interfaces` method in the `Quasar` model to process bulk interfaces, returning a structured list of interface details. - Updated the TTP template to reflect the change from `interfaces` to `bulkinterfaces` for better organization of interface configurations. --- oxi/interfaces/models/quasar.py | 19 ++++++++++++++----- oxi/interfaces/models/templates/quasar.ttp | 2 +- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/oxi/interfaces/models/quasar.py b/oxi/interfaces/models/quasar.py index f01a005..cce3a97 100644 --- a/oxi/interfaces/models/quasar.py +++ b/oxi/interfaces/models/quasar.py @@ -6,11 +6,20 @@ class Quasar(BaseDevice): template = "quasar.ttp" def interfaces(self) -> list[dict]: - inter = self.raw["interfaces"] - # test = self.raw["mass"] - print(inter) - # print(test) - return inter + 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__": diff --git a/oxi/interfaces/models/templates/quasar.ttp b/oxi/interfaces/models/templates/quasar.ttp index 45ded7a..acbd313 100644 --- a/oxi/interfaces/models/templates/quasar.ttp +++ b/oxi/interfaces/models/templates/quasar.ttp @@ -55,7 +55,7 @@ default_system = { {{ ignore("\s*") }}config {{ interface }} ipv4 address {{ ip_address }}/{{ mask }} gateway {{ gateway }} {{ ignore }} - + {{ ignore("\s*") }}config interface {{ interface | _start_ }} fec none mode force-up enable {{ ignore("\s*") }}config interface {{ interface | _start_ }} description {{ description | ORPHRASE | strip('"')}} \ No newline at end of file -- 2.53.0 From 974fff60381fefe21503c4a39070249050d6236e Mon Sep 17 00:00:00 2001 From: IluaAir Date: Fri, 13 Mar 2026 13:12:02 +0300 Subject: [PATCH 44/80] Update TTP template to escape whitespace in ignore patterns - Modified the TTP template to use double backslashes for escaping whitespace in the `ignore` function for both `interfaces` and `bulkinterfaces` groups, ensuring proper parsing of configuration lines. --- oxi/interfaces/models/templates/quasar.ttp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/oxi/interfaces/models/templates/quasar.ttp b/oxi/interfaces/models/templates/quasar.ttp index acbd313..e633654 100644 --- a/oxi/interfaces/models/templates/quasar.ttp +++ b/oxi/interfaces/models/templates/quasar.ttp @@ -52,10 +52,10 @@ default_system = { -{{ ignore("\s*") }}config {{ interface }} ipv4 address {{ ip_address }}/{{ mask }} gateway {{ gateway }} {{ ignore }} +{{ ignore("\\s*") }}config {{ interface }} ipv4 address {{ ip_address }}/{{ mask }} gateway {{ gateway }} {{ ignore }} -{{ ignore("\s*") }}config interface {{ interface | _start_ }} fec none mode force-up enable -{{ ignore("\s*") }}config interface {{ interface | _start_ }} description {{ description | ORPHRASE | strip('"')}} +{{ ignore("\\s*") }}config interface {{ interface | _start_ }} fec none mode force-up enable +{{ ignore("\\s*") }}config interface {{ interface | _start_ }} description {{ description | ORPHRASE | strip('"')}} \ No newline at end of file -- 2.53.0 From 65c82fbaf5696295210ed4684ee6cfc41c0219fa Mon Sep 17 00:00:00 2001 From: IluaAir Date: Mon, 16 Mar 2026 18:24:24 +0300 Subject: [PATCH 45/80] Refactor error handling in Node class - Updated the `Node` class to use `response.raise_for_status()` for improved error handling, replacing the previous manual check for a 500 status code. This change simplifies the error management process when fetching node data. --- oxi/node.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/oxi/node.py b/oxi/node.py index f84ca7c..a93caa0 100644 --- a/oxi/node.py +++ b/oxi/node.py @@ -18,8 +18,7 @@ class Node: if not url.endswith(".json"): url += ".json" response = self._session.get(url) - if response.status_code == 500: - raise ValueError(f"page {url} not found") + response.raise_for_status() return NodeView( session=self._session, base_url=self._base_url, data=response.json() ) -- 2.53.0 From 5b8380aeeeb93b952d8efab7a774419ef184a954 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Tue, 17 Mar 2026 01:07:23 +0300 Subject: [PATCH 46/80] Add system method to Quasar model and update TTP template - Implemented a new `system` method in the `Quasar` model to extract and format system information, including version handling. - Updated the TTP template to adjust the grouping and ignore patterns for better parsing of system details, ensuring compatibility with the new method. --- oxi/interfaces/models/quasar.py | 23 ++++++++++++++++++++++ oxi/interfaces/models/templates/quasar.ttp | 12 +++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/oxi/interfaces/models/quasar.py b/oxi/interfaces/models/quasar.py index cce3a97..a41db15 100644 --- a/oxi/interfaces/models/quasar.py +++ b/oxi/interfaces/models/quasar.py @@ -5,6 +5,23 @@ from oxi.interfaces import BaseDevice, register_parser class Quasar(BaseDevice): template = "quasar.ttp" + def system(self) -> dict: + raw = self.raw.get("system", {}) + items = raw if isinstance(raw, list) else [raw] + selected = next( + (item for item in items if item.get("assembly_version")), + items[0] if items else {}, + ) + result = dict(selected) + result["version"] = ( + result.pop("assembly_version", None) + or result.pop("engine_version", None) + or "" + ) + result.pop("assembly_version", None) + result.pop("engine_version", None) + return result + def interfaces(self) -> list[dict]: ether_interfaces: dict = self.raw["interfaces"] interfaces: list[dict] = [] @@ -28,3 +45,9 @@ if __name__ == "__main__": 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) diff --git a/oxi/interfaces/models/templates/quasar.ttp b/oxi/interfaces/models/templates/quasar.ttp index e633654..6cfbf67 100644 --- a/oxi/interfaces/models/templates/quasar.ttp +++ b/oxi/interfaces/models/templates/quasar.ttp @@ -46,16 +46,20 @@ default_system = { -# Engine {{ version }} -# Product Name {{ model }} -# Product Serial Number {{ serial_number }} +#{{ ignore("\s+Assembly\s+") }}{{ assembly_version }} +#{{ ignore("\s+Product Name\s+") }}{{ model | ORPHRASE }} +#{{ ignore("\s+Product Serial Number\s+") }}{{ serial_number }} + + +#{{ ignore("\s+Engine\s+") }}{{ engine_version }} +#{{ ignore("\s+Product Name\s+") }}{{ model | ORPHRASE }} +#{{ ignore("\s+Product Serial Number\s+") }}{{ serial_number }} {{ ignore("\\s*") }}config {{ interface }} ipv4 address {{ ip_address }}/{{ mask }} gateway {{ gateway }} {{ ignore }} -{{ ignore("\\s*") }}config interface {{ interface | _start_ }} fec none mode force-up enable {{ ignore("\\s*") }}config interface {{ interface | _start_ }} description {{ description | ORPHRASE | strip('"')}} \ No newline at end of file -- 2.53.0 From 1d0f5ed68543521a7bcb4a529be06a710a78c630 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Wed, 18 Mar 2026 00:01:22 +0300 Subject: [PATCH 47/80] Refactor Quasar model by removing system method and updating TTP template - Removed the `system` method from the `Quasar` model to streamline system information handling. - Updated the TTP template to enhance the formatting of system details, including version and product information, for improved clarity and organization. --- oxi/interfaces/models/quasar.py | 17 ----------------- oxi/interfaces/models/templates/quasar.ttp | 17 +++++++++-------- 2 files changed, 9 insertions(+), 25 deletions(-) diff --git a/oxi/interfaces/models/quasar.py b/oxi/interfaces/models/quasar.py index a41db15..3f87156 100644 --- a/oxi/interfaces/models/quasar.py +++ b/oxi/interfaces/models/quasar.py @@ -5,23 +5,6 @@ from oxi.interfaces import BaseDevice, register_parser class Quasar(BaseDevice): template = "quasar.ttp" - def system(self) -> dict: - raw = self.raw.get("system", {}) - items = raw if isinstance(raw, list) else [raw] - selected = next( - (item for item in items if item.get("assembly_version")), - items[0] if items else {}, - ) - result = dict(selected) - result["version"] = ( - result.pop("assembly_version", None) - or result.pop("engine_version", None) - or "" - ) - result.pop("assembly_version", None) - result.pop("engine_version", None) - return result - def interfaces(self) -> list[dict]: ether_interfaces: dict = self.raw["interfaces"] interfaces: list[dict] = [] diff --git a/oxi/interfaces/models/templates/quasar.ttp b/oxi/interfaces/models/templates/quasar.ttp index 6cfbf67..98e20fa 100644 --- a/oxi/interfaces/models/templates/quasar.ttp +++ b/oxi/interfaces/models/templates/quasar.ttp @@ -46,16 +46,17 @@ default_system = { -#{{ ignore("\s+Assembly\s+") }}{{ assembly_version }} -#{{ ignore("\s+Product Name\s+") }}{{ model | ORPHRASE }} -#{{ ignore("\s+Product Serial Number\s+") }}{{ serial_number }} +# 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 }} - -#{{ ignore("\s+Engine\s+") }}{{ engine_version }} -#{{ ignore("\s+Product Name\s+") }}{{ model | ORPHRASE }} -#{{ ignore("\s+Product Serial Number\s+") }}{{ serial_number }} - {{ ignore("\\s*") }}config {{ interface }} ipv4 address {{ ip_address }}/{{ mask }} gateway {{ gateway }} {{ ignore }} -- 2.53.0 From a107662e996b65ede53e5bd9f75518e265aa4366 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Wed, 18 Mar 2026 00:15:09 +0300 Subject: [PATCH 48/80] Enhance OxiAPI and Node classes with type hints and property updates - Updated the `OxiAPI` class to check for `None` explicitly when setting authentication credentials. - Added type hints to the `Node` class and introduced a TODO for future enhancements. - Refactored properties in the `NodeView` class to include type hints and improved handling of optional data retrieval. --- oxi/core.py | 3 ++- oxi/interfaces/contract.py | 4 ++-- oxi/node.py | 2 +- oxi/view.py | 27 ++++++++++++++++----------- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/oxi/core.py b/oxi/core.py index 9a618f2..fa14a57 100644 --- a/oxi/core.py +++ b/oxi/core.py @@ -3,6 +3,7 @@ from requests import Session from .node import Node +# TODO: Add custom adapter for Oxi class OxiAPI: def __init__( self, @@ -14,7 +15,7 @@ class OxiAPI: self.base_url = url.rstrip("/") self._session = Session() self._session.verify = verify - if username and password: + if username is not None and password is not None: self._session.auth = (username, password) self.node = Node(self._session, self.base_url) diff --git a/oxi/interfaces/contract.py b/oxi/interfaces/contract.py index 091e663..d5bbb24 100644 --- a/oxi/interfaces/contract.py +++ b/oxi/interfaces/contract.py @@ -8,7 +8,7 @@ class Base(BaseModel): class System(BaseModel): """ - Requred + Required """ model: str @@ -18,7 +18,7 @@ class System(BaseModel): class Interfaces(Base): """ - Requred + Required """ name: str = Field(alias="interface") diff --git a/oxi/node.py b/oxi/node.py index a93caa0..0ea9b20 100644 --- a/oxi/node.py +++ b/oxi/node.py @@ -7,11 +7,11 @@ if TYPE_CHECKING: from requests import Session +# TODO: Add type hints class Node: def __init__(self, session: "Session", base_url: str): self._session = session self._base_url = base_url - self._data = None def __call__(self, name: str) -> NodeView: url = f"{self._base_url}/node/show/{name}" diff --git a/oxi/view.py b/oxi/view.py index 0e12cf2..88e4885 100644 --- a/oxi/view.py +++ b/oxi/view.py @@ -20,36 +20,41 @@ class NodeView: return response.status_code @property - def ip(self): + def name(self) -> str: + return self._data.get("name") + + @property + def ip(self) -> str: return self._data.get("ip") @property - def full_name(self): + def full_name(self) -> str: return self._data.get("full_name") @property - def group(self): + def group(self) -> str: return self._data.get("group") @property - def model(self): + def model(self) -> str: return self._data.get("model") @property - def last_status(self): - return self._data.get("last").get("status") + def last_status(self) -> str: + last = self._data.get("last") or {} + return last.get("status") @property - def last_check(self): - return self._data.get("last").get("start") + def last_check(self) -> str: + last = self._data.get("last") or {} + return last.get("start") - @property - def refresh(self): + def refresh(self) -> str: result = self._updater() if result != 200: raise ValueError(f"Failed to refresh node {self.full_name}") return "OK" @cached_property - def config(self): + def config(self) -> NodeConfig: return NodeConfig(self._session, self.full_name, self.model, self._base_url) -- 2.53.0 From 8cebbf743a57cae4951762f48abce35629bce167 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Thu, 26 Mar 2026 00:31:13 +0300 Subject: [PATCH 49/80] Add OxiAdapter for enhanced HTTP request handling in OxiAPI - Introduced a new `OxiAdapter` class that extends `HTTPAdapter` to manage timeouts and retries for HTTP requests. - Integrated the `OxiAdapter` into the `OxiAPI` class, setting a default timeout and enabling retry logic for both HTTP and HTTPS requests. --- oxi/adapter.py | 21 +++++++++++++++++++++ oxi/core.py | 8 ++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 oxi/adapter.py diff --git a/oxi/adapter.py b/oxi/adapter.py new file mode 100644 index 0000000..87786ad --- /dev/null +++ b/oxi/adapter.py @@ -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) diff --git a/oxi/core.py b/oxi/core.py index fa14a57..0563c84 100644 --- a/oxi/core.py +++ b/oxi/core.py @@ -1,9 +1,10 @@ from typing import Optional from requests import Session + +from oxi.adapter import OxiAdapter from .node import Node -# TODO: Add custom adapter for Oxi class OxiAPI: def __init__( self, @@ -14,8 +15,11 @@ class OxiAPI: ): self.base_url = url.rstrip("/") self._session = Session() + self._adapter = OxiAdapter(timeout=10, max_retries=3) + self._session.mount("https://", self._adapter) + self._session.mount("http://", self._adapter) self._session.verify = verify - if username is not None and password is not None: + if username and password: self._session.auth = (username, password) self.node = Node(self._session, self.base_url) -- 2.53.0 From 61892d8f514e14771e0157fe5dcd96fd194e6f98 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Thu, 26 Mar 2026 00:39:43 +0300 Subject: [PATCH 50/80] Add OxiAPIError exception class for improved error handling - Introduced a new `OxiAPIError` class to standardize error reporting in the OxiAPI. - The class includes an optional status code for enhanced context in error messages. --- oxi/exception.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 oxi/exception.py diff --git a/oxi/exception.py b/oxi/exception.py new file mode 100644 index 0000000..77f746b --- /dev/null +++ b/oxi/exception.py @@ -0,0 +1,12 @@ +from typing import Optional + + +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]}" -- 2.53.0 From 1cc225917e4b2b731312c92143cb32e113f9df7a Mon Sep 17 00:00:00 2001 From: IluaAir Date: Thu, 26 Mar 2026 19:51:51 +0300 Subject: [PATCH 51/80] Add OxiApi create_session method for better view --- oxi/core.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/oxi/core.py b/oxi/core.py index 0563c84..dc23219 100644 --- a/oxi/core.py +++ b/oxi/core.py @@ -14,15 +14,24 @@ class OxiAPI: verify: bool = True, ): self.base_url = url.rstrip("/") - self._session = Session() - self._adapter = OxiAdapter(timeout=10, max_retries=3) - self._session.mount("https://", self._adapter) - self._session.mount("http://", self._adapter) - self._session.verify = verify - if username and password: - self._session.auth = (username, password) + self._session = self.__create_session(username, password, verify) 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): return self -- 2.53.0 From 0b92e342e5f8193b5bf4686560e78ef23b868d1e Mon Sep 17 00:00:00 2001 From: IluaAir Date: Thu, 26 Mar 2026 20:10:05 +0300 Subject: [PATCH 52/80] Enhance error handling in OxiAPI and Node classes - Updated the `reload` method in the `OxiAPI` class to catch `HTTPError` exceptions and raise a custom `OxiAPIError` with context. - Improved the `__call__` method in the `Node` class to handle `HTTPError` exceptions similarly, providing context-specific error messages. - Introduced a new class method `from_http_error` in `OxiAPIError` for standardized error message generation based on HTTP status codes. --- oxi/core.py | 10 +++++++--- oxi/exception.py | 24 +++++++++++++++++++++++- oxi/node.py | 18 ++++++++++++------ 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/oxi/core.py b/oxi/core.py index dc23219..5662c85 100644 --- a/oxi/core.py +++ b/oxi/core.py @@ -1,7 +1,8 @@ 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 @@ -42,6 +43,9 @@ class OxiAPI: return self._session.close() def reload(self): - reload_response = self._session.get(f"{self.base_url}/reload") - reload_response.raise_for_status() + 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 diff --git a/oxi/exception.py b/oxi/exception.py index 77f746b..614627d 100644 --- a/oxi/exception.py +++ b/oxi/exception.py @@ -1,4 +1,16 @@ -from typing import Optional +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): @@ -10,3 +22,13 @@ class OxiAPIError(Exception): 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) diff --git a/oxi/node.py b/oxi/node.py index 0ea9b20..828970f 100644 --- a/oxi/node.py +++ b/oxi/node.py @@ -1,5 +1,9 @@ from typing import TYPE_CHECKING +from requests import HTTPError + +from oxi.exception import OxiAPIError + from .view import NodeView @@ -7,18 +11,20 @@ if TYPE_CHECKING: from requests import Session -# TODO: Add type hints class Node: def __init__(self, session: "Session", base_url: str): self._session = session self._base_url = base_url def __call__(self, name: str) -> NodeView: - url = f"{self._base_url}/node/show/{name}" - if not url.endswith(".json"): - url += ".json" - response = self._session.get(url) - response.raise_for_status() + try: + url = f"{self._base_url}/node/show/{name}" + if not url.endswith(".json"): + url += ".json" + response = self._session.get(url) + response.raise_for_status() + except HTTPError as e: + raise OxiAPIError.from_http_error(e, context=f"Node {name}") from e return NodeView( session=self._session, base_url=self._base_url, data=response.json() ) -- 2.53.0 From 9fd0ce1516a0d352034283cdf2eac72bc5271aff Mon Sep 17 00:00:00 2001 From: IluaAir Date: Fri, 17 Apr 2026 20:45:16 +0300 Subject: [PATCH 53/80] Implement enhanced error detection for NodeNotFound in OxiAPIError - Added a new helper function `_looks_like_node_not_found_html` to identify NodeNotFound errors based on HTTP response content. - Updated the `from_http_error` method in the `OxiAPIError` class to convert 500 status codes to 404 when a NodeNotFound error is detected, improving error handling and user feedback. --- oxi/exception.py | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/oxi/exception.py b/oxi/exception.py index 614627d..be3adaf 100644 --- a/oxi/exception.py +++ b/oxi/exception.py @@ -13,10 +13,32 @@ _STATUS_MESSAGES: dict[int, str] = { } +def _looks_like_node_not_found_html(e: "HTTPError") -> bool: + resp = getattr(e, "response", None) + if resp is None: + return False + try: + content_type = (resp.headers or {}).get("Content-Type", "") + except Exception: + content_type = "" + if "text/html" not in (content_type or "").lower(): + return False + try: + body = (resp.text or "")[:20_000] + except Exception: + return False + return ( + "Oxidized::NodeNotFound" in body + or "NodeNotFound" in body + or "Oxidized::NodeNotFound" in body + ) + + class OxiAPIError(Exception): def __init__(self, message: str, status_code: Optional[int] = None): super().__init__(message) self.status_code = status_code + self.message = message def __str__(self): if self.status_code is not None: @@ -25,10 +47,19 @@ class OxiAPIError(Exception): @classmethod def from_http_error(cls, e: "HTTPError", context: str = "") -> "OxiAPIError": - status = e.response.status_code + resp = getattr(e, "response", None) + status = resp.status_code if resp is not None else None + + if status == 500 and _looks_like_node_not_found_html(e): + status = 404 + 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}" + base = ( + (_STATUS_MESSAGES.get(status) if status is not None else None) + or (resp.reason if resp is not None else None) + or (f"HTTP {status}" if status is not None else "Request failed") + ) message = f"{context}: {base}" if context else base return cls(message, status) -- 2.53.0 From 5fa56d46af6516be7c776ab6bb33f7f9276f5ea3 Mon Sep 17 00:00:00 2001 From: IluaAir <ilya@air.ru> Date: Sat, 18 Apr 2026 19:21:25 +0300 Subject: [PATCH 54/80] Enhance BaseDevice initialization and error handling - Updated the `BaseDevice` class constructor to accept an optional `name` parameter for better context in error messages. - Improved the `_validate_contract` method to raise an `OxiAPIError` with a descriptive message if the node is not found. - Modified the `_run_ttp` method to include a check for node existence, returning `None` if not found. - Changed the test file name in the `Eltex` model to reflect a more descriptive context for testing node not found scenarios. --- oxi/conf.py | 2 +- oxi/interfaces/base.py | 18 +++++++++++++++++- oxi/interfaces/models/eltex.py | 2 +- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/oxi/conf.py b/oxi/conf.py index cd89be4..cc2a9ff 100644 --- a/oxi/conf.py +++ b/oxi/conf.py @@ -60,7 +60,7 @@ class NodeConfig: self._device: type[BaseDevice] = device_registry.get(self._model.lower()) if self._device is None: raise ValueError(f"Device model '{self._model}' not found in registry") - self._parsed_data = self._device(self.text).parse() + self._parsed_data = self._device(self.text, name=self._full_name).parse() @cached_property def _response(self): diff --git a/oxi/interfaces/base.py b/oxi/interfaces/base.py index bdd4308..62610ee 100644 --- a/oxi/interfaces/base.py +++ b/oxi/interfaces/base.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod from pathlib import Path from ttp import ttp +from oxi.exception import OxiAPIError from oxi.interfaces.contract import Device import xml.etree.ElementTree as ET from oxi.interfaces.contract import Interfaces, System, Vlans @@ -10,8 +11,9 @@ class BaseDevice(ABC): _REQUIRED_SECTIONS: frozenset[str] = frozenset({"system", "interfaces"}) _OPTIONAL_SECTIONS: frozenset[str] = frozenset({"vlans"}) - def __init__(self, config: str): + def __init__(self, config: str, name: str | None = None): self.config: str = config + self.name = name self._loaded_template = self._load_template() self._declared_sections = None @@ -69,6 +71,13 @@ class BaseDevice(ABC): return self.raw.get("system", None) def _validate_contract(self) -> dict: + if self.raw is None: + msg = ( + f"Node {self.name} not found" + if self.name + else "Node not found" + ) + raise OxiAPIError(msg, status_code=404) system_data = self.system() interfaces_data = self.interfaces() or [] result = { @@ -114,6 +123,13 @@ class BaseDevice(ABC): def _run_ttp(self) -> dict: """Основной парсер""" + pattern = """node not {{found}}""" + parser = ttp(data=self.config, template=pattern) + parser.parse() + res = parser.result() + if res[0][0]: + # raise OxiAPIError(f"Node {self.name} not found", status_code=404) + return None p = ttp(data=self.config, template=self._loaded_template) p.parse() raw: dict = p.result()[0][0] diff --git a/oxi/interfaces/models/eltex.py b/oxi/interfaces/models/eltex.py index 1d9f3de..f4a08bf 100644 --- a/oxi/interfaces/models/eltex.py +++ b/oxi/interfaces/models/eltex.py @@ -37,7 +37,7 @@ class Eltex(BaseDevice): if __name__ == "__main__": - with open("./test6.txt") as file: + with open("./test_not_found.txt") as file: data = file.read() eltex = Eltex(data) print(eltex.parse()) -- 2.53.0 From 5a4cfa18d4b6dceb861c715516ffcef8d5ff4d53 Mon Sep 17 00:00:00 2001 From: IluaAir <ilya@air.ru> Date: Mon, 27 Apr 2026 09:50:22 +0300 Subject: [PATCH 55/80] Update Python version requirement and clean up imports - Changed the required Python version in `pyproject.toml` from 3.11 to 3.10 to broaden compatibility. - Removed an unused import from `oxi/conf.py` to streamline the code and improve readability. --- oxi/conf.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/oxi/conf.py b/oxi/conf.py index cc2a9ff..acc2b70 100644 --- a/oxi/conf.py +++ b/oxi/conf.py @@ -1,6 +1,6 @@ from functools import cached_property import json -from typing import TYPE_CHECKING, Generic, Iterator, Type, TypeVar +from typing import TYPE_CHECKING, Generic, Iterator, TypeVar from pydantic import BaseModel diff --git a/pyproject.toml b/pyproject.toml index d30eb06..76e2c37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "0.1.0" description = "Oxidized API client" readme = "README.md" license = { file = "LICENSE" } -requires-python = ">=3.11" +requires-python = ">=3.10" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: Apache Software License", -- 2.53.0 From 74647bea5bb1a99a6f3f4de34fdfc5300377cc19 Mon Sep 17 00:00:00 2001 From: IluaAir <ilya@air.ru> Date: Mon, 27 Apr 2026 15:39:23 +0300 Subject: [PATCH 56/80] Update Huawei TTP template for improved system information display - Modified the Huawei TTP template to enhance the system information output by including the line identifier and slot number alongside the version and serial number. This change aims to provide clearer context for system diagnostics. --- oxi/interfaces/models/templates/huawei.ttp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/oxi/interfaces/models/templates/huawei.ttp b/oxi/interfaces/models/templates/huawei.ttp index 22f6283..2585629 100644 --- a/oxi/interfaces/models/templates/huawei.ttp +++ b/oxi/interfaces/models/templates/huawei.ttp @@ -45,7 +45,8 @@ default_system = { </vars> <group name="system" default="default_system"> -# VRP (R) software, Version {{ version }} ({{ model }} {{ serial_number }}) +# VRP (R) software, Version {{ version }} {{ _line_ }} +# ESN of slot {{ slot_number }}: {{ serial_number }} </group> <group name="interfaces"> -- 2.53.0 From e8c33b0e647710a35d41a9a4d222f0ae1c9ab32c Mon Sep 17 00:00:00 2001 From: IluaAir <ilya@air.ru> Date: Mon, 25 May 2026 15:41:50 +0300 Subject: [PATCH 57/80] Add VLAN range expansion utility and improve VLAN processing in Qtech model - Introduced a new helper function `_expand_vlan_range` to convert VLAN range strings into a list of individual VLAN IDs. - Enhanced the `vlans` method in the `Qtech` class to utilize the new function, improving the handling of VLAN IDs and ensuring proper processing of both individual and range-based VLAN inputs. --- oxi/interfaces/models/qtech.py | 58 ++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/oxi/interfaces/models/qtech.py b/oxi/interfaces/models/qtech.py index ab62ba8..605fb9d 100644 --- a/oxi/interfaces/models/qtech.py +++ b/oxi/interfaces/models/qtech.py @@ -3,6 +3,30 @@ from oxi.interfaces import register_parser from oxi.interfaces.base import BaseDevice +def _expand_vlan_range(value: str) -> list[str]: + """Разворачивает строку вида '1,7,14-15,200-205' в список ['1','7','14','15',...].""" + result: list[str] = [] + if not value: + return result + for part in value.split(","): + part = part.strip() + if not part: + continue + if "-" in part: + start_s, end_s = part.split("-", 1) + try: + start, end = int(start_s), int(end_s) + except ValueError: + result.append(part) + continue + if start > end: + start, end = end, start + result.extend(str(i) for i in range(start, end + 1)) + else: + result.append(part) + return result + + @register_parser(["QTECH"]) class Qtech(BaseDevice): template = "qtech.ttp" @@ -13,22 +37,23 @@ class Qtech(BaseDevice): def vlans(self) -> list[dict]: vlans_ttp = self.raw["vlans"] - vlans = [] - named_vlan = set() + vlans: list[dict] = [] + named_vlan: set[str] = set() for item in vlans_ttp: - if item.get("vlan_id"): - named_vlan.add(item.get("vlan_id")) + vlan_id = item.get("vlan_id") + if vlan_id and "," not in vlan_id and "-" not in vlan_id: + named_vlan.add(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}) + continue + + ids = item.get("vlan_ids") or vlan_id or "" + tail = item.get("vlan_tail") + if tail: + ids = f"{ids},{tail}" + for vid in _expand_vlan_range(ids): + if vid in named_vlan: + continue + vlans.append({"vlan_id": vid}) return vlans @@ -39,3 +64,8 @@ if __name__ == "__main__": qtech = Qtech(data) qt = qtech.parse() print(qt) + with open("./test3-1.txt") as file: + data = file.read() + qtech = Qtech(data) + qt = qtech.parse() + print(qt) -- 2.53.0 From 41c4cc48e9393029065d465224ca7368d36953ec Mon Sep 17 00:00:00 2001 From: IluaAir <ilya@air.ru> Date: Mon, 25 May 2026 16:01:38 +0300 Subject: [PATCH 58/80] Update project description and enhance documentation for clarity - Revised the project description in `pyproject.toml` to better reflect the functionality of the `oxipy` client. - Improved the README.md by adding detailed explanations of the project structure, installation instructions, and usage examples. - Updated documentation files to enhance clarity and organization, including sections on extending models and writing TTP templates. - Adjusted various TTP templates to ensure consistency and accuracy in the parsing of device configurations. --- README.md | 294 ++++++++---------- docs/extending-models.md | 209 ++++++------- docs/templates.md | 289 +++++++++-------- oxi/interfaces/base.py | 25 +- oxi/interfaces/models/eltex.py | 53 +++- oxi/interfaces/models/h3c.py | 12 +- oxi/interfaces/models/huawei.py | 4 +- oxi/interfaces/models/qtech.py | 17 +- oxi/interfaces/models/quasar.py | 7 +- oxi/interfaces/models/templates/_template.ttp | 45 +-- oxi/interfaces/models/templates/eltex.ttp | 41 +-- oxi/interfaces/models/templates/h3c.ttp | 40 +-- oxi/interfaces/models/templates/huawei.ttp | 39 +-- oxi/interfaces/models/templates/qtech.ttp | 9 + oxi/interfaces/models/templates/quasar.ttp | 41 +-- pyproject.toml | 2 +- uv.lock | 39 ++- 17 files changed, 524 insertions(+), 642 deletions(-) diff --git a/README.md b/README.md index 92028b5..e065633 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,42 @@ # oxipy -Python-клиент для работы с Oxidized API — системой управления конфигурацией сетевых устройств. Предоставляет удобный интерфейс для получения конфигураций узлов, их парсинга и работы с результатами. +`oxipy` is a Python client for the [Oxidized](https://github.com/ytti/oxidized) API. +It fetches device configurations from Oxidized and parses them into structured +Pydantic models using bundled TTP templates. -## Содержание +Oxidized remains responsible for collecting and storing configuration backups. +`oxipy` focuses on consuming those backups from Python code and exposing common +configuration sections such as system data, interfaces, and VLANs. -- [Установка](#установка) -- [Быстрый старт](#быстрый-старт) +## Contents + +- [Installation](#installation) +- [Quick Start](#quick-start) - [API Reference](#api-reference) - [OxiAPI](#oxiapi) - [NodeView](#nodeview) - [NodeConfig](#nodeconfig) - [ModelView](#modelview) -- [Поддерживаемые устройства](#поддерживаемые-устройства) -- [Дополнительно](#дополнительно) +- [Supported Devices](#supported-devices) +- [Additional Documentation](#additional-documentation) ---- +## Installation -## Установка +The package is distributed through a private Gitea Package Registry and from the +source repository. It is not published to PyPI. -> Пакет распространяется через Gitea Package Registry и исходники репозитория. -> В PyPI пакет не публикуется. +**Requirements:** Python 3.10+ -**Требования:** Python 3.11+ +### From Gitea Package Registry -### Из Gitea Package Registry - -Добавьте registry в конфигурацию pip и установите пакет: +Install the package by pointing `pip` to the private registry: ```bash pip install oxipy \ --index-url https://gitea.imbastark.ru/api/packages/Netbox/pypi/simple/ ``` -Или пропишите registry постоянно в `pip.conf` / `pip.ini`, чтобы не указывать `--index-url` каждый раз: +You can also configure the registry permanently in `pip.conf` or `pip.ini`: ```ini # ~/.config/pip/pip.conf (Linux/macOS) @@ -42,35 +46,35 @@ pip install oxipy \ extra-index-url = https://gitea.imbastark.ru/api/packages/Netbox/pypi/simple/ ``` -После этого достаточно: +After that, install normally: ```bash pip install oxipy ``` -Если registry требует аутентификации, передайте токен: +If the registry requires authentication, pass a token in the index URL: ```bash pip install oxipy \ --index-url https://__token__:<your_token>@gitea.imbastark.ru/api/packages/Netbox/pypi/simple/ ``` -### Из репозитория Gitea +### From Gitea Source -Установка напрямую через pip без клонирования: +Install directly from the repository: ```bash pip install git+https://gitea.imbastark.ru/Netbox/oxipy.git ``` -Конкретный тег или ветка: +Install a specific tag or branch: ```bash pip install git+https://gitea.imbastark.ru/Netbox/oxipy.git@v0.1.0 pip install git+https://gitea.imbastark.ru/Netbox/oxipy.git@dev ``` -Для разработки (editable install): +For local development: ```bash git clone https://gitea.imbastark.ru/Netbox/oxipy @@ -78,9 +82,7 @@ cd oxipy pip install -e . ``` ---- - -## Быстрый старт +## Quick Start ```python from oxi import OxiAPI @@ -89,39 +91,39 @@ 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.ip) +print(node.model) +print(node.full_name) print(node.config.system.model) -print(node.config.interfaces.json()) -print(node.config.vlans.json()) - ->>> Sprinter (KN-3710) ->>> -[ - {"name":"Bridge1","ip_address":"192.168.1.1","mask":24,"description":"\"Guest network\""}, - {"name":"Bridge0","ip_address":"172.16.1.1","mask":24,"description":"\"Home network\""} -] ->>> -[ - {"vlan_id":1,"name":"Home VLAN"}, - {"vlan_id":2,"name":"Подключение Ethernet"}, - {"vlan_id":3,"name":"Home network"} -] +print(node.config.interfaces.dump_json()) +print(node.config.vlans.dump_json()) ``` ---- +Example output: + +```text +192.168.1.1 +keenetic +router/HQ +Sprinter (KN-3710) +[ + {"interface": "Bridge1", "ip_address": "192.168.1.1", "mask": 24, "description": "Guest network"}, + {"interface": "Bridge0", "ip_address": "172.16.1.1", "mask": 24, "description": "Home network"} +] +[ + {"vlan_id": 1, "description": "Home VLAN"}, + {"vlan_id": 2, "description": "Ethernet uplink"}, + {"vlan_id": 3, "description": "Home network"} +] +``` ## API Reference ### OxiAPI -Точка входа. Управляет HTTP-сессией и предоставляет доступ к узлам. +`OxiAPI` is the entry point. It manages the HTTP session and provides access to +Oxidized nodes. ```python OxiAPI( @@ -132,61 +134,54 @@ OxiAPI( ) ``` +| Parameter | Type | Description | +| --- | --- | --- | +| `url` | `str` | Base URL of the Oxidized API, for example `https://oxi.example.com`. | +| `username` | `str | None` | Optional username for HTTP basic authentication. | +| `password` | `str | None` | Optional password for HTTP basic authentication. | +| `verify` | `bool` | Whether to verify TLS certificates. Defaults to `True`. | -| Параметр | Тип | Описание | -| ---------- | ------ | --------------------------------------------------------- | -| `url` | `str` | Базовый URL Oxi API, например `https://oxi.example.com` | -| `username` | `str` | Имя пользователя для базовой аутентификации (опционально) | -| `password` | `str` | Пароль для базовой аутентификации (опционально) | -| `verify` | `bool` | Проверять SSL-сертификат. `True` по умолчанию | - - -**Пример:** +Example: ```python -# Без аутентификации +# Without authentication api = OxiAPI(url="https://oxi.example.com") -# С базовой аутентификацией +# With HTTP basic authentication api = OxiAPI( url="https://oxi.example.com", username="admin", password="secret", ) -# Использование как контекстного менеджера (автоматически закрывает сессию) +# As a context manager. The HTTP session is closed automatically. with OxiAPI(url="https://oxi.example.com") as api: node = api.node("HQ") print(node.ip) - ->>> 192.168.1.1 ``` #### `api.node(name)` -Возвращает `[NodeView](#nodeview)` для указанного узла. +Returns a `NodeView` for the requested Oxidized node. ```python node = api.node("HQ") ``` ---- - ### NodeView -Представление узла сети. Содержит метаданные и ленивый доступ к конфигурации. +`NodeView` represents one network device. It contains metadata returned by +Oxidized and lazy access to the fetched configuration. +| Property | Type | Description | +| --- | --- | --- | +| `ip` | `str` | Node IP address. | +| `full_name` | `str` | Full node name in Oxidized. | +| `group` | `str` | Oxidized group the node belongs to. | +| `model` | `str` | Device model key used to select a parser. | +| `config` | `NodeConfig` | Device configuration, fetched and parsed on first access. | -| Свойство | Тип | Описание | -| ----------- | ------------ | ---------------------------------------------------- | -| `ip` | `str` | IP-адрес узла | -| `full_name` | `str` | Полное имя узла в Oxi | -| `group` | `str` | Группа, к которой принадлежит узел | -| `model` | `str` | Модель устройства (используется для парсинга) | -| `config` | `NodeConfig` | Конфигурация узла (загружается при первом обращении) | - - -**Пример:** +Example: ```python node = api.node("HQ") @@ -194,135 +189,106 @@ node = api.node("HQ") print(node.ip) print(node.group) print(node.model) - ->>> 192.168.1.1 ->>> branch-office ->>> keenetic ``` ---- - ### NodeConfig -Загружает и парсит конфигурацию устройства. Использует TTP-шаблоны, соответствующие модели устройства. +`NodeConfig` fetches and parses a device configuration. The parser is selected +from the device registry by the node `model` value returned by Oxidized. -Доступ к секциям конфигурации осуществляется через свойства, возвращающие `[ModelView](#modelview)`. +Configuration sections are exposed through properties that return `ModelView` +objects. +| Property | Returns | Description | +| --- | --- | --- | +| `system` | `ModelView[System]` | System information. | +| `interfaces` | `ModelView[list[Interfaces]]` | Parsed interface list. | +| `vlans` | `ModelView[list[Vlans]]` | Parsed VLAN list, if the template provides VLAN data. | +| `text` | `str` | Raw configuration text fetched from Oxidized. | -| Свойство | Возвращает | Описание | -| ------------ | ----------------------------- | ---------------------------------- | -| `system` | `ModelView[System]` | Системная информация об устройстве | -| `interfaces` | `ModelView[list[Interfaces]]` | Список интерфейсов | -| `vlans` | `ModelView[list[Vlans]]` | Список VLAN (если есть) | -| `text` | `str` | Сырой текст конфигурации | - - -**Пример:** +Example: ```python cfg = node.config -# Системная информация -print(cfg.system.model) -print(cfg.system.serial_number) -print(cfg.system.version) +print(cfg.system.model) +print(cfg.system.serial_number) +print(cfg.system.version) ->>> Mikrotik RB951Ui-2nD ->>> B88C0B31117B ->>> 7.16.1 - -# Итерация по интерфейсам for iface in cfg.interfaces: print(iface.name, iface.ip_address, iface.mask) -# Индексация first_iface = cfg.interfaces[0] print(first_iface.name) - -# Количество интерфейсов print(len(cfg.interfaces)) -# JSON-дамп любой секции -print(cfg.interfaces.json()) -print(cfg.vlans.json()) -print(cfg.system.json()) +print(cfg.interfaces.dump_json()) +print(cfg.vlans.dump_json()) +print(cfg.system.dump_json()) -# Сырая конфигурация текстом print(cfg.text) ``` ---- +`NodeConfig` also provides `dump()` and `dump_json()` methods for the whole +parsed device object. ### ModelView -Обёртка над Pydantic-моделью или списком моделей. Обеспечивает сериализацию, итерацию и прозрачный доступ к атрибутам. +`ModelView` wraps either a single Pydantic model or a list of Pydantic models. +It provides serialization, iteration for list sections, and transparent access +to model attributes. +| Method / operation | Applies to | Description | +| --- | --- | --- | +| `.dump()` | single model and list | Returns a Python `dict` or `list` using aliases. | +| `.dump_json()` | single model and list | Returns a JSON string using aliases. | +| `.<attr>` | single model and list | Proxies attribute access to the wrapped model. | +| `iter(view)` | list only | Iterates over wrapped models. | +| `len(view)` | list only | Returns the number of wrapped models. | +| `view[i]` | list only | Returns an item or slice. | -| Метод / свойство | Применимо к | Описание | -| ---------------- | ------------ | ------------------------------------------------- | -| `.json()` | оба варианта | Возвращает JSON-строку (с `by_alias=True`) | -| `.<attr>` | оба варианта | Проксирует обращение к атрибутам вложенной модели | -| `iter(view)` | список | Итерация по элементам списка моделей | -| `len(view)` | список | Количество элементов в списке | -| `view[i]` | список | Получение элемента по индексу или срез | +`__iter__`, `__len__`, and `__getitem__` are available only for list-backed +sections such as `interfaces` and `vlans`. Calling them on `system` raises +`TypeError`. - -> `__iter__`, `__len__` и `__getitem__` доступны только для `interfaces` и `vlans` (они оборачивают список). Вызов этих методов на `system` (одиночная модель) вызовет `TypeError`. - -**Примеры:** +Examples: ```python -# Одиночная модель — system -view = node.config.system -print(view.json()) ->>> '{"model":"RB951Ui-2nD","serial_number":"B88C0B31117B","version":"7.12.1"}' -print(view.model) # 'RB951Ui-2nD' -print(view.serial_number) # 'B88C0B31117B' +system = node.config.system +print(system.dump_json()) +print(system.model) +print(system.serial_number) ->>> RB951Ui-2nD ->>> B88C0B31117B -# Список — interfaces interfaces = node.config.interfaces -# Итерация for iface in interfaces: print(iface.name, iface.ip_address) -# Длина -print(len(interfaces)) # 5 - -# Индексация и срезы -first = interfaces[0] -top3 = interfaces[:3] - -# JSON всего списка -print(interfaces.json()) +print(len(interfaces)) +print(interfaces[0]) +print(interfaces[:3]) +print(interfaces.dump()) ``` ---- +## Supported Devices -## Поддерживаемые устройства +Registry keys are compared with the Oxidized node `model` value +case-insensitively. +| Device | Registry keys | +| --- | --- | +| Keenetic | `ndms`, `keenetic`, `keeneticos` | +| MikroTik | `routeros`, `ros`, `mikrotik` | +| Qtech | `qtech` | +| Huawei | `huawei`, `vrp` | +| Eltex | `eltex` | +| H3C | `h3c` | +| Quasar | `qos`, `quasar` | -| Устройство | Ключи реестра | -| ---------- | -------------------------------- | -| Keenetic | `ndms`, `keenetic`, `keeneticos` | -| MikroTik | `routeros`, `ros`, `mikrotik` | -| Qtech | `qtech` | -| Huawei | `huawei`, `vrp` | -| Eltex | `eltex` | -| H3C | `h3c` | -| Quasar | `qos`, `quasar` | +You can add support for another device family by creating a new device model +and TTP template. See [Extending Device Models](docs/extending-models.md). +## Additional Documentation -Ключи реестра — это значения поля `model`, возвращаемого API для узла. Регистр не учитывается. - -Добавить поддержку нового устройства можно самостоятельно — подробнее в разделе [Расширение моделей](docs/extending-models.md). - ---- - -## Дополнительно - -- [Написание TTP-шаблонов](docs/templates.md) -- [Расширение и переопределение моделей устройств](docs/extending-models.md) - +- [Writing TTP Templates](docs/templates.md) +- [Extending Device Models](docs/extending-models.md) diff --git a/docs/extending-models.md b/docs/extending-models.md index dfe30c8..f6dd620 100644 --- a/docs/extending-models.md +++ b/docs/extending-models.md @@ -1,46 +1,49 @@ -# Расширение и переопределение моделей устройств +# Extending Device Models -oxipy предоставляет гибкий механизм расширения через наследование от `BaseDevice`. После того как TTP-шаблон разобрал конфигурацию в сырой словарь `self.raw`, данные проходят через три метода экземпляра — `system()`, `interfaces()`, `vlans()` — перед тем как попасть в контракт. Переопределяя эти методы, можно трансформировать, фильтровать и обогащать данные без изменения шаблона или контракта. +`oxipy` parses an Oxidized configuration in two stages. A TTP template first +extracts raw dictionaries from the text, then a device model normalizes those +dictionaries before Pydantic validates them against the public contract. -## Содержание +Device models extend `BaseDevice`. Override `system()`, `interfaces()`, or +`vlans()` when the raw TTP result needs vendor-specific cleanup. -- [Архитектура: путь данных](#архитектура-путь-данных) -- [Регистрация нового устройства](#регистрация-нового-устройства) -- [Переопределение методов (monkey patching)](#переопределение-методов-monkey-patching) +## Contents + +- [Data Flow](#data-flow) +- [Registering a Device](#registering-a-device) +- [Method Overrides](#method-overrides) - [interfaces()](#interfaces) - [vlans()](#vlans) - [system()](#system) -- [Полный пример: новое устройство](#полный-пример-новое-устройство) -- [Контракт: ожидаемые структуры](#контракт-ожидаемые-структуры) +- [Complete Example](#complete-example) +- [Expected Contract](#expected-contract) ---- +## Data Flow -## Архитектура: путь данных - -``` -текст конфигурации - │ - ▼ - TTP-шаблон (.ttp) - │ парсит в сырой словарь - ▼ +```text +configuration text + | + v + TTP template (.ttp) + | + v self.raw: dict - │ - ├──► system() → dict - ├──► interfaces() → list[dict] - └──► vlans() → list[dict] - │ - ▼ - _validate_contract() - │ создаёт Pydantic-модели - ▼ - Device(system, interfaces, vlans) + | + +--> system() -> dict + +--> interfaces() -> list[dict] + +--> vlans() -> list[dict] + | + v + Pydantic validation + | + v + Device(system, interfaces, vlans) ``` -Методы `system()`, `interfaces()`, `vlans()` — это точки расширения. Базовая реализация просто возвращает данные из `self.raw`: +The extension methods are intentionally small. The base implementation returns +data directly from `self.raw`: ```python -# BaseDevice (упрощённо) def interfaces(self) -> list[dict]: return self.raw.get("interfaces", []) @@ -51,18 +54,16 @@ def system(self) -> dict: return self.raw.get("system", None) ``` ---- +## Registering a Device -## Регистрация нового устройства +To add support for a new vendor: -Чтобы добавить поддержку нового вендора: - -1. Создайте файл в `oxi/interfaces/models/`, например `cisco.py`. -2. Создайте шаблон `oxi/interfaces/models/templates/cisco.ttp`. -3. Унаследуйте класс от `BaseDevice` и зарегистрируйте его декоратором `@register_parser`. +1. Create a Python file in `oxi/interfaces/models/`, for example `cisco.py`. +2. Create a template in `oxi/interfaces/models/templates/`, for example + `cisco.ttp`. +3. Subclass `BaseDevice` and register it with `@register_parser`. ```python -# oxi/interfaces/models/cisco.py from oxi.interfaces import register_parser from oxi.interfaces.base import BaseDevice @@ -72,26 +73,26 @@ class CiscoIOS(BaseDevice): template = "cisco.ttp" ``` -Декоратор `@register_parser` принимает список строк — это ключи, по которым устройство ищется в реестре. Поле `model` от API сравнивается с этими ключами без учёта регистра. +`@register_parser` accepts a string or a list of strings. These values are the +registry keys used to match the Oxidized node `model` field. Matching is +case-insensitive. -После добавления файла он автоматически импортируется через `pkgutil` при старте приложения — явно импортировать не нужно. +Model modules are imported automatically through `pkgutil` when +`oxi.interfaces` is loaded, so you do not need to import your model class +manually. ---- - -## Переопределение методов (monkey patching) +## Method Overrides ### interfaces() -Используйте переопределение, когда нужно: +Override `interfaces()` when you need to: -- Преобразовать формат IP-адреса (например, `netmask` → `prefix_length`). -- Декодировать escape-последовательности в описаниях. -- Переименовать ключи, не совпадающие с контрактом. -- Фильтровать служебные интерфейсы. +- Convert dotted decimal netmasks to prefix lengths. +- Decode escaped descriptions. +- Rename keys that do not match the contract. +- Filter service-only interfaces. -**Пример: конвертация маски подсети в префикс** - -TTP возвращает `netmask` как `255.255.255.0`, а контракт `Interfaces` ожидает `mask` как целое число (prefix length): +Example: convert a netmask to a prefix length. ```python from ipaddress import ip_interface @@ -114,19 +115,17 @@ class MyVendor(BaseDevice): return result ``` -**Пример: фильтрация служебных интерфейсов** +Example: filter management interfaces. ```python def interfaces(self) -> list[dict]: return [ item for item in self.raw.get("interfaces", []) - if not item.get("name", "").startswith("lo") + if not item.get("interface", "").startswith("Mgmt") ] ``` -**Пример: декодирование Unicode escape-последовательностей** - -Некоторые устройства (например, Keenetic) хранят кириллические описания как `\xd0\xb8\xd0\xbc\xd1\x8f`: +Example: decode escaped UTF-8 descriptions. ```python def _decode_utf(self, text: str) -> str: @@ -140,6 +139,7 @@ def _decode_utf(self, text: str) -> str: ) return text + def interfaces(self) -> list[dict]: interfaces = self.raw.get("interfaces", []) for item in interfaces: @@ -148,75 +148,83 @@ def interfaces(self) -> list[dict]: return interfaces ``` ---- - ### vlans() -Аналогично `interfaces()`. Используйте для нормализации ID, декодирования названий, обогащения данными из других секций. +Override `vlans()` to normalize VLAN IDs, expand compressed ranges, decode +names, or merge details from multiple template groups. -**Пример: добавление префикса к имени VLAN** +Example: add a generated VLAN name. ```python def vlans(self) -> list[dict]: result = [] for item in self.raw.get("vlans", []): - item["description"] = f"VLAN_{item.get('id', '?')}" + item["description"] = f"VLAN_{item.get('vlan_id', '?')}" result.append(item) return result ``` -**Пример: объединение данных из нескольких секций** +Example: merge data from another raw group. ```python def vlans(self) -> list[dict]: - vlans = {v["id"]: v for v in self.raw.get("vlans", [])} - # обогащаем данными из другой секции, если она есть + vlans = {item["vlan_id"]: item for item in self.raw.get("vlans", [])} for extra in self.raw.get("vlan_details", []): - vlan_id = extra.get("id") + vlan_id = extra.get("vlan_id") if vlan_id in vlans: vlans[vlan_id].update(extra) return list(vlans.values()) ``` ---- +Example: expand a comma-separated VLAN range. + +```python +def _expand_vlan_range(value: str) -> list[str]: + result = [] + for part in value.split(","): + if "-" not in part: + result.append(part.strip()) + continue + start, end = (int(item) for item in part.split("-", 1)) + result.extend(str(vlan_id) for vlan_id in range(start, end + 1)) + return result +``` ### system() -Переопределяйте, если структура системной секции отличается от ожидаемой контрактом, или нужно вычислить поля: +Override `system()` when the system section needs computed fields or data from +another raw group. -**Пример: собрать серийный номер из нескольких полей** +Example: assemble a serial number from two fields. ```python def system(self) -> dict: raw_system = self.raw.get("system", {}) - # Устройство возвращает серийный номер в двух частях part1 = raw_system.get("serial_part1", "") part2 = raw_system.get("serial_part2", "") raw_system["serial_number"] = f"{part1}-{part2}" return raw_system ``` -**Пример: нормализация строки версии** +Example: normalize a version string. ```python def system(self) -> dict: raw_system = self.raw.get("system", {}) - # Убираем лишнее из "7.12.1 (stable)" → "7.12.1" version = raw_system.get("version", "") raw_system["version"] = version.split()[0] if version else version return raw_system ``` ---- +## Complete Example -## Полный пример: новое устройство +Assume a Cisco IOS-like device where: -Допустим, нужно добавить поддержку Cisco IOS, где: -- IP-адрес и маска разделены пробелом в конфигурации (`ip address 10.0.0.1 255.255.255.0`). -- Описание интерфейса может содержать несколько слов. -- Серийный номер разделён дефисом в двух строках. +- IP address and netmask are separated by a space. +- Interface descriptions can contain several words. +- System fields are present in separate lines. -**Шаблон** (`oxi/interfaces/models/templates/cisco.ttp`): +Template: `oxi/interfaces/models/templates/cisco.ttp` ```xml <vars> @@ -240,12 +248,12 @@ interface {{ interface | _start_ }} </group> <group name="vlans"> -vlan {{ id | _start_ }} - name {{ description }} +vlan {{ vlan_id | _start_ }} + name {{ name | ORPHRASE }} </group> ``` -**Класс устройства** (`oxi/interfaces/models/cisco.py`): +Device model: `oxi/interfaces/models/cisco.py` ```python from ipaddress import ip_interface @@ -260,12 +268,10 @@ class CiscoIOS(BaseDevice): def interfaces(self) -> list[dict]: result = [] for item in self.raw.get("interfaces", []): - # Конвертируем маску подсети в длину префикса if item.get("ip_address") and item.get("netmask"): iface = ip_interface(f"{item['ip_address']}/{item['netmask']}") item["mask"] = iface.network.prefixlen item.pop("netmask", None) - # Фильтруем интерфейсы управления if item.get("interface", "").startswith("Mgmt"): continue result.append(item) @@ -273,53 +279,48 @@ class CiscoIOS(BaseDevice): def system(self) -> dict: raw_system = self.raw.get("system", {}) - # Нормализуем версию: "15.2(4)M3" → оставляем как есть - # Убираем лишние пробелы в модели if raw_system.get("model"): raw_system["model"] = raw_system["model"].strip() return raw_system ``` ---- +## Expected Contract -## Контракт: ожидаемые структуры +Methods must return structures accepted by `oxi.interfaces.contract`. -Методы должны возвращать данные в следующем формате. Контракт жёстко проверяется Pydantic. - -### `system()` → `dict` +### `system() -> dict` ```python { - "model": "RB951Ui-2nD", # str, обязательно - "serial_number": "B88C0B31117B", # str, обязательно - "version": "7.12.1", # str, обязательно + "model": "RB951Ui-2nD", + "serial_number": "B88C0B31117B", + "version": "7.12.1", } ``` -### `interfaces()` → `list[dict]` +### `interfaces() -> list[dict]` ```python [ { - "interface": "ether1", # str, обязательно (alias для поля name) - "ip_address": "192.168.1.1", # str | None - "mask": 24, # int | None (длина префикса) - "description": "LAN", # str | None + "interface": "ether1", + "ip_address": "192.168.1.1", + "mask": 24, + "description": "LAN", }, - ... ] ``` -### `vlans()` → `list[dict]` +### `vlans() -> list[dict]` ```python [ { - "id": 10, # int, обязательно (alias для поля vlan_id) - "description": "MGMT", # str | None (alias для поля name) + "vlan_id": 10, + "description": "MGMT", }, - ... ] ``` -> Если имя ключа в словаре совпадает с **alias** поля Pydantic-модели, а не с именем атрибута — используйте alias. Модели сконфигурированы с `populate_by_name=True`, поэтому принимаются оба варианта. +The Pydantic models use `populate_by_name=True` for aliased models, so both +field names and aliases are accepted where aliases exist. diff --git a/docs/templates.md b/docs/templates.md index 86cf78d..3a4a23e 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -1,86 +1,86 @@ -# Написание TTP-шаблонов +# Writing TTP Templates -oxipy использует библиотеку [TTP (Template Text Parser)](https://ttp.readthedocs.io/) для парсинга конфигураций сетевых устройств в структурированные данные. Шаблоны хранятся в директории `oxi/interfaces/models/templates/`. +`oxipy` uses [TTP (Template Text Parser)](https://ttp.readthedocs.io/) to turn +network device configurations fetched from Oxidized into structured data. +Templates are stored in `oxi/interfaces/models/templates/`. -## Содержание +## Contents -- [Структура шаблона](#структура-шаблона) -- [Обязательные группы](#обязательные-группы) -- [Секция system](#секция-system) -- [Секция interfaces](#секция-interfaces) -- [Секция vlans](#секция-vlans) -- [TTP: основные возможности](#ttp-основные-возможности) -- [Переменные по умолчанию](#переменные-по-умолчанию) -- [Практические примеры](#практические-примеры) -- [Валидация шаблона](#валидация-шаблона) +- [Template Structure](#template-structure) +- [Required Groups](#required-groups) +- [The system Group](#the-system-group) +- [The interfaces Group](#the-interfaces-group) +- [The vlans Group](#the-vlans-group) +- [Useful TTP Features](#useful-ttp-features) +- [Default Variables](#default-variables) +- [Full Example](#full-example) +- [Validation](#validation) ---- +## Template Structure -## Структура шаблона - -Каждый шаблон — это `.ttp`-файл, состоящий из следующих блоков: +Each template is a `.ttp` file with a small set of conventional blocks: ```xml <doc> - Описание шаблона (опционально) + Optional template documentation. </doc> <vars> - <!-- Переменные по умолчанию для групп --> + <!-- Default values for groups. --> </vars> <group name="system"> - <!-- Правила для системной информации --> + <!-- Rules for system information. --> </group> <group name="interfaces"> - <!-- Правила для интерфейсов --> + <!-- Rules for interfaces. --> </group> <group name="vlans"> - <!-- Правила для VLAN (опционально) --> + <!-- Optional rules for VLANs. --> </group> ``` -Файл-заготовка находится в `oxi/interfaces/models/templates/_template.ttp`. +Use `oxi/interfaces/models/templates/_template.ttp` as the starting point for a +new parser. ---- +## Required Groups -## Обязательные группы +The framework requires two groups in every template: -Фреймворк требует наличия в шаблоне **двух обязательных групп**: +| Group | Required | Description | +| --- | --- | --- | +| `system` | Yes | Device system information. | +| `interfaces` | Yes | Interface configuration. | +| `vlans` | No | VLAN configuration. | -| Группа | Обязательна | Описание | -|--------------|-------------|-------------------------------| -| `system` | Да | Системная информация | -| `interfaces` | Да | Конфигурация интерфейсов | -| `vlans` | Нет | Конфигурация VLAN | +If a required group is missing from the template or from the TTP result, +`BaseDevice` raises `ValueError`. -Если обязательная группа отсутствует в шаблоне или TTP не вернул её данные, будет выброшено `ValueError`. +If a template declares an optional `vlans` group, `oxipy` expects TTP to return +that group. Omit the group completely for devices where VLAN parsing is not +implemented. ---- +## The system Group -## Секция system +The `system` group must return one dictionary with these fields: -Должна возвращать словарь со следующими полями: +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `model` | `str` | Yes | Device model. | +| `serial_number` | `str` | Yes | Device serial number. | +| `version` | `str` | Yes | Firmware, software, or build version chosen by the parser. | -| Поле | Тип | Обязательное | Описание | -|-----------------|------|--------------|---------------------| -| `model` | str | Да | Модель устройства | -| `serial_number` | str | Да | Серийный номер | -| `version` | str | Да | Версия прошивки | +Example for MikroTik: -**Пример (MikroTik):** - -Конфигурация: -``` +```text # version: 7.12.1 (stable) # model = RB951Ui-2nD # serial number = B88C0B31117B ``` -Шаблон: -``` +```xml <group name="system"> # version: {{ version }}{{ ignore('.*') }} # model = {{ model }} @@ -88,17 +88,15 @@ oxipy использует библиотеку [TTP (Template Text Parser)](htt </group> ``` -**Пример (Keenetic):** +Example for Keenetic: -Конфигурация: -``` +```text ! release: 4.1.7.1-1 ! model: Keenetic Extra ! hw_version: F02B4E7A1C90 ``` -Шаблон: -``` +```xml <group name="system"> ! release: {{ version }} ! model: {{ model | ORPHRASE }} @@ -106,34 +104,34 @@ oxipy использует библиотеку [TTP (Template Text Parser)](htt </group> ``` ---- +## The interfaces Group -## Секция interfaces +The `interfaces` group must return a list of dictionaries. Each dictionary +describes one interface. -Должна возвращать список словарей. Каждый словарь описывает один интерфейс. +The `Interfaces` contract expects these fields: -Поля, которые ожидает контракт `Interfaces`: +| Contract field | TTP name / alias | Type | Required | +| --- | --- | --- | --- | +| `name` | `interface` | `str` | Yes | +| `ip_address` | `ip_address` | `IPv4Address | None` | No | +| `mask` | `mask` | `int | None` | No | +| `description` | `description` | `str | None` | No | -| Поле | TTP-имя / alias | Тип | Обязательное | -|---------------|-----------------|------------------|--------------| -| `name` | `interface` | str | Да | -| `ip_address` | `ip_address` | IPv4Address | Нет | -| `mask` | `mask` | int (prefix len) | Нет | -| `description` | `description` | str | Нет | +The Pydantic field `name` has the alias `interface`, so templates should usually +emit `interface`. You can also emit `name` because the models allow population +by field name, or you can normalize keys in the device class by overriding +`interfaces()`. -> **Важно:** поле `name` в Pydantic-модели имеет алиас `interface`, поэтому в шаблоне переменную нужно называть именно `interface` **или** переопределить метод `interfaces()` в классе модели (см. [Расширение моделей](extending-models.md)). +Example for MikroTik: -**Пример (MikroTik):** - -Конфигурация: -``` +```text /ip address add address=192.168.1.1/24 interface=ether1 network=192.168.1.0 add address=10.0.0.1/30 comment="WAN link" interface=ether2 network=10.0.0.0 ``` -Шаблон: -``` +```xml <group name="interfaces"> /ip address add address={{ ip_address | _start_ }}/{{ mask }} interface={{ interface }} network={{ network }} @@ -141,108 +139,104 @@ add address={{ ip_address | _start_ }}/{{ mask }} comment={{ description | ORPHR </group> ``` -**Пример (Keenetic):** +Example for CLI-style devices: -Конфигурация: -``` -interface GigabitEthernet0/0 - description "WAN" - ip address 10.0.0.2 255.255.255.252 -interface GigabitEthernet0/1 - ip address 192.168.1.1 255.255.255.0 +```text +interface Vlanif120 + description SSH + ip address 10.26.196.254 255.255.255.0 ``` -Шаблон: -``` +```xml <group name="interfaces"> -interface {{ name | _start_ | exclude("Vlan") }} - description {{ description | ORPHRASE }} - ip address {{ ip_address }} {{ netmask }} +interface {{ interface | _start_ }} + description {{ description | ORPHRASE }} + ip address {{ ip_address }} {{ mask | to_cidr }} </group> ``` -Здесь переменная называется `name`, а не `interface` — это покрывается переопределением метода `interfaces()` в классе `Keenetic`. +Use TTP's `to_cidr` formatter when the device uses dotted decimal masks. ---- +## The vlans Group -## Секция vlans +The `vlans` group is optional. If it is declared, it must return a list of VLAN +dictionaries. -Необязательная группа. Если объявлена в шаблоне, фреймворк ожидает её наличия в результате TTP. +The `Vlans` contract expects these fields: -Поля контракта `Vlans`: +| Contract field | Alias | Type | Required | +| --- | --- | --- | --- | +| `vlan_id` | none | `int` | Yes | +| `name` | `description` | `str | None` | No | -| Поле | TTP-имя / alias | Тип | Обязательное | -|-----------|-----------------|------|--------------| -| `vlan_id` | `id` | int | Да | -| `name` | `description` | str | Нет | +`name` has the alias `description`, so either key is accepted. Existing parsers +use both forms depending on the vendor format. -> `vlan_id` имеет алиас `id`, поэтому в шаблоне переменная должна называться `id` либо переименовываться в методе `vlans()`. +Example: -**Пример (Keenetic):** - -Конфигурация: -``` -interface Bridge0/Vlan10 - description "MGMT" -interface Bridge0/Vlan20 - description "SERVERS" +```text +vlan 10 + name MGMT ``` -Шаблон: -``` +```xml <group name="vlans"> -interface {{ ignore }}/Vlan{{ id }} - description {{ description | ORPHRASE | strip('"') }} +vlan {{ vlan_id | _start_ }} + name {{ name | ORPHRASE }} </group> ``` ---- +For compressed vendor syntax such as `vlan batch 101 to 103 110`, parse the raw +range in the template and normalize it in the device class when needed. -## TTP: основные возможности +## Useful TTP Features -### Маркеры строк +### Line markers -| Маркер | Описание | -|-------------|---------------------------------------------------------------| -| `_start_` | Строка с этой переменной считается началом нового совпадения | -| `_end_` | Строка с этой переменной завершает совпадение группы | +| Marker | Description | +| --- | --- | +| `_start_` | Starts a new group match from the current line. | +| `_end_` | Ends the current group match. | -``` -add address={{ ip_address | _start_ }}/{{ mask }} interface={{ name }} +```xml +interface {{ interface | _start_ }} ``` -### Модификаторы переменных +### Variable modifiers -| Модификатор | Описание | -|------------------------|-----------------------------------------------------------| -| `ORPHRASE` | Захватывает одно слово или фразу (до конца строки) | -| `exclude("pattern")` | Пропускает строку, если захваченное значение содержит паттерн | -| `strip('"')` | Удаляет символ из начала и конца захваченного значения | -| `replace("old","new")` | Заменяет подстроку в захваченном значении | -| `re("pattern")` | Принимает значение, только если оно соответствует regex | -| `ignore` | Захватывает, но игнорирует значение (не включает в результат) | -| `ignore('.*')` | Игнорирует всё до конца строки | +| Modifier | Description | +| --- | --- | +| `ORPHRASE` | Captures a word or phrase to the end of the line. | +| `exclude("pattern")` | Skips the match when the captured value contains the pattern. | +| `strip('"')` | Removes a character from both ends of the captured value. | +| `replace("old","new")` | Replaces text inside the captured value. | +| `re("pattern")` | Accepts the value only if it matches the regex. | +| `ignore` | Captures and discards the value. | +| `ignore('.*')` | Discards the rest of the line. | +| `to_cidr` | Converts a dotted decimal netmask to a prefix length. | +| `unrange("-", ",")` | Expands ranges such as `10-12` using a comma separator. | +| `split(",")` | Splits a captured string into a list. | -### Комментарии в шаблоне +### Template comments -Строки, начинающиеся с `##`, — это комментарии TTP и не влияют на парсинг: +Lines beginning with `##` are TTP comments: -``` +```xml ## disabled no comment -add address={{ ip_address | _start_ }}/{{ mask }} interface={{ name }} +add address={{ ip_address | _start_ }}/{{ mask }} interface={{ interface }} ``` ---- +## Default Variables -## Переменные по умолчанию - -Блок `<vars>` позволяет задавать значения по умолчанию для группы через атрибут `default`: +The `<vars>` block can define default values for a group through the group's +`default` attribute: ```xml <vars> default_system = { "model": "", - "serial_number": "" + "serial_number": "", + "version": "" } </vars> @@ -253,17 +247,16 @@ default_system = { </group> ``` -Если шаблон не нашёл совпадений для группы, будет возвращён словарь из `default_system`. +If the group does not match anything, TTP returns the default dictionary. ---- +## Full Example -## Практические примеры - -### Полный шаблон для нового устройства (пример: Cisco IOS) +This simplified Cisco IOS-style example shows the expected shape of a complete +template: ```xml <doc> - Шаблон для парсинга Cisco IOS running-config +Cisco IOS running-config parser. </doc> <vars> @@ -283,24 +276,24 @@ System serial number : {{ serial_number }} <group name="interfaces"> interface {{ interface | _start_ }} description {{ description | ORPHRASE }} - ip address {{ ip_address }} {{ netmask }} - shutdown {{ shutdown | set("True") }} + ip address {{ ip_address }} {{ mask | to_cidr }} </group> <group name="vlans"> -vlan {{ id | _start_ }} - name {{ description }} +vlan {{ vlan_id | _start_ }} + name {{ name | ORPHRASE }} </group> ``` ---- +## Validation -## Валидация шаблона +`BaseDevice` performs two validation passes: -Фреймворк автоматически выполняет два уровня проверки: +1. Template structure validation checks that the template declares the required + `system` and `interfaces` groups. +2. Parse result validation checks that TTP actually returned the required groups + for the given configuration. -1. **Валидация структуры шаблона** — при создании объекта устройства парсятся XML-теги `<group>` и проверяется наличие обязательных секций (`system`, `interfaces`). - -2. **Валидация результата парсинга** — после запуска TTP проверяется, что обязательные группы действительно присутствуют в результате (т.е. конфигурация содержала соответствующие строки). - -При нарушении любого условия выбрасывается `ValueError` с подробным описанием проблемы. +After that, parsed data is validated by Pydantic models from +`oxi.interfaces.contract`. Invalid structures raise the original Pydantic +validation error. diff --git a/oxi/interfaces/base.py b/oxi/interfaces/base.py index 62610ee..0e3cbe2 100644 --- a/oxi/interfaces/base.py +++ b/oxi/interfaces/base.py @@ -24,10 +24,7 @@ class BaseDevice(ABC): @abstractmethod def template(self) -> str: """ - Expected structure: - Название файла с парсером ttp - Returns: - None + Name of the TTP template file used by this device parser. """ def vlans(self) -> list[dict]: @@ -35,14 +32,14 @@ class BaseDevice(ABC): Parse VLAN configuration from self.raw['vlans']. Expected structure: - [{"id": 10, "description": "MGMT"}, {"id": 15, "name": "SSH"}, ...] + [{"vlan_id": 10, "description": "MGMT"}, {"vlan_id": 15, "name": "SSH"}, ...] Returns: - list[Vlans]: список VLAN из секции vlans, - пустой список если секция отсутствует. + list[Vlans]: VLANs from the vlans section, or an empty list + when the section is absent. Raises: - ValueError: если raw содержит некорректные данные. + ValueError: if raw data cannot be validated by the contract. """ return self.raw.get("vlans", []) @@ -51,10 +48,10 @@ class BaseDevice(ABC): Parse Interface configuration from self.raw['interfaces']. Expected raw structure: - [{"name": "GEthernet1/0/1", "ip_address": "192.168.1.1", "mask": "24", "description": "IPBB interface"}] + [{"interface": "GEthernet1/0/1", "ip_address": "192.168.1.1", "mask": "24", "description": "IPBB interface"}] Raises: - ValueError: если raw содержит некорректные данные. + ValueError: if raw data cannot be validated by the contract. """ return self.raw.get("interfaces", []) @@ -66,7 +63,7 @@ class BaseDevice(ABC): {"model":"RB951Ui-2nD", serial_number: "B88C0B31117B", "version": "7.12.1"} Raises: - ValueError: если raw содержит некорректные данные. + ValueError: if raw data cannot be validated by the contract. """ return self.raw.get("system", None) @@ -97,14 +94,14 @@ class BaseDevice(ABC): return result def _load_template(self): - """Подгрузка темплейтов из папки models/templates""" + """Load the device TTP template from models/templates.""" path = Path(__file__).parent / "models" / "templates" / self.template if not path.exists(): raise FileNotFoundError(f"Template {self.template} not found") return path.read_text(encoding="utf-8") def _validate_template_groups(self) -> None: - """Проверяем только обязательные группы в template.""" + """Validate that the template declares all required groups.""" try: root = ET.fromstring(self._loaded_template) except ET.ParseError: @@ -122,7 +119,7 @@ class BaseDevice(ABC): ) def _run_ttp(self) -> dict: - """Основной парсер""" + """Run the node-not-found check and then parse the config with TTP.""" pattern = """node not {{found}}""" parser = ttp(data=self.config, template=pattern) parser.parse() diff --git a/oxi/interfaces/models/eltex.py b/oxi/interfaces/models/eltex.py index f4a08bf..ee59af0 100644 --- a/oxi/interfaces/models/eltex.py +++ b/oxi/interfaces/models/eltex.py @@ -2,6 +2,30 @@ from oxi.interfaces import register_parser from oxi.interfaces.base import BaseDevice +def _expand_vlan_range(value: str | list[str]) -> list[str]: + if isinstance(value, list): + value = ",".join(str(item) for item in value) + + result: list[str] = [] + for part in value.split(","): + part = part.strip() + if not part: + continue + if "-" not in part: + result.append(part) + continue + start_s, end_s = part.split("-", 1) + try: + start, end = int(start_s), int(end_s) + except ValueError: + result.append(part) + continue + if start > end: + start, end = end, start + result.extend(str(vlan_id) for vlan_id in range(start, end + 1)) + return result + + @register_parser("eltex") class Eltex(BaseDevice): template = "eltex.ttp" @@ -17,22 +41,23 @@ class Eltex(BaseDevice): def vlans(self) -> list[dict]: vlans_ttp = self.raw.get("vlans", []) - vlans = [] - named_vlan = set() + vlans: list[dict] = [] + named_vlan: set[str] = set() for item in vlans_ttp: - if item.get("vlan_id"): - named_vlan.add(item.get("vlan_id")) + vlan_id = item.get("vlan_id") + if vlan_id: + named_vlan.add(str(vlan_id)) vlans.append(item) - else: - 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}) + continue + + ids = item.get("vlan_ids", "") + tail = item.get("vlan_tail") + if tail: + ids = [*ids, tail] if isinstance(ids, list) else f"{ids},{tail}" + for vid in _expand_vlan_range(ids): + if vid in named_vlan: + continue + vlans.append({"vlan_id": vid}) return vlans diff --git a/oxi/interfaces/models/h3c.py b/oxi/interfaces/models/h3c.py index f97b209..db2444f 100644 --- a/oxi/interfaces/models/h3c.py +++ b/oxi/interfaces/models/h3c.py @@ -6,15 +6,17 @@ class H3C(BaseDevice): template = "h3c.ttp" def vlans(self) -> list[dict]: - vlan_list = self.raw["vlans"] - vlans = [] + vlan_list = self.raw.get("vlans", []) + vlans: list[dict] = [] for item in vlan_list: - if item.get("vlans_id"): - vlans.extend([{'vlan_id': vln }for vln in item.get("vlans_id")]) - else: + vlan_ids = item.get("vlans_id") + if not vlan_ids: vlans.append(item) + continue + vlans.extend({"vlan_id": vlan_id} for vlan_id in vlan_ids) return vlans + if __name__ == "__main__": with open("./test5.txt") as file: data = file.read() diff --git a/oxi/interfaces/models/huawei.py b/oxi/interfaces/models/huawei.py index 4d53dbb..ddb90a1 100644 --- a/oxi/interfaces/models/huawei.py +++ b/oxi/interfaces/models/huawei.py @@ -6,8 +6,8 @@ from oxi.interfaces.base import BaseDevice class Huawei(BaseDevice): template = "huawei.ttp" - def vlans(self): - vlan_ids = self.raw["vlans"].get("vlan_ids") + def vlans(self) -> list[dict]: + vlan_ids = self.raw.get("vlans", {}).get("vlan_ids", []) return [{"vlan_id": vlan} for vlan in vlan_ids] diff --git a/oxi/interfaces/models/qtech.py b/oxi/interfaces/models/qtech.py index 605fb9d..cf2247b 100644 --- a/oxi/interfaces/models/qtech.py +++ b/oxi/interfaces/models/qtech.py @@ -1,10 +1,12 @@ -import os from oxi.interfaces import register_parser from oxi.interfaces.base import BaseDevice -def _expand_vlan_range(value: str) -> list[str]: - """Разворачивает строку вида '1,7,14-15,200-205' в список ['1','7','14','15',...].""" +def _expand_vlan_range(value: str | list[str]) -> list[str]: + """Expand values like '1,7,14-15' into individual VLAN IDs.""" + if isinstance(value, list): + value = ",".join(str(item) for item in value) + result: list[str] = [] if not value: return result @@ -31,12 +33,8 @@ def _expand_vlan_range(value: str) -> list[str]: class Qtech(BaseDevice): template = "qtech.ttp" - def system(self) -> dict: - system = self.raw["system"] - return system - def vlans(self) -> list[dict]: - vlans_ttp = self.raw["vlans"] + vlans_ttp = self.raw.get("vlans", []) vlans: list[dict] = [] named_vlan: set[str] = set() for item in vlans_ttp: @@ -49,7 +47,7 @@ class Qtech(BaseDevice): ids = item.get("vlan_ids") or vlan_id or "" tail = item.get("vlan_tail") if tail: - ids = f"{ids},{tail}" + ids = [*ids, tail] if isinstance(ids, list) else f"{ids},{tail}" for vid in _expand_vlan_range(ids): if vid in named_vlan: continue @@ -58,7 +56,6 @@ class Qtech(BaseDevice): if __name__ == "__main__": - print(os.path.abspath(os.curdir)) with open("./test3.txt") as file: data = file.read() qtech = Qtech(data) diff --git a/oxi/interfaces/models/quasar.py b/oxi/interfaces/models/quasar.py index 3f87156..5d320f8 100644 --- a/oxi/interfaces/models/quasar.py +++ b/oxi/interfaces/models/quasar.py @@ -6,9 +6,9 @@ class Quasar(BaseDevice): template = "quasar.ttp" def interfaces(self) -> list[dict]: - ether_interfaces: dict = self.raw["interfaces"] + ether_interface: dict = self.raw.get("interfaces", {}) interfaces: list[dict] = [] - bulk_interfaces: dict = self.raw["bulkinterfaces"] + bulk_interfaces: dict = self.raw.get("bulkinterfaces", {}) for key, value in bulk_interfaces.items(): interfaces.append( { @@ -18,7 +18,8 @@ class Quasar(BaseDevice): "mask": value.get("mask"), } ) - interfaces.append(ether_interfaces) + if ether_interface: + interfaces.append(ether_interface) return interfaces diff --git a/oxi/interfaces/models/templates/_template.ttp b/oxi/interfaces/models/templates/_template.ttp index 2362b98..c582b1a 100644 --- a/oxi/interfaces/models/templates/_template.ttp +++ b/oxi/interfaces/models/templates/_template.ttp @@ -1,41 +1,20 @@ <doc> -Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте -в <vendor>.ttp и заполните группы под формат конфигурации вашего устройства. +Base template for a new device parser. Copy this file, rename it to +<vendor>.ttp, and fill the groups for the target configuration format. -Обязательные группы: system, interfaces. -Опциональная группа: vlans — добавляйте только если устройство поддерживает VLAN. +Required groups: system, interfaces. +Optional group: vlans. Add it only when VLAN parsing is implemented. ---- Группа system --- -Должна возвращать одиночный словарь с полями: - model (str) — модель устройства - serial_number (str) — серийный номер - version (str) — версия прошивки +system must return one dictionary with: model, serial_number, version. +interfaces must return a list of dictionaries with: interface, ip_address, +mask, description. Use a prefix length for mask; convert dotted decimal masks +with `to_cidr` or in the device class. +vlans must return dictionaries with vlan_id and optional name/description. ---- Группа interfaces --- -Должна возвращать список словарей. Каждый элемент: - interface (str) — имя интерфейса (alias поля name) - ip_address (str|None) — IPv4-адрес - mask (int|None) — длина префикса (напр. 24) - description (str|None) — описание интерфейса +Useful TTP modifiers: ORPHRASE, _start_, strip(), replace(), exclude(), +ignore, ignore('.*'), to_cidr, unrange(), split(). - Если устройство возвращает маску в виде 255.255.255.0, конвертируйте - её в prefix length в методе interfaces() класса устройства. - ---- Группа vlans --- -Должна возвращать список словарей. Каждый элемент: - id (int) — номер VLAN (alias поля vlan_id) - description (str|None) — название VLAN (alias поля name) - ---- Полезные модификаторы TTP --- - {{ field | ORPHRASE }} — одно слово или фраза до конца строки - {{ field | _start_ }} — начало новой записи группы - {{ field | strip('"') }} — убрать кавычки - {{ field | replace("yes","True") }} — замена подстроки - {{ field | exclude("pattern") }} — пропустить строку при совпадении - {{ ignore }} — захватить и выбросить значение - {{ ignore('.*') }} — выбросить всё до конца строки - -Подробнее: docs/templates.md +See docs/templates.md for details. </doc> <vars> default_system = { diff --git a/oxi/interfaces/models/templates/eltex.ttp b/oxi/interfaces/models/templates/eltex.ttp index 2d8660c..ce66112 100644 --- a/oxi/interfaces/models/templates/eltex.ttp +++ b/oxi/interfaces/models/templates/eltex.ttp @@ -1,41 +1,10 @@ <doc> -Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте -в <vendor>.ttp и заполните группы под формат конфигурации вашего устройства. +Eltex configuration parser. -Обязательные группы: system, interfaces. -Опциональная группа: vlans — добавляйте только если устройство поддерживает VLAN. - ---- Группа system --- -Должна возвращать одиночный словарь с полями: - model (str) — модель устройства - serial_number (str) — серийный номер - version (str) — версия прошивки - ---- Группа interfaces --- -Должна возвращать список словарей. Каждый элемент: - interface (str) — имя интерфейса (alias поля name) - ip_address (str|None) — IPv4-адрес - mask (int|None) — длина префикса (напр. 24) - description (str|None) — описание интерфейса - - Если устройство возвращает маску в виде 255.255.255.0, конвертируйте - её в prefix length в методе interfaces() класса устройства. - ---- Группа vlans --- -Должна возвращать список словарей. Каждый элемент: - id (int) — номер VLAN (alias поля vlan_id) - description (str|None) — название VLAN (alias поля name) - ---- Полезные модификаторы TTP --- - {{ field | ORPHRASE }} — одно слово или фраза до конца строки - {{ field | _start_ }} — начало новой записи группы - {{ field | strip('"') }} — убрать кавычки - {{ field | replace("yes","True") }} — замена подстроки - {{ field | exclude("pattern") }} — пропустить строку при совпадении - {{ ignore }} — захватить и выбросить значение - {{ ignore('.*') }} — выбросить всё до конца строки - -Подробнее: docs/templates.md +The system group reads software version data and the serial group extracts +serial numbers from the unit table. The interfaces group parses interface IP +settings. The vlans group supports named VLAN interfaces and compressed VLAN +lists. </doc> <vars> default_system = { diff --git a/oxi/interfaces/models/templates/h3c.ttp b/oxi/interfaces/models/templates/h3c.ttp index 8c3abe6..afbe090 100644 --- a/oxi/interfaces/models/templates/h3c.ttp +++ b/oxi/interfaces/models/templates/h3c.ttp @@ -1,41 +1,9 @@ <doc> -Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте -в <vendor>.ttp и заполните группы под формат конфигурации вашего устройства. +H3C configuration parser. -Обязательные группы: system, interfaces. -Опциональная группа: vlans — добавляйте только если устройство поддерживает VLAN. - ---- Группа system --- -Должна возвращать одиночный словарь с полями: - model (str) — модель устройства - serial_number (str) — серийный номер - version (str) — версия прошивки - ---- Группа interfaces --- -Должна возвращать список словарей. Каждый элемент: - interface (str) — имя интерфейса (alias поля name) - ip_address (str|None) — IPv4-адрес - mask (int|None) — длина префикса (напр. 24) - description (str|None) — описание интерфейса - - Если устройство возвращает маску в виде 255.255.255.0, конвертируйте - её в prefix length в методе interfaces() класса устройства. - ---- Группа vlans --- -Должна возвращать список словарей. Каждый элемент: - id (int) — номер VLAN (alias поля vlan_id) - description (str|None) — название VLAN (alias поля name) - ---- Полезные модификаторы TTP --- - {{ field | ORPHRASE }} — одно слово или фраза до конца строки - {{ field | _start_ }} — начало новой записи группы - {{ field | strip('"') }} — убрать кавычки - {{ field | replace("yes","True") }} — замена подстроки - {{ field | exclude("pattern") }} — пропустить строку при совпадении - {{ ignore }} — захватить и выбросить значение - {{ ignore('.*') }} — выбросить всё до конца строки - -Подробнее: docs/templates.md +The system group reads boot image version and board model data. The interfaces +group parses interface IP settings. The vlans groups parse both named VLANs and +range-style VLAN declarations. </doc> <vars> default_system = { diff --git a/oxi/interfaces/models/templates/huawei.ttp b/oxi/interfaces/models/templates/huawei.ttp index 2585629..4686572 100644 --- a/oxi/interfaces/models/templates/huawei.ttp +++ b/oxi/interfaces/models/templates/huawei.ttp @@ -1,40 +1,9 @@ <doc> -Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте -в <vendor>.ttp и заполните группы под формат конфигурации вашего устройства. +Huawei VRP configuration parser. -Обязательные группы: system, interfaces. -Опциональная группа: vlans — добавляйте только если устройство поддерживает VLAN. - ---- Группа system --- -Должна возвращать одиночный словарь с полями: - model (str) — модель устройства - serial_number (str) — серийный номер - version (str) — версия прошивки - ---- Группа interfaces --- -Должна возвращать список словарей. Каждый элемент: - interface (str) — имя интерфейса (alias поля name) - ip_address (str|None) — IPv4-адрес - mask (int|None) — длина префикса (напр. 24) - description (str|None) — описание интерфейса - - Если устройство возвращает маску в виде 255.255.255.0, конвертируйте - её в prefix length в методе interfaces() класса устройства. - ---- Группа vlans --- -Должна возвращать список словарей. Каждый элемент: - id (int) — номер VLAN (alias поля vlan_id) - description (str|None) — название VLAN (alias поля name) - ---- Полезные модификаторы TTP --- - {{ field | ORPHRASE }} — одно слово или фраза до конца строки - {{ field | _start_ }} — начало новой записи группы - {{ field | strip('"') }} — убрать кавычки - {{ field | replace("yes","True") }} — замена подстроки - {{ field | exclude("pattern") }} — пропустить строку при совпадении - {{ ignore }} — захватить и выбросить значение - {{ ignore('.*') }} — выбросить всё до конца строки -Подробнее: docs/templates.md +The system group reads VRP version and slot ESN data. The interfaces group +parses interface blocks and converts dotted decimal masks to prefix lengths. +The vlans group parses `vlan batch` declarations and emits VLAN IDs. </doc> <vars> default_system = { diff --git a/oxi/interfaces/models/templates/qtech.ttp b/oxi/interfaces/models/templates/qtech.ttp index f606893..1db628d 100644 --- a/oxi/interfaces/models/templates/qtech.ttp +++ b/oxi/interfaces/models/templates/qtech.ttp @@ -1,4 +1,13 @@ <doc> +Qtech switch configuration parser. + +The system group reads the model, serial number, and build number. For Qtech, +system.version intentionally stores the build number from lines like +`Version 2.2.0C Build 96279`. + +The interfaces group parses CLI interface blocks and converts dotted decimal +masks to prefix lengths. The vlans group supports named VLANs, comma-separated +VLAN lists, ranges, and continuation lines. </doc> <vars> default_system = { diff --git a/oxi/interfaces/models/templates/quasar.ttp b/oxi/interfaces/models/templates/quasar.ttp index 98e20fa..9408d9f 100644 --- a/oxi/interfaces/models/templates/quasar.ttp +++ b/oxi/interfaces/models/templates/quasar.ttp @@ -1,41 +1,10 @@ <doc> -Базовый шаблон для нового устройства. Скопируйте этот файл, переименуйте -в <vendor>.ttp и заполните группы под формат конфигурации вашего устройства. +Quasar configuration parser. -Обязательные группы: system, interfaces. -Опциональная группа: vlans — добавляйте только если устройство поддерживает VLAN. - ---- Группа system --- -Должна возвращать одиночный словарь с полями: - model (str) — модель устройства - serial_number (str) — серийный номер - version (str) — версия прошивки - ---- Группа interfaces --- -Должна возвращать список словарей. Каждый элемент: - interface (str) — имя интерфейса (alias поля name) - ip_address (str|None) — IPv4-адрес - mask (int|None) — длина префикса (напр. 24) - description (str|None) — описание интерфейса - - Если устройство возвращает маску в виде 255.255.255.0, конвертируйте - её в prefix length в методе interfaces() класса устройства. - ---- Группа vlans --- -Должна возвращать список словарей. Каждый элемент: - id (int) — номер VLAN (alias поля vlan_id) - description (str|None) — название VLAN (alias поля name) - ---- Полезные модификаторы TTP --- - {{ field | ORPHRASE }} — одно слово или фраза до конца строки - {{ field | _start_ }} — начало новой записи группы - {{ field | strip('"') }} — убрать кавычки - {{ field | replace("yes","True") }} — замена подстроки - {{ field | exclude("pattern") }} — пропустить строку при совпадении - {{ ignore }} — захватить и выбросить значение - {{ ignore('.*') }} — выбросить всё до конца строки - -Подробнее: docs/templates.md +The system group supports Assembly-based and Engine-based firmware blocks. The +interfaces group parses the management Ethernet address, while bulkinterfaces +collects per-port descriptions that the Python model merges into interface +records. </doc> <vars> default_system = { diff --git a/pyproject.toml b/pyproject.toml index 76e2c37..3e081d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "oxipy" version = "0.1.0" -description = "Oxidized API client" +description = "Python client for Oxidized API with TTP-based config parsing" readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index 4ecb60b..c3bf3aa 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.11" +requires-python = ">=3.10" [[package]] name = "annotated-types" @@ -26,6 +26,22 @@ version = "3.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, @@ -143,6 +159,19 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, @@ -221,6 +250,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, -- 2.53.0 From 2a03240414368f4c7d8491742ba4fdf30746be1b Mon Sep 17 00:00:00 2001 From: IluaAir <ilya@air.ru> Date: Thu, 28 May 2026 15:55:20 +0300 Subject: [PATCH 59/80] Update setuptools version and modify license information in pyproject.toml - Updated the required setuptools version from 61 to 77 to ensure compatibility with the latest features and improvements. - Changed the license format in `pyproject.toml` to specify "Apache-2.0" directly and added a reference to the license file for clarity. --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3e081d9..83e4615 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] @@ -7,11 +7,11 @@ name = "oxipy" version = "0.1.0" description = "Python client for Oxidized API with TTP-based config parsing" readme = "README.md" -license = { file = "LICENSE" } +license = "Apache-2.0" +license-files = ["LICENSE"] requires-python = ">=3.10" classifiers = [ "Programming Language :: Python :: 3", - "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", ] dependencies = [ -- 2.53.0 From 494cc9b08be701b3956bfbf26d934e80897a6626 Mon Sep 17 00:00:00 2001 From: IluaAir <ilya@air.ru> Date: Fri, 29 May 2026 15:53:24 +0300 Subject: [PATCH 60/80] Update project repository URL and simplify installation instructions in README.md - Added a repository URL section in `pyproject.toml` to link to the GitHub repository. - Updated installation instructions in `README.md` to reflect the change from Gitea to GitHub as the source for package installation, removing references to the private Gitea Package Registry. --- README.md | 46 +++++++--------------------------------------- pyproject.toml | 3 +++ 2 files changed, 10 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index e065633..2ca7943 100644 --- a/README.md +++ b/README.md @@ -22,62 +22,30 @@ configuration sections such as system data, interfaces, and VLANs. ## Installation -The package is distributed through a private Gitea Package Registry and from the -source repository. It is not published to PyPI. +The package is distributed from the source repository. It is not published to +PyPI yet. **Requirements:** Python 3.10+ -### From Gitea Package Registry - -Install the package by pointing `pip` to the private registry: - -```bash -pip install oxipy \ - --index-url https://gitea.imbastark.ru/api/packages/Netbox/pypi/simple/ -``` - -You can also configure the registry permanently in `pip.conf` or `pip.ini`: - -```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/ -``` - -After that, install normally: - -```bash -pip install oxipy -``` - -If the registry requires authentication, pass a token in the index URL: - -```bash -pip install oxipy \ - --index-url https://__token__:<your_token>@gitea.imbastark.ru/api/packages/Netbox/pypi/simple/ -``` - -### From Gitea Source +### From GitHub Source Install directly from the repository: ```bash -pip install git+https://gitea.imbastark.ru/Netbox/oxipy.git +pip install git+https://github.com/sttarsky/oxipy.git ``` Install a specific tag or branch: ```bash -pip install git+https://gitea.imbastark.ru/Netbox/oxipy.git@v0.1.0 -pip install git+https://gitea.imbastark.ru/Netbox/oxipy.git@dev +pip install git+https://github.com/sttarsky/oxipy.git@v0.1.0 +pip install git+https://github.com/sttarsky/oxipy.git@dev ``` For local development: ```bash -git clone https://gitea.imbastark.ru/Netbox/oxipy +git clone https://github.com/sttarsky/oxipy cd oxipy pip install -e . ``` diff --git a/pyproject.toml b/pyproject.toml index 83e4615..066d1ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,9 @@ dependencies = [ "ttp>=0.10.0", ] +[project.urls] +Repository = "https://github.com/sttarsky/oxipy" + [tool.setuptools.packages.find] where = ["."] include = ["oxi*"] -- 2.53.0 From a55fc938f02c6e45b678cb4d16c0c1e876d9020b Mon Sep 17 00:00:00 2001 From: IluaAir <ilya@air.ru> Date: Sat, 6 Jun 2026 11:06:03 +0300 Subject: [PATCH 61/80] Add optional dependencies for development and update .gitignore - Added `.DS_Store` to `.gitignore` to prevent macOS system files from being tracked. - Introduced optional development dependencies in `pyproject.toml`, including `pytest` and `responses`, to facilitate testing and development. - Updated `uv.lock` with new package dependencies and versions for improved compatibility and functionality. - Created new test files `conftest.py` and `test_view.py` to establish testing fixtures and implement unit tests for the `ModelView` class. --- .gitignore | 1 + pyproject.toml | 18 +++- tests/conftest.py | 8 ++ tests/test_view.py | 72 +++++++++++++++ uv.lock | 219 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_view.py diff --git a/.gitignore b/.gitignore index 964223f..ab9f627 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ main.py # Virtual environments .venv .idea +.DS_Store # etc files *.txt \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 066d1ab..75bb927 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,9 +20,19 @@ dependencies = [ "ttp>=0.10.0", ] +[project.optional-dependencies] +dev = [ + "pytest>=8", + "responses>=0.25", +] + [project.urls] Repository = "https://github.com/sttarsky/oxipy" +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] + [tool.setuptools.packages.find] where = ["."] include = ["oxi*"] @@ -31,4 +41,10 @@ include = ["oxi*"] include-package-data = true [tool.setuptools.package-data] -"oxi" = ["**/*.ttp"] \ No newline at end of file +"oxi" = ["**/*.ttp"] + +[dependency-groups] +dev = [ + "pytest>=9.0.3", + "responses>=0.26.1", +] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4c39a0a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +from pathlib import Path + +FIXTURES = Path(__file__).parent / "fixtures" + + +def load(device: str, name: str = "config.conf") -> str: + """Read a device config fixture from tests/fixtures/<device>/<name>.""" + return (FIXTURES / device / name).read_text(encoding="utf-8") diff --git a/tests/test_view.py b/tests/test_view.py new file mode 100644 index 0000000..ea38ac8 --- /dev/null +++ b/tests/test_view.py @@ -0,0 +1,72 @@ +import json + +import pytest + +from oxi.conf import ModelView +from oxi.interfaces.contract import Interfaces, System + + +@pytest.fixture +def system_view(): + system = System(model="RB951", serial_number="ABC123", version="7.12") + return ModelView(system) + + +@pytest.fixture +def interfaces_view(): + items = [ + Interfaces(interface="eth0", ip_address="192.168.1.1", mask=24), + Interfaces(interface="eth1", description="uplink"), + ] + return ModelView(items) + + +class TestSingleModelView: + def test_attribute_proxy(self, system_view): + assert system_view.model == "RB951" + assert system_view.serial_number == "ABC123" + + def test_dump(self, system_view): + assert system_view.dump() == { + "model": "RB951", + "serial_number": "ABC123", + "version": "7.12", + } + + def test_dump_json(self, system_view): + assert json.loads(system_view.dump_json())["model"] == "RB951" + + def test_iter_raises(self, system_view): + with pytest.raises(TypeError): + iter(system_view) + + def test_len_raises(self, system_view): + with pytest.raises(TypeError): + len(system_view) + + def test_getitem_raises(self, system_view): + with pytest.raises(TypeError): + system_view[0] + + +class TestListModelView: + def test_len(self, interfaces_view): + assert len(interfaces_view) == 2 + + def test_iter(self, interfaces_view): + names = [iface.name for iface in interfaces_view] + assert names == ["eth0", "eth1"] + + def test_getitem(self, interfaces_view): + assert interfaces_view[0].name == "eth0" + + def test_slice(self, interfaces_view): + assert len(interfaces_view[:1]) == 1 + + def test_dump_uses_aliases(self, interfaces_view): + dumped = interfaces_view.dump() + assert dumped[0]["interface"] == "eth0" + + def test_dump_json_keeps_unicode(self): + view = ModelView([Interfaces(interface="eth0", description="Дом")]) + assert "Дом" in view.dump_json() diff --git a/uv.lock b/uv.lock index c3bf3aa..36a43d8 100644 --- a/uv.lock +++ b/uv.lock @@ -109,6 +109,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -118,6 +139,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "oxipy" version = "0.1.0" @@ -128,6 +158,12 @@ dependencies = [ { name = "ttp" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "responses" }, +] + [package.metadata] requires-dist = [ { name = "pydantic", specifier = ">=2.12.5" }, @@ -135,6 +171,30 @@ requires-dist = [ { name = "ttp", specifier = ">=0.10.0" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.3" }, + { name = "responses", specifier = ">=0.26.1" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -268,6 +328,97 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -283,6 +434,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "responses" +version = "0.26.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/58/1fb6de3503428196df78638f991ec8095274f1ee9723e272ee4d9ff0092b/responses-0.26.1.tar.gz", hash = "sha256:2eb3218553cc8f79b57d257bac23af5e1bf381f5b9390b1767816f0843e01dc2", size = 83088, upload-time = "2026-05-21T19:56:39.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/31/6a620b4427d546b9e7cca8b3b8c5f0559d9cef2bb9eedcda7f73c1473c19/responses-0.26.1-py3-none-any.whl", hash = "sha256:8aacc4586eb08fb2208ef64a9eb4258d9b0c6e6f4260845f2f018ab847495345", size = 35502, upload-time = "2026-05-21T19:56:38.046Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "ttp" version = "0.10.0" -- 2.53.0 From bebbe78163595f07fdf09d86fc4da759bd4163dc Mon Sep 17 00:00:00 2001 From: IluaAir <ilya@air.ru> Date: Sat, 6 Jun 2026 11:13:32 +0300 Subject: [PATCH 62/80] Add unit tests for OxiAPI node functionality - Introduced a new test file `test_network.py` to implement unit tests for the `OxiAPI` class. - Added tests for node retrieval, configuration fetching, error handling for missing nodes, and response status checks. - Utilized the `responses` library to mock API responses for comprehensive testing of various scenarios, including successful retrieval and error cases. --- tests/test_network.py | 87 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/test_network.py diff --git a/tests/test_network.py b/tests/test_network.py new file mode 100644 index 0000000..c314048 --- /dev/null +++ b/tests/test_network.py @@ -0,0 +1,87 @@ +import pytest +import responses + +from conftest import load +from oxi import OxiAPI +from oxi.exception import OxiAPIError + +BASE = "https://oxi.example.com" + +NODE_DATA = { + "name": "HQ", + "full_name": "grp/HQ", + "model": "keenetic", + "ip": "192.168.1.1", + "group": "grp", +} + + +@responses.activate +def test_node_show_returns_view(): + responses.get(f"{BASE}/node/show/HQ.json", json=NODE_DATA) + + api = OxiAPI(url=BASE) + node = api.node("HQ") + + assert node.ip == "192.168.1.1" + assert node.model == "keenetic" + assert node.full_name == "grp/HQ" + assert node.group == "grp" + + +@responses.activate +def test_node_config_fetches_and_parses(): + responses.get(f"{BASE}/node/show/HQ.json", json=NODE_DATA) + responses.get(f"{BASE}/node/fetch/grp/HQ", body=load("keenetic")) + + api = OxiAPI(url=BASE) + config = api.node("HQ").config + + assert config.system.model == "Sprinter (KN-3710)" + assert len(config.interfaces) > 0 + + +@responses.activate +def test_node_not_found_maps_to_404(): + responses.get(f"{BASE}/node/show/missing.json", status=404) + + api = OxiAPI(url=BASE) + with pytest.raises(OxiAPIError) as exc: + api.node("missing") + + assert exc.value.status_code == 404 + + +@responses.activate +def test_500_with_node_not_found_html_maps_to_404(): + responses.get( + f"{BASE}/node/show/ghost.json", + status=500, + content_type="text/html", + body="<html><title>Oxidized::NodeNotFound", + ) + + api = OxiAPI(url=BASE) + with pytest.raises(OxiAPIError) as exc: + api.node("ghost") + + assert exc.value.status_code == 404 + + +@responses.activate +def test_reload_returns_status_code(): + responses.get(f"{BASE}/reload", status=200) + + api = OxiAPI(url=BASE) + assert api.reload() == 200 + + +@responses.activate +def test_unknown_model_raises_value_error(): + data = {**NODE_DATA, "model": "unknown_vendor"} + responses.get(f"{BASE}/node/show/HQ.json", json=data) + responses.get(f"{BASE}/node/fetch/grp/HQ", body="whatever") + + api = OxiAPI(url=BASE) + with pytest.raises(ValueError, match="not found in registry"): + api.node("HQ").config -- 2.53.0 From c4f20d3241b2d48acfb0bbfa9f631a1183ee2d1c Mon Sep 17 00:00:00 2001 From: IluaAir Date: Sat, 6 Jun 2026 11:13:50 +0300 Subject: [PATCH 63/80] Update Huawei TTP template to include model information in version display - Modified the Huawei TTP template to append the model identifier to the version string, enhancing the clarity of system information output for diagnostics. --- oxi/interfaces/models/templates/huawei.ttp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oxi/interfaces/models/templates/huawei.ttp b/oxi/interfaces/models/templates/huawei.ttp index 4686572..e832977 100644 --- a/oxi/interfaces/models/templates/huawei.ttp +++ b/oxi/interfaces/models/templates/huawei.ttp @@ -14,7 +14,7 @@ default_system = { -# VRP (R) software, Version {{ version }} {{ _line_ }} +# VRP (R) software, Version {{ version }} ({{ model }} {{ _line_ }} # ESN of slot {{ slot_number }}: {{ serial_number }} -- 2.53.0 From f446ae52e753ec3f1950ef8a0bc9806788c45447 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Sat, 6 Jun 2026 13:49:55 +0300 Subject: [PATCH 64/80] Add VLAN range expansion utility and corresponding unit tests - Introduced a new utility function `expand_vlan_range` in `utils.py` to expand VLAN range strings into individual VLAN IDs. - Created a new test file `test_units.py` with unit tests for the `expand_vlan_range` function, covering various scenarios including simple ranges, reversed ranges, non-numeric inputs, and list inputs. - Enhanced test coverage for other functionalities in the `device_registry` and template validation classes. --- oxi/interfaces/utils.py | 25 +++++++++++++++ tests/test_units.py | 71 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 oxi/interfaces/utils.py create mode 100644 tests/test_units.py diff --git a/oxi/interfaces/utils.py b/oxi/interfaces/utils.py new file mode 100644 index 0000000..f91466d --- /dev/null +++ b/oxi/interfaces/utils.py @@ -0,0 +1,25 @@ +def expand_vlan_range(value: str | list[str]) -> list[str]: + """Expand values like '1,7,14-15' into individual VLAN IDs.""" + if isinstance(value, list): + value = ",".join(str(item) for item in value) + + result: list[str] = [] + if not value: + return result + for part in value.split(","): + part = part.strip() + if not part: + continue + if "-" in part: + start_s, end_s = part.split("-", 1) + try: + start, end = int(start_s), int(end_s) + except ValueError: + result.append(part) + continue + if start > end: + start, end = end, start + result.extend(str(i) for i in range(start, end + 1)) + else: + result.append(part) + return result diff --git a/tests/test_units.py b/tests/test_units.py new file mode 100644 index 0000000..ad7cf32 --- /dev/null +++ b/tests/test_units.py @@ -0,0 +1,71 @@ +import pytest + +from conftest import load +from oxi.exception import OxiAPIError +from oxi.interfaces import device_registry +from oxi.interfaces.base import BaseDevice +from oxi.interfaces.contract import Interfaces, System +from oxi.interfaces.utils import expand_vlan_range as eltex_expand +from oxi.interfaces.utils import expand_vlan_range as qtech_expand + + +class TestExpandVlanRange: + @pytest.mark.parametrize("expand", [qtech_expand, eltex_expand]) + def test_simple_and_range(self, expand): + assert expand("1,7,14-15") == ["1", "7", "14", "15"] + + @pytest.mark.parametrize("expand", [qtech_expand, eltex_expand]) + def test_reversed_range_is_normalized(self, expand): + assert expand("15-13") == ["13", "14", "15"] + + @pytest.mark.parametrize("expand", [qtech_expand, eltex_expand]) + def test_non_numeric_range_kept_verbatim(self, expand): + assert expand("a-b") == ["a-b"] + + @pytest.mark.parametrize("expand", [qtech_expand, eltex_expand]) + def test_empty(self, expand): + assert expand("") == [] + + @pytest.mark.parametrize("expand", [qtech_expand, eltex_expand]) + def test_list_input(self, expand): + assert expand(["1", "3-4"]) == ["1", "3", "4"] + + +class TestKeeneticDecodeUtf: + @pytest.fixture(scope="class") + def keenetic(self): + return device_registry["keenetic"](load("keenetic")) + + def test_plain_text_passthrough(self, keenetic): + assert keenetic._decode_utf("Plain ASCII") == "Plain ASCII" + + def test_escaped_utf8_is_decoded(self, keenetic): + assert keenetic._decode_utf(r'"\xd0\x94\xd0\xbe\xd0\xbc"') == "Дом" + + +class TestTemplateValidation: + def test_missing_required_group_raises(self): + class OnlySystem(BaseDevice): + template = "dummy.ttp" + + def _load_template(self): + return '' + + with pytest.raises(ValueError, match="missing required groups"): + OnlySystem("data") + + def test_missing_template_file_raises(self): + class NoTemplate(BaseDevice): + template = "does_not_exist.ttp" + + with pytest.raises(FileNotFoundError): + NoTemplate("data") + + +class TestNodeNotFound: + def test_not_found_config_raises_on_parse(self): + device = device_registry["eltex"](load("eltex", "not_found.conf"), name="HQ") + assert device.raw is None + with pytest.raises(OxiAPIError) as exc: + device.parse() + assert exc.value.status_code == 404 -- 2.53.0 From 229bef99f6bd0163e29620445b87f977a6d29886 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Sat, 6 Jun 2026 13:55:01 +0300 Subject: [PATCH 65/80] Add UTF-8 decoding utility and corresponding unit tests - Introduced a new utility function `decode_utf` in `utils.py` to decode escaped UTF-8 descriptions. - Updated unit tests in `test_units.py` to include tests for the `decode_utf` function, covering plain text and escaped UTF-8 scenarios. - Refactored existing tests to streamline the usage of the `expand_vlan_range` function. --- oxi/interfaces/utils.py | 14 ++++++++++++++ tests/test_units.py | 27 +++++++++++---------------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/oxi/interfaces/utils.py b/oxi/interfaces/utils.py index f91466d..7348000 100644 --- a/oxi/interfaces/utils.py +++ b/oxi/interfaces/utils.py @@ -23,3 +23,17 @@ def expand_vlan_range(value: str | list[str]) -> list[str]: else: result.append(part) return result + + +def decode_utf(text: str): + """Decode escaped UTF-8 descriptions.""" + if "\\x" in text: + desc = text.strip('"') + decoded = ( + desc.encode("utf-8") + .decode("unicode_escape") + .encode("latin1") + .decode("utf-8") + ) + return decoded + return text diff --git a/tests/test_units.py b/tests/test_units.py index ad7cf32..0df7e8e 100644 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -5,42 +5,37 @@ from oxi.exception import OxiAPIError from oxi.interfaces import device_registry from oxi.interfaces.base import BaseDevice from oxi.interfaces.contract import Interfaces, System -from oxi.interfaces.utils import expand_vlan_range as eltex_expand -from oxi.interfaces.utils import expand_vlan_range as qtech_expand +from oxi.interfaces.utils import decode_utf, expand_vlan_range class TestExpandVlanRange: - @pytest.mark.parametrize("expand", [qtech_expand, eltex_expand]) + @pytest.mark.parametrize("expand", [expand_vlan_range]) def test_simple_and_range(self, expand): assert expand("1,7,14-15") == ["1", "7", "14", "15"] - @pytest.mark.parametrize("expand", [qtech_expand, eltex_expand]) + @pytest.mark.parametrize("expand", [expand_vlan_range]) def test_reversed_range_is_normalized(self, expand): assert expand("15-13") == ["13", "14", "15"] - @pytest.mark.parametrize("expand", [qtech_expand, eltex_expand]) + @pytest.mark.parametrize("expand", [expand_vlan_range]) def test_non_numeric_range_kept_verbatim(self, expand): assert expand("a-b") == ["a-b"] - @pytest.mark.parametrize("expand", [qtech_expand, eltex_expand]) + @pytest.mark.parametrize("expand", [expand_vlan_range]) def test_empty(self, expand): assert expand("") == [] - @pytest.mark.parametrize("expand", [qtech_expand, eltex_expand]) + @pytest.mark.parametrize("expand", [expand_vlan_range]) def test_list_input(self, expand): assert expand(["1", "3-4"]) == ["1", "3", "4"] -class TestKeeneticDecodeUtf: - @pytest.fixture(scope="class") - def keenetic(self): - return device_registry["keenetic"](load("keenetic")) +class TestDecodeUtf: + def test_plain_text_passthrough(self): + assert decode_utf("Plain ASCII") == "Plain ASCII" - def test_plain_text_passthrough(self, keenetic): - assert keenetic._decode_utf("Plain ASCII") == "Plain ASCII" - - def test_escaped_utf8_is_decoded(self, keenetic): - assert keenetic._decode_utf(r'"\xd0\x94\xd0\xbe\xd0\xbc"') == "Дом" + def test_escaped_utf8_is_decoded(self): + assert decode_utf(r'"\xd0\x94\xd0\xbe\xd0\xbc"') == "Дом" class TestTemplateValidation: -- 2.53.0 From d329ddc4ad879579a436792d44b34ba4cecd270c Mon Sep 17 00:00:00 2001 From: IluaAir Date: Sat, 6 Jun 2026 13:55:51 +0300 Subject: [PATCH 66/80] Add unit tests for device registry model parsing - Introduced a new test file `test_models.py` to implement unit tests for various device models in the `device_registry`. - Added parameterized tests to validate the parsing of device configurations against expected JSON outputs and to ensure required sections are present in the parsed models. - Enhanced test coverage for multiple device types including Mikrotik, Keenetic, Qtech, Huawei, Eltex, and Quasar. --- tests/test_models.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tests/test_models.py diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..f9f1f6f --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,38 @@ +import json + +import pytest + +from conftest import FIXTURES, load +from oxi.interfaces import device_registry + +MODEL_CASES = [ + ("mikrotik", "config.conf", "config.expected.json"), + ("keenetic", "config.conf", "config.expected.json"), + ("qtech", "config_1.conf", "config_1.expected.json"), + ("qtech", "config_2.conf", "config_2.expected.json"), + ("huawei", "config.conf", "config.expected.json"), + ("eltex", "config.conf", "config.expected.json"), + ("quasar", "config_1.conf", "config_1.expected.json"), + ("quasar", "config_2.conf", "config_2.expected.json"), +] + + +@pytest.mark.parametrize("model_key, fixture, expected_file", MODEL_CASES) +def test_parse_matches_golden(model_key, fixture, expected_file): + cls = device_registry[model_key] + raw = load(model_key, fixture) + + parsed = cls(raw).parse().model_dump(by_alias=True, mode="json") + + expected = json.loads((FIXTURES / model_key / expected_file).read_text("utf-8")) + assert parsed == expected + + +@pytest.mark.parametrize("model_key, fixture, _expected", MODEL_CASES) +def test_parse_has_required_sections(model_key, fixture, _expected): + cls = device_registry[model_key] + device = cls(load(model_key, fixture)).parse() + + assert device.system is not None + assert isinstance(device.interfaces, list) + assert isinstance(device.vlans, list) -- 2.53.0 From 168111e23c43cd1ff2e0bcde04b8673727bec959 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Sun, 7 Jun 2026 08:41:59 +0300 Subject: [PATCH 67/80] Refactor Keenetic model to utilize centralized UTF-8 decoding utility - Removed the internal `_decode_utf` method from the `Keenetic` class and replaced its usage with the new `decode_utf` utility function for decoding interface descriptions. - Added a new configuration file `config.conf` for Keenetic devices to facilitate testing. - Introduced an expected output JSON file `config.expected.json` to validate the parsing of Keenetic configurations against expected results. --- oxi/interfaces/models/keenetic.py | 24 +- tests/fixtures/keenetic/config.conf | 341 +++++++++++++++++++ tests/fixtures/keenetic/config.expected.json | 161 +++++++++ 3 files changed, 505 insertions(+), 21 deletions(-) create mode 100644 tests/fixtures/keenetic/config.conf create mode 100644 tests/fixtures/keenetic/config.expected.json diff --git a/oxi/interfaces/models/keenetic.py b/oxi/interfaces/models/keenetic.py index 89ea3c7..5764f23 100644 --- a/oxi/interfaces/models/keenetic.py +++ b/oxi/interfaces/models/keenetic.py @@ -1,24 +1,13 @@ from ipaddress import ip_interface from oxi.interfaces import register_parser from oxi.interfaces.base import BaseDevice +from oxi.interfaces.utils import decode_utf @register_parser(["NDMS", "keenetic", "KeeneticOS"]) class Keenetic(BaseDevice): template = "keenetic.ttp" - def _decode_utf(self, text: str): - if "\\x" in text: - desc = text.strip('"') - decoded = ( - desc.encode("utf-8") - .decode("unicode_escape") - .encode("latin1") - .decode("utf-8") - ) - return decoded - return text - def interfaces(self): interfaces: list[dict] = self.raw["interfaces"] for item in interfaces: @@ -29,7 +18,7 @@ class Keenetic(BaseDevice): item["mask"] = ipaddress.network.prefixlen item.pop("netmask", "Key not found") if item.get("description"): - decoded = self._decode_utf(item.get("description", "")) + decoded = decode_utf(item.get("description", "")) item["description"] = decoded return interfaces @@ -37,13 +26,6 @@ class Keenetic(BaseDevice): vlans = self.raw["vlans"] for item in vlans: if item.get("description"): - decoded = self._decode_utf(item.get("description", "")) + decoded = decode_utf(item.get("description", "")) item["description"] = decoded return vlans - - -if __name__ == "__main__": - with open("./test2.txt") as file: - data = file.read() - mikr = Keenetic(data) - print(mikr.parse().model_dump_json()) diff --git a/tests/fixtures/keenetic/config.conf b/tests/fixtures/keenetic/config.conf new file mode 100644 index 0000000..dfab0d1 --- /dev/null +++ b/tests/fixtures/keenetic/config.conf @@ -0,0 +1,341 @@ +! +! release: 4.03.C.6.2-7 +! sandbox: stable +! title: 4.3.6.2 +! arch: mips +! +! ndm: +! exact: 0-a3057529fd +! cdate: 29 Sep 2025 +! +! bsp: +! exact: 0-03b50470c4 +! cdate: 30 Sep 2025 +! +! ndw: +! features: dual_image,led_control,wifi_button,wifi5ghz, +! vht2ghz,mimo2ghz,mimo5ghz,atf2ghz,atf5ghz,wifi6,wifi_ft, +! wpa3,hwnat +! components: base,cloudcontrol,corewireless,ddns,dhcpd, +! dns-filter,dns-https,dns-tls,dot1x,easyconfig,igmp,ip6, +! lang-en,lang-ru,miniupnpd,mws,nathelper-ftp,nathelper- +! h323,nathelper-pptp,nathelper-rtsp,nathelper-sip,ndmp, +! ndns,openvpn,pingcheck,ppe,pppoe,pptp,ssh,trafficcontrol, +! wireguard +! +! ndw3: +! version: 1.101.18.1 +! +! ndw4: +! version: 4.3.C.6.2 +! +! manufacturer: Keenetic Ltd. +! vendor: Keenetic +! series: KN +! model: Sprinter (KN-3710) +! hw_version: 7777777 +! hw_type: router +! hw_id: KN-3710 +! device: Sprinter +! region: EA +! description: Keenetic Sprinter (KN-3710) +! $$$ Agent: http/rci +! $$$ Last change: Fri, 3 Oct 2025 18:37:40 GMT +! $$$ Model: Keenetic Sprinter +! $$$ Username: admin +! $$$ Version: 2.06.1 +system + set net.ipv4.ip_forward 1 + set net.ipv4.neigh.default.gc_thresh1 256 + set net.ipv4.neigh.default.gc_thresh2 1024 + set net.ipv4.neigh.default.gc_thresh3 2048 + set net.ipv4.tcp_fin_timeout 30 + set net.ipv4.tcp_keepalive_time 120 + set net.ipv6.conf.all.forwarding 1 + set net.ipv6.neigh.default.gc_thresh1 256 + set net.ipv6.neigh.default.gc_thresh2 1024 + set net.ipv6.neigh.default.gc_thresh3 2048 + set net.netfilter.nf_conntrack_tcp_timeout_established 1200 + set vm.overcommit_memory 0 + set vm.vfs_cache_pressure 1000 + clock timezone Europe/Berlin + domainname WORKGROUP + hostname test_HW + caption default + description "Keenetic Sprinter (KN-3710)" + ndss dump-report disable +! +dyndns profile _WEBADMIN +! +interface GigabitEthernet0 + up +! +interface GigabitEthernet0/1 + rename 1 + switchport mode access + switchport access vlan 1 + up +! +interface GigabitEthernet0/2 + rename 2 + switchport mode access + switchport access vlan 1 + up +! +interface GigabitEthernet0/3 + rename 3 + switchport mode access + switchport access vlan 1 + up +! +interface GigabitEthernet0/Vlan1 + description "Home VLAN" + ip dhcp client dns-routes + ip name-servers + up +! +interface GigabitEthernet0/Vlan2 + rename ISP + description "\xd0\x9f\xd0\xbe\xd0\xb4\xd0\xba\xd0\xbb\xd1\x8e\xd1\x87\xd0\xb5\xd0\xbd\xd0\xb8\xd0\xb5 Ethernet" + dyndns nobind + mac address factory wan + security-level public + ip address dhcp + ip dhcp client hostname test_HW + ip dhcp client dns-routes + ip mtu 1500 + ip access-group _WEBADMIN_ISP in + ip global 57342 + ip no name-servers + igmp upstream + ipv6 address auto + ipv6 prefix auto + ipv6 no name-servers auto + up +! +interface GigabitEthernet0/0 + rename 0 + role inet for ISP + switchport mode access + switchport access vlan 2 + up +! +interface GigabitEthernet0/Vlan3 + dyndns nobind + ip dhcp client dns-routes + ip name-servers + up +! +interface WifiMaster0 + country-code RU + compatibility BGN+AX + rekey-interval 86400 + up +! +interface WifiMaster0/AccessPoint0 + mac access-list type none + authentication wpa-psk ns3 7777ggggddddsss + encryption enable + encryption wpa2 + ip dhcp client dns-routes + ssid test_HW_2.4G + up +! +interface WifiMaster0/AccessPoint1 + mac access-list type none + security-level private + encryption no enable + ip dhcp client dns-routes + down +! +interface WifiMaster0/AccessPoint2 + mac access-list type none + security-level private + encryption no enable + ip dhcp client dns-routes + down +! +interface WifiMaster0/WifiStation0 + security-level public + encryption no enable + ip dhcp client dns-routes + standby enable + standby timeout 600 + down +! +interface WifiMaster1 + country-code RU + compatibility AN+AC+AX + channel width 40-above/80 + rekey-interval 86400 + up +! +interface WifiMaster1/AccessPoint0 + mac access-list type none + authentication wpa-psk ns3 7777ggggddddsss + encryption enable + encryption wpa2 + ip dhcp client dns-routes + ssid test_HW_5G + up +! +interface WifiMaster1/AccessPoint1 + mac access-list type none + security-level private + encryption no enable + ip dhcp client dns-routes + down +! +interface WifiMaster1/AccessPoint2 + mac access-list type none + security-level private + encryption no enable + ip dhcp client dns-routes + down +! +interface WifiMaster1/WifiStation0 + security-level public + encryption no enable + ip dhcp client dns-routes + standby enable + standby timeout 600 + down +! +interface Bridge0 + rename Home + description "Home network" + dyndns nobind + include GigabitEthernet0/Vlan1 + include WifiMaster0/AccessPoint0 + include WifiMaster1/AccessPoint0 + mac access-list type none + security-level private + ip address 17.36.1.1 255.255.255.0 + ip dhcp client dns-routes + ip access-group _WEBADMIN_Home in + ip name-servers + band-steering + up +! +interface Bridge1 + rename Guest + description "Guest network" + traffic-shape rate 5120 + dyndns nobind + include GigabitEthernet0/Vlan3 + mac access-list type none + peer-isolation + security-level protected + ip address 10.1.30.1 255.255.255.0 + ip dhcp client dns-routes + ip name-servers + down +! +interface Bridge2 + rename Test + mac access-list type none + security-level public + ip dhcp client dns-routes + up +! +interface OpenVPN0 + description test_HW-udp + role misc + security-level public + ip dhcp client dns-routes + ip tcp adjust-mss pmtu + ip name-servers + ipv6 name-servers auto + openvpn accept-routes + openvpn connect + up +! +interface OpenVPN2 + description test_HW-tcp + role misc + dyndns nobind + security-level public + ip dhcp client dns-routes + ip tcp adjust-mss pmtu + openvpn accept-routes + openvpn connect + down +! +interface Wireguard0 + description test_HW + dyndns nobind + security-level public + ip address 10.3.100.1 255.255.255.0 + ip mtu 1324 + ip tcp adjust-mss pmtu + wireguard listen-port 65513 + wireguard peer 7777ggggddddsss= !test_HW + allow-ips 0.0.0.0 0.0.0.0 + connect + ! + up +! +interface Wireguard1 + description test_HW + dyndns nobind + security-level private + ip address 10.1.100.1 255.255.255.0 + ip mtu 1324 + ip access-group _WEBADMIN_Wireguard1 in + ip tcp adjust-mss pmtu + wireguard listen-port 65511 + wireguard peer 7777ggggddddsss= !test_HW + allow-ips 10.1.100.0 255.255.255.0 + allow-ips 17.36.3.0 255.255.255.0 + allow-ips 17.36.1.0 255.255.255.0 + allow-ips 0.0.0.0 0.0.0.0 + connect + ! + up +! +interface Wireguard2 + description test_HW + dyndns nobind + security-level private + ip address 10.2.100.1 255.255.255.0 + ip access-group _WEBADMIN_Wireguard2 in + ip tcp adjust-mss pmtu + wireguard listen-port 65512 + wireguard peer 7777ggggddddsss= !test_HW + allow-ips 0.0.0.0 0.0.0.0 + connect + ! + up +! +ip ssh + port 22 + security-level public + lockout-policy 5 15 3 +! +ip hotspot + policy Home permit + host 7777ggggddddsss permit + host 7777ggggddddsss priority 4 +! +ipv6 subnet Default + bind Home + mode slaac + prefix length 64 + number 0 +! +ppe software +ppe hardware +upnp lan Home +service dhcp +service dns-proxy +service http +service telnet +service ssh +service ntp +service upnp +! +easyconfig disable +components + auto-update disable + auto-update channel stable +! \ No newline at end of file diff --git a/tests/fixtures/keenetic/config.expected.json b/tests/fixtures/keenetic/config.expected.json new file mode 100644 index 0000000..fee724e --- /dev/null +++ b/tests/fixtures/keenetic/config.expected.json @@ -0,0 +1,161 @@ +{ + "system": { + "model": "Sprinter (KN-3710)", + "serial_number": "7777777", + "version": "4.03.C.6.2-7" + }, + "interfaces": [ + { + "interface": "GigabitEthernet0", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "GigabitEthernet0/1", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "GigabitEthernet0/2", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "GigabitEthernet0/3", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "GigabitEthernet0/0", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "WifiMaster0", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "WifiMaster0/AccessPoint0", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "WifiMaster0/AccessPoint1", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "WifiMaster0/AccessPoint2", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "WifiMaster0/WifiStation0", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "WifiMaster1", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "WifiMaster1/AccessPoint0", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "WifiMaster1/AccessPoint1", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "WifiMaster1/AccessPoint2", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "WifiMaster1/WifiStation0", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "Bridge0", + "ip_address": "17.36.1.1", + "mask": 24, + "description": "Home network" + }, + { + "interface": "Bridge1", + "ip_address": "10.1.30.1", + "mask": 24, + "description": "Guest network" + }, + { + "interface": "Bridge2", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "OpenVPN0", + "ip_address": null, + "mask": null, + "description": "test_HW-udp" + }, + { + "interface": "OpenVPN2", + "ip_address": null, + "mask": null, + "description": "test_HW-tcp" + }, + { + "interface": "Wireguard0", + "ip_address": "10.3.100.1", + "mask": 24, + "description": "test_HW" + }, + { + "interface": "Wireguard1", + "ip_address": "10.1.100.1", + "mask": 24, + "description": "test_HW" + }, + { + "interface": "Wireguard2", + "ip_address": "10.2.100.1", + "mask": 24, + "description": "test_HW" + } + ], + "vlans": [ + { + "vlan_id": 1, + "description": "Home VLAN" + }, + { + "vlan_id": 2, + "description": "Подключение Ethernet" + }, + { + "vlan_id": 3, + "description": "Home network" + } + ] +} \ No newline at end of file -- 2.53.0 From 170a2ebf859e2e0d51211f031a985edbfff0865e Mon Sep 17 00:00:00 2001 From: IluaAir Date: Sun, 7 Jun 2026 08:42:41 +0300 Subject: [PATCH 68/80] Refactor Eltex model to use centralized VLAN range expansion utility - Replaced the internal `_expand_vlan_range` function in the `Eltex` class with the new `expand_vlan_range` utility from `utils.py` for improved code maintainability. - Added a new configuration file `config.conf` for Eltex devices to facilitate testing. - Introduced an expected output JSON file `config.expected.json` to validate the parsing of Eltex configurations against expected results. --- oxi/interfaces/models/eltex.py | 34 +----- tests/fixtures/eltex/config.conf | 55 +++++++++ tests/fixtures/eltex/config.expected.json | 137 ++++++++++++++++++++++ tests/fixtures/eltex/not_found.conf | 1 + 4 files changed, 195 insertions(+), 32 deletions(-) create mode 100644 tests/fixtures/eltex/config.conf create mode 100644 tests/fixtures/eltex/config.expected.json create mode 100644 tests/fixtures/eltex/not_found.conf diff --git a/oxi/interfaces/models/eltex.py b/oxi/interfaces/models/eltex.py index ee59af0..4bf652d 100644 --- a/oxi/interfaces/models/eltex.py +++ b/oxi/interfaces/models/eltex.py @@ -1,29 +1,6 @@ from oxi.interfaces import register_parser from oxi.interfaces.base import BaseDevice - - -def _expand_vlan_range(value: str | list[str]) -> list[str]: - if isinstance(value, list): - value = ",".join(str(item) for item in value) - - result: list[str] = [] - for part in value.split(","): - part = part.strip() - if not part: - continue - if "-" not in part: - result.append(part) - continue - start_s, end_s = part.split("-", 1) - try: - start, end = int(start_s), int(end_s) - except ValueError: - result.append(part) - continue - if start > end: - start, end = end, start - result.extend(str(vlan_id) for vlan_id in range(start, end + 1)) - return result +from oxi.interfaces.utils import expand_vlan_range @register_parser("eltex") @@ -54,15 +31,8 @@ class Eltex(BaseDevice): tail = item.get("vlan_tail") if tail: ids = [*ids, tail] if isinstance(ids, list) else f"{ids},{tail}" - for vid in _expand_vlan_range(ids): + for vid in expand_vlan_range(ids): if vid in named_vlan: continue vlans.append({"vlan_id": vid}) return vlans - - -if __name__ == "__main__": - with open("./test_not_found.txt") as file: - data = file.read() - eltex = Eltex(data) - print(eltex.parse()) diff --git a/tests/fixtures/eltex/config.conf b/tests/fixtures/eltex/config.conf new file mode 100644 index 0000000..c212bc3 --- /dev/null +++ b/tests/fixtures/eltex/config.conf @@ -0,0 +1,55 @@ +! +Active-image: flash://system/images/mes3300-669-3R3.ros +! Version: 6.6.9.3 +! Commit: 3a5c2e39 +! Build: 3 (master) +! MD5 Digest: 7bc289cc18be560954bd5cb0afd9b2d5 +! Date: 22-Sep-2025 +! Time: 12:38:20 +! Inactive-image: flash://system/images/_image1.bin +! Version: 6.6.2 +! Commit: 3ebc7503 +! Build: 5 (master) +! MD5 Digest: a3f15a788c97c71e07e90d84c0ff3b12 +! Date: 20-Nov-2023 +! Time: 16:39:20 +! +! Unit MAC address Hardware version Serial number +! ---- ----------------- ---------------- ------------- +! 1 90:54:b7:6b:9d:40 01.01.01 ESG7007778 +! 2 90:54:b7:6b:bb:80 01.01.01 ESG7007777 +! +! +! +interface TenGigabitEthernet1/0/2 + shutdown + description FREE + storm-control broadcast pps 3000 + storm-control multicast pps 3000 +exit +! +interface TenGigabitEthernet1/0/11 + shutdown + description FREE + storm-control broadcast pps 3000 + storm-control multicast pps 3000 +exit +! +interface vlan 1700 + name sw-test_HW + ip address 13.36.8.1 255.255.255.0 +exit +! +vlan database + vlan 114-115,120,130,414,610,999-1000,1701-1703,1705,1801,2001,2011 + vlan 2021-2022,3157-3158,3333-3334 +exit +! +interface vlan 666 + name test +exit +! +interface vlan 777 + name test2 +exit +! diff --git a/tests/fixtures/eltex/config.expected.json b/tests/fixtures/eltex/config.expected.json new file mode 100644 index 0000000..c6b9ead --- /dev/null +++ b/tests/fixtures/eltex/config.expected.json @@ -0,0 +1,137 @@ +{ + "system": { + "model": "", + "serial_number": "ESG7007778", + "version": "6.6.9.3" + }, + "interfaces": [ + { + "interface": "TenGigabitEthernet1/0/2", + "ip_address": null, + "mask": null, + "description": "FREE" + }, + { + "interface": "TenGigabitEthernet1/0/11", + "ip_address": null, + "mask": null, + "description": "FREE" + }, + { + "interface": "vlan 1700", + "ip_address": "13.36.8.1", + "mask": 24, + "description": "sw-test_HW" + }, + { + "interface": "vlan 666", + "ip_address": null, + "mask": null, + "description": "test" + }, + { + "interface": "vlan 777", + "ip_address": null, + "mask": null, + "description": "test2" + } + ], + "vlans": [ + { + "vlan_id": 1700, + "description": "sw-test_HW" + }, + { + "vlan_id": 114, + "description": null + }, + { + "vlan_id": 115, + "description": null + }, + { + "vlan_id": 120, + "description": null + }, + { + "vlan_id": 130, + "description": null + }, + { + "vlan_id": 414, + "description": null + }, + { + "vlan_id": 610, + "description": null + }, + { + "vlan_id": 999, + "description": null + }, + { + "vlan_id": 1000, + "description": null + }, + { + "vlan_id": 1701, + "description": null + }, + { + "vlan_id": 1702, + "description": null + }, + { + "vlan_id": 1703, + "description": null + }, + { + "vlan_id": 1705, + "description": null + }, + { + "vlan_id": 1801, + "description": null + }, + { + "vlan_id": 2001, + "description": null + }, + { + "vlan_id": 2011, + "description": null + }, + { + "vlan_id": 2021, + "description": null + }, + { + "vlan_id": 2022, + "description": null + }, + { + "vlan_id": 3157, + "description": null + }, + { + "vlan_id": 3158, + "description": null + }, + { + "vlan_id": 3333, + "description": null + }, + { + "vlan_id": 3334, + "description": null + }, + { + "vlan_id": 666, + "description": "test" + }, + { + "vlan_id": 777, + "description": "test2" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/eltex/not_found.conf b/tests/fixtures/eltex/not_found.conf new file mode 100644 index 0000000..205464c --- /dev/null +++ b/tests/fixtures/eltex/not_found.conf @@ -0,0 +1 @@ +node not found \ No newline at end of file -- 2.53.0 From 1bc01c9c1b89064afa39d2d4f1a0b35c2cad9c31 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Sun, 7 Jun 2026 08:44:35 +0300 Subject: [PATCH 69/80] Add Huawei configuration files for testing - Introduced a new configuration file `config.conf` for Huawei devices, detailing interface settings and VLAN configurations. - Added an expected output JSON file `config.expected.json` to validate the parsing of Huawei configurations against expected results, including system model, serial number, and interface details. --- tests/fixtures/huawei/config.conf | 38 +++++++++ tests/fixtures/huawei/config.expected.json | 95 ++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 tests/fixtures/huawei/config.conf create mode 100644 tests/fixtures/huawei/config.expected.json diff --git a/tests/fixtures/huawei/config.conf b/tests/fixtures/huawei/config.conf new file mode 100644 index 0000000..990fb8f --- /dev/null +++ b/tests/fixtures/huawei/config.conf @@ -0,0 +1,38 @@ +# Huawei Versatile Routing Platform Software +# VRP (R) software, Version 5.170 (S5731 V200R019C00SPC500) +# Copyright (C) 2000-2019 HUAWEI TECH Co., Ltd. +# +# DDR Memory Size : 4096 M bytes +# FLASH Total Memory Size : 1024 M bytes +# FLASH Available Memory Size : 739 M bytes +# Pcb Version : VER.B +# BootROM Version : 0000.04e4 +# BootLoad Version : 0213.0000 +# CPLD Version : 0104 +# Software Version : VRP (R) Software, Version 5.170 (V200R019C00SPC500) +# FLASH Version : 0000.0000 +# PWR1 information +# Pcb Version : PWR VER.D +# PWR2 information +# Pcb Version : PWR VER.D +# FAN1 information +# Pcb Version : NA +# FAN2 information +# Pcb Version : NA +# ESN of slot 1: 102266666666 +# ESN of slot 2: 102288888888 +interface GigabitEthernet0/0/33 + port link-type access + port default vlan 101 + loopback-detect enable + stp disable + storm-control broadcast min-rate 1500 max-rate 2500 + storm-control multicast min-rate 1000 max-rate 2000 + storm-control action error-down +# +interface Vlanif120 + description SSH + ip address 10.26.196.254 255.255.255.0 +# +vlan batch 13 26 101 to 103 110 120 130 201 to 204 209 to 212 350 360 +# \ No newline at end of file diff --git a/tests/fixtures/huawei/config.expected.json b/tests/fixtures/huawei/config.expected.json new file mode 100644 index 0000000..39280d0 --- /dev/null +++ b/tests/fixtures/huawei/config.expected.json @@ -0,0 +1,95 @@ +{ + "system": { + "model": "S5731", + "serial_number": "102266666666", + "version": "5.170" + }, + "interfaces": [ + { + "interface": "GigabitEthernet0/0/33", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "Vlanif120", + "ip_address": "10.26.196.254", + "mask": 24, + "description": "SSH" + } + ], + "vlans": [ + { + "vlan_id": 13, + "description": null + }, + { + "vlan_id": 26, + "description": null + }, + { + "vlan_id": 101, + "description": null + }, + { + "vlan_id": 102, + "description": null + }, + { + "vlan_id": 103, + "description": null + }, + { + "vlan_id": 110, + "description": null + }, + { + "vlan_id": 120, + "description": null + }, + { + "vlan_id": 130, + "description": null + }, + { + "vlan_id": 201, + "description": null + }, + { + "vlan_id": 202, + "description": null + }, + { + "vlan_id": 203, + "description": null + }, + { + "vlan_id": 204, + "description": null + }, + { + "vlan_id": 209, + "description": null + }, + { + "vlan_id": 210, + "description": null + }, + { + "vlan_id": 211, + "description": null + }, + { + "vlan_id": 212, + "description": null + }, + { + "vlan_id": 350, + "description": null + }, + { + "vlan_id": 360, + "description": null + } + ] +} \ No newline at end of file -- 2.53.0 From 0ef5e7798ae72a5fe1a980851fedf7496d382673 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Sun, 7 Jun 2026 08:47:08 +0300 Subject: [PATCH 70/80] Refactor Qtech model to utilize centralized VLAN range expansion utility - Replaced the internal `_expand_vlan_range` function in the `Qtech` class with the new `expand_vlan_range` utility from `utils.py` for improved code maintainability. - Added new configuration files `config_1.conf` and `config_2.conf` for Qtech devices to facilitate testing. - Introduced expected output JSON files `config_1.expected.json` and `config_2.expected.json` to validate the parsing of Qtech configurations against expected results. --- oxi/interfaces/models/qtech.py | 43 +---- tests/fixtures/qtech/config_1.conf | 42 +++++ tests/fixtures/qtech/config_1.expected.json | 185 ++++++++++++++++++++ tests/fixtures/qtech/config_2.conf | 36 ++++ tests/fixtures/qtech/config_2.expected.json | 185 ++++++++++++++++++++ 5 files changed, 450 insertions(+), 41 deletions(-) create mode 100644 tests/fixtures/qtech/config_1.conf create mode 100644 tests/fixtures/qtech/config_1.expected.json create mode 100644 tests/fixtures/qtech/config_2.conf create mode 100644 tests/fixtures/qtech/config_2.expected.json diff --git a/oxi/interfaces/models/qtech.py b/oxi/interfaces/models/qtech.py index cf2247b..fc70cf9 100644 --- a/oxi/interfaces/models/qtech.py +++ b/oxi/interfaces/models/qtech.py @@ -1,32 +1,6 @@ from oxi.interfaces import register_parser from oxi.interfaces.base import BaseDevice - - -def _expand_vlan_range(value: str | list[str]) -> list[str]: - """Expand values like '1,7,14-15' into individual VLAN IDs.""" - if isinstance(value, list): - value = ",".join(str(item) for item in value) - - result: list[str] = [] - if not value: - return result - for part in value.split(","): - part = part.strip() - if not part: - continue - if "-" in part: - start_s, end_s = part.split("-", 1) - try: - start, end = int(start_s), int(end_s) - except ValueError: - result.append(part) - continue - if start > end: - start, end = end, start - result.extend(str(i) for i in range(start, end + 1)) - else: - result.append(part) - return result +from oxi.interfaces.utils import expand_vlan_range @register_parser(["QTECH"]) @@ -48,21 +22,8 @@ class Qtech(BaseDevice): tail = item.get("vlan_tail") if tail: ids = [*ids, tail] if isinstance(ids, list) else f"{ids},{tail}" - for vid in _expand_vlan_range(ids): + for vid in expand_vlan_range(ids): if vid in named_vlan: continue vlans.append({"vlan_id": vid}) return vlans - - -if __name__ == "__main__": - with open("./test3.txt") as file: - data = file.read() - qtech = Qtech(data) - qt = qtech.parse() - print(qt) - with open("./test3-1.txt") as file: - data = file.read() - qtech = Qtech(data) - qt = qtech.parse() - print(qt) diff --git a/tests/fixtures/qtech/config_1.conf b/tests/fixtures/qtech/config_1.conf new file mode 100644 index 0000000..df0dcb1 --- /dev/null +++ b/tests/fixtures/qtech/config_1.conf @@ -0,0 +1,42 @@ +! QTECH LLC Internetwork Operating System Software +! QSW-8330-40T-DC Series Software, Version 2.2.0C Build 96279, RELEASE SOFTWARE +! ROM: System Bootstrap, Version 0.4.7,hardware version:A +! Serial num:6060606060606060, ID num:555555555555 +! System image file is "Switch.bin" +! QTECH LLC QSW-8330-40T-DC RISC +! 524288K bytes of memory,16384K bytes of flash +! Base ethernet MAC Address: 08:c6:b3:08:cf:ff +! snmp info: +! vend_ID:27514 product_ID:404 system_ID:1.3.6.1.4.1.27514 +interface GigaEthernet1/0/9 + shutdown + description FREE + switchport pvid 102 + storm-control broadcast threshold 15 + storm-control broadcast action shutdown + storm-control broadcast auto_resume 60s + storm-control multicast threshold 10 + storm-control multicast action shutdown + storm-control multicast auto_resume 60s + qos policy IPP3 ingress +! +interface VLAN1 + ip address 192.168.0.1 255.255.0.0 + ip mtu 1500 + no ip directed-broadcast +! +interface VLAN1002 + description test-1002 + ip address 13.36.8.1 255.255.255.0 + ip mtu 1500 + no ip directed-broadcast +! +vlan 772 + name test +! +vlan 888 + name test_super +! +vlan 1,7,14-15,44,101-102,115,117-124,130-136,139,167,200-205,772 +,1607 +vlan 888,2016,2085-2088 diff --git a/tests/fixtures/qtech/config_1.expected.json b/tests/fixtures/qtech/config_1.expected.json new file mode 100644 index 0000000..e9721f4 --- /dev/null +++ b/tests/fixtures/qtech/config_1.expected.json @@ -0,0 +1,185 @@ +{ + "system": { + "model": "QSW-8330-40T-DC", + "serial_number": "6060606060606060", + "version": "96279" + }, + "interfaces": [ + { + "interface": "GigaEthernet1/0/9", + "ip_address": null, + "mask": null, + "description": "FREE" + }, + { + "interface": "VLAN1", + "ip_address": "192.168.0.1", + "mask": 16, + "description": null + }, + { + "interface": "VLAN1002", + "ip_address": "13.36.8.1", + "mask": 24, + "description": "test-1002" + } + ], + "vlans": [ + { + "vlan_id": 772, + "description": "test" + }, + { + "vlan_id": 888, + "description": "test_super" + }, + { + "vlan_id": 1, + "description": null + }, + { + "vlan_id": 7, + "description": null + }, + { + "vlan_id": 14, + "description": null + }, + { + "vlan_id": 15, + "description": null + }, + { + "vlan_id": 44, + "description": null + }, + { + "vlan_id": 101, + "description": null + }, + { + "vlan_id": 102, + "description": null + }, + { + "vlan_id": 115, + "description": null + }, + { + "vlan_id": 117, + "description": null + }, + { + "vlan_id": 118, + "description": null + }, + { + "vlan_id": 119, + "description": null + }, + { + "vlan_id": 120, + "description": null + }, + { + "vlan_id": 121, + "description": null + }, + { + "vlan_id": 122, + "description": null + }, + { + "vlan_id": 123, + "description": null + }, + { + "vlan_id": 124, + "description": null + }, + { + "vlan_id": 130, + "description": null + }, + { + "vlan_id": 131, + "description": null + }, + { + "vlan_id": 132, + "description": null + }, + { + "vlan_id": 133, + "description": null + }, + { + "vlan_id": 134, + "description": null + }, + { + "vlan_id": 135, + "description": null + }, + { + "vlan_id": 136, + "description": null + }, + { + "vlan_id": 139, + "description": null + }, + { + "vlan_id": 167, + "description": null + }, + { + "vlan_id": 200, + "description": null + }, + { + "vlan_id": 201, + "description": null + }, + { + "vlan_id": 202, + "description": null + }, + { + "vlan_id": 203, + "description": null + }, + { + "vlan_id": 204, + "description": null + }, + { + "vlan_id": 205, + "description": null + }, + { + "vlan_id": 1607, + "description": null + }, + { + "vlan_id": 2016, + "description": null + }, + { + "vlan_id": 2085, + "description": null + }, + { + "vlan_id": 2086, + "description": null + }, + { + "vlan_id": 2087, + "description": null + }, + { + "vlan_id": 2088, + "description": null + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/qtech/config_2.conf b/tests/fixtures/qtech/config_2.conf new file mode 100644 index 0000000..cf22ea4 --- /dev/null +++ b/tests/fixtures/qtech/config_2.conf @@ -0,0 +1,36 @@ +! QTECH LLC Internetwork Operating System Software +! QSW-8330-40T-DC Series Software, Version 2.2.0C Build 96279, RELEASE SOFTWARE +! ROM: System Bootstrap, Version 0.4.7,hardware version:A +! Serial num:6060606060606060, ID num:555555555555 +! System image file is "Switch.bin" +! QTECH LLC QSW-8330-40T-DC RISC +! 524288K bytes of memory,16384K bytes of flash +! Base ethernet MAC Address: 08:c6:b3:08:cf:f7 +! snmp info: +! vend_ID:27514 product_ID:404 system_ID:1.3.6.1.4.1.27514 +interface GigaEthernet1/0/9 + shutdown + description FREE + switchport pvid 102 + storm-control broadcast threshold 15 + storm-control broadcast action shutdown + storm-control broadcast auto_resume 60s + storm-control multicast threshold 10 + storm-control multicast action shutdown + storm-control multicast auto_resume 60s + qos policy IPP3 ingress +! +interface VLAN1 + ip address 192.168.0.1 255.255.0.0 + ip mtu 1500 + no ip directed-broadcast +! +interface VLAN1002 + description test-1002 + ip address 13.36.8.1 255.255.255.0 + ip mtu 1500 + no ip directed-broadcast +! +vlan 1,7,14-15,44,101-102,115,117-124,130-136,139,167,200-205,772 +,1607 +vlan 888,2016,2085-2088 diff --git a/tests/fixtures/qtech/config_2.expected.json b/tests/fixtures/qtech/config_2.expected.json new file mode 100644 index 0000000..e9a4a80 --- /dev/null +++ b/tests/fixtures/qtech/config_2.expected.json @@ -0,0 +1,185 @@ +{ + "system": { + "model": "QSW-8330-40T-DC", + "serial_number": "6060606060606060", + "version": "96279" + }, + "interfaces": [ + { + "interface": "GigaEthernet1/0/9", + "ip_address": null, + "mask": null, + "description": "FREE" + }, + { + "interface": "VLAN1", + "ip_address": "192.168.0.1", + "mask": 16, + "description": null + }, + { + "interface": "VLAN1002", + "ip_address": "13.36.8.1", + "mask": 24, + "description": "test-1002" + } + ], + "vlans": [ + { + "vlan_id": 1, + "description": null + }, + { + "vlan_id": 7, + "description": null + }, + { + "vlan_id": 14, + "description": null + }, + { + "vlan_id": 15, + "description": null + }, + { + "vlan_id": 44, + "description": null + }, + { + "vlan_id": 101, + "description": null + }, + { + "vlan_id": 102, + "description": null + }, + { + "vlan_id": 115, + "description": null + }, + { + "vlan_id": 117, + "description": null + }, + { + "vlan_id": 118, + "description": null + }, + { + "vlan_id": 119, + "description": null + }, + { + "vlan_id": 120, + "description": null + }, + { + "vlan_id": 121, + "description": null + }, + { + "vlan_id": 122, + "description": null + }, + { + "vlan_id": 123, + "description": null + }, + { + "vlan_id": 124, + "description": null + }, + { + "vlan_id": 130, + "description": null + }, + { + "vlan_id": 131, + "description": null + }, + { + "vlan_id": 132, + "description": null + }, + { + "vlan_id": 133, + "description": null + }, + { + "vlan_id": 134, + "description": null + }, + { + "vlan_id": 135, + "description": null + }, + { + "vlan_id": 136, + "description": null + }, + { + "vlan_id": 139, + "description": null + }, + { + "vlan_id": 167, + "description": null + }, + { + "vlan_id": 200, + "description": null + }, + { + "vlan_id": 201, + "description": null + }, + { + "vlan_id": 202, + "description": null + }, + { + "vlan_id": 203, + "description": null + }, + { + "vlan_id": 204, + "description": null + }, + { + "vlan_id": 205, + "description": null + }, + { + "vlan_id": 772, + "description": null + }, + { + "vlan_id": 1607, + "description": null + }, + { + "vlan_id": 888, + "description": null + }, + { + "vlan_id": 2016, + "description": null + }, + { + "vlan_id": 2085, + "description": null + }, + { + "vlan_id": 2086, + "description": null + }, + { + "vlan_id": 2087, + "description": null + }, + { + "vlan_id": 2088, + "description": null + } + ] +} \ No newline at end of file -- 2.53.0 From a617bd6ecdc26709f75735185c9eb9e89b33abec Mon Sep 17 00:00:00 2001 From: IluaAir Date: Sun, 7 Jun 2026 08:47:51 +0300 Subject: [PATCH 71/80] Add description fields to Eltex interface template - Updated the Eltex TTP template to include 'name' and 'description' fields for interfaces, enhancing clarity and detail in the generated configurations. --- oxi/interfaces/models/templates/eltex.ttp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/oxi/interfaces/models/templates/eltex.ttp b/oxi/interfaces/models/templates/eltex.ttp index ce66112..b47e14b 100644 --- a/oxi/interfaces/models/templates/eltex.ttp +++ b/oxi/interfaces/models/templates/eltex.ttp @@ -26,6 +26,8 @@ Active-image: {{ ignore }} {{ _start_ }} interface {{ interface | ORPHRASE }} ip address {{ ip_address }} {{ mask | to_cidr }} + name {{ description }} + description {{ description }} -- 2.53.0 From 2ea056aa170f9834a658362d698af94520501da9 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Sun, 7 Jun 2026 09:06:24 +0300 Subject: [PATCH 72/80] Refactor Mikrotik model and update TTP template for improved configuration handling - Removed unused imports and main execution block from the Mikrotik model for cleaner code. - Updated the Mikrotik TTP template to adjust the order of parameters in the 'add' command, enhancing clarity in the generated configurations. - Added a new configuration file `config.conf` for Mikrotik devices to facilitate testing and validation of parsing functionality. --- oxi/interfaces/models/mikrotik.py | 9 -- oxi/interfaces/models/templates/mikrotik.ttp | 2 +- tests/fixtures/mikrotik/config.conf | 123 +++++++++++++++++++ 3 files changed, 124 insertions(+), 10 deletions(-) create mode 100644 tests/fixtures/mikrotik/config.conf diff --git a/oxi/interfaces/models/mikrotik.py b/oxi/interfaces/models/mikrotik.py index 25a6eae..6d5acd4 100644 --- a/oxi/interfaces/models/mikrotik.py +++ b/oxi/interfaces/models/mikrotik.py @@ -1,4 +1,3 @@ -import os from oxi.interfaces import register_parser from oxi.interfaces.base import BaseDevice @@ -6,11 +5,3 @@ from oxi.interfaces.base import BaseDevice @register_parser(["routeros", "ros", "mikrotik"]) class Mikrotik(BaseDevice): template = "mikrotik.ttp" - - -if __name__ == "__main__": - print(os.path.abspath(os.curdir)) - with open("./test.txt") as file: - data = file.read() - mikr = Mikrotik(data) - print(mikr.parse().json()) diff --git a/oxi/interfaces/models/templates/mikrotik.ttp b/oxi/interfaces/models/templates/mikrotik.ttp index a6cbace..47b5028 100644 --- a/oxi/interfaces/models/templates/mikrotik.ttp +++ b/oxi/interfaces/models/templates/mikrotik.ttp @@ -42,6 +42,6 @@ add comment={{ comment | _start_ | ORPHRASE | exclude("disabled=") | strip('"')} ## disabled with comment with/without quotes add comment={{ comment | _start_ | ORPHRASE | exclude("disabled=") | strip('"')}} disabled={{ disabled | replace("yes","True") | strip('"') }} interface={{ interface }} name={{ name | ORPHRASE | strip('"') }} vlan-id={{ vlan_id }} ## disabled no comment -add interface={{ interface | _start_ }} name={{ name | ORPHRASE | strip('"') }} vlan-id={{ vlan_id }} disabled={{ disabled | replace("yes","True") | strip('"') }} +add disabled={{ disabled | _start_ | replace("yes","True") | strip('"') }} interface={{ interface }} name={{ name | ORPHRASE | strip('"') }} vlan-id={{ vlan_id }} diff --git a/tests/fixtures/mikrotik/config.conf b/tests/fixtures/mikrotik/config.conf new file mode 100644 index 0000000..326f5b3 --- /dev/null +++ b/tests/fixtures/mikrotik/config.conf @@ -0,0 +1,123 @@ +# 2026-02-18 22:32:59 by RouterOS 7.19.3 +# software id = 0V5S-56MC +# version: 7.12 +# model = C52iG-5HaxD2HaxD +# serial number = HE108BBGW0B +/interface bridge +add name=bridge.LAN +/interface ethernet +set [ find default-name=ether1 ] mac-address=C4:AD:32:B2:A1:9A poe-out=off +set [ find default-name=ether4 ] comment=test +/interface vlan +add comment="super test vlan" interface=ether4 name="test vlan" vlan-id=255 +add disabled=yes interface=ether5 name="test test vlan" vlan-id=254 +/interface list +add name=LAN +add name=WAN +/interface wifi channel +add band=2ghz-ax disabled=no name=ch-2ghz width=20/40mhz +add band=5ghz-ax disabled=no name=ch-5ghz width=20/40mhz +/interface wifi security +add authentication-types=wpa2-psk name=common-auth wps=disable +/interface wifi configuration +add name=common-auth security=common-auth ssid=test_HW +/interface wifi +set [ find default-name=wifi1 ] channel=ch-5ghz configuration=common-auth configuration.mode=ap disabled=no +set [ find default-name=wifi2 ] channel=ch-2ghz configuration=common-auth configuration.mode=ap disabled=no +/ip pool +add name=dhcp_pool0 ranges=172.16.3.2-172.16.3.254 +add name=dhcp_pool1 ranges=192.168.3.2-192.168.3.254 +/ip dhcp-server +add address-pool=dhcp_pool0 interface=bridge.LAN lease-time=10h name=dhcp1 +/ppp profile +add name=new_antizapret on-down="/ip dns cache flush\r\ + \n" on-up="/ip dns cache flush\r\ + \n" +add name=robovps on-down="/ip dns cache flush" on-up="/ip dns cache flush" use-ipv6=no +/routing table +add disabled=no fib name=test_HW_table +/interface bridge port +add bridge=bridge.LAN interface=ether2 learn=yes +add bridge=bridge.LAN interface=ether3 +add bridge=bridge.LAN interface=ether4 +add bridge=bridge.LAN interface=ether5 +add bridge=bridge.LAN interface=wifi1 +add bridge=bridge.LAN interface=wifi2 +/ipv6 settings +set disable-ipv6=yes forward=no +/interface detect-internet +set detect-interface-list=all +/interface list member +add interface=bridge.LAN list=LAN +add interface=ether1 list=WAN +/interface ovpn-server server +add mac-address=FE:25:E0:B8:66:01 name=ovpn-server1 +/ip address +add address=172.16.3.1/24 interface=bridge.LAN network=172.16.3.0 +add address=10.38.3.245/24 interface=ether1 network=10.38.3.0 +add address=10.1.100.2/24 interface=wireguard2 network=10.1.100.0 +add address=100.10.10.1/24 disabled=yes interface=ether4 network=100.10.10.0 +/ip dhcp-server lease +add address=172.16.3.20 client-id=1:d8:3a:dd:22:28:1d mac-address=D8:3A:DD:21:28:1D server=dhcp1 +add address=172.16.3.4 client-id=1:2c:cd:29:1a:ea:6d comment=test_HW mac-address=1E:CD:29:8A:EA:6D server=dhcp1 +/ip dhcp-server network +add address=172.16.3.0/24 dns-server=172.16.3.1 gateway=172.16.3.1 +/ip dns +set allow-remote-requests=yes servers=217.10.44.35 +/ip dns static +add address=172.16.3.20 regexp=".*\\.home\$" type=A +add address=172.16.3.20 regexp=".*\\.home.uk\$" type=A +add address=172.16.3.20 disabled=yes regexp=".*\\.home.uk\$" type=A +/ip firewall address-list +add address=172.16.3.0/24 list=test_HW +add address=172.16.2.0/24 list=test_HW +add address=172.16.1.0/24 list=test_HW +add address=255.255.255.255 list=test_HW +/ip firewall filter +add action=drop chain=forward out-interface=ether1 src-address=172.16.3.11 +add action=accept chain=input src-address=255.255.255.255 +add action=accept chain=forward in-interface=wireguard2 +add action=accept chain=forward connection-state=established,related in-interface=ether1 +add action=accept chain=input connection-state=established,related in-interface=ether1 +add action=accept chain=input in-interface=bridge.LAN +add action=drop chain=input in-interface=ether1 +add action=drop chain=forward in-interface=ether1 +/ip firewall mangle +add action=passthrough chain=prerouting connection-mark=test_HW src-address=172.16.3.20 +/ip firewall nat +add action=masquerade chain=srcnat out-interface=ether1 +add action=masquerade chain=srcnat comment=test_HW out-interface=test_HW +/ip firewall service-port +set ftp disabled=yes +set tftp disabled=yes +set h323 disabled=yes +/ip route +add disabled=no distance=1 dst-address=172.16.1.0/24 gateway=10.1.100.1 +add disabled=no distance=1 dst-address=172.16.2.0/24 gateway=10.1.100.1 +add disabled=no distance=1 dst-address=255.255.255.255/32 gateway=10.38.3.1 +add disabled=no distance=3 dst-address=0.0.0.0/0 gateway=10.38.3.1 routing-table=main scope=30 suppress-hw-offload=no target-scope=10 +add disabled=yes distance=2 dst-address=0.0.0.0/0 gateway=10.38.3.1 routing-table=main scope=30 suppress-hw-offload=no target-scope=10 +add comment=test_HW disabled=no distance=1 dst-address=185.255.255.255/32 gateway=10.38.3.1 routing-table=main scope=30 suppress-hw-offload=no target-scope=10 +add comment=test_HW disabled=no distance=1 dst-address=192.168.255.255/32 gateway=10.38.3.1 routing-table=main scope=30 suppress-hw-offload=no target-scope=10 +/ip service +set telnet disabled=yes +/system clock +set time-zone-name=Europe/Berlin +/system identity +set name=test_HW +/system logging +set 0 topics=info,!wireless +/system note +set show-at-login=no +/system ntp client +set enabled=yes +/system ntp client servers +add address=pool.ntp.org +/tool mac-server +set allowed-interface-list=LAN +/tool mac-server mac-winbox +set allowed-interface-list=LAN +/tool mac-server ping +set enabled=no +/user aaa +set default-group=full use-radius=yes \ No newline at end of file -- 2.53.0 From 9c90279868f400c1a478dc49f99ef3c17469bb3e Mon Sep 17 00:00:00 2001 From: IluaAir Date: Sun, 7 Jun 2026 09:06:52 +0300 Subject: [PATCH 73/80] Add normalization method for TTP group results in BaseDevice class - Introduced a static method `_as_list` to normalize TTP group results, ensuring consistent list output regardless of input type (dict or list). - Updated the `_validate_contract` method to utilize `_as_list` for processing interfaces and VLANs, improving code clarity and reliability. --- oxi/interfaces/base.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/oxi/interfaces/base.py b/oxi/interfaces/base.py index 0e3cbe2..3d51188 100644 --- a/oxi/interfaces/base.py +++ b/oxi/interfaces/base.py @@ -67,6 +67,19 @@ class BaseDevice(ABC): """ return self.raw.get("system", None) + @staticmethod + def _as_list(data) -> list: + """Normalize a TTP group result to a list. + + TTP returns a single dict when a group matches exactly one entry and a + list when it matches several. Callers always expect a list. + """ + if data is None: + return [] + if isinstance(data, dict): + return [data] + return data + def _validate_contract(self) -> dict: if self.raw is None: msg = ( @@ -76,7 +89,7 @@ class BaseDevice(ABC): ) raise OxiAPIError(msg, status_code=404) system_data = self.system() - interfaces_data = self.interfaces() or [] + interfaces_data = self._as_list(self.interfaces()) result = { "system": System(**system_data), "interfaces": [Interfaces(**item) for item in interfaces_data], @@ -89,7 +102,7 @@ class BaseDevice(ABC): f"{self.__class__.__name__}: template '{self.template}' declares optional group " f"'vlans', but TTP did not return it." ) - vlans_data = self.vlans() or [] + vlans_data = self._as_list(self.vlans()) result["vlans"] = [Vlans(**item) for item in vlans_data] return result -- 2.53.0 From acb3a6291ce4fcbebe5f6f1e7cf28a0a93210cc4 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Sun, 7 Jun 2026 09:07:17 +0300 Subject: [PATCH 74/80] Add expected configuration output for Mikrotik devices - Introduced a new JSON file `config.expected.json` containing expected system and interface configurations for Mikrotik devices, including model, serial number, IP addresses, and VLAN details. - This addition facilitates testing and validation of the parsing functionality for Mikrotik configurations. --- tests/fixtures/mikrotik/config.expected.json | 43 ++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/fixtures/mikrotik/config.expected.json diff --git a/tests/fixtures/mikrotik/config.expected.json b/tests/fixtures/mikrotik/config.expected.json new file mode 100644 index 0000000..04427e6 --- /dev/null +++ b/tests/fixtures/mikrotik/config.expected.json @@ -0,0 +1,43 @@ +{ + "system": { + "model": "C52iG-5HaxD2HaxD", + "serial_number": "HE108BBGW0B", + "version": "7.12" + }, + "interfaces": [ + { + "interface": "bridge.LAN", + "ip_address": "172.16.3.1", + "mask": 24, + "description": null + }, + { + "interface": "ether1", + "ip_address": "10.38.3.245", + "mask": 24, + "description": null + }, + { + "interface": "wireguard2", + "ip_address": "10.1.100.2", + "mask": 24, + "description": null + }, + { + "interface": "ether4", + "ip_address": "100.10.10.1", + "mask": 24, + "description": null + } + ], + "vlans": [ + { + "vlan_id": 255, + "description": "test vlan" + }, + { + "vlan_id": 254, + "description": "test test vlan" + } + ] +} \ No newline at end of file -- 2.53.0 From 686cd6d7157250e2fab9d62db3992346d999c8e9 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Sun, 7 Jun 2026 09:12:47 +0300 Subject: [PATCH 75/80] Remove main execution block from Quasar model and add configuration files for testing - Eliminated the main execution block from the Quasar model for cleaner code. - Introduced new configuration files `config_1.conf` and `config_2.conf` for Quasar devices, detailing interface settings and IP configurations. - Added expected output JSON files `config_1.expected.json` and `config_2.expected.json` to validate the parsing of Quasar configurations against expected results. --- oxi/interfaces/models/quasar.py | 14 --- tests/fixtures/quasar/config_1.conf | 41 +++++++++ tests/fixtures/quasar/config_1.expected.json | 58 ++++++++++++ tests/fixtures/quasar/config_2.conf | 43 +++++++++ tests/fixtures/quasar/config_2.expected.json | 94 ++++++++++++++++++++ 5 files changed, 236 insertions(+), 14 deletions(-) create mode 100644 tests/fixtures/quasar/config_1.conf create mode 100644 tests/fixtures/quasar/config_1.expected.json create mode 100644 tests/fixtures/quasar/config_2.conf create mode 100644 tests/fixtures/quasar/config_2.expected.json diff --git a/oxi/interfaces/models/quasar.py b/oxi/interfaces/models/quasar.py index 5d320f8..a5c8859 100644 --- a/oxi/interfaces/models/quasar.py +++ b/oxi/interfaces/models/quasar.py @@ -21,17 +21,3 @@ class Quasar(BaseDevice): if ether_interface: interfaces.append(ether_interface) return interfaces - - -if __name__ == "__main__": - with open("./test7.txt") as file: - data = file.read() - quasar = Quasar(data) - qt = quasar.parse() - print(qt) - print() - with open("./test8.txt") as file: - data = file.read() - quasar = Quasar(data) - qt = quasar.parse() - print(qt) diff --git a/tests/fixtures/quasar/config_1.conf b/tests/fixtures/quasar/config_1.conf new file mode 100644 index 0000000..3a37374 --- /dev/null +++ b/tests/fixtures/quasar/config_1.conf @@ -0,0 +1,41 @@ +# Copyright © 2021-2022, TechArgos LLC +# ----------- ----------------- +# Subsystem Version +# ----------- ----------------- +# Engine 0.2.17.2022-10-21 +# DPlane 0.2.18.2022-05-16 +# BfMonitor 1.1.2.2022-08-25 +# CLI.core 1.0.3.2022-09-30 +# CLI.engine 1.2.2.2022-10-12 +# RConsole 0.3.4.2022-01-12 +# RcAppParams 0.3.1.2022-01-12 +# SNMP 0.0.11.2022-04-26 +# Zabbix 0.2.13.2022-01-26 +# WebUI 1.1.3.2022-10-19 +# BF.core 9.3.1.2021-01-30 +# +# ------------------------- --------------- +# Platform EEPROM field Value +# ------------------------- --------------- +# Product Name Quasar-T-Q-0002 +# Product Number HB4NC011234M +# Local MAC N/A +# Product Serial Number WEE1C1CC0004A +# Product Version 0.0 +# System Manufacturing Date 2026-06-07 + config ethernet ipv4 address 25.25.1.221/24 gateway 25.25.1.254 enable + + config interface 7/4 fec none mode force-up enable + config interface 8/1 fec none mode force-up enable + config interface 8/2 fec none mode force-up enable + config interface 8/3 fec none mode force-up enable + config interface 8/4 fec none mode force-up enable + config interface 9/1 fec none mode force-up enable + config interface 9/2 fec none mode force-up enable + config interface 7/4 description "IN DWDM / OUT TEST_HW_08_N0_p1" + config interface 8/1 description "IN DWDM / OUT TEST_HW_09_N1_p0" + config interface 8/2 description "IN DWDM / OUT TEST_HW_09_N0_p0" + config interface 8/3 description "IN DWDM / OUT TEST_HW_10_N1_p0" + config interface 8/4 description "IN DWDM / OUT TEST_HW_10_N0_p0" + config interface 9/1 description "IN DWDM / OUT TEST_HW_11_N0_p0" + config interface 9/2 description "IN DWDM / OUT TEST_HW_11_N0_p1" diff --git a/tests/fixtures/quasar/config_1.expected.json b/tests/fixtures/quasar/config_1.expected.json new file mode 100644 index 0000000..592ffbd --- /dev/null +++ b/tests/fixtures/quasar/config_1.expected.json @@ -0,0 +1,58 @@ +{ + "system": { + "model": "Quasar-T-Q-0002", + "serial_number": "WEE1C1CC0004A", + "version": "0.2.17.2022-10-21" + }, + "interfaces": [ + { + "interface": "7/4", + "ip_address": null, + "mask": null, + "description": "IN DWDM / OUT TEST_HW_08_N0_p1" + }, + { + "interface": "8/1", + "ip_address": null, + "mask": null, + "description": "IN DWDM / OUT TEST_HW_09_N1_p0" + }, + { + "interface": "8/2", + "ip_address": null, + "mask": null, + "description": "IN DWDM / OUT TEST_HW_09_N0_p0" + }, + { + "interface": "8/3", + "ip_address": null, + "mask": null, + "description": "IN DWDM / OUT TEST_HW_10_N1_p0" + }, + { + "interface": "8/4", + "ip_address": null, + "mask": null, + "description": "IN DWDM / OUT TEST_HW_10_N0_p0" + }, + { + "interface": "9/1", + "ip_address": null, + "mask": null, + "description": "IN DWDM / OUT TEST_HW_11_N0_p0" + }, + { + "interface": "9/2", + "ip_address": null, + "mask": null, + "description": "IN DWDM / OUT TEST_HW_11_N0_p1" + }, + { + "interface": "ethernet", + "ip_address": "25.25.1.221", + "mask": 24, + "description": null + } + ], + "vlans": [] +} \ No newline at end of file diff --git a/tests/fixtures/quasar/config_2.conf b/tests/fixtures/quasar/config_2.conf new file mode 100644 index 0000000..9d21864 --- /dev/null +++ b/tests/fixtures/quasar/config_2.conf @@ -0,0 +1,43 @@ +# Copyright © 2021-2024, TechArgos LLC +# ----------- ------------------ +# Component Version +# ----------- ------------------ +# Assembly 0.2.23_9.9.1_GA +# Engine 2.23.15.2024-08-22 +# DPlane 0.2.20.2023-10-17 +# BfMonitor 1.4.0.2024-08-27 +# CLI.core 1.0.3.2022-09-30 +# CLI.engine 1.3.4.2024-08-22 +# RConsole 1.0.1.2022-12-23 +# RcAppParams 1.0.1.2022-12-23 +# SNMP 0.3.0.2024-07-08 +# Zabbix 0.2.13.2022-01-26 +# WebUI 1.3.34.2024-08-30 +# NMS.agent 1.2.12.2024-08-21 +# SysLog 0.7.12.2024-02-02 +# NTPd 0.1.5.2024-07-15 +# AAA 0.0.8.2023-11-16 +# +# ------------------------- -------------------- +# Platform EEPROM field Value +# ------------------------- -------------------- +# Product Name D5232C-T +# Part Number TA-PB-D5232C-T-AC-PI +# Local MAC N/A +# Product Serial Number WHF1C87123456A +# Product Version 0.0 +# System Manufacturing Date 2026-06-07 + config ethernet ipv4 address 25.25.18.19/24 gateway 25.25.18.254 enable + config interface 1/1 description "TEST_HW_1_1" + config interface 1/3 description "TEST_HW_1_3" + config interface 2/1 description "TEST_HW_2_1" + config interface 2/2 description "TEST_HW_2_2" + config interface 2/3 description "TEST_HW_2_3" + config interface 2/4 description "TEST_HW_2_4" + config interface 3/1 description "TEST_HW_3_1" + config interface 3/3 description "TEST_HW_3_3" + config interface 3/4 description "TEST_HW_3_4" + config interface 4/1 description "TEST_HW_4_1" + config interface 4/2 description "TEST_HW_4_2" + config interface 4/3 description "TEST_HW_4_3" + config interface 4/4 description "TEST_HW_4_4" \ No newline at end of file diff --git a/tests/fixtures/quasar/config_2.expected.json b/tests/fixtures/quasar/config_2.expected.json new file mode 100644 index 0000000..1ddaf5a --- /dev/null +++ b/tests/fixtures/quasar/config_2.expected.json @@ -0,0 +1,94 @@ +{ + "system": { + "model": "D5232C-T", + "serial_number": "WHF1C87123456A", + "version": "0.2.23_9.9.1_GA" + }, + "interfaces": [ + { + "interface": "1/1", + "ip_address": null, + "mask": null, + "description": "TEST_HW_1_1" + }, + { + "interface": "1/3", + "ip_address": null, + "mask": null, + "description": "TEST_HW_1_3" + }, + { + "interface": "2/1", + "ip_address": null, + "mask": null, + "description": "TEST_HW_2_1" + }, + { + "interface": "2/2", + "ip_address": null, + "mask": null, + "description": "TEST_HW_2_2" + }, + { + "interface": "2/3", + "ip_address": null, + "mask": null, + "description": "TEST_HW_2_3" + }, + { + "interface": "2/4", + "ip_address": null, + "mask": null, + "description": "TEST_HW_2_4" + }, + { + "interface": "3/1", + "ip_address": null, + "mask": null, + "description": "TEST_HW_3_1" + }, + { + "interface": "3/3", + "ip_address": null, + "mask": null, + "description": "TEST_HW_3_3" + }, + { + "interface": "3/4", + "ip_address": null, + "mask": null, + "description": "TEST_HW_3_4" + }, + { + "interface": "4/1", + "ip_address": null, + "mask": null, + "description": "TEST_HW_4_1" + }, + { + "interface": "4/2", + "ip_address": null, + "mask": null, + "description": "TEST_HW_4_2" + }, + { + "interface": "4/3", + "ip_address": null, + "mask": null, + "description": "TEST_HW_4_3" + }, + { + "interface": "4/4", + "ip_address": null, + "mask": null, + "description": "TEST_HW_4_4" + }, + { + "interface": "ethernet", + "ip_address": "25.25.18.19", + "mask": 24, + "description": null + } + ], + "vlans": [] +} \ No newline at end of file -- 2.53.0 From 8edd1a296ce34dadfe2627be45bd653a3ae0cbb7 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Thu, 11 Jun 2026 16:16:14 +0300 Subject: [PATCH 76/80] Add H3C device support with configuration and expected output files - Introduced support for H3C devices by adding a new configuration file `config.conf` containing interface and VLAN settings. - Created an expected output JSON file `config.expected.json` to validate the parsing of H3C configurations. - Updated the test model cases to include H3C for comprehensive testing of device parsing functionality. --- oxi/interfaces/models/h3c.py | 9 +- oxi/interfaces/models/templates/eltex.ttp | 6 +- oxi/interfaces/models/templates/h3c.ttp | 1 + oxi/interfaces/models/templates/huawei.ttp | 2 +- tests/fixtures/h3c/config.conf | 169 +++++++++++++++++++++ tests/fixtures/h3c/config.expected.json | 155 +++++++++++++++++++ tests/test_models.py | 1 + 7 files changed, 331 insertions(+), 12 deletions(-) create mode 100644 tests/fixtures/h3c/config.conf create mode 100644 tests/fixtures/h3c/config.expected.json diff --git a/oxi/interfaces/models/h3c.py b/oxi/interfaces/models/h3c.py index db2444f..3ad40da 100644 --- a/oxi/interfaces/models/h3c.py +++ b/oxi/interfaces/models/h3c.py @@ -14,11 +14,4 @@ class H3C(BaseDevice): vlans.append(item) continue vlans.extend({"vlan_id": vlan_id} for vlan_id in vlan_ids) - return vlans - - -if __name__ == "__main__": - with open("./test5.txt") as file: - data = file.read() - h3c = H3C(data) - print(h3c.parse()) \ No newline at end of file + return vlans \ No newline at end of file diff --git a/oxi/interfaces/models/templates/eltex.ttp b/oxi/interfaces/models/templates/eltex.ttp index b47e14b..57240b6 100644 --- a/oxi/interfaces/models/templates/eltex.ttp +++ b/oxi/interfaces/models/templates/eltex.ttp @@ -26,13 +26,13 @@ Active-image: {{ ignore }} {{ _start_ }} interface {{ interface | ORPHRASE }} ip address {{ ip_address }} {{ mask | to_cidr }} - name {{ description }} - description {{ description }} + name {{ description | ORPHRASE}} + description {{ description | ORPHRASE }} interface vlan {{ vlan_id | _start_ }} - name {{ name }} + name {{ name | ORPHRASE }} vlan {{ _db_ | _start_ }} vlan {{ vlan_ids | joinmatches(',') | unrange("-", ",") | split(",")}} diff --git a/oxi/interfaces/models/templates/h3c.ttp b/oxi/interfaces/models/templates/h3c.ttp index afbe090..dcf1e2c 100644 --- a/oxi/interfaces/models/templates/h3c.ttp +++ b/oxi/interfaces/models/templates/h3c.ttp @@ -21,6 +21,7 @@ default_system = { interface {{ interface }} + description {{ description | ORPHRASE }} ip address {{ ip_address }} {{ mask | to_cidr }} diff --git a/oxi/interfaces/models/templates/huawei.ttp b/oxi/interfaces/models/templates/huawei.ttp index e832977..4464e2c 100644 --- a/oxi/interfaces/models/templates/huawei.ttp +++ b/oxi/interfaces/models/templates/huawei.ttp @@ -20,7 +20,7 @@ default_system = { interface {{ interface }} - description {{ description }} + description {{ description | ORPHRASE }} ip address {{ ip_address }} {{ mask | to_cidr }} diff --git a/tests/fixtures/h3c/config.conf b/tests/fixtures/h3c/config.conf new file mode 100644 index 0000000..81eca56 --- /dev/null +++ b/tests/fixtures/h3c/config.conf @@ -0,0 +1,169 @@ +# H3C Comware Software, Version 7.1.070, Release 6616P01 +# Copyright (c) 2004-2021 New H3C Technologies Co., Ltd. All rights reserved. +# Last reboot reason : User reboot +# +# Boot image: flash:/s9820-cmw710-boot-r6616p01.bin +# Boot image version: 7.1.070, Release 6616P01 +# Compiled May 06 2021 11:00:00 +# System image: flash:/s9820-cmw710-system-r6616p01.bin +# System image version: 7.1.070, Release 6616P01 +# Compiled May 06 2021 11:00:00 +# +# +# MPU(M) Slot 1: +# H3C S9820-64H MPU(M) with 1 C2538 Processor(s) +# BOARD TYPE: S9820-64H +# DRAM: 8192M bytes +# FLASH: 3630M bytes +# NVRAM: 0K bytes +# PCB 1 Version: VER.A +# PCB 2 Version: VER.B +# PCB 3 Version: VER.A +# PCB 4 Version: VER.A +# Basic BootWare Version: 105 +# Extended BootWare Version: 108 +# CPLD 1 Version: 002 +# CPLD 2 Version: 027 +# CPLD 3 Version: 002 +# CPLD 4 Version: 002 +# FPGA 1 Version: 001 +# Release Version: H3C S9820-64H-6616P01 +# Patch Version: None +# Reboot Cause: UserReboot +# [SubSlot 0] 64QSFP28 +# +# MPU(S) Slot 2: +# H3C S9820-64H MPU(S) with 1 C2538 Processor(s) +# BOARD TYPE: S9820-64H +# DRAM: 8192M bytes +# FLASH: 3630M bytes +# NVRAM: 0K bytes +# PCB 1 Version: VER.A +# PCB 2 Version: VER.B +# PCB 3 Version: VER.A +# PCB 4 Version: VER.A +# Basic BootWare Version: 105 +# Extended BootWare Version: 108 +# CPLD 1 Version: 002 +# CPLD 2 Version: 027 +# CPLD 3 Version: 002 +# CPLD 4 Version: 002 +# FPGA 1 Version: 001 +# Release Version: H3C S9820-64H-6616P01 +# Patch Version: None +# Reboot Cause: IRFMergeReboot +# [SubSlot 0] 64QSFP28 +# Slot Type State Subslot Soft Ver Patch Ver +# 1 S9820-64H Master 0 S9820-64H-6616P01 None +# 2 S9820-64H Standby 0 S9820-64H-6616P01 None +# +vlan 1 +# +vlan 12 + name BGP to OSPF1 +# +vlan 13 + name BGP to OSPF2 +# +vlan 15 + name HW_TEST_1 + description HW_TEST_1 +# +vlan 222 + name MGMT + description MGMT +# +vlan 222 +# +vlan 1112 to 1116 +# +vlan 1122 + name DATA +# +vlan 1123 +# +vlan 1200 +# +vlan 1512 +# +vlan 1513 + description cluster HW_TEST_1 +# +vlan 2000 + description cluster HW_TEST_2 +# +vlan 3377 + name VRF3377 +# +irf-port 1/2 + port group interface HundredGigE1/0/63 + port group interface HundredGigE1/0/64 +# +irf-port 2/1 + port group interface HundredGigE2/0/63 + port group interface HundredGigE2/0/64 +# +interface Bridge-Aggregation1 + description HW_TEST_1 + port link-type trunk + undo port trunk permit vlan 1 + port trunk permit vlan 221 1112 to 1116 1512 2000 + link-aggregation mode dynamic +# +interface Bridge-Aggregation2 + description HW_TEST_2 + port link-type trunk + undo port trunk permit vlan 1 + port trunk permit vlan 221 1112 to 1116 1512 + link-aggregation mode dynamic +# +interface Bridge-Aggregation3 + description HW_TEST_3 + port link-type trunk + undo port trunk permit vlan 1 + port trunk permit vlan 221 1112 to 1116 1512 2000 + link-aggregation mode dynamic +# +interface Bridge-Aggregation4 + description HW_TEST_4 + port link-type trunk + undo port trunk permit vlan 1 + port trunk permit vlan 221 1112 to 1116 1512 + link-aggregation mode dynamic + # +interface NULL0 +# +interface Vlan-interface1 + dhcp client identifier ascii 0098a92d5735b0-VLAN0001 +# +interface Vlan-interface12 + description BGP to OSPF1 + mtu 9008 + ip address 15.12.16.246 255.255.255.252 +# +interface Vlan-interface3000 + description L3 to HW_TEST_3 + ip binding vpn-instance HW_TEST_3 + ip address 192.168.19.254 255.255.255.128 +# +interface HundredGigE1/0/3 + port link-mode bridge + description HW_TEST_1 + port link-type trunk + undo port trunk permit vlan 1 + port trunk permit vlan 221 1112 to 1116 1512 + storm-constrain broadcast pps 1100 1000 + storm-constrain multicast pps 1100 1000 + storm-constrain control shutdown + port link-aggregation group 2 +# +interface HundredGigE1/0/63 + description HW_TEST_2 +# +interface M-GigabitEthernet0/0/0 + ip address 192.168.10.101 255.255.255.0 + dhcp client identifier hex 0298a92d5735b0 +# +interface M-GigabitEthernet0/0/1 + dhcp client identifier hex 0298a92d5735b0 +# diff --git a/tests/fixtures/h3c/config.expected.json b/tests/fixtures/h3c/config.expected.json new file mode 100644 index 0000000..f255686 --- /dev/null +++ b/tests/fixtures/h3c/config.expected.json @@ -0,0 +1,155 @@ +{ + "system": { + "model": "S9820-64H", + "serial_number": "", + "version": "7.1.070" + }, + "interfaces": [ + { + "interface": "Bridge-Aggregation1", + "ip_address": null, + "mask": null, + "description": "HW_TEST_1" + }, + { + "interface": "Bridge-Aggregation2", + "ip_address": null, + "mask": null, + "description": "HW_TEST_2" + }, + { + "interface": "Bridge-Aggregation3", + "ip_address": null, + "mask": null, + "description": "HW_TEST_3" + }, + { + "interface": "Bridge-Aggregation4", + "ip_address": null, + "mask": null, + "description": "HW_TEST_4" + }, + { + "interface": "NULL0", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "Vlan-interface1", + "ip_address": null, + "mask": null, + "description": null + }, + { + "interface": "Vlan-interface12", + "ip_address": "15.12.16.246", + "mask": 30, + "description": "BGP to OSPF1" + }, + { + "interface": "Vlan-interface3000", + "ip_address": "192.168.19.254", + "mask": 25, + "description": "L3 to HW_TEST_3" + }, + { + "interface": "HundredGigE1/0/3", + "ip_address": null, + "mask": null, + "description": "HW_TEST_1" + }, + { + "interface": "HundredGigE1/0/63", + "ip_address": null, + "mask": null, + "description": "HW_TEST_2" + }, + { + "interface": "M-GigabitEthernet0/0/0", + "ip_address": "192.168.10.101", + "mask": 24, + "description": null + }, + { + "interface": "M-GigabitEthernet0/0/1", + "ip_address": null, + "mask": null, + "description": null + } + ], + "vlans": [ + { + "vlan_id": 1, + "description": null + }, + { + "vlan_id": 12, + "description": null + }, + { + "vlan_id": 13, + "description": null + }, + { + "vlan_id": 15, + "description": "HW_TEST_1" + }, + { + "vlan_id": 222, + "description": "MGMT" + }, + { + "vlan_id": 222, + "description": null + }, + { + "vlan_id": 1122, + "description": "DATA" + }, + { + "vlan_id": 1123, + "description": null + }, + { + "vlan_id": 1200, + "description": null + }, + { + "vlan_id": 1512, + "description": null + }, + { + "vlan_id": 1513, + "description": null + }, + { + "vlan_id": 2000, + "description": null + }, + { + "vlan_id": 3377, + "description": "HW_TEST_1" + }, + { + "vlan_id": 1112, + "description": null + }, + { + "vlan_id": 1113, + "description": null + }, + { + "vlan_id": 1114, + "description": null + }, + { + "vlan_id": 1115, + "description": null + }, + { + "vlan_id": 1116, + "description": null + } + ] +} \ No newline at end of file diff --git a/tests/test_models.py b/tests/test_models.py index f9f1f6f..d2222a3 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -12,6 +12,7 @@ MODEL_CASES = [ ("qtech", "config_2.conf", "config_2.expected.json"), ("huawei", "config.conf", "config.expected.json"), ("eltex", "config.conf", "config.expected.json"), + ("h3c", "config.conf", "config.expected.json"), ("quasar", "config_1.conf", "config_1.expected.json"), ("quasar", "config_2.conf", "config_2.expected.json"), ] -- 2.53.0 From c40cae1561d72d80ccc1bd925bbc7ae56b680e29 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Thu, 11 Jun 2026 23:22:45 +0300 Subject: [PATCH 77/80] Update dependencies and clean up Huawei model code - Updated `pytest` to version 9.0.3 and `responses` to version 0.26.1 in `pyproject.toml`. - Removed the main execution block from the Huawei model for cleaner code structure. --- oxi/interfaces/models/huawei.py | 7 ------- pyproject.toml | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/oxi/interfaces/models/huawei.py b/oxi/interfaces/models/huawei.py index ddb90a1..a6d8938 100644 --- a/oxi/interfaces/models/huawei.py +++ b/oxi/interfaces/models/huawei.py @@ -9,10 +9,3 @@ class Huawei(BaseDevice): def vlans(self) -> list[dict]: vlan_ids = self.raw.get("vlans", {}).get("vlan_ids", []) return [{"vlan_id": vlan} for vlan in vlan_ids] - - -if __name__ == "__main__": - with open("./test4.txt") as file: - data = file.read() - huawei = Huawei(data) - print(huawei.parse()) diff --git a/pyproject.toml b/pyproject.toml index 75bb927..de41b3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,8 +22,8 @@ dependencies = [ [project.optional-dependencies] dev = [ - "pytest>=8", - "responses>=0.25", + "pytest>=9.0.3", + "responses>=0.26.1", ] [project.urls] -- 2.53.0 From 3c0e70b3200e3f4216c471941f88a682eca1e913 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Thu, 11 Jun 2026 23:47:17 +0300 Subject: [PATCH 78/80] Update project configuration and dependencies - Added `.vscode` to `.gitignore` to exclude Visual Studio Code settings. - Updated `pyproject.toml` to include `ruff` for linting and configured its settings. - Modified `uv.lock` to include `ruff` in both optional and development dependencies. - Refactored type hints in several files to use `str | None` for optional parameters. - Cleaned up unused imports and whitespace in various modules for improved code clarity. --- .gitignore | 2 +- oxi/__init__.py | 1 - oxi/adapter.py | 3 +-- oxi/conf.py | 5 ++-- oxi/core.py | 10 ++++---- oxi/exception.py | 4 ++-- oxi/interfaces/__init__.py | 4 ++-- oxi/interfaces/base.py | 21 ++++++++--------- oxi/interfaces/contract.py | 1 + oxi/interfaces/models/__init__.py | 2 +- oxi/interfaces/models/h3c.py | 2 +- oxi/interfaces/models/keenetic.py | 1 + oxi/node.py | 1 - oxi/view.py | 1 - pyproject.toml | 8 +++++++ tests/test_models.py | 2 +- tests/test_network.py | 4 ++-- tests/test_units.py | 3 +-- uv.lock | 38 ++++++++++++++++++++++++++++++- 19 files changed, 76 insertions(+), 37 deletions(-) diff --git a/.gitignore b/.gitignore index ab9f627..7f3e0c2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,6 @@ main.py .venv .idea .DS_Store - +.vscode # etc files *.txt \ No newline at end of file diff --git a/oxi/__init__.py b/oxi/__init__.py index c6ef94b..492517d 100644 --- a/oxi/__init__.py +++ b/oxi/__init__.py @@ -1,6 +1,5 @@ from .core import OxiAPI - __all__ = [ "OxiAPI", ] diff --git a/oxi/adapter.py b/oxi/adapter.py index 87786ad..d8b26cc 100644 --- a/oxi/adapter.py +++ b/oxi/adapter.py @@ -1,4 +1,3 @@ -from typing import Optional from requests.adapters import HTTPAdapter from urllib3.util import Retry @@ -6,7 +5,7 @@ from urllib3.util import Retry class OxiAdapter(HTTPAdapter): def __init__( self, - timeout: Optional[int] = None, + timeout: int | None = None, max_retries: int = 3, *args, **kwargs, diff --git a/oxi/conf.py b/oxi/conf.py index acc2b70..d354396 100644 --- a/oxi/conf.py +++ b/oxi/conf.py @@ -1,6 +1,7 @@ -from functools import cached_property import json -from typing import TYPE_CHECKING, Generic, Iterator, TypeVar +from collections.abc import Iterator +from functools import cached_property +from typing import TYPE_CHECKING, Generic, TypeVar from pydantic import BaseModel diff --git a/oxi/core.py b/oxi/core.py index 5662c85..d28017c 100644 --- a/oxi/core.py +++ b/oxi/core.py @@ -1,8 +1,8 @@ -from typing import Optional from requests import HTTPError, Session from oxi.adapter import OxiAdapter from oxi.exception import OxiAPIError + from .node import Node @@ -10,8 +10,8 @@ class OxiAPI: def __init__( self, url: str, - username: Optional[str] = None, - password: Optional[str] = None, + username: str | None = None, + password: str | None = None, verify: bool = True, ): self.base_url = url.rstrip("/") @@ -20,8 +20,8 @@ class OxiAPI: def __create_session( self, - username: Optional[str] = None, - password: Optional[str] = None, + username: str | None = None, + password: str | None = None, verify: bool = True, ) -> Session: session = Session() diff --git a/oxi/exception.py b/oxi/exception.py index be3adaf..f55ed78 100644 --- a/oxi/exception.py +++ b/oxi/exception.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING if TYPE_CHECKING: from requests import HTTPError @@ -35,7 +35,7 @@ def _looks_like_node_not_found_html(e: "HTTPError") -> bool: class OxiAPIError(Exception): - def __init__(self, message: str, status_code: Optional[int] = None): + def __init__(self, message: str, status_code: int | None = None): super().__init__(message) self.status_code = status_code self.message = message diff --git a/oxi/interfaces/__init__.py b/oxi/interfaces/__init__.py index c5f11ea..f401ed6 100644 --- a/oxi/interfaces/__init__.py +++ b/oxi/interfaces/__init__.py @@ -1,4 +1,4 @@ -from typing import Callable, Type +from collections.abc import Callable from .base import BaseDevice @@ -7,7 +7,7 @@ device_registry = {} def register_parser( name: list[str] | str, -) -> Callable[[Type[BaseDevice]], Type[BaseDevice]]: +) -> Callable[[type[BaseDevice]], type[BaseDevice]]: def wrapper(cls): name_list = [] if isinstance(name, str): diff --git a/oxi/interfaces/base.py b/oxi/interfaces/base.py index 3d51188..5c5667b 100644 --- a/oxi/interfaces/base.py +++ b/oxi/interfaces/base.py @@ -1,10 +1,11 @@ +import xml.etree.ElementTree as ET from abc import ABC, abstractmethod from pathlib import Path + from ttp import ttp + from oxi.exception import OxiAPIError -from oxi.interfaces.contract import Device -import xml.etree.ElementTree as ET -from oxi.interfaces.contract import Interfaces, System, Vlans +from oxi.interfaces.contract import Device, Interfaces, System, Vlans class BaseDevice(ABC): @@ -40,7 +41,7 @@ class BaseDevice(ABC): Raises: ValueError: if raw data cannot be validated by the contract. - """ + """ # noqa: E501 return self.raw.get("vlans", []) def interfaces(self) -> list[dict]: @@ -52,7 +53,7 @@ class BaseDevice(ABC): Raises: ValueError: if raw data cannot be validated by the contract. - """ + """ # noqa: E501 return self.raw.get("interfaces", []) def system(self) -> dict: @@ -82,11 +83,7 @@ class BaseDevice(ABC): def _validate_contract(self) -> dict: if self.raw is None: - msg = ( - f"Node {self.name} not found" - if self.name - else "Node not found" - ) + msg = f"Node {self.name} not found" if self.name else "Node not found" raise OxiAPIError(msg, status_code=404) system_data = self.system() interfaces_data = self._as_list(self.interfaces()) @@ -99,8 +96,8 @@ class BaseDevice(ABC): if "vlans" in self._declared_sections: if "vlans" not in self.raw: raise ValueError( - f"{self.__class__.__name__}: template '{self.template}' declares optional group " - f"'vlans', but TTP did not return it." + f"{self.__class__.__name__}: template '{self.template}' " + f"declares optional group 'vlans', but TTP did not return it." ) vlans_data = self._as_list(self.vlans()) result["vlans"] = [Vlans(**item) for item in vlans_data] diff --git a/oxi/interfaces/contract.py b/oxi/interfaces/contract.py index d5bbb24..44a289f 100644 --- a/oxi/interfaces/contract.py +++ b/oxi/interfaces/contract.py @@ -1,4 +1,5 @@ from ipaddress import IPv4Address + from pydantic import BaseModel, ConfigDict, Field diff --git a/oxi/interfaces/models/__init__.py b/oxi/interfaces/models/__init__.py index a3a9678..af8e78a 100644 --- a/oxi/interfaces/models/__init__.py +++ b/oxi/interfaces/models/__init__.py @@ -3,5 +3,5 @@ import pkgutil package = __package__ -for loader, module_name, is_pkg in pkgutil.iter_modules(__path__): +for _, module_name, _ in pkgutil.iter_modules(__path__): importlib.import_module(f"{package}.{module_name}") diff --git a/oxi/interfaces/models/h3c.py b/oxi/interfaces/models/h3c.py index 3ad40da..f956607 100644 --- a/oxi/interfaces/models/h3c.py +++ b/oxi/interfaces/models/h3c.py @@ -14,4 +14,4 @@ class H3C(BaseDevice): vlans.append(item) continue vlans.extend({"vlan_id": vlan_id} for vlan_id in vlan_ids) - return vlans \ No newline at end of file + return vlans diff --git a/oxi/interfaces/models/keenetic.py b/oxi/interfaces/models/keenetic.py index 5764f23..41d5248 100644 --- a/oxi/interfaces/models/keenetic.py +++ b/oxi/interfaces/models/keenetic.py @@ -1,4 +1,5 @@ from ipaddress import ip_interface + from oxi.interfaces import register_parser from oxi.interfaces.base import BaseDevice from oxi.interfaces.utils import decode_utf diff --git a/oxi/node.py b/oxi/node.py index 828970f..c88df7e 100644 --- a/oxi/node.py +++ b/oxi/node.py @@ -6,7 +6,6 @@ from oxi.exception import OxiAPIError from .view import NodeView - if TYPE_CHECKING: from requests import Session diff --git a/oxi/view.py b/oxi/view.py index 88e4885..951b234 100644 --- a/oxi/view.py +++ b/oxi/view.py @@ -3,7 +3,6 @@ from typing import TYPE_CHECKING from .conf import NodeConfig - if TYPE_CHECKING: from requests import Session diff --git a/pyproject.toml b/pyproject.toml index de41b3f..e1a6eb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,4 +47,12 @@ include-package-data = true dev = [ "pytest>=9.0.3", "responses>=0.26.1", + "ruff>=0.15.17", ] + +[tool.ruff] +target-version = "py310" +line-length = 88 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] diff --git a/tests/test_models.py b/tests/test_models.py index d2222a3..95f8860 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,8 +1,8 @@ import json import pytest - from conftest import FIXTURES, load + from oxi.interfaces import device_registry MODEL_CASES = [ diff --git a/tests/test_network.py b/tests/test_network.py index c314048..92e54c7 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -1,7 +1,7 @@ import pytest import responses - from conftest import load + from oxi import OxiAPI from oxi.exception import OxiAPIError @@ -84,4 +84,4 @@ def test_unknown_model_raises_value_error(): api = OxiAPI(url=BASE) with pytest.raises(ValueError, match="not found in registry"): - api.node("HQ").config + _ = api.node("HQ").config diff --git a/tests/test_units.py b/tests/test_units.py index 0df7e8e..846e7fa 100644 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -1,10 +1,9 @@ import pytest - from conftest import load + from oxi.exception import OxiAPIError from oxi.interfaces import device_registry from oxi.interfaces.base import BaseDevice -from oxi.interfaces.contract import Interfaces, System from oxi.interfaces.utils import decode_utf, expand_vlan_range diff --git a/uv.lock b/uv.lock index 36a43d8..0e2bfb7 100644 --- a/uv.lock +++ b/uv.lock @@ -158,23 +158,34 @@ dependencies = [ { name = "ttp" }, ] -[package.dev-dependencies] +[package.optional-dependencies] dev = [ { name = "pytest" }, { name = "responses" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "responses" }, + { name = "ruff" }, +] + [package.metadata] requires-dist = [ { name = "pydantic", specifier = ">=2.12.5" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, { name = "requests", specifier = ">=2.32.5" }, + { name = "responses", marker = "extra == 'dev'", specifier = ">=0.26.1" }, { name = "ttp", specifier = ">=0.10.0" }, ] +provides-extras = ["dev"] [package.metadata.requires-dev] dev = [ { name = "pytest", specifier = ">=9.0.3" }, { name = "responses", specifier = ">=0.26.1" }, + { name = "ruff", specifier = ">=0.15.17" }, ] [[package]] @@ -448,6 +459,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/31/6a620b4427d546b9e7cca8b3b8c5f0559d9cef2bb9eedcda7f73c1473c19/responses-0.26.1-py3-none-any.whl", hash = "sha256:8aacc4586eb08fb2208ef64a9eb4258d9b0c6e6f4260845f2f018ab847495345", size = 35502, upload-time = "2026-05-21T19:56:38.046Z" }, ] +[[package]] +name = "ruff" +version = "0.15.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, + { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, + { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, +] + [[package]] name = "tomli" version = "2.4.1" -- 2.53.0 From dd7f11738048d9a6807feb63eddd7b798779aff3 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Thu, 11 Jun 2026 23:57:11 +0300 Subject: [PATCH 79/80] Add CI workflows for Gitea and GitHub - Created a CI workflow for Gitea with linting and testing jobs using Python versions 3.10 to 3.13. - Established a CI workflow for GitHub with similar linting and testing jobs, ensuring consistency across platforms. - Both workflows utilize the `uv` tool for dependency management and testing, enhancing the project's CI/CD capabilities. --- .gitea/workflows/ci.yml | 40 ++++++++++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .github/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..b3189ca --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + lint: + runs-on: rassbery + container: catthehacker/ubuntu:act-22.04 + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v6 + - name: Sync dependencies + run: uv sync --group dev + - name: Ruff lint + run: uv run ruff check --output-format=github . + - name: Ruff format check + run: uv run ruff format --check . + + test: + runs-on: rassbery + container: catthehacker/ubuntu:act-22.04 + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Sync dependencies + run: uv sync --group dev + - name: Run tests + run: uv run pytest -q diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f5da988 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v6 + - name: Sync dependencies + run: uv sync --group dev + - name: Ruff lint + run: uv run ruff check --output-format=github . + - name: Ruff format check + run: uv run ruff format --check . + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Sync dependencies + run: uv sync --group dev + - name: Run tests + run: uv run pytest -q -- 2.53.0 From 074f2e9340ca7a7e51e67b503be1de6aeff783b4 Mon Sep 17 00:00:00 2001 From: IluaAir Date: Fri, 12 Jun 2026 00:03:55 +0300 Subject: [PATCH 80/80] Update .gitignore and remove .python-version file - Added .python-version to .gitignore to prevent tracking of Python version files. - Deleted the .python-version file as it is no longer needed for the project. --- .gitignore | 1 + .python-version | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 .python-version diff --git a/.gitignore b/.gitignore index 7f3e0c2..517cdfa 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ wheels/ main.py # Virtual environments .venv +.python-version .idea .DS_Store .vscode diff --git a/.python-version b/.python-version deleted file mode 100644 index 24ee5b1..0000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.13 -- 2.53.0