diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..ca26bb544337076f370e6bcbee370ab337cf163b --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +APP_NAME="Service Resource Manager" +APP_DESCRIPTION="Service Resource Manager is the brains of the ETSI SDG Open Operator Platform - OpenOP" +APP_VERSION="1.5.0" + +POSTGRES_SETTINGS__URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/srm" +POSTGRES_SETTINGS__ECHO = true +POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP = true + +NATS_SETTINGS__URL = "nats://localhost:4222" +NATS_SETTINGS__CONNECT_TIMEOUT = 10 +NATS_SETTINGS__MAX_RECONNECT_ATTEMPTS = 3 +NATS_SETTINGS__DRAIN_TIMEOUT = 30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..ebfbaf677bb837f75d3c0fef96ef560fa885243b --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# environment +.env + +# code editors +.vscode/ + +# AI software tools +.claude +CLAUDE.md + +.tox/ +htmlcov/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.idea/ +.coverage +.python_version +coverage.xml +.cache diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c93f5414d3e7dd2039beba696f63ad22c7d4603e..1e9ac238ba6a47edb238b62ef2d6a8ac8ae3172d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,30 +1,72 @@ +default: + image: python:3.12-slim + cache: + paths: + - .cache/uv + before_script: + - cd "$CI_PROJECT_DIR/${BACKEND_DIR:-.}" + - pip install uv + - export UV_SYSTEM_CERTS=1 + - uv sync --locked --extra dev + - . .venv/bin/activate + stages: + - type + - architecture + - lint + - format + - test - build - - push variables: - IMAGE_NAME: $CI_REGISTRY/$CI_PROJECT_PATH - IMAGE_TAG: latest + UV_CACHE_DIR: "$CI_PROJECT_DIR/.cache/uv" + +type: + stage: type + script: + - mypy src/ tests/ -before_script: - - docker info +architecture: + stage: architecture + script: + - lint-imports + +lint: + stage: lint + script: + - ruff check src/ tests/ + +format: + stage: format + script: + - ruff format --check src/ tests/ + +test: + stage: test + variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + services: + - docker:dind + script: + - pytest tests/unit tests/integration tests/api --cov=src/srm --cov-branch --cov-report=term-missing || [ $? -eq 5 ] #bypass no tests found error. + coverage: '/TOTAL.+ ([0-9]{1,3}(?:\.[0-9]+)?%)/' build: - tags: - - "shell" stage: build + image: docker:cli + variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + services: + - docker:dind + before_script: + - docker info script: - - docker build -t $IMAGE_NAME:$IMAGE_TAG . - only: - - main - -push: - tags: - - "shell" - stage: push - script: - - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" $CI_REGISTRY --password-stdin - - docker tag $IMAGE_NAME:$IMAGE_TAG $CI_REGISTRY_IMAGE:$IMAGE_TAG - - docker push $CI_REGISTRY_IMAGE:$IMAGE_TAG - only: - - main + - export TEST_IMAGE_TAG="ci-${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}" + - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" "$CI_REGISTRY" --password-stdin + - docker build --network=host -t "$CI_REGISTRY_IMAGE:$TEST_IMAGE_TAG" . + - docker push "$CI_REGISTRY_IMAGE:$TEST_IMAGE_TAG" + - docker logout "$CI_REGISTRY" + rules: + - if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop"' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8220cacc1d0be84b7a9665b6ae9b79ff7bd5893f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,37 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-yaml + - id: check-toml + - id: end-of-file-fixer + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.15.6 + hooks: + # Run the linter. + - id: ruff-check + # Run the formatter. + - id: ruff-format + +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.19.1 # Use the sha / tag you want to point at + hooks: + - id: mypy + files: ^src/srm/|^tests/ + additional_dependencies: + - "asgi-lifespan>=2.1.0" + - "docker>=7.0.0" + - "fastapi[standard]>=0.135.1" + - "httpx>=0.27.0" + - "nats-py>=2.10.0" + - "pydantic-settings>=2.13.1" + - "pytest>=9.0.2" + - "pytest-asyncio>=0.24" + - "python-dotenv>=1.2.2" + - "sqlalchemy>=2.0.48" + - "structlog>=25.5.0" + - "testcontainers[postgres]>=4.13.2" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..e0254bee59463426d94ea296617d029ce3ff6cd0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# syntax=docker/dockerfile:1 + +FROM python:3.12-slim AS builder +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DEFAULT_TIMEOUT=120 + +COPY pyproject.toml ./ +COPY uv.lock ./ + +RUN uv sync --locked + +FROM python:3.12-slim AS runtime + +WORKDIR /app + +COPY --from=builder /app/.venv /app/.venv +COPY src/ ./src/ + +ENV PATH="/app/.venv/bin:$PATH" +ENV PYTHONPATH="/app/src" +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 + +EXPOSE 8080 + +CMD ["uvicorn", "srm.main:create_app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 261eeb9e9f8b2b4b0d119366dda99c6fd7d35c64..0000000000000000000000000000000000000000 --- a/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - 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/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..50e3a40115d37e20646418f7d1bd687ee40365ea --- /dev/null +++ b/Makefile @@ -0,0 +1,61 @@ +.PHONY: install clean lint format precommit type test coverage run + +SRC := src/srm +DEV ?= false + +export PYTHONPYCACHEPREFIX := .cache/pycache + +export UV_SYSTEM_CERTS := 1 + +## Dev tools Packages Handling +ifeq ($(DEV),true) + EXTRAS := --extra dev +else + EXTRAS := +endif + +## Uv sync command +# --locked fails fast if uv.lock is out of sync with pyproject.toml, instead of +# silently re-resolving, so local dev and CI always install the same versions. +UV_SYNC = uv sync --locked $(EXTRAS) + +## Make Commands +install: + $(UV_SYNC) + +clean: + rm -rf .venv + rm -rf .cache + rm -rf src/*.egg-info + +lint: + make install DEV=true + .venv/bin/ruff check --fix + +format: + make install DEV=true + .venv/bin/ruff format + +precommit: + make install DEV=true + .venv/bin/pre-commit run --all-files + +architecture: + make install DEV=true + .venv/bin/lint-imports --cache-dir .cache/import-linter + +type: + make install DEV=true + .venv/bin/mypy src/ tests/ + +test: + make install DEV=true + .venv/bin/pytest + +coverage: + make install DEV=true + .venv/bin/pytest --cov=$(SRC) --cov-branch --cov-report=term-missing + +run: + make install + .venv/bin/uvicorn srm.main:create_app --reload diff --git a/README.md b/README.md index 79c5ed40a2558ba1b231579aa21344dbdc7fc726..5fa2455fe94a23c444750e20e5ed79976964fc69 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,6 @@ # Service Resource Manager -The Service Resource Manager (SRM) is a web service written in Python and based on the Flask micro web framework. It implements the Service Resource manager role of the Operator Platform, defined by the [GSMA Operator Platform Group (OPG)](https://www.gsma.com/solutions-and-impact/technologies/networks/gsma_resources/gsma-operator-platform-group-september-2024-publications/) +A Python, FastAPI-based microservice to work as the brains of ETSI SDG OpenOP. -## Description - -The Service Resource Manager facilitates the North-South Bound Interface (NSBI) specification of GSMA, by acting as an interconnection link between the CAMARA-defined API exposed by the Open Exposure Gateway, and the transformation functions that expose the underlying infrastructure techology. SRM seamlessly handles the Application Provider's request for infrastructure access by selecting the appropriate transformation function. - -SRM supports the following CAMARA functions: - -
- -| Edge Cloud Management API | Network Exposure API (QoD & Traffic Influence)| -| ------------- | ------------- | -| Application Metadata registration | Create QoD Session | -| App Metadata Removal | Remove QoD Session | -| App Metadata Retrieval | Retrieve QoD Session | -| Application Instantiation | Create Traffic Influence Resource | -| Application Instance Retrieval | Remove Traffic Influence Resource | -| Application Instance Removal | Retrieve Traffic Influence Resource | -| | Retrieve Location | - -## Deployment - -SRM can be deployed in a Kubernetes cluster. When deployment manifests (e.g. _srm-deployment.yaml_) are provided, they create a SRM Deployment resource and its supporting native K8s Service. The following table contains the necessary environment variables for the Kubernetes adapter. If you have defined a custom adapter, include your variables accordingly. - -### Adapter environment variables - -| Kubernetes | aerOS | i2Edge | -| ------------- | ------------- |-------------| -| KUBERNETES_MASTER_PORT (eg. 10.10.10.10) | | FLAVOUR_ID | -| EMP_STORAGE_URI | aerOS_HLO_TOKEN | -| KUBERNETES_MASTER_TOKEN | aerOS_ACCESS_TOKEN | - | - -### Common environment variables -| Name | Description| -| ------------- | ------------- | -| ADAPTER_BASE_URL| Base address for the edge cloud adapter | -| ARTIFACT_MANAGER_ADDRESS | Address of the Artifact Manager | -| EDGE_CLOUD_ADAPTER_NAME | The adapter SRM is going to use throughout its lifecycle.| -| PLATFORM_PROVIDER| The Edge Cloud infrastructure provider| -| NETWORK_ADAPTER_NAME | The Network function exposure adapter| -| NETWORK_ADAPTER_BASE_URL | The address of the network adapter| - -## Usage - -Assuming an instance of Open Exposure Gateway (OEG) is running so that CAMARA APIs are accessible, here are a few request examples with responses, all CAMARA compatible. These examples call the OEG (which may forward to SRM); SRM itself exposes the backend APIs (e.g. `/serviceFunction`, `/deployedServiceFunction` for edge cloud, `/sessions`, `/traffic-influences`, `/location/retrieve` for network exposure). - -### Get all registered apps - -_curl -X GET http://[OEG_root_url]/apps_ - -Example response: - -_[ - { - "appId": "68503f9fe81dc7441fdaae94", - "appRepo": { - "imagePath": "mongo:4.4.18" - }, - "componentSpec": [ - { - "componentName": "mongodb", - "networkInterfaces": [ - { - "port": 27017, - "protocol": "TCP" - } - ] - } - ], - "name": "mongodb", - "packageType": "QCOW2" - }, - { - "appId": "685122aa8fff437507ec8932", - "appRepo": { - "imagePath": "nginx" - }, - "componentSpec": [ - { - "componentName": "nginx", - "networkInterfaces": [ - { - "port": 80, - "protocol": "TCP" - }, - { - "port": 443, - "protocol": "TCP" - } - ] - } - ], - "name": "nginx", - "packageType": "QCOW2" - } -]_ - -### Register app metadata - -_curl -X POST http://[OEG_root_url]/apps --data '{"name": "nginx", "version": "1", "packageType": "QCOW2", "appRepo": {"imagePath": "nginx", "type": "PRIVATEREPO"} -, "componentSpec": [{"componentName": "nginx", "networkInterfaces": [{"protocol": "TCP", "port": 80, "interfaceId": "Uj6qThvzkegxa3L4b88", "visibilityType": "VISIBILITY_EXTERNAL"}, {"protoco -l": "TCP", "port": 443, "interfaceId": "Uj6qThvzkegxa3L4b88", "visibilityType": "VISIBILITY_EXTERNAL"}]}]}' -H "Content-Type: application/json"_ - -Example Response: - -_{ - "appId": "685bdc7dc2db24cc0e8927dc" -}_ - -### Instantiate registered app - -_curl -X POST http://[OEG_root_url]/appinstances --data '{"appId": "685bdc7dc2db24cc0e8927dc", "name": "nginx-test", "appZones": [{"EdgeCloudZone":{"edgeCloudZoneI -d": "f39c5ea3-f4e3-472f-b080-2f3b81c39995", "edgeCloudZoneName": "k3d-sunriseop-agent-2", "edgeCloudProvider": "ISI"}}]}' -H "Content-Type: application/json"_ - -Example Response: - -_{ - "appId": "685bdc7dc2db24cc0e8927dc", - "appInstanceId": "f3b7788a-e133-46c7-9cbd-7a8501123567", - "appProvider": null, - "componentEndpointInfo": {}, - "edgeCloudZoneId": "zorro-solutions", - "kubernetesClusterRef": "", - "name": "nginx-test", - "status": "unknown" -}_ \ No newline at end of file +## WIP +WIP diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..a7a3783e663b2993575a4ac1b733f9ef65956564 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,104 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "service-resource-manager" +description="OpenOP's Service Resource Manager Module" +readme="README.md" +authors = [ + {name="Dimitris Gogos", email="dgogos@intracom-telecom.com"}, + {name="Paris Stentoumis", email="stentps@intracom-telecom.com"}, +] +dynamic=["version"] +license="LicenseRef-My-License" +requires-python = "==3.12.*" +dependencies = [ + "fastapi[standard]>=0.135.1", + "pydantic-settings>=2.13.1", + "python-dotenv>=1.2.2", + "structlog>=25.5.0", + "sunrise6g-opensdk==2.0.0", + "sqlalchemy>=2.0.48", + "asyncpg>=0.31.0", + "nats-py>=2.10.0", +] + +[project.optional-dependencies] +dev = [ + "asgi-lifespan>=2.1.0", + "import-linter>=2.11", + "mypy>=1.19.1", + "pre-commit>=4.5.1", + "pytest>=9.0.2", + "pytest-asyncio>=0.24", + "pytest-cov>=6.0.0", + "testcontainers[postgres]>=4.13.2", + "ruff>=0.15.6", +] + +[tool.ruff] +line-length = 100 +target-version = "py312" +cache-dir = ".cache/ruff" + +[tool.ruff.lint] +select = ["E", "W", "F", "FAST", "I"] +ignore = ["E3"] + +[tool.ruff.format] +docstring-code-format = true + +[tool.mypy] +python_version = "3.12" +cache_dir = ".cache/mypy" +mypy_path = "src" +strict = true +ignore_missing_imports = true + +[tool.importlinter] +root_package = "srm" + +[[tool.importlinter.contracts]] +name = "Domain independence" +type = "forbidden" +source_modules = ["srm.domain"] +forbidden_modules = ["srm.application", "srm.api", "srm.adapters"] + +[[tool.importlinter.contracts]] +name = "Application cannot import infrastructure" +type = "forbidden" +source_modules = ["srm.application"] +forbidden_modules = ["srm.api", "srm.adapters"] + +[tool.pytest.ini_options] +cache_dir = ".cache/pytest" +testpaths = ["tests"] +pythonpath = ["."] +asyncio_mode = "auto" +markers = [ + "integration: tests that require a running Docker daemon (testcontainers)", +] + +# Coverage measurement is opt-in via explicit --cov flags (see Makefile's +# `coverage` target / CI), not enabled here — keeping it out of addopts means +# a plain `pytest ` for local/interactive runs stays fast and isn't +# subject to a repo-wide coverage figure computed from a partial test run. +[tool.coverage.run] +branch = true +omit = [ + "*/tests/*", + "*/test/*", + "*/migrations/*", + "*/__main__.py", +] + +[tool.coverage.report] +show_missing = true +skip_covered = false +precision = 1 +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", +] diff --git a/service-resource-manager-implementation/.dockerignore b/service-resource-manager-implementation/.dockerignore deleted file mode 100644 index cdd823e64e7e91ae85da84f22410ecb7eb370ae2..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/.dockerignore +++ /dev/null @@ -1,72 +0,0 @@ -.travis.yaml -.swagger-codegen-ignore -README.md -tox.ini -git_push.sh -test-requirements.txt -setup.py - -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*,cover -.hypothesis/ -venv/ -.python-version - -# Translations -*.mo -*.pot - -# Django stuff: -*.log - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -#Ipython Notebook -.ipynb_checkpoints diff --git a/service-resource-manager-implementation/.gitignore b/service-resource-manager-implementation/.gitignore deleted file mode 100644 index a655050c2631466828b5b8bfc59ae27f9ac02dc5..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/.gitignore +++ /dev/null @@ -1,64 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*,cover -.hypothesis/ -venv/ -.python-version - -# Translations -*.mo -*.pot - -# Django stuff: -*.log - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -#Ipython Notebook -.ipynb_checkpoints diff --git a/service-resource-manager-implementation/Dockerfile b/service-resource-manager-implementation/Dockerfile deleted file mode 100644 index 0d3a85eb206aadaacb6c2e34e76b0f4bdcd59e83..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -FROM python:3.12-alpine - -RUN mkdir -p /usr/src/app -WORKDIR /usr/src/app - -COPY requirements.txt /usr/src/app/ -ENV PYTHONUNBUFFERED=1 - -RUN python3 -m venv .venv -RUN source .venv/bin/activate - -RUN pip3 install --upgrade pip - -RUN pip3 install wheel --trusted-host pypi.org --trusted-host pypi.python.org --trusted-host=files.pythonhosted.org - -RUN pip3 install --trusted-host pypi.org --trusted-host pypi.python.org --trusted-host=files.pythonhosted.org --no-cache-dir -r requirements.txt --extra-index-url https://labs.etsi.org/rep/api/v4/projects/396/packages/pypi/simple --trusted-host labs.etsi.org - -COPY . /usr/src/app - -EXPOSE 8080 - -ENTRYPOINT ["python3"] - -CMD ["-m", "src"] - diff --git a/service-resource-manager-implementation/README.md b/service-resource-manager-implementation/README.md deleted file mode 100644 index f5a11c674fc19ee452a833c12cc0090cf1a70a4e..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# Swagger generated server - -## Overview -This server was generated by the [swagger-codegen](https://github.com/swagger-api/swagger-codegen) project. By using the -[OpenAPI-Spec](https://github.com/swagger-api/swagger-core/wiki) from a remote server, you can easily generate a server stub. This -is an example of building a swagger-enabled Flask server. - -This example uses the [Connexion](https://github.com/zalando/connexion) library on top of Flask. - -## Requirements -Python 3.5.2+ - -## Usage -To run the server, please execute the following from the root directory: - -``` -pip3 install -r requirements.txt -``` -then replace the values in export_env_par.txt (pi-edge requires mongodb and postgres instances) -and execute: - - -``` -source export_env_par.txt -python3 -m src -``` - -and open your browser to here: - -``` -http://localhost:8080/piedge-connector/2.0.0 -``` - -Your Swagger definition lives here: - -``` -http://localhost:8080/piedge-connector/2.0.0/swagger.json -``` - -To launch the integration tests, use tox: -``` -sudo pip install tox -tox -``` - -## Running with Docker - -To run the server on a Docker container, please execute the following from the root directory: - -```bash -# building the image -docker build -t src . - -# starting up a container -docker run -p 8080:8080 src -``` - - -## Running with K8S - -Go to deployment_files folder, replace ENV parameters in piedge_deploy_generic.yaml file and execute: -``` -kubectl create namespace pi-edge-system -kubectl apply -f piedge_deploy_tandem.yaml -n pi-edge-system -``` -Note: -Prometheus and Grafana should be available in monitoring namespace, if not please deploy them using the following url: -https://devopscube.com/setup-prometheus-monitoring-on-kubernetes/ \ No newline at end of file diff --git a/service-resource-manager-implementation/pytest.ini b/service-resource-manager-implementation/pytest.ini deleted file mode 100644 index 82e481dfa1507165a63dd7e660085f06461c9e62..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/pytest.ini +++ /dev/null @@ -1,5 +0,0 @@ -[pytest] -testpaths = tests -pythonpath = . -log_cli = true -log_cli_level = WARNING diff --git a/service-resource-manager-implementation/requirements.txt b/service-resource-manager-implementation/requirements.txt deleted file mode 100644 index bd15bd202eaf655ceea95ad7166329c26ce9e7c1..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -connexion<3.0.0 -connexion[swagger-ui] -setuptools >= 21.0.0 -requests==2.32.4 -psycopg2-binary -urllib3 -pydantic-extra-types==2.10.3 -sunrise6g-opensdk==1.1.1 \ No newline at end of file diff --git a/service-resource-manager-implementation/setup.py b/service-resource-manager-implementation/setup.py deleted file mode 100644 index 8a20a011431c6cf2d2ec28acbd777d6cbea9cdd8..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/setup.py +++ /dev/null @@ -1,37 +0,0 @@ -# coding: utf-8 - -import sys -from setuptools import setup, find_packages - -NAME = "src" -VERSION = "1.0.0" -# To install the library, run the following -# -# python setup.py install -# -# prerequisite: setuptools -# http://pypi.python.org/pypi/setuptools - -REQUIRES = [ - "connexion" - # "swagger-ui-bundle>=0.0.2", - # "requests" -] - -setup( - name=NAME, - version=VERSION, - description="π-edge Controller API", - author_email="nikpsarom@intracom-telecom.com", - url="", - keywords=["Swagger", "pi-edge Controller API"], - install_requires=REQUIRES, - packages=find_packages(), - package_data={'': ['swagger/swagger.yaml']}, - include_package_data=True, - entry_points={ - 'console_scripts': ['src=src.__main__:main']}, - long_description="""\ - API exposed by π-edge for \"PaaS & Service Function\" - based interaction with NFV MANO. - """ -) diff --git a/service-resource-manager-implementation/src/__main__.py b/service-resource-manager-implementation/src/__main__.py deleted file mode 100644 index da8b42ef6b3f8009d8117f36ddc86f243c2e1b18..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/src/__main__.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python3 - -import connexion -import logging -import src.encoder as encoder -from json import JSONEncoder -# from connexion.options import SwaggerUIOptions - - -import urllib3 -urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) -def main(): - logging.basicConfig(level=logging.INFO) - app = connexion.App(__name__, specification_dir='./swagger/') - # swagger_options = SwaggerUIOptions(swagger_ui_path="/docs") - app.app.json_encoder = JSONEncoder - app.add_api('swagger.yaml', - # swagger_ui_options=swagger_options, - strict_validation=True, - arguments={'title': 'Service Resource Manager Controller API'}, - pythonic_params=True) - app.run(port=8080) - - -if __name__ == '__main__': - main() diff --git a/service-resource-manager-implementation/src/controllers/__init__.py b/service-resource-manager-implementation/src/controllers/__init__.py deleted file mode 100644 index 0791ded1ffe6f67bc0eccc2f30c6312a48363ad5..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/src/controllers/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -import urllib3 -urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) \ No newline at end of file diff --git a/service-resource-manager-implementation/src/controllers/app_instance_controller.py b/service-resource-manager-implementation/src/controllers/app_instance_controller.py deleted file mode 100644 index db1a110e33dbfa3b25f100020e274348ae7528b8..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/src/controllers/app_instance_controller.py +++ /dev/null @@ -1,65 +0,0 @@ -import logging - -logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.DEBUG) - -def deploy_app(helm_request_body): - """ - Deploys an application via Helm. - Adapter-level function: - - accepts Python dict - - returns Python dict - - raises exceptions on failure - """ - - logger.info("Submitting request to edge cloud transformation function") - - if not isinstance(helm_request_body, dict): - raise ValueError("Invalid request body") - - if not helm_request_body.get("uri"): - raise ValueError("Helm chart uri is missing") - - if not helm_request_body.get("deployment_name"): - raise ValueError("Helm chart deployment name is missing") - - try: - # submit_helm_chart MUST return dict or list - result = submit_helm_chart(helm_request_body) - - if not isinstance(result, (dict, list)): - raise TypeError( - f"submit_helm_chart returned unsupported type: {type(result)}" - ) - - return result - - except Exception as e: - logger.exception("Error submitting helm chart") - raise - - -def get_app_instances(body): - """ - Instantiates app on the edge cloud platform. - Adapter-level function. - """ - - logger.info("Deploying app instance") - - if not isinstance(body, dict): - raise ValueError("Invalid request body") - - try: - result = app_deploy(body) - - if not isinstance(result, (dict, list)): - raise TypeError( - f"app_deploy returned unsupported type: {type(result)}" - ) - - return result - - except Exception as e: - logger.exception("Error instantiating app") - raise diff --git a/service-resource-manager-implementation/src/controllers/artifact_controller.py b/service-resource-manager-implementation/src/controllers/artifact_controller.py deleted file mode 100644 index ea47af5d9ea7ae5d2df852b4be923df0187f73d4..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/src/controllers/artifact_controller.py +++ /dev/null @@ -1,38 +0,0 @@ -import logging -import json -import connexion -from src.models.copy_artifact_model import CopyArtifactModel -from src.models.artifact_exists_model import ArtifactExistsModel -from src.utils import artifact_connector - -def artifact_exists(): - if connexion.request.is_json: - try: - # artifact = ArtifactExistsModel.from_dict(connexion.request.get_json()) - if connexion.request.get_json().get('registry_url') is None: - return 'The registyr url must be provided', 400 - if connexion.request.get_json().get('artefact_name') is None: - return 'Image name must be provided', 400 - response = artifact_connector.artifact_exists(connexion.request.get_json()) - return response.json() - except Exception as e: - logging.error(e.args) - return e.args, 500 - else: - return 'Request format must be JSON', 400 - -def copy_artifact(): - if connexion.request.is_json: - try: - artifact = CopyArtifactModel.from_dict(connexion.request.get_json()) - if artifact.dst_registry is None or artifact.src_registry is None: - return 'The source and destination registries must be provided', 400 - if artifact.src_image_name is None: - return 'The image name must be provided', 400 - response = artifact_connector.copy_artifact(artifact.to_dict()) - return response.json() - except Exception as e: - logging.error(e.args) - return e.args - else: - return 'Request format must be JSON', 400 \ No newline at end of file diff --git a/service-resource-manager-implementation/src/controllers/edge_cloud_management_controller.py b/service-resource-manager-implementation/src/controllers/edge_cloud_management_controller.py deleted file mode 100644 index 5b6267e1adaee386c24472ea80d3a085374a4eff..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/src/controllers/edge_cloud_management_controller.py +++ /dev/null @@ -1,216 +0,0 @@ -import connexion -import logging -import uuid -from os import environ -from sunrise6g_opensdk import Sdk as sdkclient - -logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.DEBUG) - -# --------------------------------------------------------------------- -# Adapter initialization -# --------------------------------------------------------------------- - -edgecloud_adapter = None - -if environ.get("EDGE_CLOUD_ADAPTER_NAME"): - edgecloud_adapter_name = environ["EDGE_CLOUD_ADAPTER_NAME"] - edgecloud_adapter_base_url = environ.get("ADAPTER_BASE_URL") - - edgecloud_adapter_specs = { - "client_name": edgecloud_adapter_name, - "base_url": edgecloud_adapter_base_url, - } - edgecloud_adapter_specs.update(environ) - - logger.info("Creating edge cloud adapter with env: %s", edgecloud_adapter_specs) - - adapters = sdkclient.create_adapters_from( - adapter_specs={"edgecloud": edgecloud_adapter_specs} - ) - edgecloud_adapter = adapters.get("edgecloud") - -if edgecloud_adapter is None: - raise RuntimeError("Edge cloud adapter could not be initialized") - -# --------------------------------------------------------------------- -# Helpers: normalize adapter responses -# --------------------------------------------------------------------- - -def _safe_http_json_response(response): - """ - Normalize adapter responses: - - list / dict → return directly (200) - - requests.Response → parse JSON safely - """ - - # Adapter already returned Python data - if isinstance(response, (list, dict)): - return response, 200 - - # Adapter returned nothing - if response is None: - return {"error": "Adapter returned no response"}, 502 - - # Adapter returned a bare status code integer - if isinstance(response, int): - return {}, response - - # Adapter returned a plain string message - if isinstance(response, str): - return {"message": response}, 200 - - # Adapter returned HTTP response - try: - if not response.content: - return {}, response.status_code - return response.json(), response.status_code - except Exception as e: - logger.exception("Failed to parse adapter HTTP response") - return { - "error": "Invalid response from adapter", - "details": str(e) - }, 502 - - -def _ensure_edge_cloud_region(data): - """ - Ensure every zone/node object carries an edgeCloudRegion field. - Falls back to 'unknown' when the adapter omits the field. - """ - if isinstance(data, list): - for item in data: - if isinstance(item, dict) and not item.get("edgeCloudRegion"): - item["edgeCloudRegion"] = "unknown" - elif isinstance(data, dict): - if not data.get("edgeCloudRegion"): - data["edgeCloudRegion"] = "unknown" - return data - -# --------------------------------------------------------------------- -# Catalogue (onboarded service functions) -# --------------------------------------------------------------------- - -def register_service_function(body=None): - if not connexion.request.is_json: - return {"error": "Invalid JSON payload"}, 400 - - try: - payload = connexion.request.get_json() - if not payload.get("appId"): - payload["appId"] = str(uuid.uuid4()) - response = edgecloud_adapter.onboard_app(payload) - return _safe_http_json_response(response) - except Exception as e: - logger.exception("Failed to register service function") - return {"error": "Registration failed", "details": str(e)}, 500 - - -def get_service_function(service_function_id: str): - try: - response = edgecloud_adapter.get_onboarded_app(service_function_id) - return _safe_http_json_response(response) - except Exception as e: - logger.exception("Failed to get service function") - return {"error": "Fetch failed", "details": str(e)}, 500 - - -def get_service_functions(): - try: - response = edgecloud_adapter.get_all_onboarded_apps() - return _safe_http_json_response(response) - except Exception as e: - logger.exception("Failed to list service functions") - return {"error": "Fetch failed", "details": str(e)}, 500 - - -def deregister_service_function(service_function_id: str): - try: - response = edgecloud_adapter.delete_onboarded_app(service_function_id) - return _safe_http_json_response(response) - except Exception as e: - logger.exception("Failed to deregister service function") - return {"error": "Deletion failed", "details": str(e)}, 500 - -# --------------------------------------------------------------------- -# Deployment -# --------------------------------------------------------------------- - -def deploy_service_function(): - if not connexion.request.is_json: - return {"error": "Invalid JSON payload"}, 400 - - try: - body = connexion.request.get_json() - - response = edgecloud_adapter.deploy_app( - body.get("appId"), - body.get("appZones") - ) - - return _safe_http_json_response(response) - - except Exception as e: - logger.exception("Failed to deploy service function") - return { - "error": "Deployment failed", - "details": str(e) - }, 500 - - -def delete_deployed_service_function(app_id: str): - try: - response = edgecloud_adapter.undeploy_app(app_id) - return _safe_http_json_response(response) - except Exception as e: - logger.exception("Failed to undeploy service function") - return {"error": "Undeploy failed", "details": str(e)}, 500 - - -def get_deployed_service_function(app_id: str): - try: - response = edgecloud_adapter.get_deployed_app(app_id=app_id) - return _safe_http_json_response(response) - except Exception as e: - logger.exception("Failed to get deployed service function") - return {"error": "Fetch failed", "details": str(e)}, 500 - - -def get_deployed_service_functions(): - try: - response = edgecloud_adapter.get_all_deployed_apps() - return _safe_http_json_response(response) - except Exception as e: - logger.exception("Failed to list deployed service functions") - return {"error": "Fetch failed", "details": str(e)}, 500 - -# --------------------------------------------------------------------- -# Edge cloud nodes / zones -# --------------------------------------------------------------------- - -def get_nodes(): - """ - Returns the edge cloud zones. - Normalizes the HTTP response and ensures edgeCloudRegion is always present. - """ - try: - response = edgecloud_adapter.get_edge_cloud_zones() - data, status = _safe_http_json_response(response) - return _ensure_edge_cloud_region(data), status - except Exception as e: - logger.exception("Failed to get edge cloud zones") - return {"error": "Fetch failed", "details": str(e)}, 500 - - -def node_details(node_id: str): - """ - Returns details for a specific edge cloud zone. - Normalizes the HTTP response and ensures edgeCloudRegion is always present. - """ - try: - response = edgecloud_adapter.get_edge_cloud_zones_details(zone_id=node_id) - data, status = _safe_http_json_response(response) - return _ensure_edge_cloud_region(data), status - except Exception as e: - logger.exception("Failed to get node details") - return {"error": "Fetch failed", "details": str(e)}, 500 diff --git a/service-resource-manager-implementation/src/controllers/network_functions_controller.py b/service-resource-manager-implementation/src/controllers/network_functions_controller.py deleted file mode 100644 index cd95b55b7cb3aa0b739a64c8ca4f447f3a324336..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/src/controllers/network_functions_controller.py +++ /dev/null @@ -1,187 +0,0 @@ -from os import environ -import logging -import connexion -from sunrise6g_opensdk.common.sdk import Sdk as sdkclient -from sunrise6g_opensdk.network.core.schemas import RetrievalLocationRequest - -logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.DEBUG) -network_adapter = None - -if environ.get('NETWORK_ADAPTER_NAME') and environ.get('NETWORK_ADAPTER_BASE_URL'): - network_adapter_name = environ.get('NETWORK_ADAPTER_NAME') - adapter_base_url = environ.get('NETWORK_ADAPTER_BASE_URL') - scs_as_id = environ.get('SCS_AS_ID') - network_adapter_specs = { - 'client_name': network_adapter_name, - 'base_url': adapter_base_url, - 'scs_as_id': scs_as_id, - } - print('Creating network adapter with specs: ', network_adapter_specs) - adapters = sdkclient.create_adapters_from(adapter_specs={'network': network_adapter_specs}) - network_adapter = adapters.get("network") - -# else: -# logging.error('Network adapter has not been specified! Aborting...') -# sys.exit() - - -def _safe_http_json_response(response): - """ - Normalize adapter responses: - - list / dict → return directly (200) - - Pydantic model → serialize via model_dump (200) - - requests.Response → parse JSON body and status code safely - """ - if isinstance(response, (list, dict)): - return response, 200 - - if response is None: - return {"error": "Adapter returned no response"}, 502 - - if hasattr(response, 'model_dump'): - return response.model_dump(mode='json', exclude_none=True), 200 - - try: - return response.json(), response.status_code - except Exception as e: - logger.exception("Failed to parse adapter HTTP response") - return {"error": "Invalid response from adapter", "details": str(e)}, 502 - - -def create_qod_session(): - if connexion.request.is_json: - try: - if network_adapter is not None: - response = network_adapter.create_qod_session(connexion.request.get_json()) - return _safe_http_json_response(response) - else: - return { - "status": 503, - "code": "UNAVAILABLE", - "message": "Service unavailable: Network Adapter", - }, 503 - except Exception as ce_: - logger.error(ce_) - return {"error": str(ce_)}, 500 - else: - return {'error': 'Could not read JSON payload.'}, 400 - - -def get_qod_session(session_id: str): - try: - if network_adapter is not None: - response = network_adapter.get_qod_session(session_id) - return _safe_http_json_response(response) - else: - return { - "status": 503, - "code": "UNAVAILABLE", - "message": "Service unavailable: Network Adapter", - }, 503 - except Exception as ce_: - logger.error(ce_) - return {"error": str(ce_)}, 500 - - -def delete_qod_session(session_id: str): - try: - if network_adapter is not None: - network_adapter.delete_qod_session(session_id) - return "", 204 - else: - return { - "status": 503, - "code": "UNAVAILABLE", - "message": "Service unavailable: Network Adapter", - }, 503 - except Exception as ce_: - logger.error(ce_) - return {"error": str(ce_)}, 500 - - -def create_traffic_influence_resource(body): - try: - if network_adapter is not None: - response = network_adapter.create_traffic_influence_resource(traffic_influence_info=body) - return _safe_http_json_response(response) - else: - return { - "status": 503, - "code": "UNAVAILABLE", - "message": "Service unavailable: Network Adapter", - }, 503 - except Exception as ce_: - logger.error(ce_) - return {"error": str(ce_)}, 500 - - -def delete_traffic_influence_resource(traffic_influence_id: str): - try: - if network_adapter is not None: - response = network_adapter.delete_traffic_influence_resource(resource_id=traffic_influence_id) - return _safe_http_json_response(response) - else: - return { - "status": 503, - "code": "UNAVAILABLE", - "message": "Service unavailable: Network Adapter", - }, 503 - except Exception as ce_: - logger.error(ce_) - return {"error": str(ce_)}, 500 - - -def get_traffic_influence_resource(traffic_influence_id: str): - try: - if network_adapter is not None: - response = network_adapter.get_individual_traffic_influence_resource(resource_id=traffic_influence_id) - return _safe_http_json_response(response) - else: - return { - "status": 503, - "code": "UNAVAILABLE", - "message": "Service unavailable: Network Adapter", - }, 503 - except Exception as ce_: - logger.error(ce_) - return {"error": str(ce_)}, 500 - - -def get_all_traffic_influence_resources(): - try: - if network_adapter is not None: - response = network_adapter.get_all_traffic_influence_resources() - return _safe_http_json_response(response) - else: - return { - "status": 503, - "code": "UNAVAILABLE", - "message": "Service unavailable: Network Adapter", - }, 503 - except Exception as ce_: - logger.error(ce_) - return {"error": str(ce_)}, 500 - - -def retrieve_location(): - """Retrieve the location of a device via the CAMARA Location Retrieval API.""" - - if connexion.request.is_json: - try: - if network_adapter is not None: - body = connexion.request.get_json() - retrieve_location_request = RetrievalLocationRequest.model_validate(body) - response = network_adapter.create_monitoring_event_subscription(retrieve_location_request) - return _safe_http_json_response(response) - else: - return { - "status": 503, - "code": "UNAVAILABLE", - "message": "Service unavailable: Network Adapter", - }, 503 - except Exception as ce_: - logger.error(ce_) - return {"error": str(ce_)}, 500 - else: - return {"error": "Could not read JSON payload."}, 400 diff --git a/service-resource-manager-implementation/src/controllers/nodes_controller.py b/service-resource-manager-implementation/src/controllers/nodes_controller.py deleted file mode 100644 index c7713c8626d2f0ab715b970ec9c984d9cf4a6ccc..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/src/controllers/nodes_controller.py +++ /dev/null @@ -1,45 +0,0 @@ -import logging -import os - - -logger=logging.getLogger(__name__) - -adapter_name = os.environ['EDGE_CLOUD_ADAPTER_NAME'] -adapter = None - -if adapter_name=='aeros': - from src.adapters.edgecloud.adapters.aeros.client import EdgeApplicationManager - adapter = EdgeApplicationManager() -elif adapter_name=='i2edge': - from src.adapters.edgecloud.adapters.i2edge.client import EdgeApplicationManager - adapter = EdgeApplicationManager() -elif adapter_name=='piedge': - from src.adapters.edgecloud.adapters.kubernetes.client import EdgeApplicationManager - adapter = EdgeApplicationManager() - - -def get_nodes(): # noqa: E501 - """Returns the edge nodes status. - - # noqa: E501 - - :rtype: NodesResponse - """ - try: - response = adapter.get_edge_cloud_zones() - return response - except Exception as ce_: - logger.info(ce_) - -def node_details(node_id: str): # noqa: E501 - """Returns the edge nodes status. - - # noqa: E501 - - :rtype: NodesResponse - """ - try: - response = adapter.get_edge_cloud_zones_details(node_id=node_id) - return response - except Exception as ce_: - logger.info(ce_) diff --git a/service-resource-manager-implementation/src/controllers/operations_controller.py b/service-resource-manager-implementation/src/controllers/operations_controller.py deleted file mode 100644 index 528d97bde59269facefbd7be47228e3238b4c902..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/src/controllers/operations_controller.py +++ /dev/null @@ -1,44 +0,0 @@ -# import paramiko -import logging -from src.models.helm_install_model import HelmInstall -from os import environ -import connexion - -# master_node_password=environ.get("KUBERNETES_MASTER_PASSWORD").strip() -# master_node_hostname=environ.get("KUBERNETES_MASTER_HOSTNAME").strip() -# master_node_ip=environ.get("KUBERNETES_MASTER_IP").strip() -# master_node_port=environ.get("KUBERNETES_MASTER_PORT").strip() - -def install_helm_chart(helm=None): - pass -# logging.info('Installing helm chart') -# if connexion.request.is_json: -# try: -# # logging.info(connexion.request.get_json()) -# helm =connexion.request.get_json() -# ssh=paramiko.SSHClient() -# ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) -# ssh.connect(master_node_ip,22, username='dlaskaratos', password=master_node_password) -# creds_string='' -# if helm.get('repo_password') is not None and helm.get('repo_username') is not None: -# creds_string=' --username '+helm['repo_username']+' --password '+helm['repo_password'] -# stdin, stdout, stderr= ssh.exec_command('echo | sudo helm install '+helm['deployment_name']+' '+helm['uri']+creds_string) -# stdout.channel.set_combine_stderr(True) -# lines=stdout.readlines() -# return lines -# except Exception as e: -# logging.error(e) -# return e.__cause__ -# else: -# return 'Error installing helm chart' - -def uninstall_helm_chart(name: str): - pass -# logging.info('Uninstalling helm chart') -# ssh=paramiko.SSHClient() -# ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) -# ssh.connect(master_node_ip,22, master_node_hostname,master_node_password) -# stdin, stdout, stderr= ssh.exec_command('echo | sudo helm uninstall '+name) -# stdout.channel.set_combine_stderr(True) -# lines=stdout.readlines() -# return lines diff --git a/service-resource-manager-implementation/src/encoder.py b/service-resource-manager-implementation/src/encoder.py deleted file mode 100644 index 1de7e669b817565d8ed1cf32f7f6289d432adb92..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/src/encoder.py +++ /dev/null @@ -1,19 +0,0 @@ -from connexion.jsonifier import Jsonifier -import six - -from src.models.base_model_ import Model - -class JSONEncoder(Jsonifier): - include_nulls = False - - def default(self, o): - if isinstance(o, Model): - dikt = {} - for attr, _ in six.iteritems(o.swagger_types): - value = getattr(o, attr) - if value is None and not self.include_nulls: - continue - attr = o.attribute_map[attr] - dikt[attr] = value - return dikt - return Jsonifier.default(self, o) diff --git a/service-resource-manager-implementation/src/models/__init__.py b/service-resource-manager-implementation/src/models/__init__.py deleted file mode 100644 index 8b9ed336c9b95b8ffc313638b8dcfaa17aba5cfa..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/src/models/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -# coding: utf-8 - -# flake8: noqa -from __future__ import absolute_import -# import models into model package - -from src.models.artifact_exists_model import ArtifactExistsModel -from src.models.base_model_ import Model -from src.models.copy_artifact_model import CopyArtifactModel -from src.models.helm_install_model import HelmInstall - diff --git a/service-resource-manager-implementation/src/models/artifact_exists_model.py b/service-resource-manager-implementation/src/models/artifact_exists_model.py deleted file mode 100644 index 96401faf3e9db72e494c26b156995164e35af369..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/src/models/artifact_exists_model.py +++ /dev/null @@ -1,154 +0,0 @@ -from __future__ import absolute_import -from datetime import date, datetime # noqa: F401 -from typing import List, Dict - -from src.models.base_model_ import Model -from src import util - -class ArtifactExistsModel(Model): - - def __init__(self, registry_url : str=None, artefact_name: str=None, artefact_tag: str=None, username: str=None, password: str=None): - - self.swagger_types = { - 'registry_url': str, - 'artefact_name': str, - 'artefact_tag': str, - 'username': str, - 'password': str - } - - self.attribute_map = { - 'registry_url': 'registry_url', - 'artefact_name': 'artefact_name', - 'artefact_tag': 'artefact_tag', - 'username': 'username', - 'password': 'password' - } - self._registry_url = registry_url - self._artefact_name = artefact_name - self._artefact_tag = artefact_tag - self._username = username - self._password = password - - - @classmethod - def from_dict(cls, dikt) -> 'ArtifactExistsModel': - """Returns the dict as a model - - :param dikt: A dict. - :type: dict - :return: The CopyArtifactModel. # noqa: E501 - :rtype: CopyArtifactModel - """ - return util.deserialize_model(dikt, cls) - - @classmethod - def to_dict(self) -> dict: - dict_object = {} - if self.registry_url is not None: - dict_object['registry_url'] = self.registry_url - if self.artefact_name is not None: - dict_object['artefact_name'] = self.artefact_name - if self.artefact_tag is not None: - dict_object['artefact_tag'] = self.artefact_tag - if self.username is not None: - dict_object['username'] = self.username - if self.password is not None: - dict_object['password'] = self.password - return dict_object - - @property - def registry_url(self) -> str: - """Gets the registry_url of this ArtifactExistsModel. - - :return: The registry_url of this ArtifactExistsModel. - :rtype: str - """ - return self._registry_url - - @registry_url.setter - def registry_url(self, registry_url: str): - """Sets the registry_url of this ArtifactExistsModel. - - :param name: The registry_url of this ArtifactExistsModel. - :type name: str - """ - - self._registry_url = registry_url - - - @property - def artefact_name(self) -> str: - """Gets the artefact_name of this ArtifactExistsModel. - - :return: The artefact_name of this ArtifactExistsModel. - :rtype: str - """ - return self._artefact_name - - @artefact_name.setter - def artefact_name(self, artefact_name: str): - """Sets the artefact_name of this ArtifactExistsModel. - - :param name: The artefact_name of this ArtifactExistsModel. - :type name: str - """ - - self._artefact_name = artefact_name - - @property - def artefact_tag(self) -> str: - """Gets the artefact_tag of this ArtifactExistsModel. - - :return: The artefact_tag of this ArtifactExistsModel. - :rtype: str - """ - return self._artefact_tag - - @artefact_tag.setter - def artefact_tag(self, artefact_tag: str): - """Sets the artefact_tag of this ArtifactExistsModel. - - :param name: The artefact_tag of this ArtifactExistsModel. - :type name: str - """ - - self._artefact_tag = artefact_tag - - @property - def username(self) -> str: - """Gets the username of this ArtifactExistsModel. - - :return: The username of this ArtifactExistsModel. - :rtype: str - """ - return self._username - - @username.setter - def username(self, username: str): - """Sets the username of this ArtifactExistsModel. - - :param name: The username of this ArtifactExistsModel. - :type name: str - """ - - self._username = username - - @property - def password(self) -> str: - """Gets the password of this ArtifactExistsModel. - - :return: The password of this ArtifactExistsModel. - :rtype: str - """ - return self._password - - @password.setter - def password(self, password: str): - """Sets the password of this ArtifactExistsModel. - - :param name: The password of this ArtifactExistsModel. - :type name: str - """ - - self._password = password \ No newline at end of file diff --git a/service-resource-manager-implementation/src/models/base_model_.py b/service-resource-manager-implementation/src/models/base_model_.py deleted file mode 100644 index f168c28658ebdd6e0165fe196d65307afcc1641a..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/src/models/base_model_.py +++ /dev/null @@ -1,69 +0,0 @@ -import pprint - -import six -import typing - -from src import util - -T = typing.TypeVar('T') - - -class Model(object): - # swaggerTypes: The key is attribute name and the - # value is attribute type. - swagger_types = {} - - # attributeMap: The key is attribute name and the - # value is json key in definition. - attribute_map = {} - - @classmethod - def from_dict(cls: typing.Type[T], dikt) -> T: - """Returns the dict as a model""" - return util.deserialize_model(dikt, cls) - - def to_dict(self): - """Returns the model properties as a dict - - :rtype: dict - """ - result = {} - - for attr, _ in six.iteritems(self.swagger_types): - value = getattr(self, attr) - if isinstance(value, list): - result[attr] = list(map( - lambda x: x.to_dict() if hasattr(x, "to_dict") else x, - value - )) - elif hasattr(value, "to_dict"): - result[attr] = value.to_dict() - elif isinstance(value, dict): - result[attr] = dict(map( - lambda item: (item[0], item[1].to_dict()) - if hasattr(item[1], "to_dict") else item, - value.items() - )) - else: - result[attr] = value - - return result - - def to_str(self): - """Returns the string representation of the model - - :rtype: str - """ - return pprint.pformat(self.to_dict()) - - def __repr__(self): - """For `print` and `pprint`""" - return self.to_str() - - def __eq__(self, other): - """Returns true if both objects are equal""" - return self.__dict__ == other.__dict__ - - def __ne__(self, other): - """Returns true if both objects are not equal""" - return not self == other diff --git a/service-resource-manager-implementation/src/models/copy_artifact_model.py b/service-resource-manager-implementation/src/models/copy_artifact_model.py deleted file mode 100644 index 057aad31fb84b99740a09d569ba9ccaa524b332b..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/src/models/copy_artifact_model.py +++ /dev/null @@ -1,250 +0,0 @@ -from __future__ import absolute_import -from datetime import date, datetime # noqa: F401 -from typing import List, Dict - -from src.models.base_model_ import Model -from src import util - -class CopyArtifactModel(Model): - - def __init__(self, src_registry: str, src_image_name: str, src_image_tag: str, dst_registry: str, dst_image_name: str=None, dst_image_tag: str=None, src_username: str=None, - src_password: str=None, dst_username:str=None, dst_password: str=None): - - self.swagger_types = { - 'src_registry': str, - 'src_image_name': str, - 'src_image_tag': str, - 'dst_registry': str, - 'dst_image_name': str, - 'dst_image_tag': str, - 'src_username': str, - 'src_password': str, - 'dst_username': str, - 'dst_password': str - } - - self.attribute_map = { - 'src_registry': 'src_registry', - 'src_image_name': 'src_image_name', - 'src_image_tag': 'src_image_tag', - 'dst_registry': 'dst_registry', - 'dst_image_name': 'dst_image_name', - 'dst_image_tag': 'dst_image_tag', - 'src_username': 'src_username', - 'src_password': 'src_password', - 'dst_username': 'dst_username', - 'dst_password': 'dst_password' - } - self._src_registry = src_registry - self._src_image_name = src_image_name - self._src_image_tag = src_image_tag - self._dst_registry = dst_registry - self._dst_image_name = dst_image_name - self._dst_image_tag = dst_image_tag - self._src_username=src_username - self._src_password=src_password - self._dst_username=dst_username - self._dst_password=dst_password - - @classmethod - def from_dict(cls, dikt) -> 'CopyArtifactModel': - """Returns the dict as a model - - :param dikt: A dict. - :type: dict - :return: The CopyArtifactModel. # noqa: E501 - :rtype: CopyArtifactModel - """ - return util.deserialize_model(dikt, cls) - - @property - def src_registry(self) -> str: - """Gets the src_registry of this CopyArtifactModel. - - :return: The src_registry of this CopyArtifactModel. - :rtype: str - """ - return self._src_registry - - @src_registry.setter - def src_registry(self, src_registry: str): - """Sets the src_registry of this CopyArtifactModel. - - :param name: The src_registry of this CopyArtifactModel. - :type name: str - """ - - self._src_registry = src_registry - - - @property - def src_image_name(self) -> str: - """Gets the _src_image_name of this CopyArtifactModel. - - :return: The _src_image_name of this CopyArtifactModel. - :rtype: str - """ - return self._src_image_name - - @src_image_name.setter - def src_image_name(self, src_image_name: str): - """Sets the src_image_name of this CopyArtifactModel. - - :param name: The src_image_name of this CopyArtifactModel. - :type name: str - """ - - self._src_image_name = src_image_name - - @property - def src_image_tag(self) -> str: - """Gets the src_image_tag of this CopyArtifactModel. - - :return: The src_image_tag of this CopyArtifactModel. - :rtype: str - """ - return self._src_image_tag - - @src_image_tag.setter - def src_image_tag(self, src_image_tag: str): - """Sets the src_image_tag of this CopyArtifactModel. - - :param name: The src_image_tag of this CopyArtifactModel. - :type name: str - """ - - self._src_image_tag = src_image_tag - - @property - def dst_registry(self) -> str: - """Gets the dst_registry of this CopyArtifactModel. - - :return: The dst_registry of this CopyArtifactModel. - :rtype: str - """ - return self._dst_registry - - @dst_registry.setter - def dst_registry(self, dst_registry: str): - """Sets the dst_registry of this CopyArtifactModel. - - :param name: The dst_registry of this CopyArtifactModel. - :type name: str - """ - - self._dst_registry = dst_registry - - @property - def dst_image_name(self) -> str: - """Gets the dst_image_name of this CopyArtifactModel. - - :return: The dst_image_name of this CopyArtifactModel. - :rtype: str - """ - return self._dst_image_name - - @dst_image_name.setter - def dst_image_name(self, dst_image_name: str): - """Sets the dst_image_name of this CopyArtifactModel. - - :param name: The dst_image_name of this CopyArtifactModel. - :type name: str - """ - - self._dst_image_name = dst_image_name - - - @property - def dst_image_tag(self) -> str: - """Gets the dst_image_tag of this CopyArtifactModel. - - :return: The dst_image_tag of this CopyArtifactModel. - :rtype: str - """ - return self._dst_image_tag - - @dst_image_tag.setter - def dst_image_tag(self, dst_image_tag: str): - """Sets the dst_image_tag of this CopyArtifactModel. - - :param name: The dst_image_tag of this CopyArtifactModel. - :type name: str - """ - - self._dst_image_tag = dst_image_tag - - @property - def src_username(self) -> str: - """Gets the src_username of this CopyArtifactModel. - - :return: The src_username of this CopyArtifactModel. - :rtype: str - """ - return self._src_username - - @src_username.setter - def src_username(self, src_username: str): - """Sets the src_username of this CopyArtifactModel. - - :param name: The src_username of this CopyArtifactModel. - :type name: str - """ - - self._src_username = src_username - - @property - def src_password(self) -> str: - """Gets the src_password of this CopyArtifactModel. - - :return: The src_password of this CopyArtifactModel. - :rtype: str - """ - return self._src_password - - @src_password.setter - def src_password(self, src_password: str): - """Sets the src_password of this CopyArtifactModel. - - :param name: The src_password of this CopyArtifactModel. - :type name: str - """ - - self._src_password = src_password - - @property - def dst_username(self) -> str: - """Gets the dst_username of this CopyArtifactModel. - - :return: The dst_username of this CopyArtifactModel. - :rtype: str - """ - return self._dst_username - - @dst_username.setter - def dst_username(self, dst_username: str): - """Sets the dst_username of this CopyArtifactModel. - - :param name: The dst_username of this CopyArtifactModel. - :type name: str - """ - - self._dst_username = dst_username - - @property - def dst_password(self) -> str: - """Gets the dst_password of this CopyArtifactModel. - - :return: The dst_password of this CopyArtifactModel. - :rtype: str - """ - return self._dst_password - - @dst_password.setter - def dst_password(self, dst_password: str): - """Sets the dst_password of this CopyArtifactModel. - - :param name: The dst_password of this CopyArtifactModel. - :type name: str - """ - - self._dst_password = dst_password \ No newline at end of file diff --git a/service-resource-manager-implementation/src/models/helm_install_model.py b/service-resource-manager-implementation/src/models/helm_install_model.py deleted file mode 100644 index 66fada05de55ed0078eff47f49c6e7ea8414399e..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/src/models/helm_install_model.py +++ /dev/null @@ -1,134 +0,0 @@ -from __future__ import absolute_import -from datetime import date, datetime # noqa: F401 - -from typing import List, Dict # noqa: F401 - -from src.models.base_model_ import Model -from src import util - -class HelmInstall(Model): - def __init__(self, uri: str, deployment_name: str, repo_username: str=None, repo_password: str=None): # noqa: E501 - """HelmInstallModel - a model defined in Swagger - - :param name: The name of this HelmInstall. # noqa: E501 - :type name: str - :param hostname: The hostname of this HelmInstall. # noqa: E501 - :type hostname: str - :param ip: The ip of this HelmInstall. # noqa: E501 - :type ip: str - :param password: The password of this HelmInstall. # noqa: E501 - :type password: str - """ - self.swagger_types = { - 'uri': str, - 'deployment_name': str, - 'repo_username': str, - 'repo_password': str - } - - self.attribute_map = { - 'uri': 'uri', - 'deployment_name': 'deployment_name', - 'repo_username': 'repo_username', - 'repo_password': 'repo_password' - } - self._uri = uri - self._deployment_name = deployment_name - self._repo_password = repo_password - self._repo_username = repo_username - - @classmethod - def from_dict(cls, dikt) -> 'HelmInstall': - """Returns the dict as a model - - :param dikt: A dict. - :type: dict - :return: The addNode of this AddNode. # noqa: E501 - :rtype: AddNode - """ - return util.deserialize_model(dikt, cls) - - @property - def uri(self) -> str: - """Gets the uri of a HelmInstallModel - - - :return: The uri of this HelmInstallModel. - :rtype: str - """ - return self._uri - - @uri.setter - def uri(self, uri: str): - """Sets the name of this HelmInstallModel. - - - :param name: The name of this HelmInstallModel. - :type name: str - """ - - self._uri = uri - - @property - def deployment_name(self) -> str: - """Gets the deployment_name of this HelmInstallModel. - - - :return: The deployment_name of this HelmInstallModel. - :rtype: str - """ - return self._deployment_name - - @deployment_name.setter - def deployment_name(self, deployment_name: str): - """Sets the hostname of this HelmInstallModel. - - - :param hostname: The hostname of this HelmInstallModel. - :type hostname: str - """ - - self._deployment_name = deployment_name - - @property - def repo_username(self) -> str: - """Gets the repo_username of this HelmInstallModel. - - - :return: The repo_username of this HelmInstallModel. - :rtype: str - """ - return self._repo_username - - @repo_username.setter - def repo_username(self, repo_username: str): - """Sets the repo_username of this HelmInstallModel. - - - :param repo_username: The repo_username of this HelmInstallModel. - :type repo_username: str - """ - - self._repo_username = repo_username - - - @property - def repo_password(self) -> str: - """Gets the repo_username of this HelmInstallModel. - - - :return: The repo_username of this HelmInstallModel. - :rtype: str - """ - return self._repo_password - - @repo_password.setter - def repo_password(self, repo_password: str): - """Sets the repo_password of this HelmInstallModel. - - - :param repo_password: The repo_password of this HelmInstallModel. - :type repo_password: str - """ - - self._repo_password = repo_password \ No newline at end of file diff --git a/service-resource-manager-implementation/src/properties.conf b/service-resource-manager-implementation/src/properties.conf deleted file mode 100644 index cf9e4dc217f2010ee8def3a1d6775ec8081f6eaa..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/src/properties.conf +++ /dev/null @@ -1,5 +0,0 @@ -driver=docker - -#docker -DOCKER_HOST=146.124.106.177 -DOCKER_PORT=2377 \ No newline at end of file diff --git a/service-resource-manager-implementation/src/services/artifact_service.py b/service-resource-manager-implementation/src/services/artifact_service.py deleted file mode 100644 index 406dbbff677f914c2ec5c184bff8f2a304c25ecc..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/src/services/artifact_service.py +++ /dev/null @@ -1,20 +0,0 @@ -import logging -import requests -import os -import json - -artifact_manager_ip = os.environ['ARTIFACT_MANAGER_ADDRESS'] - -def artifact_exists(body): - logging.info('Contacting Artifact Manager') - body = json.dumps(body) - headers = {'Content-Type': 'application/json'} - response = requests.post('http://'+artifact_manager_ip+'/artefact-exists/', headers=headers, json=body) - return response - -def copy_artifact(body): - logging.info('Submitting artifact to Artifact Manager') - body = json.dumps(body) - headers = {'Content-Type': 'application/json'} - response = requests.post('http://'+artifact_manager_ip+'/copy-artefact', headers=headers, json=body) - return response \ No newline at end of file diff --git a/service-resource-manager-implementation/src/services/edge_cloud_service.py b/service-resource-manager-implementation/src/services/edge_cloud_service.py deleted file mode 100644 index 73ab19f84fced1302aeae8c8bd0069e5e342958d..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/src/services/edge_cloud_service.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import absolute_import -import logging -from os import environ -import requests -import json -from src.clients.edgecloud.clients import aeros, i2edge, piedge - - -logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.DEBUG) - -adapter_name = environ['ADAPTER_NAME'] -adapter_ip = environ['K8S_ADAPTER_ADDRESS'] -edge_cloud_provider = environ['PLATFORM_PROVIDER'] -adapter = None - -if adapter_name=='aeros': - from src.clients.edgecloud.clients.aeros.client import EdgeApplicationManager - adapter = EdgeApplicationManager() -elif adapter_name=='i2edge': - from src.clients.edgecloud.clients.i2edge.client import EdgeApplicationManager - adapter = EdgeApplicationManager() -elif adapter_name=='piedge': - from src.clients.edgecloud.clients.piedge.client import EdgeApplicationManager - adapter = EdgeApplicationManager() - - -def get_nodes(): - zone_list = None - return zone_list - -def submit_helm_chart(body): - logger.info('Contacting Kubernetes adapter at '+adapter_ip) - headers = {'Content-type': 'application/json'} - data = json.dumps(body) - helm_response = requests.post('http://'+adapter_ip+'/piedge-connector/2.0.0/helm', data=data, headers=headers) - return helm_response - -# def app_instance_deploy(body): -# logger.info('Contacting Kubernetes adapter at '+adapter_ip) -# headers = {'Content-type': 'application/json'} -# data = json.dumps(body) -# app_response = requests.post('http://'+adapter_ip+'/piedge-connector/2.0.0/deployedServiceFunction', json=body) -# return app_response - -def app_instance_info(id: str): - logger.info('Contacting Kubernetes adapter at '+adapter_ip) - headers = {'Content-type': 'application/json'} - app_info_response = requests.get('http://'+adapter_ip+'/piedge-connector/2.0.0/deployedServiceFunction/'+id) - return app_info_response - -def delete_app_instance(id: str): - logger.info('Deleting app with instance id: ['+id+']') - delete_app_response = requests.delete('http://'+adapter_ip+'/piedge-connector/2.0.0/deployedServiceFunction/'+id) - return delete_app_response \ No newline at end of file diff --git a/service-resource-manager-implementation/src/swagger/swagger.yaml b/service-resource-manager-implementation/src/swagger/swagger.yaml deleted file mode 100644 index a5a7645bbd5dbc753c1c5123a09c20633ca4c612..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/src/swagger/swagger.yaml +++ /dev/null @@ -1,1401 +0,0 @@ -openapi: 3.0.0 -info: - title: SRM Controller API - description: | - API exposed by SRM for for CAMARA-deefined operations. - termsOfService: http://swagger.io/terms/ - contact: - email: dlaskaratos@intracom-telecom.com - license: - name: Apache 2.0 - url: http://www.apache.org/licenses/LICENSE-2.0.html - version: 2.0.0 -externalDocs: - description: Find out more about Swagger - url: http://swagger.io -servers: -- url: http://vitrualserver:8080/srm/1.0.0 -paths: - /node/{node_id}: - get: - tags: - - Nodes - summary: Get Node details by Node identifier - operationId: node_details - parameters: - - name: node_id - in: path - description: Gets node details - required: true - style: simple - explode: false - schema: - type: string - responses: - "200": - description: Node details retrieved - "405": - description: Method not allowed - "404": - description: Node does not exist - x-openapi-router-controller: src.controllers.edge_cloud_management_controller - /helm: - post: - tags: - - Helm Chart Operations - summary: Install a helm chart - operationId: install_helm_chart - requestBody: - description: Request to install a helm chart - content: - application/json: - schema: - $ref: '#/components/schemas/HelmChartInstall' - responses: - "200": - description: Helm chart installed - "405": - description: Method not allowed - x-openapi-router-controller: src.controllers.operations_controller - /helm/{name}: - delete: - tags: - - Helm Chart Operations - summary: Uninstall helm chart. - operationId: uninstall_helm_chart - parameters: - - name: name - in: path - description: Uninstalls a helm chart by name - required: true - style: simple - explode: false - schema: - type: string - responses: - "200": - description: Helm chart uninstalled - "405": - description: Method not allowed - x-openapi-router-controller: src.controllers.operations_controller - /copy-artefact: - post: - tags: - - Artefact Management - summary: Copies artefact from source repository to destination repository - operationId: copy_artifact - requestBody: - description: Artifact details including image name, tag, source repository username and password tec - content: - application/json: - schema: - $ref: '#/components/schemas/CopyArtifactModel' - responses: - "200": - description: Artifact successfully copied - "400": - description: Mandatory fields missing - x-openapi-router-controller: src.controllers.artifact_controller - /artefact-exists: - post: - tags: - - Artefact Management - summary: Check if artefact exists in given repository - operationId: artifact_exists - requestBody: - description: Artefact details including image name, tag, source repository username and password tec - content: - application/json: - schema: - $ref: '#/components/schemas/ArtifactExistsModel' - responses: - "200": - description: Artifact exists - "404": - description: Artifact does not exist - "400": - description: Mandatory fields missing - x-openapi-router-controller: src.controllers.artifact_controller - /serviceFunction: - post: - tags: - - Service Functions Catalogue - summary: Register Service. - # security: - # - jwt: [ ] - operationId: register_service_function - requestBody: - description: Registration method to save service function into database - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceFunctionRegistrationRequest' - responses: - "200": - description: Service function registered - "405": - description: Method not allowed - x-openapi-router-controller: src.controllers.edge_cloud_management_controller - get: - tags: - - Service Functions Catalogue - summary: Returns service functions from the catalogue. - operationId: get_service_functions - # security: - # - jwt: [ ] - responses: - "200": - description: Returns service functions from the catalogue. - content: - application/json: - schema: - $ref: '#/components/schemas/appsResponse' - "405": - description: Method not allowed - x-openapi-router-controller: src.controllers.edge_cloud_management_controller - /serviceFunction/{serviceFunctionId}: - delete: - tags: - - Service Functions Catalogue - summary: Deregister service. - # security: - # - jwt: [ ] - operationId: deregister_service_function - parameters: - - name: serviceFunctionId - in: path - description: Returns a specific service function from the catalogue. - required: true - style: simple - explode: false - schema: - type: string - responses: - "204": - description: Service function unregistered - "405": - description: Method not allowed - x-openapi-router-controller: src.controllers.edge_cloud_management_controller - get: - tags: - - Service Functions Catalogue - summary: Returns a specific service function from the catalogue. - # security: - # - jwt: [ ] - operationId: get_service_function - parameters: - - name: serviceFunctionId - in: path - description: Returns a specific service function from the catalogue. - required: true - style: simple - explode: false - schema: - type: string - responses: - "200": - description: Returns the service function info status. - content: - application/json: - schema: - $ref: '#/components/schemas/appsResponse_apps' - "405": - description: Method not allowed - x-openapi-router-controller: src.controllers.edge_cloud_management_controller - /node: - get: - tags: - - Nodes - summary: Returns the edge nodes status. - # security: - # - jwt: [ ] - operationId: get_nodes - responses: - "200": - description: Returns the edge nodes status. - content: - application/json: - schema: - $ref: '#/components/schemas/nodesResponse' - "405": - description: Method not allowed - x-openapi-router-controller: src.controllers.edge_cloud_management_controller - /deployedServiceFunction: - post: - tags: - - Service Functions Instances - summary: Request to deploy a Service function (from the catalogue) to an edge - node. - operationId: deploy_service_function - # security: - # - jwt: [ ] - requestBody: - description: Deploy Service Function. - content: - application/json: - schema: - $ref: '#/components/schemas/DeployApp' - responses: - "200": - description: App deployed. - x-openapi-router-controller: src.controllers.edge_cloud_management_controller - get: - tags: - - Service Functions Instances - summary: Returns deployed apps. - operationId: get_deployed_service_functions - # security: - # - jwt: [ ] - responses: - "200": - description: Returns service functions from the catalogue. - content: - application/json: - schema: - $ref: '#/components/schemas/appsResponse' - "405": - description: Method not allowed - x-openapi-router-controller: src.controllers.edge_cloud_management_controller - /deployedServiceFunction/{app_id}: - delete: - tags: - - Service Functions Instances - summary: Deletes a deployed Service function. - operationId: delete_deployed_service_function - # security: - # - jwt: [ ] - parameters: - - name: app_id - in: path - description: Represents a service function from the running deployments. - required: true - style: simple - explode: false - schema: - type: string - responses: - "204": - description: Deployed service function deleted - "405": - description: Method not allowed - x-openapi-router-controller: src.controllers.edge_cloud_management_controller - get: - tags: - - Service Functions Instances - summary: Returns deployed apps. - operationId: get_deployed_service_function - parameters: - - name: app_id - in: path - required: true - schema: - type: string - responses: - "200": - description: Returns service functions from the catalogue. - content: - application/json: - schema: - $ref: '#/components/schemas/appsResponse' - "405": - description: Method not allowed - x-openapi-router-controller: src.controllers.edge_cloud_management_controller - /sessions: - post: - tags: - - Quality on Demand Functions - summary: Creates a new QoD Session - operationId: create_qod_session - requestBody: - description: QoD Session body. - content: - application/json: - schema: - $ref: '#/components/schemas/' - responses: - "200": - description: Session created. - "503": - description: Network Adapter not initialized - x-openapi-router-controller: src.controllers.network_functions_controller - /sessions/{sessionId}: - get: - tags: - - Quality on Demand Functions - summary: Retrieve details of a QoD Session - operationId: get_qod_session - parameters: - - name: sessionId - in: path - description: Represents a QoD Session. - required: true - style: simple - explode: false - schema: - type: string - responses: - "200": - description: QoD Session found - "405": - description: Method not allowed - "404": - description: Session not found - "503": - description: Network Adapter not initialized - x-openapi-router-controller: src.controllers.network_functions_controller - delete: - tags: - - Quality on Demand Functions - summary: Remove QoD Session - operationId: delete_qod_session - parameters: - - name: sessionId - in: path - description: Represents a QoD Session. - required: true - style: simple - explode: false - schema: - type: string - responses: - "201": - description: QoD Session deleted - "405": - description: Method not allowed - "404": - description: Session not found - "503": - description: Network Adapter not initialized - x-openapi-router-controller: src.controllers.network_functions_controller - /traffic-influences: - post: - tags: - - Traffic Influence Functions - summary: Creates a new TrafficInfluence resource - operationId: create_traffic_influence_resource - requestBody: - description: TrafficInfluence body. - content: - application/json: - schema: - $ref: '#/components/schemas/' - responses: - "200": - description: Resource created. - "503": - description: Network Adapter not initialized - x-openapi-router-controller: src.controllers.network_functions_controller - get: - tags: - - Traffic Influence Functions - summary: Retrieves all TrafficInfluence resources - operationId: get_all_traffic_influence_resources - responses: - "200": - description: Resources retrieved. - "503": - description: Network Adapter not initialized - x-openapi-router-controller: src.controllers.network_functions_controller - /traffic-influences/{trafficInfluenceId}: - get: - tags: - - Traffic Influence Functions - summary: Retrieve details of a TrafficInfluence resource - operationId: get_traffic_influence_resource - parameters: - - name: trafficInfluenceId - in: path - description: Represents a TrafficInfluence resource. - required: true - style: simple - explode: false - schema: - type: string - responses: - "200": - description: TrafficInfluence resource found - "405": - description: Method not allowed - "404": - description: Session not found - "503": - description: Network Adapter not initialized - x-openapi-router-controller: src.controllers.network_functions_controller - delete: - tags: - - Traffic Influence Functions - summary: Remove TrafficInfluence resource - operationId: delete_traffic_influence_resource - parameters: - - name: trafficInfluenceId - in: path - description: Represents a TrafficInfluence resource - required: true - style: simple - explode: false - schema: - type: string - responses: - "201": - description: TrafficInfluence resource deleted - "405": - description: Method not allowed - "404": - description: Session not found - "503": - description: Network Adapter not initialized - x-openapi-router-controller: src.controllers.network_functions_controller - /location/retrieve: - post: - tags: - - Location Retrieval Functions - summary: Retrieve the location of a device - operationId: retrieve_location - requestBody: - description: Location retrieval request following CAMARA Device Location API. - content: - application/json: - schema: - $ref: '#/components/schemas/RetrievalLocationRequest' - responses: - "200": - description: Device location retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/LocationResponse' - "400": - description: Invalid request - "404": - description: Device not found - "500": - description: Internal server error - "503": - description: Network Adapter not initialized - x-openapi-router-controller: src.controllers.network_functions_controller -components: - schemas: - serviceFunctionNodeMigration: - type: object - properties: - service_function_instance_name: - type: string - example: mongo-test - destination_location: - type: string - example: Peania_room - HelmChartInstall: - type: object - properties: - uri: - type: string - example: http://helm.chart.net/helm.yaml - deployment_name: - type: string - example: test_helm - repo_username: - type: string - example: test1 - repo_password: - type: string - example: test1 - iotValue: - type: object - properties: - name: - type: string - example: temperature - description: - type: string - example: Ambient temperature in Celsius - type: - type: string - example: Int64 - min: - type: integer - example: 0 - max: - type: integer - example: 100 - addIotDevice: - type: object - properties: - device_name: - type: string - example: Temp_and_Humidity_sensor_cluster_01 - description: - type: string - example: Raspberry Pi sensor cluster - device_cluster: - type: string - example: EnviromentSensorCluster - location: - type: object - properties: - address: - type: string - example: Peania 19027 - lat: - type: number - example: 37.9553 - long: - type: number - example: 23.8522 - values: - type: array - items: - items: - $ref: '#/components/schemas/iotValue' - activateSecuredSlice: - type: object - properties: - slice_name: - type: string - example: icom_slice_1 - service_functions_names: - type: array - items: - type: string - LoginRegistrationRequest: - type: object - properties: - username: - type: string - example: admin - password: - type: string - example: admin@ICOM_123! - PaasDeregistrationRequest: - type: object - properties: - paas_service_name: - type: string - example: paas_apache_gw - ServiceFunctionDeregistrationRequest: - type: object - properties: - service_function_name: - type: string - example: edgex-core-data - appDelete: - type: object - properties: - paas_service_name: - type: string - example: paas_apache_gw - chainDelete: - type: object - properties: - chain_service_name: - type: string - example: chain_apaches_gws - PaasRegistrationRequest: - type: object - properties: - paas_service_name: - type: string - example: Support-Security-Services - # paas_service_policy: - # type: string - # example: maximize-performance - service_functions: - type: array - items: - $ref: '#/components/schemas/ServiceFunctionintoPaaS' - PaasCatalogueResponse: - type: object - properties: - _id: - type: string - example: 62b9d91e309c931b320040fe - paas_service_name: - type: string - example: Support-Security-Services - service_functions: - type: array - items: - $ref: '#/components/schemas/ServiceFunctionintoPaaS' - PaassCatalogueResponse: - type: array - items: - $ref: '#/components/schemas/PaasCatalogueResponse' - ServiceFunctionintoPaaS: - type: object - properties: - service_function_identifier_name: - type: string - volume_mounts: - type: array - items: - $ref: '#/components/schemas/volume_mount_deploy' - autoscaling_metric: - type: string - env_parameters: - type: array - items: - $ref: '#/components/schemas/env_parameters' - example: - - service_function_identifier_name: ElasticSearch - volume_mounts: - - storage: 500Mi - name: volume1 - - - autoscaling_metric: cpu - - service_function_identifier_name: Kibana - autoscaling_metric: memory - env_parameters: - - name: ESTASTICSEARCH_URL - value: http://Elasticname:..... INTKUBEDNS - ServiceFunctionRegistrationRequest: - type: object - properties: - service_function_name: - type: string - example: Kibana - service_function_image: - type: string - example: kibana:7.15.2 - service_function_type: - type: string - example: Container - application_ports: - type: array - example: - - 5601 - items: - type: integer - autoscaling_policies: - type: array - items: - $ref: '#/components/schemas/autoscaling_policy' - required_volumes: - type: array - items: - $ref: '#/components/schemas/volume' - required_env_parameters: - type: array - items: - $ref: '#/components/schemas/env_parameter_name' - privileged: - type: boolean - example: false - - eopRegistrationResponse: - type: object - properties: - eopName: - type: string - eopType: - type: string - example: openness - registrationStatus: - type: string - serviceConsumerId: - type: string - example: - eopType: openness - registrationStatus: registrationStatus - serviceConsumerId: serviceConsumerId - eopName: eopName - autoscaling_policy_metric: - type: object - properties: - metric: - example: cpu - type: string - limit: - type: string - example: 1000m - request: - type: string - example: 600m - util_percent: - type: number - example: 60 - is_default: - type: boolean - example: true - autoscaling_policy: - type: object - properties: - policy: - type: string - example: maximize-performance - monitoring_metrics: - type: array - items: - $ref: '#/components/schemas/autoscaling_policy_metric' - autoscaling_policy_array: - type: array - items: - $ref: '#/components/schemas/autoscaling_policy' - volume: - type: object - properties: - name: - type: string - example: volumeconfig - path: - type: string - example: /data/config - hostpath: - type: string - example: /data/config - env_parameter_name: - type: object - properties: - name: - type: string - example: - name: ELASTICSEARCH_URL - env_parameters: - type: object - properties: - name: - type: string - value: - type: string - value_ref: - type: string - example: - name: ELASTICSEARCH_URL - value: http://elasticsearch:9200 - servicesQuery: - type: object - properties: - serviceConsumerId: - type: string - queryString: - type: string - nodesResponse: - type: object - properties: - id: - type: string - name: - type: string - example: openness - location: - type: string - serial: - type: string - node_type: - type: string - example: - serial: 146.124.106.179 - name: compute1 - location: Peania_19002_Athens - id: 237d11c4-aca6-4845-9538-ba7b3e89c0b6 - node_type: server - appsResponse: - type: object - properties: - apps: - type: array - items: - $ref: '#/components/schemas/appsResponse_apps' - example: - apps: - - id: id - - id: id - volume_mount_deploy: - type: object - properties: - name: - type: string - example: volume1 - storage: - type: string - example: 100Mi - serviceFunctionIndex: - type: object - properties: - service_function_name: - type: string - example: mongodb - service_function_index: - type: integer - example: 2 - deployServiceFunction: - type: object - properties: - service_function_name: - type: string - example: mongodb - service_function_instance_name: - type: string - example: mondodb_for_IoT_App - volume_mounts: - type: array - items: - $ref: '#/components/schemas/volume_mount_deploy' - autoscaling_metric: - type: string - example: cpu - autoscaling_policy: - type: string - example: minimize_cost - count_min: - type: integer - example: 1 - count_max: - type: integer - example: 3 - location: - type: string - example: Peania_Athens_node1 - all_node_ports: - type: boolean - example: false - monitoring_services: - type: boolean - example: false - node_ports: - type: array - items: - type: integer - example: - - 90 - - 8080 - env_parameters: - type: array - items: - $ref: '#/components/schemas/env_parameters' - deployPaas: - type: object - properties: - paas_service_name: - type: string - example: EdgeX - paas_instance_name: - type: string - example: Edgex_ICOM_deployment - # autoscaling_metric: - # type: string - # example: memory - autoscaling_type: - type: string - example: maximize_performance - data_space_enabled: - type: boolean - example: true - count_min: - type: integer - example: 1 - count_max: - type: integer - example: 5 - location: - type: string - example: Peania_Athens_node1 - all_node_ports: - type: boolean - example: false - monitoring_services: - type: boolean - example: false - node_ports: - type: array - items: - type: integer - example: - - 90 - - 8080 - addNode: - type: object - properties: - name: - type: string - example: Node_1 - hostname: - type: string - example: ubuntu - ip: - type: string - example: ubuntu - password: - type: string - example: 12fe43!!@ - location: - type: string - example: Koropi_Athens_19400 - node_type: - type: string - example: server - removeNode: - type: object - properties: - name: - type: string - example: Node_1 - hostname: - type: string - example: ubuntu - ip: - type: string - example: ubuntu - password: - type: string - example: 12fe43!!@ - deployPaaSNode: - type: object - properties: - node_name: - type: string - example: Node_1 - location: - type: string - example: Peania_Athens - paas_services: - type: array - items: - $ref: '#/components/schemas/deployPaas' - deployChain: - type: object - properties: - chain_service_function_instance_name: - type: string - chain_service_function_order: - type: array - items: - $ref: '#/components/schemas/serviceFunctionIndex' - service_functions: - type: array - items: - $ref: '#/components/schemas/deployServiceFunction' - appupdate: - type: object - properties: - command: - type: string - eopD_configurability: - type: object - properties: - foo: - type: string - example: - foo: foo - eopD_paasServices: - type: object - properties: - paasName: - type: string - paasType: - type: string - example: generic - scaleOut: - type: boolean - configurability: - $ref: '#/components/schemas/eopD_configurability' - example: - paasName: paasName - scaleOut: true - configurability: - foo: foo - paasType: generic - eopD_interconnectivitySupport: - type: object - properties: - tunnelling: - type: string - enum: - - gre - - vxlan - - gtp_u - example: - tunnelling: gre - eopD_kpis: - type: object - properties: - availability: - type: string - latency: - type: string - example: - latency: latency - availability: availability - eopRegistrationRequest_eopAuthCredentials: - type: object - properties: - username: - type: string - password: - type: string - k8sRegistrationRequest_eopAuthCredentials: - type: object - properties: - username: - type: string - password: - type: string - token: - type: string - appsResponse_apps: - type: object - properties: - id: - type: string - example: - id: id - PaasRegistrationRequest_autoscaling_policies: - type: object - properties: - metric: - type: string - limit: - type: string - request: - type: string - util_percent: - type: number - deployedappsResponse: - type: object - properties: - nodeid: - type: string - nodename: - type: string - paasid: - type: string - paasname: - type: string - status: - type: string - exposedports: - type: array - items: - type: integer - example: - paasname: paasname - nodename: nodename - exposedports: - - 0 - - 0 - nodeid: nodeid - paasid: paasid - status: status - deployedappsResponse_apps: - type: array - items: - $ref: '#/components/schemas/deployedappsResponse' - genericResponse: - type: object - properties: - code: - type: integer - object: - type: object - swarmInfo: - type: object - properties: - ID: - type: string - Version: - type: object - CreatedAt: - type: string - UpdatedAt: - type: string - Spec: - type: object - swarmInitModel: - type: object - properties: - listen_addr: - type: string - adv_addr: - type: string - subnet_size: - type: integer - default_addr_pool: - type: array - items: - type: string - data_path_port: - type: integer - force_new_cluster: - type: boolean - example: - listen_addr: 0.0.0.0:2377 - adv_addr: 192.168.1.1:2377 - subnet_size: 24 - default_addr_pool: - - 10.10.0.0/8 - - 20.20.0.0/8 - data_path_port: 4789 - force_new_cluster: false - joinNodeModel: - type: object - properties: - listen_addr: - type: string - adv_addr: - type: string - remote_addr: - type: array - items: - type: string - join_token: - type: string - example: - listen_addr: 0.0.0.0:2377 - adv_addr: 192.168.1.1:2377 - remote_addr: - - 192.168.1.2:2377 - join_token: SWMTKN-1-2us7px3tok5sasovwyiszku26v4b3ne437anvacpksgf0d91c8-7r4boxw9obe2k8qz5czejfotn - createServiceModel: - type: object - properties: - image: - type: string - name: - type: string - env: - type: array - items: - type: string - networks: - type: array - items: - type: string - ports: - type: array - items: - $ref: '#/components/schemas/createServicePortsConfig' - example: - image: "mongo:4.4.18" - name: "mongodb" - env: - - "HTTP_PROXY=http://icache.intracomtel.com:80" - - "HTTPS_PROXY=http://icache.intracomtel.com:80" - networks: - - "bricks-nw" - ports: - - name: "port1" - protocol: "tcp" - target_port: 8080 - published_port: 8080 - publish_mode: "host" - createServicePortsConfig: - type: object - properties: - name: - type: string - protocol: - type: string - target_port: - type: integer - published_port: - type: integer - publish_mode: - type: string - example: - name: string - protocol: "tcp" - target_port: 8080 - published_port: 8080 - publish_mode: "host" - # HelmChartInstall: - # type: object - # properties: - # uri: - # type: string - # example: http://helm.chart.net/helm.yaml - # deployment_name: - # type: string - # example: test_helm - # repo_username: - # type: string - # example: test1 - # repo_password: - # type: string - # example: test1 - CopyArtifactModel: - type: object - properties: - src_registry: - type: string - example: http://dockerhub.io - src_image_name: - type: string - example: mongodb - src_image_tag: - type: string - example: latest - dst_registry: - type: string - example: http://dockerhub.io - dst_image_name: - type: string - example: mongodb - dst_image_tag: - type: string - example: latest - src_username: - type: string - example: user123 - src_password: - type: string - example: 1234 - dst_username: - type: string - example: user123 - dst_password: - type: string - example: 1234 - ArtifactExistsModel: - type: object - properties: - registry_url: - type: string - example: http://dockerhub.io - artefact_name: - type: string - example: mongodb - artefact_tag: - type: string - example: latest - username: - type: string - example: user123 - password: - type: string - example: 1234 - DeployApp: - type: object - properties: - appId: - type: string - example: "123456789" - appZones: - type: array - items: - $ref: '#/components/schemas/EdgeCloudZone' - EdgeCloudZone: - type: object - properties: - edgeCloudZoneId: - type: string - example: "123456789" - edgeCloudZoneName: - type: string - example: Test - edgeCloudZoneStatus: - type: string - example: active - edgeCloudProvider: - type: string - example: Test - edgeCloudRegion: - type: string - example: Test - - RetrievalLocationRequest: - type: object - properties: - device: - $ref: '#/components/schemas/Device' - maxAge: - type: integer - description: Maximum age of the location information accepted (in seconds). - example: 60 - maxSurface: - type: integer - description: Maximum surface in square meters accepted for the location retrieval. - minimum: 1 - example: 10000 - Device: - type: object - properties: - phoneNumber: - type: string - description: Phone number of the device in E.164 format. - example: "+123456789" - networkAccessIdentifier: - type: string - description: External identifier of the device (e.g. GPSI). - example: "device@testnet.net" - ipv4Address: - $ref: '#/components/schemas/DeviceIpv4Addr' - ipv6Address: - type: string - description: IPv6 address of the device. - example: "2001:db8::1" - DeviceIpv4Addr: - type: object - properties: - publicAddress: - type: string - example: "198.51.100.1" - privateAddress: - type: string - example: "10.0.0.1" - publicPort: - type: integer - example: 59765 - LocationResponse: - type: object - properties: - lastLocationTime: - type: string - format: date-time - description: Timestamp of the last known location. - example: "2024-06-01T12:00:00Z" - area: - $ref: '#/components/schemas/LocationArea' - LocationArea: - type: object - description: Geographic area of the location (Circle or Polygon). - properties: - areaType: - type: string - enum: - - CIRCLE - - POLYGON - example: CIRCLE - center: - $ref: '#/components/schemas/GeoPoint' - radius: - type: number - description: Radius in meters (when areaType is CIRCLE). - example: 800 - boundary: - type: array - description: List of points forming the polygon boundary (when areaType is POLYGON). - items: - $ref: '#/components/schemas/GeoPoint' - GeoPoint: - type: object - properties: - latitude: - type: number - minimum: -90 - maximum: 90 - example: 37.9553 - longitude: - type: number - minimum: -180 - maximum: 180 - example: 23.8522 - securitySchemes: - registry_auth: - type: oauth2 - flows: - implicit: - authorizationUrl: http://serviceregistry.swagger.io/oauth/dialog - scopes: - write:services: modify services in your account - read:services: read your services - x-tokenInfoFunc: src.controllers.authorization_controller.check_registry_auth - x-scopeValidateFunc: src.controllers.authorization_controller.validate_scope_registry_auth - api_key: - type: apiKey - name: api_key - in: header - x-apikeyInfoFunc: src.controllers.authorization_controller.check_api_key - jwt: - type: http - scheme: bearer - bearerFormat: JWT - x-bearerInfoFunc: src.controllers.authorization_controller.decode_token diff --git a/service-resource-manager-implementation/src/type_util.py b/service-resource-manager-implementation/src/type_util.py deleted file mode 100644 index 4b74a080296a5bb9f61e28ef4205401fb8177a99..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/src/type_util.py +++ /dev/null @@ -1,32 +0,0 @@ -# coding: utf-8 - -import sys -import typing - -if sys.version_info < (3, 7): - - def is_generic(klass): - """ Determine whether klass is a generic class """ - return type(klass) == typing.GenericMeta - - def is_dict(klass): - """ Determine whether klass is a Dict """ - return klass.__extra__ == dict - - def is_list(klass): - """ Determine whether klass is a List """ - return klass.__extra__ == list - -else: - - def is_generic(klass): - """ Determine whether klass is a generic class """ - return hasattr(klass, '__origin__') - - def is_dict(klass): - """ Determine whether klass is a Dict """ - return klass.__origin__ == dict - - def is_list(klass): - """ Determine whether klass is a List """ - return klass.__origin__ == list diff --git a/service-resource-manager-implementation/src/util.py b/service-resource-manager-implementation/src/util.py deleted file mode 100644 index 79f19f39a2414879fc481511c9cf7aed3e5592e6..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/src/util.py +++ /dev/null @@ -1,142 +0,0 @@ -import datetime - -import six -import typing -from src import type_util - - -def _deserialize(data, klass): - """Deserializes dict, list, str into an object. - - :param data: dict, list or str. - :param klass: class literal, or string of class name. - - :return: object. - """ - if data is None: - return None - - if klass in six.integer_types or klass in (float, str, bool, bytearray): - return _deserialize_primitive(data, klass) - elif klass == object: - return _deserialize_object(data) - elif klass == datetime.date: - return deserialize_date(data) - elif klass == datetime.datetime: - return deserialize_datetime(data) - elif type_util.is_generic(klass): - if type_util.is_list(klass): - return _deserialize_list(data, klass.__args__[0]) - if type_util.is_dict(klass): - return _deserialize_dict(data, klass.__args__[1]) - else: - return deserialize_model(data, klass) - - -def _deserialize_primitive(data, klass): - """Deserializes to primitive type. - - :param data: data to deserialize. - :param klass: class literal. - - :return: int, long, float, str, bool. - :rtype: int | long | float | str | bool - """ - try: - value = klass(data) - except UnicodeEncodeError: - value = six.u(data) - except TypeError: - value = data - return value - - -def _deserialize_object(value): - """Return an original value. - - :return: object. - """ - return value - - -def deserialize_date(string): - """Deserializes string to date. - - :param string: str. - :type string: str - :return: date. - :rtype: date - """ - try: - from dateutil.parser import parse - return parse(string).date() - except ImportError: - return string - - -def deserialize_datetime(string): - """Deserializes string to datetime. - - The string should be in iso8601 datetime format. - - :param string: str. - :type string: str - :return: datetime. - :rtype: datetime - """ - try: - from dateutil.parser import parse - return parse(string) - except ImportError: - return string - - -def deserialize_model(data, klass): - """Deserializes list or dict to model. - - :param data: dict, list. - :type data: dict | list - :param klass: class literal. - :return: model object. - """ - instance = klass() - - if not instance.swagger_types: - return data - - for attr, attr_type in six.iteritems(instance.swagger_types): - if data is not None \ - and instance.attribute_map[attr] in data \ - and isinstance(data, (list, dict)): - value = data[instance.attribute_map[attr]] - setattr(instance, attr, _deserialize(value, attr_type)) - - return instance - - -def _deserialize_list(data, boxed_type): - """Deserializes a list and its elements. - - :param data: list to deserialize. - :type data: list - :param boxed_type: class literal. - - :return: deserialized list. - :rtype: list - """ - return [_deserialize(sub_data, boxed_type) - for sub_data in data] - - -def _deserialize_dict(data, boxed_type): - """Deserializes a dict and its elements. - - :param data: dict to deserialize. - :type data: dict - :param boxed_type: class literal. - - :return: deserialized dict. - :rtype: dict - """ - return {k: _deserialize(v, boxed_type) - for k, v in six.iteritems(data)} diff --git a/service-resource-manager-implementation/src/utils/artifact_connector.py b/service-resource-manager-implementation/src/utils/artifact_connector.py deleted file mode 100644 index 861971f9537cc746859267b9bac76b5aef842279..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/src/utils/artifact_connector.py +++ /dev/null @@ -1,20 +0,0 @@ -import logging -import requests -import os -import json - -artifact_manager_host = os.environ['ARTIFACT_MANAGER_ADDRESS'] - -def artifact_exists(body): - logging.info('Contacting Artifact Manager') - # body = json.dumps(body) - headers = {'Content-Type': 'application/json'} - response = requests.post(artifact_manager_host+'/artefact-exists/', headers=headers, json=body) - return response - -def copy_artifact(body): - logging.info('Submitting artifact to Artifact Manager') - # body = json.dumps(body) - headers = {'Content-Type': 'application/json'} - response = requests.post(artifact_manager_host+'/copy-artefact', headers=headers, json=body) - return response \ No newline at end of file diff --git a/service-resource-manager-implementation/src/utils/auxiliary_functions.py b/service-resource-manager-implementation/src/utils/auxiliary_functions.py deleted file mode 100644 index cf9224b50031c78d2eaee5275dd392b49b71b729..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/src/utils/auxiliary_functions.py +++ /dev/null @@ -1,41 +0,0 @@ -def equal_ignore_order(a, b): - """ Use only when elements are neither hashable nor sortable! """ - unmatched = list(b) - for element in a: - try: - unmatched.remove(element) - except ValueError: - return False - return not unmatched - - -def check_availability(element, collection: iter): - return element in collection - -def return_equal_ignore_order(a, b): - - """ Use only when elements are neither hashable nor sortable! """ - equal=[] - for element in a: - #if b is not None: - - if element in b: - equal.append(element) - return equal - -def prepare_name_for_k8s(name): - name = name.lower() - # deployed_name = deployed_name.replace("-", "") - name = name.replace("_", "") - deployed_name_ = ''.join([i for i in name if not i.isdigit()]) - return deployed_name_ - -def prepare_name(name, driver): - if driver!='docker': - name = name.lower() - # deployed_name = deployed_name.replace("-", "") - name = name.replace("_", "") - deployed_name_ = ''.join([i for i in name if not i.isdigit()]) - return deployed_name_.rstrip('-') - else: - return name \ No newline at end of file diff --git a/service-resource-manager-implementation/tests/conftest.py b/service-resource-manager-implementation/tests/conftest.py deleted file mode 100644 index 5a82fdb13def7616a0eea6780a295b4e8fc9712b..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/tests/conftest.py +++ /dev/null @@ -1,137 +0,0 @@ -""" -Shared fixtures for SRM controller unit tests. - -Sets up: -- A Flask test app (needed for connexion.request context) -- A mock network adapter pre-wired with a standard Location response -- Helper to POST JSON to the retrieve_location controller -""" - -import json -import sys -import os -from datetime import datetime, timezone -from unittest.mock import MagicMock, patch - -import connexion -import pytest -from flask import Flask - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) - -from sunrise6g_opensdk.network.core.schemas import ( - AreaType, - Circle, - Location, - Point, -) - - -# --------------------------------------------------------------------------- -# Constants reused across tests -# --------------------------------------------------------------------------- - -MOCK_LATITUDE = 48.8566 -MOCK_LONGITUDE = 2.3522 -MOCK_RADIUS = 150.0 -MOCK_TIMESTAMP = datetime(2026, 2, 10, 12, 0, 0, tzinfo=timezone.utc) - -PAYLOAD_BY_NAI = { - "device": {"networkAccessIdentifier": "imsi-001010000000001"}, - "maxAge": 60, -} - -PAYLOAD_BY_IP = { - "device": { - "ipv4Address": { - "publicAddress": "12.1.0.1", - "privateAddress": "12.1.0.1", - } - } -} - -PAYLOAD_BY_PHONE = { - "device": {"phoneNumber": "+10010100001"} -} - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - -@pytest.fixture() -def flask_app(): - """Minimal Flask app for request context.""" - return Flask(__name__) - - -@pytest.fixture() -def mock_location(): - """A CAMARA Location object with arbitrary coordinates for testing.""" - return Location( - lastLocationTime=MOCK_TIMESTAMP, - area=Circle( - areaType=AreaType.circle, - center=Point(latitude=MOCK_LATITUDE, longitude=MOCK_LONGITUDE), - radius=MOCK_RADIUS, - ), - ) - - -@pytest.fixture() -def mock_adapter(mock_location): - """A mock network adapter that returns a fixed Location by default.""" - adapter = MagicMock() - adapter.create_monitoring_event_subscription.return_value = mock_location - return adapter - - -@pytest.fixture(autouse=True) -def reset_network_adapter(): - """ - Reset the module-level network_adapter to None before each test so tests - are fully isolated from each other and from any real env vars. - """ - import src.controllers.network_functions_controller as ctrl - original = ctrl.network_adapter - ctrl.network_adapter = None - yield - ctrl.network_adapter = original - - -def call_retrieve_location(flask_app, payload): - """ - Helper: call retrieve_location() inside a proper Flask+connexion request - context with the given JSON payload. - Returns the controller's return value. For success (body, 200) returns just - the body for convenience; for errors returns the full (body, status) tuple. - """ - import src.controllers.network_functions_controller as ctrl - - with flask_app.test_request_context( - "/location/retrieve", - method="POST", - content_type="application/json", - data=json.dumps(payload), - ): - from flask import request as flask_request - with patch.object(connexion, "request", flask_request): - ret = ctrl.retrieve_location() - if isinstance(ret, tuple) and len(ret) == 2 and isinstance(ret[1], int) and 200 <= ret[1] < 300: - return ret[0] - return ret - - -def call_retrieve_location_plain_text(flask_app): - """Helper: call retrieve_location() with non-JSON content.""" - import src.controllers.network_functions_controller as ctrl - - with flask_app.test_request_context( - "/location/retrieve", - method="POST", - content_type="text/plain", - data="not json", - ): - from flask import request as flask_request - with patch.object(connexion, "request", flask_request): - return ctrl.retrieve_location() diff --git a/service-resource-manager-implementation/tests/test_retrieve_location.py b/service-resource-manager-implementation/tests/test_retrieve_location.py deleted file mode 100644 index e4f5f8eae80440646c6a7b0118dc12c711c7a10d..0000000000000000000000000000000000000000 --- a/service-resource-manager-implementation/tests/test_retrieve_location.py +++ /dev/null @@ -1,214 +0,0 @@ -""" -Unit tests for the retrieve_location controller. - -Structure: - TestStubMode – no adapter configured (NETWORK_ADAPTER_NAME not set) - TestWithAdapter – adapter is configured, SDK returns a Location object - TestInputValidation – bad / edge-case request bodies - TestErrorHandling – adapter raises exceptions -""" - -import pytest -from conftest import ( - MOCK_LATITUDE, - MOCK_LONGITUDE, - MOCK_RADIUS, - PAYLOAD_BY_IP, - PAYLOAD_BY_NAI, - PAYLOAD_BY_PHONE, - call_retrieve_location, - call_retrieve_location_plain_text, -) - - -# --------------------------------------------------------------------------- -# Stub mode (no adapter) -# --------------------------------------------------------------------------- - -class TestStubMode: - """When NETWORK_ADAPTER_NAME is not set the controller returns 503 UNAVAILABLE.""" - - def test_returns_503_when_no_adapter(self, flask_app): - body, status = call_retrieve_location(flask_app, PAYLOAD_BY_NAI) - assert status == 503 - - def test_503_body_has_code_unavailable(self, flask_app): - body, status = call_retrieve_location(flask_app, PAYLOAD_BY_NAI) - assert body.get("code") == "UNAVAILABLE" - - def test_503_body_has_message(self, flask_app): - body, status = call_retrieve_location(flask_app, PAYLOAD_BY_NAI) - assert "message" in body - assert "Network Adapter" in body["message"] - - def test_non_json_returns_400(self, flask_app): - result = call_retrieve_location_plain_text(flask_app) - assert result == ({"error": "Could not read JSON payload."}, 400) - - -# --------------------------------------------------------------------------- -# With adapter (mocked SDK) -# --------------------------------------------------------------------------- - -class TestWithAdapter: - """When an adapter is set the controller must delegate to it correctly.""" - - @pytest.fixture(autouse=True) - def inject_adapter(self, mock_adapter): - import src.controllers.network_functions_controller as ctrl - ctrl.network_adapter = mock_adapter - - # --- Response structure --- - - def test_returns_dict(self, flask_app): - result = call_retrieve_location(flask_app, PAYLOAD_BY_NAI) - assert isinstance(result, dict) - - def test_response_has_last_location_time(self, flask_app): - result = call_retrieve_location(flask_app, PAYLOAD_BY_NAI) - assert "lastLocationTime" in result - - def test_response_last_location_time_is_iso_string(self, flask_app): - result = call_retrieve_location(flask_app, PAYLOAD_BY_NAI) - # model_dump(mode='json') must have converted datetime → ISO string - assert isinstance(result["lastLocationTime"], str) - assert "2026-02-10" in result["lastLocationTime"] - - def test_response_has_area(self, flask_app): - result = call_retrieve_location(flask_app, PAYLOAD_BY_NAI) - assert "area" in result - - def test_response_area_type_is_circle(self, flask_app): - result = call_retrieve_location(flask_app, PAYLOAD_BY_NAI) - assert result["area"]["areaType"] == "CIRCLE" - - def test_response_coordinates(self, flask_app): - area = call_retrieve_location(flask_app, PAYLOAD_BY_NAI)["area"] - assert area["center"]["latitude"] == MOCK_LATITUDE - assert area["center"]["longitude"] == MOCK_LONGITUDE - assert area["radius"] == MOCK_RADIUS - - def test_no_none_values_in_response(self, flask_app): - """model_dump(exclude_none=True) must strip None fields.""" - result = call_retrieve_location(flask_app, PAYLOAD_BY_NAI) - def has_none(obj): - if isinstance(obj, dict): - return None in obj.values() or any(has_none(v) for v in obj.values()) - return False - assert not has_none(result) - - # --- Adapter is called with the right model --- - - def test_adapter_called_once(self, flask_app, mock_adapter): - call_retrieve_location(flask_app, PAYLOAD_BY_NAI) - mock_adapter.create_monitoring_event_subscription.assert_called_once() - - def test_adapter_receives_retrieval_location_request(self, flask_app, mock_adapter): - from sunrise6g_opensdk.network.core.schemas import RetrievalLocationRequest - call_retrieve_location(flask_app, PAYLOAD_BY_NAI) - arg = mock_adapter.create_monitoring_event_subscription.call_args.args[0] - assert isinstance(arg, RetrievalLocationRequest) - - def test_adapter_receives_correct_nai(self, flask_app, mock_adapter): - call_retrieve_location(flask_app, PAYLOAD_BY_NAI) - arg = mock_adapter.create_monitoring_event_subscription.call_args.args[0] - assert arg.device.networkAccessIdentifier is not None - - def test_adapter_receives_correct_max_age(self, flask_app, mock_adapter): - call_retrieve_location(flask_app, PAYLOAD_BY_NAI) - arg = mock_adapter.create_monitoring_event_subscription.call_args.args[0] - assert arg.maxAge == 60 - - # --- Different device identifier types --- - - def test_identify_by_ip(self, flask_app, mock_adapter): - result = call_retrieve_location(flask_app, PAYLOAD_BY_IP) - assert "area" in result - arg = mock_adapter.create_monitoring_event_subscription.call_args.args[0] - assert arg.device.ipv4Address is not None - - def test_identify_by_phone(self, flask_app, mock_adapter): - result = call_retrieve_location(flask_app, PAYLOAD_BY_PHONE) - assert "area" in result - arg = mock_adapter.create_monitoring_event_subscription.call_args.args[0] - assert arg.device.phoneNumber is not None - - def test_non_json_still_returns_400_with_adapter(self, flask_app): - result = call_retrieve_location_plain_text(flask_app) - assert result == ({"error": "Could not read JSON payload."}, 400) - - -# --------------------------------------------------------------------------- -# Input validation -# --------------------------------------------------------------------------- - -class TestInputValidation: - """Invalid request bodies must be handled gracefully, never crash.""" - - @pytest.fixture(autouse=True) - def inject_adapter(self, mock_adapter): - import src.controllers.network_functions_controller as ctrl - ctrl.network_adapter = mock_adapter - - def test_missing_device_field(self, flask_app, mock_adapter): - """No device key → Pydantic accepts it (device is Optional) but adapter may reject.""" - # Controller must not crash — it should either return a result or a 500 - result = call_retrieve_location(flask_app, {"maxAge": 30}) - assert isinstance(result, dict) or (isinstance(result, tuple) and result[1] == 500) - - def test_empty_body(self, flask_app, mock_adapter): - result = call_retrieve_location(flask_app, {}) - assert isinstance(result, dict) or (isinstance(result, tuple) and result[1] == 500) - - def test_max_age_is_passed_when_provided(self, flask_app, mock_adapter): - call_retrieve_location(flask_app, {"device": {"networkAccessIdentifier": "imsi-x"}, "maxAge": 120}) - arg = mock_adapter.create_monitoring_event_subscription.call_args.args[0] - assert arg.maxAge == 120 - - def test_max_age_is_none_when_omitted(self, flask_app, mock_adapter): - call_retrieve_location(flask_app, {"device": {"networkAccessIdentifier": "imsi-x"}}) - arg = mock_adapter.create_monitoring_event_subscription.call_args.args[0] - assert arg.maxAge is None - - def test_max_surface_is_passed_when_provided(self, flask_app, mock_adapter): - payload = {"device": {"networkAccessIdentifier": "imsi-x"}, "maxSurface": 5000} - call_retrieve_location(flask_app, payload) - arg = mock_adapter.create_monitoring_event_subscription.call_args.args[0] - assert arg.maxSurface == 5000 - - -# --------------------------------------------------------------------------- -# Error handling -# --------------------------------------------------------------------------- - -class TestErrorHandling: - """Adapter errors must be caught and returned as HTTP 500, never re-raised.""" - - @pytest.fixture(autouse=True) - def inject_adapter(self, mock_adapter): - import src.controllers.network_functions_controller as ctrl - ctrl.network_adapter = mock_adapter - - def test_adapter_generic_exception_returns_500(self, flask_app, mock_adapter): - mock_adapter.create_monitoring_event_subscription.side_effect = Exception("NEF down") - body, status = call_retrieve_location(flask_app, PAYLOAD_BY_NAI) - assert status == 500 - - def test_adapter_exception_message_in_body(self, flask_app, mock_adapter): - mock_adapter.create_monitoring_event_subscription.side_effect = Exception("connection refused") - body, status = call_retrieve_location(flask_app, PAYLOAD_BY_NAI) - assert body.get("error") == "connection refused" - - def test_adapter_value_error_returns_500(self, flask_app, mock_adapter): - mock_adapter.create_monitoring_event_subscription.side_effect = ValueError("bad response from NEF") - body, status = call_retrieve_location(flask_app, PAYLOAD_BY_NAI) - assert status == 500 - - def test_controller_does_not_reraise(self, flask_app, mock_adapter): - """The controller must never let an exception bubble up to the caller.""" - mock_adapter.create_monitoring_event_subscription.side_effect = RuntimeError("unexpected") - try: - result = call_retrieve_location(flask_app, PAYLOAD_BY_NAI) - assert result is not None - except RuntimeError: - pytest.fail("Controller re-raised the exception instead of catching it") diff --git a/src/srm/__init__.py b/src/srm/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fc1e41b67deb4c4c24b6cfb91cd3a0e8fd4d3fab --- /dev/null +++ b/src/srm/__init__.py @@ -0,0 +1,24 @@ +""" +Root package for the service. + +This package wires together all architectural layers following +Hexagonal (Ports & Adapters) Architecture. + +Structure overview: +- domain: The Core: This is pure Python. No database drivers, no HTTP clients, no Redis. The only + allowed external dependency is Pydantic (for data validation). +- application: Orchestration: Handlers sit between the API and the domain/adapters. + They follow a fixed sequence: fetch → validate → mutate → persist → publish event. + They contain no business logic of their own. If you find yourself adding an if + statement that encodes a business rule in a handler, + that rule belongs in domain/services.py. +- adapters: External Systems: Each adapter implements one or more Protocols from + domain/interfaces.py +- api: FastAPI routers, Pydantic request/response schemas, JWT authentication, and dependency + injection wiring. This layer translates HTTP into handler calls and domain objects back + into HTTP responses. +- tests: Automated test suites + +The root package should remain lightweight and focused on +composition rather than implementation. +""" diff --git a/src/srm/adapters/database/core.py b/src/srm/adapters/database/core.py new file mode 100644 index 0000000000000000000000000000000000000000..6a675ab701d790fb3188e779903e3355e2bc6862 --- /dev/null +++ b/src/srm/adapters/database/core.py @@ -0,0 +1,28 @@ +from typing import Literal + +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from srm.adapters.database.sql import get_metadata + + +async def build_engine_and_session_maker( + url: str, echo: bool | Literal["debug"] +) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: + + engine: AsyncEngine = create_async_engine(url, echo=echo) + + session_maker: async_sessionmaker[AsyncSession] = async_sessionmaker( + bind=engine, expire_on_commit=False + ) + + return engine, session_maker + + +async def schema_initialization(engine: AsyncEngine) -> None: + async with engine.begin() as conn: + await conn.run_sync(get_metadata().create_all) diff --git a/src/srm/adapters/database/mappers.py b/src/srm/adapters/database/mappers.py new file mode 100644 index 0000000000000000000000000000000000000000..982dfe2fb2198a52f9ea63d96b2ced543fbcbd33 --- /dev/null +++ b/src/srm/adapters/database/mappers.py @@ -0,0 +1,449 @@ +from uuid import UUID + +from srm.adapters.database.sql import ( + CapabilityInstanceRow, + CapabilityRow, + ControlPathBindingRow, + DomainRow, + ServiceCapabilityRequirementRow, + ServiceDeploymentUnitRow, + ServiceInstanceRow, + ServiceOrderRow, + ServiceSpecificationRow, + ZoneRow, +) +from srm.domain.models import ( + Capability, + CapabilityInstance, + ControlPathBinding, + Domain, + ServiceCapabilityRequirement, + ServiceDeploymentUnit, + ServiceInstance, + ServiceOrder, + ServiceSpecification, + Zone, +) +from srm.domain.models.canonical_parameters.compute import ComputeRequirements +from srm.domain.models.canonical_parameters.parameters import Parameters +from srm.domain.models.canonical_parameters.result import Result + + +def _row_kwargs(domain_object_id: UUID | None, **kwargs: object) -> dict[str, object]: + if domain_object_id is not None: + kwargs["id"] = domain_object_id + return kwargs + + +class ZoneMapper: + @staticmethod + def to_domain(row: ZoneRow) -> Zone: + return Zone( + id=row.id, + platform_ref=row.platform_ref, + ref=row.ref, + name=row.name, + kind=row.kind, + state=row.state, + metadata=row.zone_metadata, + domains=[DomainMapper.to_domain(domain) for domain in row.domains], + created_at=row.created_at, + updated_at=row.updated_at, + ) + + @staticmethod + def to_row(domain: Zone) -> ZoneRow: + return ZoneRow( + id=domain.id, + platform_ref=domain.platform_ref, + ref=domain.ref, + name=domain.name, + kind=domain.kind, + state=domain.state, + zone_metadata=domain.metadata, + domains=[DomainMapper.to_row(child) for child in domain.domains], + created_at=domain.created_at, + updated_at=domain.updated_at, + ) + + +class DomainMapper: + @staticmethod + def to_domain(row: DomainRow) -> Domain: + return Domain( + id=row.id, + zone_id=row.zone_id, + ref=row.ref, + name=row.name, + kind=row.kind, + state=row.state, + metadata=row.domain_metadata, + capabilities=[ + CapabilityMapper.to_domain(capability) for capability in row.capabilities + ], + created_at=row.created_at, + updated_at=row.updated_at, + ) + + @staticmethod + def to_row(domain: Domain) -> DomainRow: + return DomainRow( + **_row_kwargs( + domain.id, + zone_id=domain.zone_id, + ref=domain.ref, + name=domain.name, + kind=domain.kind, + state=domain.state, + domain_metadata=domain.metadata, + capabilities=[ + CapabilityMapper.to_row(capability) for capability in domain.capabilities + ], + created_at=domain.created_at, + updated_at=domain.updated_at, + ) + ) + + +class CapabilityMapper: + @staticmethod + def to_domain(row: CapabilityRow) -> Capability: + return Capability( + id=row.id, + domain_id=row.domain_id, + ref=row.ref, + name=row.name, + kind=row.kind, + state=row.state, + metadata=row.capability_metadata, + control_path_bindings=[ + ControlPathBindingMapper.to_domain(binding) for binding in row.control_path_bindings + ], + created_at=row.created_at, + updated_at=row.updated_at, + ) + + @staticmethod + def to_row(domain: Capability) -> CapabilityRow: + return CapabilityRow( + **_row_kwargs( + domain.id, + domain_id=domain.domain_id, + ref=domain.ref, + name=domain.name, + kind=domain.kind, + state=domain.state, + capability_metadata=domain.metadata, + control_path_bindings=[ + ControlPathBindingMapper.to_row(binding) + for binding in domain.control_path_bindings + ], + created_at=domain.created_at, + updated_at=domain.updated_at, + ) + ) + + +class ControlPathBindingMapper: + @staticmethod + def to_domain(row: ControlPathBindingRow) -> ControlPathBinding: + return ControlPathBinding( + id=row.id, + capability_id=row.capability_id, + ref=row.ref, + control_path_ref=row.control_path_ref, + supported_runtime_kinds=row.supported_runtime_kinds, + supported_actions=row.supported_actions, + priority=row.priority, + is_default=row.is_default, + state=row.state, + metadata=row.control_path_metadata, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + @staticmethod + def to_row(domain: ControlPathBinding) -> ControlPathBindingRow: + return ControlPathBindingRow( + **_row_kwargs( + domain.id, + capability_id=domain.capability_id, + ref=domain.ref, + control_path_ref=domain.control_path_ref, + supported_runtime_kinds=domain.supported_runtime_kinds, + supported_actions=domain.supported_actions, + priority=domain.priority, + is_default=domain.is_default, + state=domain.state, + control_path_metadata=domain.metadata, + created_at=domain.created_at, + updated_at=domain.updated_at, + ) + ) + + +class ServiceSpecificationMapper: + @staticmethod + def to_domain(row: ServiceSpecificationRow) -> ServiceSpecification: + return ServiceSpecification( + id=row.id, + app_provider_id=row.app_provider_id, + ref=row.ref, + name=row.name, + version=row.version, + state=row.state, + descriptor=row.descriptor, + metadata=row.service_spec_metadata, + deployment_units=[ + ServiceDeploymentUnitMapper.to_domain(unit) for unit in row.deployment_units + ], + capability_requirements=[ + ServiceCapabilityRequirementMapper.to_domain(requirement) + for requirement in row.capability_requirements + ], + created_at=row.created_at, + updated_at=row.updated_at, + ) + + @staticmethod + def to_row(domain: ServiceSpecification) -> ServiceSpecificationRow: + return ServiceSpecificationRow( + id=domain.id, + app_provider_id=domain.app_provider_id, + ref=domain.ref, + name=domain.name, + version=domain.version, + state=domain.state, + descriptor=domain.descriptor, + service_spec_metadata=domain.metadata, + deployment_units=[ + ServiceDeploymentUnitMapper.to_row(unit) for unit in domain.deployment_units + ], + capability_requirements=[ + ServiceCapabilityRequirementMapper.to_row(requirement) + for requirement in domain.capability_requirements + ], + created_at=domain.created_at, + updated_at=domain.updated_at, + ) + + +class ServiceDeploymentUnitMapper: + @staticmethod + def to_domain(row: ServiceDeploymentUnitRow) -> ServiceDeploymentUnit: + return ServiceDeploymentUnit( + id=row.id, + service_specification_id=row.service_specification_id, + ref=row.ref, + name=row.name, + runtime_kind=row.runtime_kind, + artifact_ref=row.artifact_ref, + resource_requirements=ComputeRequirements.model_validate(row.resource_requirements), + parameters_schema=row.parameters_schema, + metadata=row.deployment_unit_metadata, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + @staticmethod + def to_row(domain: ServiceDeploymentUnit) -> ServiceDeploymentUnitRow: + return ServiceDeploymentUnitRow( + **_row_kwargs( + domain.id, + service_specification_id=domain.service_specification_id, + ref=domain.ref, + name=domain.name, + runtime_kind=domain.runtime_kind, + artifact_ref=domain.artifact_ref, + resource_requirements=domain.resource_requirements.model_dump(mode="python"), + parameters_schema=domain.parameters_schema, + deployment_unit_metadata=domain.metadata, + created_at=domain.created_at, + updated_at=domain.updated_at, + ) + ) + + +class ServiceCapabilityRequirementMapper: + @staticmethod + def to_domain(row: ServiceCapabilityRequirementRow) -> ServiceCapabilityRequirement: + return ServiceCapabilityRequirement( + id=row.id, + service_specification_id=row.service_specification_id, + deployment_unit_id=row.deployment_unit_id, + ref=row.ref, + capability_kind=row.capability_kind, + domain_kind=row.domain_kind, + is_required=row.is_required, + selector=row.selector, + parameters=Parameters.model_validate(row.parameters), + policy=row.policy, + metadata=row.capability_requirement_metadata, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + @staticmethod + def to_row(domain: ServiceCapabilityRequirement) -> ServiceCapabilityRequirementRow: + return ServiceCapabilityRequirementRow( + **_row_kwargs( + domain.id, + service_specification_id=domain.service_specification_id, + deployment_unit_id=domain.deployment_unit_id, + ref=domain.ref, + capability_kind=domain.capability_kind, + domain_kind=domain.domain_kind, + is_required=domain.is_required, + selector=domain.selector, + parameters=domain.parameters.model_dump(mode="python"), + policy=domain.policy, + capability_requirement_metadata=domain.metadata, + created_at=domain.created_at, + updated_at=domain.updated_at, + ) + ) + + +class ServiceOrderMapper: + @staticmethod + def to_domain(row: ServiceOrderRow) -> ServiceOrder: + return ServiceOrder( + id=row.id, + operation_id=row.operation_id, + correlation_id=row.correlation_id, + source_component=row.source_component, + source_protocol=row.source_protocol, + order_type=row.order_type, + service_specification_id=row.service_specification_id, + target_service_instance_id=row.target_service_instance_id, + state=row.state, + payload_snapshot=row.payload_snapshot, + failure_detail=row.failure_detail, + app_provider_id=row.app_provider_id, + federation_partner_ref=row.federation_partner_ref, + completed_at=row.completed_at, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + @staticmethod + def to_row(domain: ServiceOrder) -> ServiceOrderRow: + return ServiceOrderRow( + **_row_kwargs( + domain.id, + operation_id=domain.operation_id, + correlation_id=domain.correlation_id, + source_component=domain.source_component, + source_protocol=domain.source_protocol, + order_type=domain.order_type, + service_specification_id=domain.service_specification_id, + target_service_instance_id=domain.target_service_instance_id, + state=domain.state, + payload_snapshot=domain.payload_snapshot, + failure_detail=domain.failure_detail, + app_provider_id=domain.app_provider_id, + federation_partner_ref=domain.federation_partner_ref, + completed_at=domain.completed_at, + created_at=domain.created_at, + updated_at=domain.updated_at, + ) + ) + + +class ServiceInstanceMapper: + @staticmethod + def to_domain(row: ServiceInstanceRow) -> ServiceInstance: + return ServiceInstance( + id=row.id, + zone_id=row.zone_id, + service_specification_id=row.service_specification_id, + originating_service_order_id=row.originating_service_order_id, + ref=row.ref, + state=row.state, + metadata=row.service_instance_metadata, + app_provider_id=str(row.app_provider_id), + federation_partner_ref=row.federation_partner_ref, + capabilities=[ + CapabilityInstanceMapper.to_domain(capability) + for capability in row.capability_instances + ], + terminated_at=row.terminated_at, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + @staticmethod + def to_row(domain: ServiceInstance) -> ServiceInstanceRow: + return ServiceInstanceRow( + id=domain.id, + service_specification_id=domain.service_specification_id, + zone_id=domain.zone_id, + originating_service_order_id=domain.originating_service_order_id, + ref=domain.ref, + state=domain.state, + service_instance_metadata=domain.metadata, + app_provider_id=domain.app_provider_id, + federation_partner_ref=domain.federation_partner_ref, + capability_instances=[ + CapabilityInstanceMapper.to_row(capability) for capability in domain.capabilities + ], + terminated_at=domain.terminated_at, + created_at=domain.created_at, + updated_at=domain.updated_at, + ) + + +class CapabilityInstanceMapper: + @staticmethod + def to_domain(row: CapabilityInstanceRow) -> CapabilityInstance: + return CapabilityInstance( + id=row.id, + capability_id=row.capability_id, + service_instance_id=row.service_instance_id, + originating_service_order_id=row.originating_service_order_id, + service_capability_requirement_id=row.service_capability_requirement_id, + control_path_binding_id=row.control_path_binding_id, + ref=row.ref, + control_path_ref_snapshot=row.control_path_ref_snapshot, + kind=row.kind, + state=row.state, + external_id=row.external_id, + external_ref=row.external_ref, + parameters_snapshot=Parameters.model_validate(row.parameters_snapshot), + result_summary=Result.model_validate(row.result_summary), + failure_detail=row.failure_detail, + metadata=row.capability_instance_metadata, + app_provider_id=str(row.app_provider_id), + federation_partner_ref=row.federation_partner_ref, + terminated_at=row.terminated_at, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + @staticmethod + def to_row(domain: CapabilityInstance) -> CapabilityInstanceRow: + return CapabilityInstanceRow( + **_row_kwargs( + domain.id, + capability_id=domain.capability_id, + service_instance_id=domain.service_instance_id, + originating_service_order_id=domain.originating_service_order_id, + service_capability_requirement_id=domain.service_capability_requirement_id, + control_path_binding_id=domain.control_path_binding_id, + ref=domain.ref, + control_path_ref_snapshot=domain.control_path_ref_snapshot, + kind=domain.kind, + state=domain.state, + external_id=domain.external_id, + external_ref=domain.external_ref, + parameters_snapshot=domain.parameters_snapshot.model_dump(mode="python"), + result_summary=domain.result_summary.model_dump(mode="python"), + failure_detail=domain.failure_detail, + capability_instance_metadata=domain.metadata, + app_provider_id=domain.app_provider_id, + federation_partner_ref=domain.federation_partner_ref, + terminated_at=domain.terminated_at, + created_at=domain.created_at, + updated_at=domain.updated_at, + ) + ) diff --git a/src/srm/adapters/database/repos/__init__.py b/src/srm/adapters/database/repos/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..69768f826d684ac23fa0e1e4f3c8f54eb05ac50d --- /dev/null +++ b/src/srm/adapters/database/repos/__init__.py @@ -0,0 +1,29 @@ +from srm.adapters.database.repos.catalog import ( + SqlServiceCapabilityRequirementRepository, + SqlServiceDeploymentUnitRepository, + SqlServiceSpecificationRepository, +) +from srm.adapters.database.repos.runtime_inventory import ( + SqlCapabilityInstanceRepository, + SqlServiceInstanceRepository, + SqlServiceOrderRepository, +) +from srm.adapters.database.repos.topology import ( + SqlCapabilityRepository, + SqlControlPathBindingRepository, + SqlDomainRepository, + SqlZoneRepository, +) + +__all__ = [ + "SqlCapabilityInstanceRepository", + "SqlCapabilityRepository", + "SqlControlPathBindingRepository", + "SqlDomainRepository", + "SqlServiceCapabilityRequirementRepository", + "SqlServiceDeploymentUnitRepository", + "SqlServiceInstanceRepository", + "SqlServiceOrderRepository", + "SqlServiceSpecificationRepository", + "SqlZoneRepository", +] diff --git a/src/srm/adapters/database/repos/catalog.py b/src/srm/adapters/database/repos/catalog.py new file mode 100644 index 0000000000000000000000000000000000000000..93d05b587b33c7bf78aa1b74b64d2a9f44122abd --- /dev/null +++ b/src/srm/adapters/database/repos/catalog.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from uuid import UUID + +import structlog +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from srm.adapters.database.mappers import ( + ServiceCapabilityRequirementMapper, + ServiceDeploymentUnitMapper, + ServiceSpecificationMapper, +) +from srm.adapters.database.sql import ( + ServiceCapabilityRequirementRow, + ServiceDeploymentUnitRow, + ServiceSpecificationRow, +) +from srm.adapters.errors import DuplicateServiceSpecificationError +from srm.domain.models.catalog import ( + ServiceCapabilityRequirement, + ServiceDeploymentUnit, + ServiceSpecification, +) +from srm.domain.ports.database.catalog import ( + ServiceCapabilityRequirementRepository, + ServiceDeploymentUnitRepository, + ServiceSpecificationRepository, +) + +logger: structlog.BoundLogger = structlog.getLogger(__name__) + + +def _pgcode(exc: IntegrityError) -> str | None: + return getattr(exc.orig, "pgcode", None) + + +def _log_integrity_error(exc: IntegrityError, *, entity: str, operation: str) -> None: + logger.error( + "database_integrity_error", + entity=entity, + operation=operation, + pgcode=_pgcode(exc), + error=str(exc), + ) + + +class SqlServiceSpecificationRepository(ServiceSpecificationRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, id: UUID) -> ServiceSpecification | None: + stmt = ( + select(ServiceSpecificationRow) + .options( + selectinload(ServiceSpecificationRow.deployment_units), + selectinload(ServiceSpecificationRow.capability_requirements), + ) + .where(ServiceSpecificationRow.id == id) + ) + row = await self._session.scalar(stmt) + return ServiceSpecificationMapper.to_domain(row) if row is not None else None + + async def create(self, service_specification: ServiceSpecification) -> ServiceSpecification: + row = ServiceSpecificationMapper.to_row(service_specification) + self._session.add(row) + try: + await self._session.flush() + except IntegrityError as exc: + _log_integrity_error(exc, entity="service_specification", operation="create") + if _pgcode(exc) == "23505": + raise DuplicateServiceSpecificationError() from exc + raise + specification_id = row.id + saved = await self.get_by_id(specification_id) + if saved is None: + raise RuntimeError("Created service specification could not be reloaded") + return saved + + +class SqlServiceDeploymentUnitRepository(ServiceDeploymentUnitRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, id: UUID) -> ServiceDeploymentUnit | None: + stmt = select(ServiceDeploymentUnitRow).where(ServiceDeploymentUnitRow.id == id) + row = await self._session.scalar(stmt) + return ServiceDeploymentUnitMapper.to_domain(row) if row is not None else None + + async def create(self, deployment_unit: ServiceDeploymentUnit) -> ServiceDeploymentUnit: + row = ServiceDeploymentUnitMapper.to_row(deployment_unit) + self._session.add(row) + try: + await self._session.flush() + except IntegrityError as exc: + _log_integrity_error(exc, entity="service_deployment_unit", operation="create") + raise + deployment_unit_id = row.id + saved = await self.get_by_id(deployment_unit_id) + if saved is None: + raise RuntimeError("Created deployment unit could not be reloaded") + return saved + + +class SqlServiceCapabilityRequirementRepository(ServiceCapabilityRequirementRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, id: UUID) -> ServiceCapabilityRequirement | None: + stmt = select(ServiceCapabilityRequirementRow).where( + ServiceCapabilityRequirementRow.id == id + ) + row = await self._session.scalar(stmt) + return ServiceCapabilityRequirementMapper.to_domain(row) if row is not None else None + + async def create( + self, + capability_requirement: ServiceCapabilityRequirement, + ) -> ServiceCapabilityRequirement: + row = ServiceCapabilityRequirementMapper.to_row(capability_requirement) + self._session.add(row) + try: + await self._session.flush() + except IntegrityError as exc: + _log_integrity_error(exc, entity="service_capability_requirement", operation="create") + raise + requirement_id = row.id + saved = await self.get_by_id(requirement_id) + if saved is None: + raise RuntimeError("Created capability requirement could not be reloaded") + return saved diff --git a/src/srm/adapters/database/repos/runtime_inventory.py b/src/srm/adapters/database/repos/runtime_inventory.py new file mode 100644 index 0000000000000000000000000000000000000000..50980f3e900eea4899c22e5768971d67d227d7e3 --- /dev/null +++ b/src/srm/adapters/database/repos/runtime_inventory.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from uuid import UUID + +import structlog +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from srm.adapters.database.mappers import ( + CapabilityInstanceMapper, + ServiceInstanceMapper, + ServiceOrderMapper, +) +from srm.adapters.database.sql import CapabilityInstanceRow, ServiceInstanceRow, ServiceOrderRow +from srm.domain.models.runtime_inventory import ( + CapabilityInstance, + ServiceInstance, + ServiceOrder, +) +from srm.domain.ports.database.runtime_inventory import ( + CapabilityInstanceRepository, + ServiceInstanceRepository, + ServiceOrderRepository, +) + +logger: structlog.BoundLogger = structlog.getLogger(__name__) + + +def _pgcode(exc: IntegrityError) -> str | None: + return getattr(exc.orig, "pgcode", None) + + +def _log_integrity_error(exc: IntegrityError, *, entity: str, operation: str) -> None: + logger.error( + "database_integrity_error", + entity=entity, + operation=operation, + pgcode=_pgcode(exc), + error=str(exc), + ) + + +class SqlServiceOrderRepository(ServiceOrderRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, id: UUID) -> ServiceOrder | None: + stmt = select(ServiceOrderRow).where(ServiceOrderRow.id == id) + row = await self._session.scalar(stmt) + return ServiceOrderMapper.to_domain(row) if row is not None else None + + async def create(self, service_order: ServiceOrder) -> ServiceOrder: + row = ServiceOrderMapper.to_row(service_order) + self._session.add(row) + try: + await self._session.flush() + except IntegrityError as exc: + _log_integrity_error(exc, entity="service_order", operation="create") + raise + service_order_id = row.id + saved = await self.get_by_id(service_order_id) + if saved is None: + raise RuntimeError("Created service order could not be reloaded") + return saved + + +class SqlServiceInstanceRepository(ServiceInstanceRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, id: UUID) -> ServiceInstance | None: + stmt = ( + select(ServiceInstanceRow) + .options( + selectinload(ServiceInstanceRow.capability_instances), + ) + .where(ServiceInstanceRow.id == id) + ) + row = await self._session.scalar(stmt) + return ServiceInstanceMapper.to_domain(row) if row is not None else None + + async def create(self, service_instance: ServiceInstance) -> ServiceInstance: + row = ServiceInstanceMapper.to_row(service_instance) + self._session.add(row) + try: + await self._session.flush() + except IntegrityError as exc: + _log_integrity_error(exc, entity="service_instance", operation="create") + raise + service_instance_id = row.id + saved = await self.get_by_id(service_instance_id) + if saved is None: + raise RuntimeError("Created service instance could not be reloaded") + return saved + + +class SqlCapabilityInstanceRepository(CapabilityInstanceRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, id: UUID) -> CapabilityInstance | None: + stmt = select(CapabilityInstanceRow).where(CapabilityInstanceRow.id == id) + row = await self._session.scalar(stmt) + return CapabilityInstanceMapper.to_domain(row) if row is not None else None + + async def create(self, capability_instance: CapabilityInstance) -> CapabilityInstance: + row = CapabilityInstanceMapper.to_row(capability_instance) + self._session.add(row) + try: + await self._session.flush() + except IntegrityError as exc: + _log_integrity_error(exc, entity="capability_instance", operation="create") + raise + capability_instance_id = row.id + saved = await self.get_by_id(capability_instance_id) + if saved is None: + raise RuntimeError("Created capability instance could not be reloaded") + return saved diff --git a/src/srm/adapters/database/repos/topology.py b/src/srm/adapters/database/repos/topology.py new file mode 100644 index 0000000000000000000000000000000000000000..bfb024614471c5e0dbf625fa5b693b5657fad05f --- /dev/null +++ b/src/srm/adapters/database/repos/topology.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from uuid import UUID + +import structlog +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from srm.adapters.database.mappers import ( + CapabilityMapper, + ControlPathBindingMapper, + DomainMapper, + ZoneMapper, +) +from srm.adapters.database.sql import CapabilityRow, ControlPathBindingRow, DomainRow, ZoneRow +from srm.domain.models.topology import Capability, ControlPathBinding, Domain, Zone +from srm.domain.ports.database.topology import ( + CapabilityRepository, + ControlPathBindingRepository, + DomainRepository, + ZoneRepository, +) + +logger: structlog.BoundLogger = structlog.getLogger(__name__) + + +def _pgcode(exc: IntegrityError) -> str | None: + return getattr(exc.orig, "pgcode", None) + + +def _log_integrity_error(exc: IntegrityError, *, entity: str, operation: str) -> None: + logger.error( + "database_integrity_error", + entity=entity, + operation=operation, + pgcode=_pgcode(exc), + error=str(exc), + ) + + +class SqlZoneRepository(ZoneRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, id: UUID) -> Zone | None: + stmt = ( + select(ZoneRow) + .options( + selectinload(ZoneRow.domains) + .selectinload(DomainRow.capabilities) + .selectinload(CapabilityRow.control_path_bindings) + ) + .where(ZoneRow.id == id) + ) + row = await self._session.scalar(stmt) + return ZoneMapper.to_domain(row) if row is not None else None + + async def create(self, zone: Zone) -> Zone: + row = ZoneMapper.to_row(zone) + self._session.add(row) + try: + await self._session.flush() + except IntegrityError as exc: + _log_integrity_error(exc, entity="zone", operation="create") + raise + zone_id = row.id + saved = await self.get_by_id(zone_id) + if saved is None: + raise RuntimeError("Created zone could not be reloaded") + return saved + + +class SqlDomainRepository(DomainRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, id: UUID) -> Domain | None: + stmt = ( + select(DomainRow) + .options( + selectinload(DomainRow.capabilities).selectinload( + CapabilityRow.control_path_bindings + ) + ) + .where(DomainRow.id == id) + ) + row = await self._session.scalar(stmt) + return DomainMapper.to_domain(row) if row is not None else None + + async def create(self, domain: Domain) -> Domain: + row = DomainMapper.to_row(domain) + self._session.add(row) + try: + await self._session.flush() + except IntegrityError as exc: + _log_integrity_error(exc, entity="domain", operation="create") + raise + domain_id = row.id + saved = await self.get_by_id(domain_id) + if saved is None: + raise RuntimeError("Created domain could not be reloaded") + return saved + + +class SqlCapabilityRepository(CapabilityRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, id: UUID) -> Capability | None: + stmt = ( + select(CapabilityRow) + .options(selectinload(CapabilityRow.control_path_bindings)) + .where(CapabilityRow.id == id) + ) + row = await self._session.scalar(stmt) + return CapabilityMapper.to_domain(row) if row is not None else None + + async def create(self, capability: Capability) -> Capability: + row = CapabilityMapper.to_row(capability) + self._session.add(row) + try: + await self._session.flush() + except IntegrityError as exc: + _log_integrity_error(exc, entity="capability", operation="create") + raise + capability_id = row.id + saved = await self.get_by_id(capability_id) + if saved is None: + raise RuntimeError("Created capability could not be reloaded") + return saved + + +class SqlControlPathBindingRepository(ControlPathBindingRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, id: UUID) -> ControlPathBinding | None: + stmt = select(ControlPathBindingRow).where(ControlPathBindingRow.id == id) + row = await self._session.scalar(stmt) + return ControlPathBindingMapper.to_domain(row) if row is not None else None + + async def create(self, control_path_binding: ControlPathBinding) -> ControlPathBinding: + row = ControlPathBindingMapper.to_row(control_path_binding) + self._session.add(row) + try: + await self._session.flush() + except IntegrityError as exc: + _log_integrity_error(exc, entity="control_path_binding", operation="create") + raise + binding_id = row.id + saved = await self.get_by_id(binding_id) + if saved is None: + raise RuntimeError("Created control path binding could not be reloaded") + return saved diff --git a/src/srm/adapters/database/sql.py b/src/srm/adapters/database/sql.py new file mode 100644 index 0000000000000000000000000000000000000000..063ff6771c37dc2ebcf639abc128aced62c3c0bc --- /dev/null +++ b/src/srm/adapters/database/sql.py @@ -0,0 +1,372 @@ +from datetime import datetime +from enum import Enum as PyEnum +from uuid import UUID + +from sqlalchemy import ( + Boolean, + DateTime, + Enum, + ForeignKey, + Index, + Integer, + MetaData, + String, + UniqueConstraint, + func, + text, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.ext.asyncio import AsyncAttrs +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + +from srm.domain.models.catalog.enums import RuntimeKind, ServiceSpecificationState +from srm.domain.models.runtime_inventory.enums import ( + CapabilityInstanceKind, + CapabilityInstanceState, + ServiceInstanceState, + ServiceOrderState, + ServiceOrderType, +) +from srm.domain.models.topology.enums import ( + CapabilityKind, + CapabilityState, + ControlPathBindingState, + DomainKind, + DomainState, + ZoneKind, + ZoneState, +) + + +def get_metadata() -> MetaData: + return Base.metadata + + +def _enum_type(enum_cls: type[PyEnum]) -> Enum: + return Enum( + enum_cls, + values_callable=lambda obj: [e.value for e in obj], + create_constraint=True, + native_enum=False, + length=45, + ) + + +class Base(AsyncAttrs, DeclarativeBase): + __abstract__ = True + + +class IdentifiedMixin: + id: Mapped[UUID] = mapped_column( + PG_UUID(as_uuid=True), + primary_key=True, + server_default=text("gen_random_uuid()"), + ) + + +class AuditedMixin(IdentifiedMixin): + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + onupdate=func.now(), + ) + + +class TerminatableMixin(AuditedMixin): + terminated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class ZoneRow(AuditedMixin, Base): + __tablename__ = "zone" + + platform_ref: Mapped[str | None] = mapped_column(String(255)) + ref: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + kind: Mapped[ZoneKind] = mapped_column(_enum_type(ZoneKind), nullable=False) + state: Mapped[ZoneState] = mapped_column(_enum_type(ZoneState), nullable=False) + zone_metadata: Mapped[dict[str, object]] = mapped_column( + "metadata", JSONB, nullable=False, default=dict + ) + + domains: Mapped[list["DomainRow"]] = relationship() + + +class DomainRow(AuditedMixin, Base): + __tablename__ = "domain" + __table_args__ = ( + UniqueConstraint("zone_id", "ref", name="uq_domain_zone_ref"), + Index("idx_domain_kind", "kind"), + ) + + zone_id: Mapped[UUID] = mapped_column(ForeignKey("zone.id"), nullable=False) + ref: Mapped[str] = mapped_column(String(255), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + kind: Mapped[DomainKind] = mapped_column(_enum_type(DomainKind), nullable=False) + state: Mapped[DomainState] = mapped_column(_enum_type(DomainState), nullable=False) + domain_metadata: Mapped[dict[str, object]] = mapped_column( + "metadata", JSONB, nullable=False, default=dict + ) + + capabilities: Mapped[list["CapabilityRow"]] = relationship() + + +class CapabilityRow(AuditedMixin, Base): + __tablename__ = "capability" + __table_args__ = ( + UniqueConstraint("domain_id", "ref", name="uq_capability_domain_ref"), + Index("idx_capability_kind", "kind"), + ) + + domain_id: Mapped[UUID] = mapped_column(ForeignKey("domain.id"), nullable=False) + ref: Mapped[str] = mapped_column(String(255), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + kind: Mapped[CapabilityKind] = mapped_column(_enum_type(CapabilityKind), nullable=False) + state: Mapped[CapabilityState] = mapped_column(_enum_type(CapabilityState), nullable=False) + capability_metadata: Mapped[dict[str, object]] = mapped_column( + "metadata", JSONB, nullable=False, default=dict + ) + + control_path_bindings: Mapped[list["ControlPathBindingRow"]] = relationship() + + +class ControlPathBindingRow(AuditedMixin, Base): + __tablename__ = "control_path_binding" + __table_args__ = ( + UniqueConstraint("capability_id", "ref", name="uq_cpb_capability_ref"), + UniqueConstraint("capability_id", "control_path_ref", name="uq_cpb_capability_cpref"), + Index("idx_cpb_capability", "capability_id"), + Index("idx_cpb_control_path_ref", "control_path_ref"), + Index( + "idx_cpb_supported_runtime_kinds_gin", + "supported_runtime_kinds", + postgresql_using="gin", + ), + Index("idx_cpb_supported_actions_gin", "supported_actions", postgresql_using="gin"), + ) + + capability_id: Mapped[UUID] = mapped_column(ForeignKey("capability.id"), nullable=False) + ref: Mapped[str] = mapped_column(String(255), nullable=False) + control_path_ref: Mapped[str] = mapped_column(String(255), nullable=False) + supported_runtime_kinds: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list) + supported_actions: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list) + priority: Mapped[int] = mapped_column(Integer, nullable=False, default=100) + is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + state: Mapped[ControlPathBindingState] = mapped_column( + _enum_type(ControlPathBindingState), nullable=False + ) + control_path_metadata: Mapped[dict[str, object]] = mapped_column( + "metadata", JSONB, nullable=False, default=dict + ) + + +class ServiceSpecificationRow(AuditedMixin, Base): + __tablename__ = "service_specification" + __table_args__ = ( + UniqueConstraint( + "app_provider_id", "ref", "version", name="uq_svcspec_provider_ref_version" + ), + Index("idx_svcspec_app_provider", "app_provider_id"), + ) + + app_provider_id: Mapped[str] = mapped_column(String(255), nullable=False) + ref: Mapped[str] = mapped_column(String(255), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + version: Mapped[str] = mapped_column(String(80), nullable=False) + state: Mapped[ServiceSpecificationState] = mapped_column( + _enum_type(ServiceSpecificationState), nullable=False + ) + descriptor: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False, default=dict) + service_spec_metadata: Mapped[dict[str, object]] = mapped_column( + "metadata", JSONB, nullable=False, default=dict + ) + + deployment_units: Mapped[list["ServiceDeploymentUnitRow"]] = relationship() + capability_requirements: Mapped[list["ServiceCapabilityRequirementRow"]] = relationship() + + +class ServiceDeploymentUnitRow(AuditedMixin, Base): + __tablename__ = "service_deployment_unit" + __table_args__ = ( + UniqueConstraint("service_specification_id", "ref", name="uq_svcdu_svcspec_ref"), + ) + + service_specification_id: Mapped[UUID] = mapped_column( + ForeignKey("service_specification.id"), + nullable=False, + ) + ref: Mapped[str] = mapped_column(String(255), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + runtime_kind: Mapped[RuntimeKind] = mapped_column(_enum_type(RuntimeKind), nullable=False) + artifact_ref: Mapped[str | None] = mapped_column(String(1024)) + resource_requirements: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False) + parameters_schema: Mapped[dict[str, object]] = mapped_column( + JSONB, nullable=False, default=dict + ) + deployment_unit_metadata: Mapped[dict[str, object]] = mapped_column( + "metadata", JSONB, nullable=False, default=dict + ) + + +class ServiceCapabilityRequirementRow(AuditedMixin, Base): + __tablename__ = "service_capability_requirement" + __table_args__ = ( + UniqueConstraint("service_specification_id", "ref", name="uq_svccr_svcspec_ref"), + ) + + service_specification_id: Mapped[UUID] = mapped_column( + ForeignKey("service_specification.id"), + nullable=False, + ) + deployment_unit_id: Mapped[UUID | None] = mapped_column( + ForeignKey("service_deployment_unit.id") + ) + ref: Mapped[str] = mapped_column(String(255), nullable=False) + capability_kind: Mapped[CapabilityKind] = mapped_column( + _enum_type(CapabilityKind), nullable=False + ) + domain_kind: Mapped[DomainKind | None] = mapped_column(_enum_type(DomainKind)) + is_required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + selector: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False, default=dict) + parameters: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False) + policy: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False, default=dict) + capability_requirement_metadata: Mapped[dict[str, object]] = mapped_column( + "metadata", JSONB, nullable=False, default=dict + ) + + +class ServiceOrderRow(AuditedMixin, Base): + __tablename__ = "service_order" + __table_args__ = ( + Index("idx_so_state", "state"), + Index("idx_so_type", "order_type"), + Index("idx_so_app_provider", "app_provider_id"), + Index("idx_so_spec", "service_specification_id"), + ) + + operation_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), nullable=False, unique=True) + correlation_id: Mapped[str | None] = mapped_column(String(255)) + source_component: Mapped[str | None] = mapped_column(String(80)) + source_protocol: Mapped[str | None] = mapped_column(String(80)) + order_type: Mapped[ServiceOrderType] = mapped_column( + _enum_type(ServiceOrderType), nullable=False + ) + service_specification_id: Mapped[UUID | None] = mapped_column( + ForeignKey("service_specification.id"), + ) + target_service_instance_id: Mapped[UUID | None] = mapped_column( + ForeignKey("service_instance.id") + ) + state: Mapped[ServiceOrderState] = mapped_column(_enum_type(ServiceOrderState), nullable=False) + payload_snapshot: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False, default=dict) + failure_detail: Mapped[dict[str, object] | None] = mapped_column(JSONB) + app_provider_id: Mapped[str] = mapped_column(String(255), nullable=False) + federation_partner_ref: Mapped[str | None] = mapped_column(String(255)) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class ServiceInstanceRow(TerminatableMixin, Base): + __tablename__ = "service_instance" + __table_args__ = ( + Index("idx_si_spec", "service_specification_id"), + Index("idx_si_app_provider", "app_provider_id"), + Index("idx_si_zone", "zone_id"), + Index("idx_si_state", "state"), + Index("idx_si_originating_order", "originating_service_order_id"), + Index( + "uq_si_name_per_zone", + "app_provider_id", + "ref", + "zone_id", + unique=True, + postgresql_where=text("state NOT IN ('terminated','failed')"), + ), + ) + + service_specification_id: Mapped[UUID] = mapped_column( + ForeignKey("service_specification.id"), + nullable=False, + ) + zone_id: Mapped[UUID] = mapped_column(ForeignKey("zone.id"), nullable=False) + originating_service_order_id: Mapped[UUID | None] = mapped_column( + ForeignKey("service_order.id") + ) + + ref: Mapped[str] = mapped_column(String(255), nullable=False) + state: Mapped[ServiceInstanceState] = mapped_column( + _enum_type(ServiceInstanceState), nullable=False + ) + service_instance_metadata: Mapped[dict[str, object]] = mapped_column( + "metadata", JSONB, nullable=False, default=dict + ) + app_provider_id: Mapped[str] = mapped_column(String(255), nullable=False) + federation_partner_ref: Mapped[str | None] = mapped_column(String(255)) + + originating_service_order: Mapped["ServiceOrderRow | None"] = relationship( + foreign_keys=[ + originating_service_order_id, + ] + ) + capability_instances: Mapped[list["CapabilityInstanceRow"]] = relationship() + + +class CapabilityInstanceRow(TerminatableMixin, Base): + __tablename__ = "capability_instance" + __table_args__ = ( + UniqueConstraint("service_instance_id", "ref", name="uq_ci_si_ref"), + Index("idx_ci_service_instance", "service_instance_id"), + Index("idx_ci_capability", "capability_id"), + Index("idx_ci_cpb", "control_path_binding_id"), + Index("idx_ci_kind", "kind"), + Index("idx_ci_state", "state"), + Index("idx_ci_external_id", "external_id"), + Index("idx_ci_external_ref", "external_ref"), + Index("idx_ci_app_provider", "app_provider_id"), + Index( + "uq_ci_external_ref", + "app_provider_id", + "external_ref", + unique=True, + postgresql_where=text( + "external_ref IS NOT NULL AND state NOT IN ('terminated','failed')" + ), + ), + ) + + service_instance_id: Mapped[UUID] = mapped_column( + ForeignKey("service_instance.id"), nullable=False + ) + originating_service_order_id: Mapped[UUID | None] = mapped_column( + ForeignKey("service_order.id") + ) + service_capability_requirement_id: Mapped[UUID | None] = mapped_column( + ForeignKey("service_capability_requirement.id"), + ) + capability_id: Mapped[UUID] = mapped_column(ForeignKey("capability.id"), nullable=False) + control_path_binding_id: Mapped[UUID | None] = mapped_column( + ForeignKey("control_path_binding.id"), + ) + control_path_ref_snapshot: Mapped[str | None] = mapped_column(String(255)) + ref: Mapped[str] = mapped_column(String(255), nullable=False) + kind: Mapped[CapabilityInstanceKind] = mapped_column( + _enum_type(CapabilityInstanceKind), nullable=False + ) + state: Mapped[CapabilityInstanceState] = mapped_column( + _enum_type(CapabilityInstanceState), nullable=False + ) + external_id: Mapped[str | None] = mapped_column(String(1024)) + external_ref: Mapped[str | None] = mapped_column(String(255)) + parameters_snapshot: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False) + result_summary: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False) + failure_detail: Mapped[dict[str, object] | None] = mapped_column(JSONB) + capability_instance_metadata: Mapped[dict[str, object]] = mapped_column( + "metadata", JSONB, nullable=False, default=dict + ) + app_provider_id: Mapped[str] = mapped_column(String(255), nullable=False) + federation_partner_ref: Mapped[str | None] = mapped_column(String(255)) diff --git a/src/srm/adapters/databus/nats_connection_manager.py b/src/srm/adapters/databus/nats_connection_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..47693e0af5135150b1f3b45caeae2bfeac53d5fd --- /dev/null +++ b/src/srm/adapters/databus/nats_connection_manager.py @@ -0,0 +1,76 @@ +import asyncio + +import nats +import structlog +from nats.aio.client import Client + +from srm.config import NatsSettings + +logger: structlog.BoundLogger = structlog.get_logger(__name__) + + +async def init_databus_manager(settings: NatsSettings) -> "NatsConnectionManager": + connection_manager: NatsConnectionManager = NatsConnectionManager(settings=settings) + await connection_manager.connect() + return connection_manager + + +class NatsConnectionManager: + def __init__(self, settings: NatsSettings) -> None: + self._settings = settings + self._client: Client | None = None + self._connect_lock = asyncio.Lock() + + @property + def is_connected(self) -> bool: + return self._client is not None and self._client.is_connected + + @property + def client(self) -> Client: + if self._client is None: + raise RuntimeError("NATS client is not connected") + return self._client + + async def connect(self) -> None: + async def _on_error(e: Exception) -> None: + logger.error("nats_error", error=str(e)) + + async def _on_disconnect() -> None: + logger.warning("nats_disconnected", url=self._settings.url) + + async def _on_reconnect() -> None: + logger.info("nats_reconnected", url=self._settings.url) + + async with self._connect_lock: + if self.is_connected: + logger.info("nats_already_connected") + return + + try: + self._client = await nats.connect( + servers=[self._settings.url], + connect_timeout=self._settings.connect_timeout, + max_reconnect_attempts=self._settings.max_reconnect_attempts, + drain_timeout=self._settings.drain_timeout, + error_cb=_on_error, + disconnected_cb=_on_disconnect, + reconnected_cb=_on_reconnect, + ) + except Exception as e: + logger.error("nats_error", error=str(e)) + raise + + async def close(self) -> None: + if self._client is None: + return + + client = self._client + try: + # drain() is bounded by the drain_timeout passed to connect(); on timeout it + # reports through error_cb and closes anyway, so shutdown cannot hang here. + await client.drain() + except Exception as e: + logger.warning("nats_drain_failed", error=str(e)) + await client.close() + finally: + self._client = None diff --git a/src/srm/adapters/databus/nats_publisher.py b/src/srm/adapters/databus/nats_publisher.py new file mode 100644 index 0000000000000000000000000000000000000000..c03ec677e45aa6e385acb956f259d9ecd6ddcaaf --- /dev/null +++ b/src/srm/adapters/databus/nats_publisher.py @@ -0,0 +1,20 @@ +import json +from typing import Any + +from srm.adapters.databus.nats_connection_manager import NatsConnectionManager +from srm.domain.ports.databus.publisher import DataBusPublisher + + +class NatsPublisher(DataBusPublisher): + def __init__(self, connection_manager: NatsConnectionManager) -> None: + self._connection_manager = connection_manager + + async def publish( + self, + subject: str, + payload: dict[str, Any], + headers: dict[str, str] | None = None, + ) -> None: + + body = json.dumps(payload).encode("utf-8") + await self._connection_manager.client.publish(subject, body, headers=headers) diff --git a/src/srm/adapters/errors.py b/src/srm/adapters/errors.py new file mode 100644 index 0000000000000000000000000000000000000000..cb3e5d1769cc7f4a46f9c16e61f1a39390ea7d8f --- /dev/null +++ b/src/srm/adapters/errors.py @@ -0,0 +1,12 @@ +from typing import ClassVar + + +class DuplicateEntryError(Exception): + entity_name: ClassVar[str] = "Entity" + + def __init__(self) -> None: + super().__init__(f"{self.entity_name} already exists.") + + +class DuplicateServiceSpecificationError(DuplicateEntryError): + entity_name: ClassVar[str] = "Service Specification" diff --git a/src/srm/api/context.py b/src/srm/api/context.py new file mode 100644 index 0000000000000000000000000000000000000000..199cb39da4d9f6caa5f68236cb8d833a8d27e842 --- /dev/null +++ b/src/srm/api/context.py @@ -0,0 +1,3 @@ +from contextvars import ContextVar + +originating_request_id: ContextVar[str] = ContextVar("originating_request_id", default="") diff --git a/src/srm/api/databus/nats_subscriber.py b/src/srm/api/databus/nats_subscriber.py new file mode 100644 index 0000000000000000000000000000000000000000..f1d2d705251ecd320b05265cb419df20d06f5966 --- /dev/null +++ b/src/srm/api/databus/nats_subscriber.py @@ -0,0 +1,69 @@ +from collections.abc import Awaitable, Callable + +import structlog +from nats.aio.msg import Msg +from nats.aio.subscription import Subscription + +from srm.adapters.databus.nats_connection_manager import NatsConnectionManager +from srm.api.databus.schemas import InboundMessage + +logger: structlog.BoundLogger = structlog.get_logger(__name__) + +COMMAND_SUBJECTS = [ + "command.srm.service.deploy", + "command.srm.service.scale", + "command.srm.service.terminate", + "command.srm.network.capability.activate", + "command.srm.network.capability.update", + "command.srm.network.capability.deactivate", +] + + +async def _noop_router(message: InboundMessage) -> None: + # TODO: the real router must validate in two stages (interface-contract.md §A, ADR-0032). + logger.info("Message Received", subject=message.subject, data=message.payload) + return None + + +async def subscribe_to_subjects( + connection_manager: NatsConnectionManager, +) -> list["NatsSubscriber"]: + subscribers = [ + NatsSubscriber(connection_manager=connection_manager, subject=subject, router=_noop_router) + for subject in COMMAND_SUBJECTS + ] + + for subscriber in subscribers: + await subscriber.start() + + return subscribers + + +class NatsSubscriber: + def __init__( + self, + connection_manager: NatsConnectionManager, + subject: str, + router: Callable[[InboundMessage], Awaitable[None]], + ) -> None: + self._connection_manager = connection_manager + self._subject = subject + self._router = router + self._subscription: Subscription | None = None + + async def start(self) -> None: + self._subscription = await self._connection_manager.client.subscribe( + self._subject, + cb=self._handle_message, + ) + + async def _handle_message(self, msg: Msg) -> None: + inbound_message = InboundMessage( + subject=msg.subject, + payload=msg.data, + headers=dict(msg.headers) if msg.headers is not None else {}, + ) + try: + await self._router(inbound_message) + except Exception: + logger.exception("databus_handler_failed", subject=msg.subject) diff --git a/src/srm/api/databus/schemas.py b/src/srm/api/databus/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..6dbff88643760e5ab98f961fc3615cf3619c3be9 --- /dev/null +++ b/src/srm/api/databus/schemas.py @@ -0,0 +1,145 @@ +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from srm.domain.models.canonical_parameters.parameters import ( + CapabilityParameters, + CapabilityTarget, +) + + +class CommandSchema(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class InboundMessage(BaseModel): + subject: str + payload: bytes + headers: dict[str, str] + + +class CommandEnvelopeV1(CommandSchema): + schema_version: Literal["1.0"] + operation_id: UUID + correlation_id: str + requested_at: datetime + app_provider_id: str + federation_partner_ref: str | None = None + source: Literal["nbi_camara", "nbi_tmf", "operator_portal", "federation"] + + @model_validator(mode="after") + def validate_federation_context(self) -> "CommandEnvelopeV1": + if self.source == "federation" and self.federation_partner_ref is None: + raise ValueError("federation_partner_ref is required when source=federation") + + return self + + +class PlacementConstraintsV1(CommandSchema): + model_config = ConfigDict(extra="allow") + + +class DeployPayloadV1(CommandSchema): + instance_name: str | None = None + placement_constraints: PlacementConstraintsV1 | None = None + + +class DeployTargetV1(CommandSchema): + app_instance_id: UUID + zone_id: UUID | None = None + domain_id: UUID | None = None + + @model_validator(mode="after") + def validate_pin_shape(self) -> "DeployTargetV1": + if self.domain_id is not None and self.zone_id is None: + raise ValueError("domain_id requires zone_id: a domain pin must name its zone") + + return self + + +class SrmServiceDeployV1(CommandEnvelopeV1): + service_specification_id: UUID + targets: list[DeployTargetV1] = Field(min_length=1) + deploy: DeployPayloadV1 + + +class ScalePayloadV1(CommandSchema): + replicas: int = Field(ge=0) + + +class SrmServiceScaleV1(CommandEnvelopeV1): + service_instance_id: UUID + service_specification_id: UUID | None = None + scale: ScalePayloadV1 + + +class TerminatePayloadV1(CommandSchema): + grace_period_seconds: int = Field(default=0, ge=0) + + +class SrmServiceTerminateV1(CommandEnvelopeV1): + service_instance_id: UUID + service_specification_id: UUID | None = None + terminate: TerminatePayloadV1 + + +class NetworkCapabilityPayloadV1(CommandSchema): + capability_type: str + target: CapabilityTarget + profile_ref: str | None = None + parameters: CapabilityParameters + + +class SrmNetworkCapabilityActivateV1(CommandEnvelopeV1): + service_specification_id: UUID + zone_id: UUID | None = None + domain_id: UUID | None = None + network_capability: NetworkCapabilityPayloadV1 + + @model_validator(mode="after") + def validate_pin_shape(self) -> "SrmNetworkCapabilityActivateV1": + if self.domain_id is not None and self.zone_id is None: + raise ValueError("domain_id requires zone_id: a domain pin must name its zone") + + return self + + +class NetworkCapabilityRealizationRefV1(CommandSchema): + capability_type: str + external_ref: str | None = None + service_instance_id: UUID | None = None + + @model_validator(mode="after") + def validate_reference_shape(self) -> "NetworkCapabilityRealizationRefV1": + has_external_ref = self.external_ref is not None + has_service_instance_id = self.service_instance_id is not None + + if has_external_ref == has_service_instance_id: + raise ValueError( + "network capability realization must be identified by exactly one of " + "external_ref or service_instance_id" + ) + + return self + + +class NetworkCapabilityUpdatePayloadV1(NetworkCapabilityRealizationRefV1): + target: CapabilityTarget | None = None + profile_ref: str | None = None + parameters: CapabilityParameters + + +class SrmNetworkCapabilityUpdateV1(CommandEnvelopeV1): + service_specification_id: UUID | None = None + network_capability: NetworkCapabilityUpdatePayloadV1 + + +class NetworkCapabilityDeactivatePayloadV1(NetworkCapabilityRealizationRefV1): + grace_period_seconds: int = Field(default=0, ge=0) + + +class SrmNetworkCapabilityDeactivateV1(CommandEnvelopeV1): + service_specification_id: UUID | None = None + network_capability: NetworkCapabilityDeactivatePayloadV1 diff --git a/src/srm/api/dependencies.py b/src/srm/api/dependencies.py new file mode 100644 index 0000000000000000000000000000000000000000..20b8b7d78139d6c967bbda8a614746c104ee5bdb --- /dev/null +++ b/src/srm/api/dependencies.py @@ -0,0 +1,23 @@ +from typing import Annotated, AsyncGenerator, cast + +from fastapi import Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from srm.app_state import AppState + + +def get_app_state(request: Request) -> AppState: + return cast(AppState, request.app.state) + + +async def get_session(request: Request) -> AsyncGenerator[AsyncSession, None]: + async with get_app_state(request=request).session_maker() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + +SessionDep = Annotated[AsyncSession, Depends(get_session)] diff --git a/src/srm/api/health.py b/src/srm/api/health.py new file mode 100644 index 0000000000000000000000000000000000000000..003d1e34452bbcb4cc8e3df65039b204d29655b7 --- /dev/null +++ b/src/srm/api/health.py @@ -0,0 +1,27 @@ +from fastapi import APIRouter, HTTPException, Request +from sqlalchemy import text + +from srm.api.dependencies import get_app_state + +health_router = APIRouter(tags=["Platform Health"]) + + +@health_router.get("/healthz") +async def liveness() -> bool: + return True + + +@health_router.get("/readyz") +async def readiness(request: Request) -> bool: + app_state = get_app_state(request) + + try: + async with app_state.db_engine.connect() as connection: + await connection.execute(text("SELECT 1")) + except Exception as exc: + raise HTTPException(status_code=503, detail="Postgres not ready") from exc + + if not app_state.databus_connection_manager.is_connected: + raise HTTPException(status_code=503, detail="NATS not ready") + + return True diff --git a/src/srm/api/middlewares/logging.py b/src/srm/api/middlewares/logging.py new file mode 100644 index 0000000000000000000000000000000000000000..a5a6e0196a6c1ef2e7aba8f5c6cf9d021756c097 --- /dev/null +++ b/src/srm/api/middlewares/logging.py @@ -0,0 +1,36 @@ +from time import time + +import structlog +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import Response +from structlog.contextvars import bind_contextvars, clear_contextvars + +logger: structlog.BoundLogger = structlog.get_logger(__name__) + + +class LoggingMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + clear_contextvars() + bind_contextvars( + request_id=request.headers.get("x-request-id", ""), + http_method=request.method, + path=request.url.path, + client=request.client.host if request.client else None, + ) + + logger.info("Request Received") + + time_start: float = time() + response: Response | None = None + try: + response = await call_next(request) + finally: + duration = time() - time_start + logger.info( + "Request Processed", + duration=duration, + status_code=getattr(response, "status_code", None), + ) + + return response diff --git a/src/srm/api/middlewares/middlewares.py b/src/srm/api/middlewares/middlewares.py new file mode 100644 index 0000000000000000000000000000000000000000..07531b40f14256a09208b1baa92fd0a2fb4a7908 --- /dev/null +++ b/src/srm/api/middlewares/middlewares.py @@ -0,0 +1,13 @@ +from fastapi import FastAPI + +from srm.api.middlewares.logging import LoggingMiddleware +from srm.api.middlewares.request_id import RequestIdMiddleware + + +def register_middlewares(app: FastAPI) -> None: + # Order matters. Request ID Middleware should be running first (added last) in most cases + # Middleware execution order: + # Request: last added → first added → route + # Response: route → first added → last added + app.add_middleware(LoggingMiddleware) + app.add_middleware(RequestIdMiddleware) diff --git a/src/srm/api/middlewares/request_id.py b/src/srm/api/middlewares/request_id.py new file mode 100644 index 0000000000000000000000000000000000000000..cc09819736658c834ed52cbf9a06c6d55fa9734b --- /dev/null +++ b/src/srm/api/middlewares/request_id.py @@ -0,0 +1,41 @@ +from uuid import UUID, uuid4 + +from starlette.datastructures import MutableHeaders +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import Response + +from srm.api.context import originating_request_id + + +class RequestIdMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + + headers: MutableHeaders = MutableHeaders(scope=request.scope) + header_name: str = "X-Request-ID" + request_id: str | None = headers.get(header_name) + + if request_id is None: + request_id = str(uuid4()) + headers[header_name] = request_id + else: + valid_request_id: bool + # Check if request_id is valid. Value may be defined outside of our system + try: + valid_request_id = UUID(request_id).version == 4 + except ValueError: + valid_request_id = False + + if not valid_request_id: + request_id = str(uuid4()) + headers[header_name] = request_id + + token = originating_request_id.set(request_id) + + try: + response: Response = await call_next(request) + finally: + originating_request_id.reset(token) + response.headers[header_name] = request_id + + return response diff --git a/service-resource-manager-implementation/src/__init__.py b/src/srm/api/rest/.gitkeep similarity index 100% rename from service-resource-manager-implementation/src/__init__.py rename to src/srm/api/rest/.gitkeep diff --git a/src/srm/app_state.py b/src/srm/app_state.py new file mode 100644 index 0000000000000000000000000000000000000000..799d9cbc14eca4aa990e15d8a86c3b6c04cf4445 --- /dev/null +++ b/src/srm/app_state.py @@ -0,0 +1,13 @@ +from typing import Protocol + +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +from srm.adapters.databus.nats_connection_manager import NatsConnectionManager +from srm.api.databus.nats_subscriber import NatsSubscriber + + +class AppState(Protocol): + db_engine: AsyncEngine + session_maker: async_sessionmaker[AsyncSession] + databus_connection_manager: NatsConnectionManager + databus_subscribers: list[NatsSubscriber] diff --git a/service-resource-manager-implementation/src/utils/__init__.py b/src/srm/application/__init__.py similarity index 100% rename from service-resource-manager-implementation/src/utils/__init__.py rename to src/srm/application/__init__.py diff --git a/service-resource-manager-implementation/src/services/network_function_service.py b/src/srm/application/use_cases/__init__.py similarity index 100% rename from service-resource-manager-implementation/src/services/network_function_service.py rename to src/srm/application/use_cases/__init__.py diff --git a/src/srm/config.py b/src/srm/config.py new file mode 100644 index 0000000000000000000000000000000000000000..20a4d87a63a9f7703d0fd02b406358492bfaa588 --- /dev/null +++ b/src/srm/config.py @@ -0,0 +1,54 @@ +""" +Configuration module for the microservice. + +Centralizes application settings, environment variables, +and configuration defaults. + +Responsibilities: +- Load configuration from environment variables, files, or secrets manager +- Validate configuration values +- Provide typed, centralized access to settings across all layers +- Support different environments (development, testing, production) + +Usage: +- Other modules should import from this module rather than reading + environment variables directly. +- Avoid embedding business logic here; focus solely on configuration. +""" + +from functools import lru_cache +from typing import Literal + +from pydantic import BaseModel +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class PostgreSQLSettings(BaseModel): + url: str + echo: bool | Literal["debug"] + create_schema_on_startup: bool + + +class NatsSettings(BaseModel): + url: str + connect_timeout: int + max_reconnect_attempts: int + drain_timeout: int = 30 + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_nested_delimiter="__") + + app_name: str + app_version: str + app_description: str + + postgres_settings: PostgreSQLSettings + nats_settings: NatsSettings + + +@lru_cache() +def get_settings() -> Settings: + # This was added because auth_settings are being loaded at runtime + # so mypy thinks there is an error + return Settings() # type: ignore [call-arg] diff --git a/src/srm/domain/__init__.py b/src/srm/domain/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c52b344236b368876683a3df2315214d7c192c51 --- /dev/null +++ b/src/srm/domain/__init__.py @@ -0,0 +1,107 @@ +"""Domain layer public API.""" + +from srm.domain.models import ( + Accelerator, + AcceleratorType, + ApplicationReferenceTarget, + ApplicationServerTarget, + AuditedModel, + Capability, + CapabilityInstance, + CapabilityInstanceKind, + CapabilityInstanceState, + CapabilityKind, + CapabilityParameters, + CapabilityState, + CapabilityTarget, + ComputeRequirements, + ComputeResources, + ControlPathBinding, + ControlPathBindingState, + DeviceTarget, + Domain, + DomainKind, + DomainModel, + DomainState, + IdentifiedModel, + InterfaceVisibility, + NetworkInterface, + Parameters, + Result, + ResultStatus, + RuntimeKind, + ServiceCapabilityRequirement, + ServiceDeploymentUnit, + ServiceEndpoint, + ServiceInstance, + ServiceInstanceState, + ServiceOrder, + ServiceOrderState, + ServiceOrderType, + ServiceSpecification, + ServiceSpecificationState, + SourceSpecification, + SourceSpecificationFamily, + StorageVolume, + TerminatableModel, + TopologyConstraints, + TrafficEndpoint, + TrafficFilters, + TransportProtocol, + Zone, + ZoneKind, + ZoneState, +) + +__all__ = [ + "Accelerator", + "AcceleratorType", + "ApplicationReferenceTarget", + "ApplicationServerTarget", + "AuditedModel", + "Capability", + "CapabilityInstance", + "CapabilityInstanceKind", + "CapabilityInstanceState", + "CapabilityKind", + "CapabilityParameters", + "CapabilityState", + "CapabilityTarget", + "ComputeRequirements", + "ComputeResources", + "ControlPathBinding", + "ControlPathBindingState", + "DeviceTarget", + "Domain", + "DomainKind", + "DomainModel", + "DomainState", + "IdentifiedModel", + "InterfaceVisibility", + "NetworkInterface", + "Parameters", + "Result", + "ResultStatus", + "RuntimeKind", + "ServiceCapabilityRequirement", + "ServiceDeploymentUnit", + "ServiceEndpoint", + "ServiceInstance", + "ServiceInstanceState", + "ServiceOrder", + "ServiceOrderState", + "ServiceOrderType", + "ServiceSpecification", + "ServiceSpecificationState", + "SourceSpecification", + "SourceSpecificationFamily", + "StorageVolume", + "TerminatableModel", + "TopologyConstraints", + "TrafficEndpoint", + "TrafficFilters", + "TransportProtocol", + "Zone", + "ZoneKind", + "ZoneState", +] diff --git a/src/srm/domain/models/__init__.py b/src/srm/domain/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5e91f331bc5dc51a51d44d7fd4fe25332e09808b --- /dev/null +++ b/src/srm/domain/models/__init__.py @@ -0,0 +1,115 @@ +"""Domain model public API.""" + +from srm.domain.models.canonical_parameters import ( + Accelerator, + AcceleratorType, + ApplicationReferenceTarget, + ApplicationServerTarget, + CapabilityParameters, + CapabilityTarget, + ComputeRequirements, + ComputeResources, + DeviceTarget, + InterfaceVisibility, + NetworkInterface, + Parameters, + Result, + ResultStatus, + ServiceEndpoint, + SourceSpecification, + SourceSpecificationFamily, + StorageVolume, + TopologyConstraints, + TrafficEndpoint, + TrafficFilters, +) +from srm.domain.models.catalog import ( + RuntimeKind, + ServiceCapabilityRequirement, + ServiceDeploymentUnit, + ServiceSpecification, + ServiceSpecificationState, +) +from srm.domain.models.common import ( + AuditedModel, + DomainModel, + IdentifiedModel, + TerminatableModel, + TransportProtocol, +) +from srm.domain.models.runtime_inventory import ( + CapabilityInstance, + CapabilityInstanceKind, + CapabilityInstanceState, + ServiceInstance, + ServiceInstanceState, + ServiceOrder, + ServiceOrderState, + ServiceOrderType, +) +from srm.domain.models.topology import ( + Capability, + CapabilityKind, + CapabilityState, + ControlPathBinding, + ControlPathBindingState, + Domain, + DomainKind, + DomainState, + Zone, + ZoneKind, + ZoneState, +) + +__all__ = [ + "Accelerator", + "AcceleratorType", + "ApplicationReferenceTarget", + "ApplicationServerTarget", + "AuditedModel", + "Capability", + "CapabilityInstance", + "CapabilityInstanceKind", + "CapabilityInstanceState", + "CapabilityKind", + "CapabilityParameters", + "CapabilityState", + "CapabilityTarget", + "ComputeRequirements", + "ComputeResources", + "ControlPathBinding", + "ControlPathBindingState", + "DeviceTarget", + "Domain", + "DomainKind", + "DomainModel", + "DomainState", + "IdentifiedModel", + "InterfaceVisibility", + "NetworkInterface", + "Parameters", + "Result", + "ResultStatus", + "RuntimeKind", + "ServiceCapabilityRequirement", + "ServiceDeploymentUnit", + "ServiceEndpoint", + "ServiceInstance", + "ServiceInstanceState", + "ServiceOrder", + "ServiceOrderState", + "ServiceOrderType", + "ServiceSpecification", + "ServiceSpecificationState", + "SourceSpecification", + "SourceSpecificationFamily", + "StorageVolume", + "TerminatableModel", + "TopologyConstraints", + "TrafficEndpoint", + "TrafficFilters", + "TransportProtocol", + "Zone", + "ZoneKind", + "ZoneState", +] diff --git a/src/srm/domain/models/canonical_parameters/__init__.py b/src/srm/domain/models/canonical_parameters/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8195557006652f5e37ec4614caa334f1788bfbc9 --- /dev/null +++ b/src/srm/domain/models/canonical_parameters/__init__.py @@ -0,0 +1,49 @@ +"""Canonical parameter and result domain models.""" + +from srm.domain.models.canonical_parameters.compute import ( + Accelerator, + AcceleratorType, + ComputeRequirements, + ComputeResources, + InterfaceVisibility, + NetworkInterface, + StorageVolume, + TopologyConstraints, +) +from srm.domain.models.canonical_parameters.parameters import ( + ApplicationReferenceTarget, + ApplicationServerTarget, + CapabilityParameters, + CapabilityTarget, + DeviceTarget, + Parameters, + SourceSpecification, + SourceSpecificationFamily, + TrafficEndpoint, + TrafficFilters, +) +from srm.domain.models.canonical_parameters.result import Result, ResultStatus, ServiceEndpoint + +__all__ = [ + "Accelerator", + "AcceleratorType", + "ApplicationReferenceTarget", + "ApplicationServerTarget", + "CapabilityParameters", + "CapabilityTarget", + "ComputeRequirements", + "ComputeResources", + "DeviceTarget", + "InterfaceVisibility", + "NetworkInterface", + "Parameters", + "Result", + "ResultStatus", + "ServiceEndpoint", + "SourceSpecification", + "SourceSpecificationFamily", + "StorageVolume", + "TopologyConstraints", + "TrafficEndpoint", + "TrafficFilters", +] diff --git a/src/srm/domain/models/canonical_parameters/compute.py b/src/srm/domain/models/canonical_parameters/compute.py new file mode 100644 index 0000000000000000000000000000000000000000..4a95454a3bcd8c8054eb9defe17d1307c72c242a --- /dev/null +++ b/src/srm/domain/models/canonical_parameters/compute.py @@ -0,0 +1,86 @@ +from enum import StrEnum + +from pydantic import Field + +from srm.domain.models.common import DomainModel, TransportProtocol + + +class AcceleratorType(StrEnum): + GPU = "gpu" + VPU = "vpu" + TPU = "tpu" + FPGA = "fpga" + + +class InterfaceVisibility(StrEnum): + EXTERNAL = "external" + INTERNAL = "internal" + + +class Accelerator(DomainModel): + type: AcceleratorType + + units: int + + memory_mb: int + + +class StorageVolume(DomainModel): + name: str + + size_mb: int + + mount_point: str + + +class ComputeResources(DomainModel): + cpu_millicores: int + + memory_mb: int + + accelerator: Accelerator | None = None + + storage: list[StorageVolume] = Field(default_factory=list) + + +class TopologyConstraints(DomainModel): + min_nodes: int | None = None + + min_node_cpu_millicores: int | None = None + + min_node_memory_mb: int | None = None + + min_node_gpu_memory_mb: int | None = None + + +class NetworkInterface(DomainModel): + component: str + + interface_id: str + + protocol: TransportProtocol + + port: int + + visibility: InterfaceVisibility + + +class ComputeRequirements(DomainModel): + """ + Canonical SRM Compute Resource Intent. + + Schema: srm.compute/v1 + """ + + schema_version: str = Field( + default="srm.compute/v1", + frozen=True, + ) + + compute: ComputeResources + + topology: TopologyConstraints + + interfaces: list[NetworkInterface] = Field(default_factory=list) + + standalone: bool = False diff --git a/src/srm/domain/models/canonical_parameters/parameters.py b/src/srm/domain/models/canonical_parameters/parameters.py new file mode 100644 index 0000000000000000000000000000000000000000..b9c5f73a2b77def40cab2adb92b5cd1e05688715 --- /dev/null +++ b/src/srm/domain/models/canonical_parameters/parameters.py @@ -0,0 +1,81 @@ +from enum import StrEnum +from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network + +from pydantic import Field + +from srm.domain.models.common import DomainModel, TransportProtocol + + +class SourceSpecificationFamily(StrEnum): + CAMARA = "camara" + TMF = "tmf" + GSMA_OPG = "gsma_opg" + INTERNAL = "internal" + + +class DeviceTarget(DomainModel): + phone_number: str | None = None + ipv4: IPv4Address | None = None + ipv6: IPv6Address | None = None + network_access_id: str | None = None + + +class ApplicationServerTarget(DomainModel): + ipv4: IPv4Network | None = None + ipv6: IPv6Network | None = None + + +class ApplicationReferenceTarget(DomainModel): + service_specification_ref: str + external_app_id: str | None = None + + +class CapabilityTarget(DomainModel): + device: DeviceTarget | None = None + application_server: ApplicationServerTarget | None = None + application_reference: ApplicationReferenceTarget | None = None + + +class TrafficEndpoint(DomainModel): + port: int | None = None + protocol: TransportProtocol | None = None + + +class TrafficFilters(DomainModel): + source: TrafficEndpoint | None = None + destination: TrafficEndpoint | None = None + + +class CapabilityParameters(DomainModel): + profile_ref: str | None = None + + duration_seconds: int | None = None + + traffic_filters: TrafficFilters | None = None + + extra: dict[str, object] = Field(default_factory=dict) + + +class SourceSpecification(DomainModel): + family: SourceSpecificationFamily + + api: str + + version: str + + +class Parameters(DomainModel): + """ + Schema: srm.params/v1 + """ + + schema_version: str = Field( + default="srm.params/v1", + frozen=True, + ) + + target: CapabilityTarget + + parameters: CapabilityParameters + + source_spec: SourceSpecification diff --git a/src/srm/domain/models/canonical_parameters/result.py b/src/srm/domain/models/canonical_parameters/result.py new file mode 100644 index 0000000000000000000000000000000000000000..86932dc8f959a63d2f3bca4ec6d241317a24a802 --- /dev/null +++ b/src/srm/domain/models/canonical_parameters/result.py @@ -0,0 +1,42 @@ +from enum import StrEnum +from typing import Any + +from pydantic import Field + +from srm.domain.models.common import DomainModel + + +class ResultStatus(StrEnum): + ACTIVE = "active" + FAILED = "failed" + + +class ServiceEndpoint(DomainModel): + interface_id: str + + fqdn: str + + port: int + + +class Result(DomainModel): + """ + Canonical SRM Capability Result. + + Schema: srm.result/v1 + """ + + schema_version: str = Field( + default="srm.result/v1", + frozen=True, + ) + + status: ResultStatus + + endpoints: list[ServiceEndpoint] = Field(default_factory=list) + + # + # Capability-specific backend information. + # Stored for auditing only; SRM does not interpret these fields. + # + backend: dict[str, Any] = Field(default_factory=dict) diff --git a/src/srm/domain/models/catalog/__init__.py b/src/srm/domain/models/catalog/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3a37f7a5dcabf53e0e5f42d29c487a363848b5ce --- /dev/null +++ b/src/srm/domain/models/catalog/__init__.py @@ -0,0 +1,16 @@ +"""Catalog domain models.""" + +from srm.domain.models.catalog.enums import RuntimeKind, ServiceSpecificationState +from srm.domain.models.catalog.models import ( + ServiceCapabilityRequirement, + ServiceDeploymentUnit, + ServiceSpecification, +) + +__all__ = [ + "RuntimeKind", + "ServiceCapabilityRequirement", + "ServiceDeploymentUnit", + "ServiceSpecification", + "ServiceSpecificationState", +] diff --git a/src/srm/domain/models/catalog/enums.py b/src/srm/domain/models/catalog/enums.py new file mode 100644 index 0000000000000000000000000000000000000000..d294f77d14d420d9f08438ed9624a4b858d3e36d --- /dev/null +++ b/src/srm/domain/models/catalog/enums.py @@ -0,0 +1,17 @@ +from enum import StrEnum + + +class ServiceSpecificationState(StrEnum): + DRAFT = "draft" + ACTIVE = "active" + DEPRECATED = "deprecated" + RETIRED = "retired" + + +class RuntimeKind(StrEnum): + HELM = "helm" + K8S_MANIFEST = "k8s_manifest" + CONTAINER = "container" + VM = "vm" + FUNCTION = "function" + CUSTOM = "custom" diff --git a/src/srm/domain/models/catalog/models.py b/src/srm/domain/models/catalog/models.py new file mode 100644 index 0000000000000000000000000000000000000000..5530d0ec09abb9eedc79a61c8f2e3e25770378bb --- /dev/null +++ b/src/srm/domain/models/catalog/models.py @@ -0,0 +1,58 @@ +from typing import Any +from uuid import UUID + +from pydantic import Field + +from srm.domain.models.canonical_parameters.compute import ComputeRequirements +from srm.domain.models.canonical_parameters.parameters import Parameters +from srm.domain.models.catalog.enums import RuntimeKind, ServiceSpecificationState +from srm.domain.models.common import AuditedModel +from srm.domain.models.topology.enums import CapabilityKind, DomainKind + + +class ServiceDeploymentUnit(AuditedModel): + service_specification_id: UUID + ref: str + name: str + + runtime_kind: RuntimeKind + artifact_ref: str | None = None + + resource_requirements: ComputeRequirements + parameters_schema: dict[str, Any] = Field(default_factory=dict) + + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ServiceCapabilityRequirement(AuditedModel): + service_specification_id: UUID + deployment_unit_id: UUID | None = None + + ref: str + + capability_kind: CapabilityKind + domain_kind: DomainKind | None = None + + is_required: bool = True + + selector: dict[str, Any] = Field(default_factory=dict) + parameters: Parameters + policy: dict[str, Any] = Field(default_factory=dict) + + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ServiceSpecification(AuditedModel): + app_provider_id: str + + ref: str + name: str + version: str + + state: ServiceSpecificationState = ServiceSpecificationState.ACTIVE + + descriptor: dict[str, Any] = Field(default_factory=dict) + metadata: dict[str, Any] = Field(default_factory=dict) + + deployment_units: list[ServiceDeploymentUnit] = Field(default_factory=list) + capability_requirements: list[ServiceCapabilityRequirement] = Field(default_factory=list) diff --git a/src/srm/domain/models/common.py b/src/srm/domain/models/common.py new file mode 100644 index 0000000000000000000000000000000000000000..b36b59a603f72b3b41fe6fe558126a0c0a39b16b --- /dev/null +++ b/src/srm/domain/models/common.py @@ -0,0 +1,31 @@ +from datetime import datetime +from enum import StrEnum +from uuid import UUID, uuid4 + +from pydantic import BaseModel, ConfigDict, Field + + +class DomainModel(BaseModel): + model_config = ConfigDict( + from_attributes=True, + validate_assignment=True, + extra="forbid", + ) + + +class TransportProtocol(StrEnum): + TCP = "TCP" + UDP = "UDP" + + +class IdentifiedModel(DomainModel): + id: UUID = Field(default_factory=uuid4) + + +class AuditedModel(IdentifiedModel): + created_at: datetime | None = None + updated_at: datetime | None = None + + +class TerminatableModel(AuditedModel): + terminated_at: datetime | None = None diff --git a/src/srm/domain/models/runtime_inventory/__init__.py b/src/srm/domain/models/runtime_inventory/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2b07722077419b7f265a01df0d8715acb97d5882 --- /dev/null +++ b/src/srm/domain/models/runtime_inventory/__init__.py @@ -0,0 +1,25 @@ +"""Runtime inventory domain models.""" + +from srm.domain.models.runtime_inventory.enums import ( + CapabilityInstanceKind, + CapabilityInstanceState, + ServiceInstanceState, + ServiceOrderState, + ServiceOrderType, +) +from srm.domain.models.runtime_inventory.models import ( + CapabilityInstance, + ServiceInstance, + ServiceOrder, +) + +__all__ = [ + "CapabilityInstance", + "CapabilityInstanceKind", + "CapabilityInstanceState", + "ServiceInstance", + "ServiceInstanceState", + "ServiceOrder", + "ServiceOrderState", + "ServiceOrderType", +] diff --git a/src/srm/domain/models/runtime_inventory/enums.py b/src/srm/domain/models/runtime_inventory/enums.py new file mode 100644 index 0000000000000000000000000000000000000000..d4c25c3d27fdabc9ba0197b10c4a4a695d4c35cc --- /dev/null +++ b/src/srm/domain/models/runtime_inventory/enums.py @@ -0,0 +1,50 @@ +from enum import StrEnum + + +class ServiceOrderState(StrEnum): + ACCEPTED = "accepted" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class ServiceOrderType(StrEnum): + DEPLOY_SERVICE = "deploy_service" + SCALE_SERVICE = "scale_service" + TERMINATE_SERVICE = "terminate_service" + + ACTIVATE_CAPABILITY = "activate_capability" + UPDATE_CAPABILITY = "update_capability" + DEACTIVATE_CAPABILITY = "deactivate_capability" + + RECONCILE_SERVICE = "reconcile_service" + + +class ServiceInstanceState(StrEnum): + CREATING = "creating" + ACTIVE = "active" + UPDATING = "updating" + DEGRADED = "degraded" + FAILED = "failed" + TERMINATING = "terminating" + TERMINATED = "terminated" + + +class CapabilityInstanceKind(StrEnum): + ACTIVATION = "activation" + SUBSCRIPTION = "subscription" + BINDING = "binding" + RESERVATION = "reservation" + LIFECYCLE = "lifecycle" + CUSTOM = "custom" + + +class CapabilityInstanceState(StrEnum): + CREATING = "creating" + ACTIVE = "active" + UPDATING = "updating" + DEGRADED = "degraded" + FAILED = "failed" + TERMINATING = "terminating" + TERMINATED = "terminated" diff --git a/src/srm/domain/models/runtime_inventory/models.py b/src/srm/domain/models/runtime_inventory/models.py new file mode 100644 index 0000000000000000000000000000000000000000..e75b636dca3c7b0371373d7d03aa39266b7719df --- /dev/null +++ b/src/srm/domain/models/runtime_inventory/models.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import Field + +from srm.domain.models.canonical_parameters.parameters import Parameters +from srm.domain.models.canonical_parameters.result import Result +from srm.domain.models.common import AuditedModel, TerminatableModel +from srm.domain.models.runtime_inventory.enums import ( + CapabilityInstanceKind, + CapabilityInstanceState, + ServiceInstanceState, + ServiceOrderState, + ServiceOrderType, +) + + +class ServiceOrder(AuditedModel): + operation_id: UUID + + correlation_id: str | None = None + + source_component: str | None = None + source_protocol: str | None = None + + order_type: ServiceOrderType + + service_specification_id: UUID | None = None + + target_service_instance_id: UUID | None = None + + state: ServiceOrderState + + payload_snapshot: dict[str, Any] = Field(default_factory=dict) + failure_detail: dict[str, Any] | None = None + + app_provider_id: str + federation_partner_ref: str | None = None + + completed_at: datetime | None = None + + +class CapabilityInstance(TerminatableModel): + capability_id: UUID + service_instance_id: UUID + originating_service_order_id: UUID | None = None + + service_capability_requirement_id: UUID | None = None + control_path_binding_id: UUID | None = None + + control_path_ref_snapshot: str | None = None + + ref: str + + kind: CapabilityInstanceKind + state: CapabilityInstanceState + + external_id: str | None = None + external_ref: str | None = None + + parameters_snapshot: Parameters + result_summary: Result + + failure_detail: dict[str, Any] | None = None + + metadata: dict[str, Any] = Field(default_factory=dict) + + app_provider_id: str + federation_partner_ref: str | None = None + + +class ServiceInstance(TerminatableModel): + service_specification_id: UUID + zone_id: UUID + originating_service_order_id: UUID | None = None + + ref: str + state: ServiceInstanceState + + metadata: dict[str, Any] = Field(default_factory=dict) + + app_provider_id: str + federation_partner_ref: str | None = None + + capabilities: list[CapabilityInstance] = Field(default_factory=list) diff --git a/src/srm/domain/models/topology/__init__.py b/src/srm/domain/models/topology/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..dd15c3d4f3fd1492431ad76a552f2691247bb3db --- /dev/null +++ b/src/srm/domain/models/topology/__init__.py @@ -0,0 +1,26 @@ +"""Topology domain models.""" + +from srm.domain.models.topology.enums import ( + CapabilityKind, + CapabilityState, + ControlPathBindingState, + DomainKind, + DomainState, + ZoneKind, + ZoneState, +) +from srm.domain.models.topology.models import Capability, ControlPathBinding, Domain, Zone + +__all__ = [ + "Capability", + "CapabilityKind", + "CapabilityState", + "ControlPathBinding", + "ControlPathBindingState", + "Domain", + "DomainKind", + "DomainState", + "Zone", + "ZoneKind", + "ZoneState", +] diff --git a/src/srm/domain/models/topology/enums.py b/src/srm/domain/models/topology/enums.py new file mode 100644 index 0000000000000000000000000000000000000000..815db8f84f528fb8dd283cfbcd6f5407e79444cc --- /dev/null +++ b/src/srm/domain/models/topology/enums.py @@ -0,0 +1,52 @@ +from enum import StrEnum + + +class ZoneKind(StrEnum): + RESOURCE = "resource" + AUTHORITY = "authority" + FEDERATION_BUSINESS = "federation_business" + LOGICAL = "logical" + CUSTOM = "custom" + + +class ZoneState(StrEnum): + ACTIVE = "active" + OFFLINE = "offline" + UNKNOWN = "unknown" + + +class DomainKind(StrEnum): + COMPUTE = "compute" + NETWORK = "network" + DATA = "data" + TRUST = "trust" + OBSERVABILITY = "observability" + BUSINESS = "business" + FEDERATION = "federation" + CUSTOM = "custom" + + +class DomainState(StrEnum): + ACTIVE = "active" + OFFLINE = "offline" + UNKNOWN = "unknown" + + +class CapabilityState(StrEnum): + ACTIVE = "active" + OFFLINE = "offline" + UNKNOWN = "unknown" + + +class CapabilityKind(StrEnum): + QUERY_AVAILABLE_RESOURCES = "query_available_resources" + DEPLOY_WORKLOAD = "deploy_workload" + QOD_SESSION = "qod_session" + TRAFFIC_INFLUENCE = "traffic_influence" + SIM_SWAP = "sim_swap" + + +class ControlPathBindingState(StrEnum): + ACTIVE = "active" + OFFLINE = "offline" + UNKNOWN = "unknown" diff --git a/src/srm/domain/models/topology/models.py b/src/srm/domain/models/topology/models.py new file mode 100644 index 0000000000000000000000000000000000000000..b30977d81fb073ee4c606ebd82d65147bd43f502 --- /dev/null +++ b/src/srm/domain/models/topology/models.py @@ -0,0 +1,74 @@ +from typing import Any +from uuid import UUID + +from pydantic import Field + +from srm.domain.models.common import AuditedModel +from srm.domain.models.topology.enums import ( + CapabilityKind, + CapabilityState, + ControlPathBindingState, + DomainKind, + DomainState, + ZoneKind, + ZoneState, +) + + +class Zone(AuditedModel): + platform_ref: str | None = None + + ref: str + name: str + + kind: ZoneKind + state: ZoneState + + metadata: dict[str, Any] = Field(default_factory=dict) + + domains: list["Domain"] = Field(default_factory=list) + + +class Domain(AuditedModel): + zone_id: UUID + + ref: str + name: str + + kind: DomainKind + state: DomainState + + metadata: dict[str, Any] = Field(default_factory=dict) + + capabilities: list["Capability"] = Field(default_factory=list) + + +class Capability(AuditedModel): + domain_id: UUID + + ref: str + name: str + + kind: CapabilityKind + state: CapabilityState + + metadata: dict[str, Any] = Field(default_factory=dict) + + control_path_bindings: list["ControlPathBinding"] = Field(default_factory=list) + + +class ControlPathBinding(AuditedModel): + capability_id: UUID + + ref: str + control_path_ref: str + + supported_runtime_kinds: list[str] = Field(default_factory=list) + supported_actions: list[str] = Field(default_factory=list) + + priority: int = 100 + is_default: bool = False + + state: ControlPathBindingState + + metadata: dict[str, Any] = Field(default_factory=dict) diff --git a/src/srm/domain/ports/database/__init__.py b/src/srm/domain/ports/database/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1460f674ebefe415c6c73c59708c8715f3b16c24 --- /dev/null +++ b/src/srm/domain/ports/database/__init__.py @@ -0,0 +1,31 @@ +"""Domain ports.""" + +from srm.domain.ports.database.catalog import ( + ServiceCapabilityRequirementRepository, + ServiceDeploymentUnitRepository, + ServiceSpecificationRepository, +) +from srm.domain.ports.database.runtime_inventory import ( + CapabilityInstanceRepository, + ServiceInstanceRepository, + ServiceOrderRepository, +) +from srm.domain.ports.database.topology import ( + CapabilityRepository, + ControlPathBindingRepository, + DomainRepository, + ZoneRepository, +) + +__all__ = [ + "CapabilityInstanceRepository", + "CapabilityRepository", + "ControlPathBindingRepository", + "DomainRepository", + "ServiceCapabilityRequirementRepository", + "ServiceDeploymentUnitRepository", + "ServiceInstanceRepository", + "ServiceOrderRepository", + "ServiceSpecificationRepository", + "ZoneRepository", +] diff --git a/src/srm/domain/ports/database/catalog.py b/src/srm/domain/ports/database/catalog.py new file mode 100644 index 0000000000000000000000000000000000000000..e7e0215c46b05c5f4810c3c137f744e05ebce50e --- /dev/null +++ b/src/srm/domain/ports/database/catalog.py @@ -0,0 +1,43 @@ +"""Catalog repository ports.""" + +from abc import ABC, abstractmethod +from uuid import UUID + +from srm.domain.models.catalog import ( + ServiceCapabilityRequirement, + ServiceDeploymentUnit, + ServiceSpecification, +) + + +class ServiceSpecificationRepository(ABC): + @abstractmethod + async def get_by_id(self, id: UUID) -> ServiceSpecification | None: + pass + + @abstractmethod + async def create(self, service_specification: ServiceSpecification) -> ServiceSpecification: + pass + + +class ServiceDeploymentUnitRepository(ABC): + @abstractmethod + async def get_by_id(self, id: UUID) -> ServiceDeploymentUnit | None: + pass + + @abstractmethod + async def create(self, deployment_unit: ServiceDeploymentUnit) -> ServiceDeploymentUnit: + pass + + +class ServiceCapabilityRequirementRepository(ABC): + @abstractmethod + async def get_by_id(self, id: UUID) -> ServiceCapabilityRequirement | None: + pass + + @abstractmethod + async def create( + self, + capability_requirement: ServiceCapabilityRequirement, + ) -> ServiceCapabilityRequirement: + pass diff --git a/src/srm/domain/ports/database/runtime_inventory.py b/src/srm/domain/ports/database/runtime_inventory.py new file mode 100644 index 0000000000000000000000000000000000000000..bdf08340c8ca55e540a5318b4f4452fc73a8da4d --- /dev/null +++ b/src/srm/domain/ports/database/runtime_inventory.py @@ -0,0 +1,40 @@ +"""Runtime inventory repository ports.""" + +from abc import ABC, abstractmethod +from uuid import UUID + +from srm.domain.models.runtime_inventory import ( + CapabilityInstance, + ServiceInstance, + ServiceOrder, +) + + +class ServiceOrderRepository(ABC): + @abstractmethod + async def get_by_id(self, id: UUID) -> ServiceOrder | None: + pass + + @abstractmethod + async def create(self, service_order: ServiceOrder) -> ServiceOrder: + pass + + +class ServiceInstanceRepository(ABC): + @abstractmethod + async def get_by_id(self, id: UUID) -> ServiceInstance | None: + pass + + @abstractmethod + async def create(self, service_instance: ServiceInstance) -> ServiceInstance: + pass + + +class CapabilityInstanceRepository(ABC): + @abstractmethod + async def get_by_id(self, id: UUID) -> CapabilityInstance | None: + pass + + @abstractmethod + async def create(self, capability_instance: CapabilityInstance) -> CapabilityInstance: + pass diff --git a/src/srm/domain/ports/database/topology.py b/src/srm/domain/ports/database/topology.py new file mode 100644 index 0000000000000000000000000000000000000000..a208cff74b647ec3fd26557feb4583cad2de56ca --- /dev/null +++ b/src/srm/domain/ports/database/topology.py @@ -0,0 +1,51 @@ +"""Topology repository ports.""" + +from abc import ABC, abstractmethod +from uuid import UUID + +from srm.domain.models.topology import ( + Capability, + ControlPathBinding, + Domain, + Zone, +) + + +class ZoneRepository(ABC): + @abstractmethod + async def get_by_id(self, id: UUID) -> Zone | None: + pass + + @abstractmethod + async def create(self, zone: Zone) -> Zone: + pass + + +class DomainRepository(ABC): + @abstractmethod + async def get_by_id(self, id: UUID) -> Domain | None: + pass + + @abstractmethod + async def create(self, domain: Domain) -> Domain: + pass + + +class CapabilityRepository(ABC): + @abstractmethod + async def get_by_id(self, id: UUID) -> Capability | None: + pass + + @abstractmethod + async def create(self, capability: Capability) -> Capability: + pass + + +class ControlPathBindingRepository(ABC): + @abstractmethod + async def get_by_id(self, id: UUID) -> ControlPathBinding | None: + pass + + @abstractmethod + async def create(self, control_path_binding: ControlPathBinding) -> ControlPathBinding: + pass diff --git a/src/srm/domain/ports/databus/publisher.py b/src/srm/domain/ports/databus/publisher.py new file mode 100644 index 0000000000000000000000000000000000000000..613f70db017bf0ad413a2c9d325b7504348c42e3 --- /dev/null +++ b/src/srm/domain/ports/databus/publisher.py @@ -0,0 +1,10 @@ +from typing import Any, Protocol + + +class DataBusPublisher(Protocol): + async def publish( + self, + subject: str, + payload: dict[str, Any], + headers: dict[str, str] | None = None, + ) -> None: ... diff --git a/src/srm/main.py b/src/srm/main.py new file mode 100644 index 0000000000000000000000000000000000000000..4e4e5f7100619460e03de948b996619b41108411 --- /dev/null +++ b/src/srm/main.py @@ -0,0 +1,105 @@ +""" +Application entrypoint. + +Responsible for bootstrapping and starting the service. + +Responsibilities: +- Initialize configuration +- Wire dependencies (dependency injection) +- Start API server (e.g., HTTP framework) + +This is the primary entrypoint for running the service. +""" + +from contextlib import asynccontextmanager +from typing import AsyncIterator + +import structlog +from fastapi import FastAPI + +from srm.adapters.database.core import ( + build_engine_and_session_maker, + schema_initialization, +) +from srm.adapters.databus.nats_connection_manager import ( + NatsConnectionManager, + init_databus_manager, +) +from srm.api.databus.nats_subscriber import NatsSubscriber, subscribe_to_subjects +from srm.api.health import health_router +from srm.api.middlewares.middlewares import register_middlewares +from srm.config import Settings, get_settings + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + try: + settings = get_settings() + except Exception as e: + structlog.get_logger().error("Failed to load settings", error=str(e)) + raise + + logger = structlog.get_logger() + logger.info("Starting", app=settings.app_name, version=settings.app_version) + + try: + engine, session_maker = await build_engine_and_session_maker( + url=settings.postgres_settings.url, + echo=settings.postgres_settings.echo, + ) + + if settings.postgres_settings.create_schema_on_startup: + await schema_initialization(engine) + + except Exception as e: + logger.error("Database engine init failed!", error=str(e)) + raise + try: + databus_manager: NatsConnectionManager = await init_databus_manager( + settings=settings.nats_settings + ) + except Exception as e: + logger.error("Databus connection init failed!", error=str(e)) + await engine.dispose() + raise + + try: + databus_subscribers: list[NatsSubscriber] = await subscribe_to_subjects(databus_manager) + except Exception as e: + logger.error("Databus subscription failed!", error=str(e)) + await databus_manager.close() + await engine.dispose() + raise + + app.state.db_engine = engine + app.state.session_maker = session_maker + app.state.databus_connection_manager = databus_manager + app.state.databus_subscribers = databus_subscribers + + yield + + logger.info("Shutting down application") + # Drain the bus first: draining delivers in-flight messages to their handlers, and + # those handlers still need a live DB engine. + await app.state.databus_connection_manager.close() + app.state.databus_subscribers = [] + await engine.dispose() + + +def create_app() -> FastAPI: + settings: Settings = get_settings() + + app: FastAPI = FastAPI( + title=settings.app_name, + description=settings.app_description, + version=settings.app_version, + lifespan=lifespan, + ) + + # Add middlewares + register_middlewares(app=app) + + # Add routers + app.include_router(health_router) + + return app diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/api/databus/__init__.py b/tests/api/databus/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/api/databus/test_nats_subscriber.py b/tests/api/databus/test_nats_subscriber.py new file mode 100644 index 0000000000000000000000000000000000000000..65a97ef29f97a32bb19003aa89764b083cddaede --- /dev/null +++ b/tests/api/databus/test_nats_subscriber.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from unittest.mock import AsyncMock, MagicMock + +import pytest +import structlog.testing + +from srm.adapters.databus.nats_connection_manager import NatsConnectionManager +from srm.api.databus.nats_subscriber import ( + COMMAND_SUBJECTS, + NatsSubscriber, + subscribe_to_subjects, +) +from srm.api.databus.schemas import InboundMessage + + +@dataclass +class FakeMsg: + subject: str + data: bytes + headers: dict[str, str] | None = field(default=None) + + +@pytest.fixture +def connection_manager() -> MagicMock: + manager = MagicMock(spec=NatsConnectionManager) + manager.client = AsyncMock() + return manager + + +def make_subscriber( + connection_manager: MagicMock, subject: str = "command.srm.service.deploy" +) -> tuple[NatsSubscriber, AsyncMock]: + router = AsyncMock() + subscriber = NatsSubscriber( + connection_manager=connection_manager, + subject=subject, + router=router, + ) + return subscriber, router + + +async def test_start_subscribes_via_connection_manager_client( + connection_manager: MagicMock, +) -> None: + subscriber, _ = make_subscriber(connection_manager) + + await subscriber.start() + + connection_manager.client.subscribe.assert_awaited_once() + call_kwargs = connection_manager.client.subscribe.call_args + assert call_kwargs.args == ("command.srm.service.deploy",) + assert call_kwargs.kwargs["cb"] == subscriber._handle_message + assert subscriber._subscription is not None + + +async def test_handle_message_builds_inbound_message_and_invokes_router( + connection_manager: MagicMock, +) -> None: + subscriber, router = make_subscriber(connection_manager) + msg = FakeMsg( + subject="command.srm.service.deploy", + data=b'{"operation_id": "abc-123"}', + headers={"x-correlation-id": "corr-1"}, + ) + + await subscriber._handle_message(msg) # type: ignore[arg-type] + + router.assert_awaited_once() + assert router.await_args is not None + (inbound,) = router.await_args.args + assert isinstance(inbound, InboundMessage) + assert inbound.subject == "command.srm.service.deploy" + assert inbound.payload == b'{"operation_id": "abc-123"}' + assert inbound.headers == {"x-correlation-id": "corr-1"} + + +async def test_handle_message_defaults_headers_to_empty_dict_when_none( + connection_manager: MagicMock, +) -> None: + subscriber, router = make_subscriber(connection_manager) + msg = FakeMsg(subject="command.srm.service.deploy", data=b"{}", headers=None) + + await subscriber._handle_message(msg) # type: ignore[arg-type] + + assert router.await_args is not None + (inbound,) = router.await_args.args + assert inbound.headers == {} + + +async def test_handle_message_passes_malformed_payload_through_unparsed( + connection_manager: MagicMock, +) -> None: + subscriber, router = make_subscriber(connection_manager) + msg = FakeMsg(subject="command.srm.service.deploy", data=b'{"operation_id": ') + + await subscriber._handle_message(msg) # type: ignore[arg-type] + + router.assert_awaited_once() + assert router.await_args is not None + (inbound,) = router.await_args.args + assert inbound.payload == b'{"operation_id": ' + + +async def test_handle_message_logs_dropped_command_when_router_fails( + connection_manager: MagicMock, +) -> None: + subscriber, router = make_subscriber(connection_manager) + router.side_effect = RuntimeError("handler blew up") + msg = FakeMsg(subject="command.srm.service.deploy", data=b"{}") + + with structlog.testing.capture_logs() as logs: + await subscriber._handle_message(msg) # type: ignore[arg-type] + + router.assert_awaited_once() + assert [(entry["event"], entry["subject"], entry["log_level"]) for entry in logs] == [ + ("databus_handler_failed", "command.srm.service.deploy", "error") + ] + + +EXPECTED_COMMAND_SUBJECTS = [ + "command.srm.service.deploy", + "command.srm.service.scale", + "command.srm.service.terminate", + "command.srm.network.capability.activate", + "command.srm.network.capability.update", + "command.srm.network.capability.deactivate", +] + + +def test_command_subjects_matches_the_contract() -> None: + assert sorted(COMMAND_SUBJECTS) == sorted(EXPECTED_COMMAND_SUBJECTS) + + +async def test_subscribe_to_subjects_registers_all_command_subjects( + connection_manager: MagicMock, +) -> None: + subscribers = await subscribe_to_subjects(connection_manager) + + assert sorted(sub._subject for sub in subscribers) == sorted(EXPECTED_COMMAND_SUBJECTS) + assert connection_manager.client.subscribe.await_count == len(EXPECTED_COMMAND_SUBJECTS) diff --git a/tests/api/databus/test_schemas.py b/tests/api/databus/test_schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..dac5f7d5dbdc606145d32265043e1308284400c0 --- /dev/null +++ b/tests/api/databus/test_schemas.py @@ -0,0 +1,436 @@ +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from pydantic import ValidationError + +from srm.api.databus.schemas import ( + CommandEnvelopeV1, + DeployPayloadV1, + DeployTargetV1, + NetworkCapabilityDeactivatePayloadV1, + NetworkCapabilityPayloadV1, + NetworkCapabilityUpdatePayloadV1, + PlacementConstraintsV1, + ScalePayloadV1, + SrmNetworkCapabilityActivateV1, + SrmNetworkCapabilityDeactivateV1, + SrmNetworkCapabilityUpdateV1, + SrmServiceDeployV1, + SrmServiceScaleV1, + SrmServiceTerminateV1, + TerminatePayloadV1, +) +from srm.domain.models.canonical_parameters.parameters import ( + CapabilityParameters, + CapabilityTarget, +) + + +def _envelope(**overrides: object) -> dict[str, object]: + envelope: dict[str, object] = { + "schema_version": "1.0", + "operation_id": str(uuid4()), + "correlation_id": "corr-1", + "requested_at": "2026-07-03T12:00:00+00:00", + "app_provider_id": "provider-1", + "source": "nbi_camara", + } + envelope.update(overrides) + return envelope + + +class TestSchemaVersion: + """§B.1: schema_version is required. §A: an unsupported version is unanswerable + and must be dead-lettered, so it must never parse as if it were 1.0.""" + + def test_envelope_requires_schema_version(self) -> None: + payload = _envelope() + del payload["schema_version"] + + with pytest.raises(ValidationError, match="schema_version"): + CommandEnvelopeV1.model_validate(payload) + + @pytest.mark.parametrize("version", ["2.0", "banana"]) + def test_envelope_rejects_unsupported_schema_version(self, version: str) -> None: + with pytest.raises(ValidationError, match="schema_version"): + CommandEnvelopeV1.model_validate(_envelope(schema_version=version)) + + def test_every_command_inherits_the_rule(self) -> None: + """The rule lives on the envelope, so a v2 producer cannot have its message + silently processed as v1 on any command.srm.* subject.""" + payload = _envelope( + schema_version="2.0", + service_instance_id=str(uuid4()), + scale={"replicas": 3}, + ) + + with pytest.raises(ValidationError, match="schema_version"): + SrmServiceScaleV1.model_validate(payload) + + +class TestSource: + @pytest.mark.parametrize("source", ["nbi_camara", "nbi_tmf", "operator_portal"]) + def test_envelope_accepts_each_non_federation_source(self, source: str) -> None: + assert CommandEnvelopeV1.model_validate(_envelope(source=source)).source == source + + @pytest.mark.parametrize("source", ["nbi_rest", "FEDERATION", ""]) + def test_envelope_rejects_unknown_source(self, source: str) -> None: + with pytest.raises(ValidationError, match="source"): + CommandEnvelopeV1.model_validate(_envelope(source=source)) + + +class TestFederationContext: + def test_federation_source_requires_partner_ref(self) -> None: + with pytest.raises(ValidationError, match="federation_partner_ref is required"): + CommandEnvelopeV1.model_validate(_envelope(source="federation")) + + def test_federation_source_rejects_explicit_null_partner_ref(self) -> None: + with pytest.raises(ValidationError, match="federation_partner_ref is required"): + CommandEnvelopeV1.model_validate( + _envelope(source="federation", federation_partner_ref=None) + ) + + def test_federation_source_accepts_partner_ref(self) -> None: + command = CommandEnvelopeV1.model_validate( + _envelope(source="federation", federation_partner_ref="ptr-OperatorB") + ) + + assert command.federation_partner_ref == "ptr-OperatorB" + + def test_non_federation_source_may_omit_partner_ref(self) -> None: + assert CommandEnvelopeV1.model_validate(_envelope()).federation_partner_ref is None + + def test_every_command_inherits_the_rule(self) -> None: + payload = _envelope( + source="federation", + service_specification_id=str(uuid4()), + targets=[{"app_instance_id": str(uuid4())}], + deploy={}, + ) + + with pytest.raises(ValidationError, match="federation_partner_ref is required"): + SrmServiceDeployV1.model_validate(payload) + + +class TestUnknownFields: + def test_envelope_rejects_unknown_fields(self) -> None: + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + CommandEnvelopeV1.model_validate(_envelope(unexpected="value")) + + def test_deploy_target_rejects_unknown_pin_typo(self) -> None: + with pytest.raises(ValidationError, match="zone_ID"): + DeployTargetV1.model_validate( + { + "app_instance_id": str(uuid4()), + "zone_ID": str(uuid4()), + } + ) + + def test_deploy_v1_rejects_unknown_nested_target_field(self) -> None: + payload = _envelope( + service_specification_id=str(uuid4()), + targets=[{"app_instance_id": str(uuid4()), "zone_ID": str(uuid4())}], + deploy={}, + ) + + with pytest.raises(ValidationError, match="zone_ID"): + SrmServiceDeployV1.model_validate(payload) + + def test_deploy_payload_rejects_unknown_fields(self) -> None: + with pytest.raises(ValidationError, match="metadata"): + DeployPayloadV1.model_validate({"metadata": {"owner": "test"}}) + + def test_placement_constraints_allow_open_hints(self) -> None: + constraints = PlacementConstraintsV1.model_validate( + { + "preferred_zone_ref": "athens-edge", + "latency_budget_ms": 20, + } + ) + + assert constraints.model_extra == { + "preferred_zone_ref": "athens-edge", + "latency_budget_ms": 20, + } + + +def test_deploy_v1_parses_minimal_valid_payload() -> None: + payload = _envelope( + service_specification_id=str(uuid4()), + targets=[{"app_instance_id": str(uuid4())}], + deploy={}, + ) + + command = SrmServiceDeployV1.model_validate(payload) + + assert command.schema_version == "1.0" + assert command.deploy == DeployPayloadV1() + assert command.targets[0].zone_id is None + assert command.targets[0].domain_id is None + + +def test_deploy_v1_rejects_empty_targets() -> None: + payload = _envelope( + service_specification_id=str(uuid4()), + targets=[], + deploy={}, + ) + + with pytest.raises(ValidationError): + SrmServiceDeployV1.model_validate(payload) + + +def test_deploy_target_pins_default_to_none() -> None: + target = DeployTargetV1(app_instance_id=uuid4()) + assert target.zone_id is None + assert target.domain_id is None + + +def test_deploy_target_accepts_zone_and_domain_pin() -> None: + zone_id, domain_id = uuid4(), uuid4() + target = DeployTargetV1(app_instance_id=uuid4(), zone_id=zone_id, domain_id=domain_id) + assert target.zone_id == zone_id + assert target.domain_id == domain_id + + +class TestPinShape: + def test_deploy_target_rejects_domain_pin_without_zone(self) -> None: + with pytest.raises(ValidationError, match="domain_id requires zone_id"): + DeployTargetV1(app_instance_id=uuid4(), domain_id=uuid4()) + + def test_deploy_v1_rejects_domain_pin_without_zone_on_any_target(self) -> None: + payload = _envelope( + service_specification_id=str(uuid4()), + targets=[ + {"app_instance_id": str(uuid4()), "zone_id": str(uuid4())}, + {"app_instance_id": str(uuid4()), "domain_id": str(uuid4())}, + ], + deploy={}, + ) + + with pytest.raises(ValidationError, match="domain_id requires zone_id"): + SrmServiceDeployV1.model_validate(payload) + + def test_deploy_target_accepts_explicit_null_pins(self) -> None: + """§B.2: null and omitted optional pin fields are equivalent.""" + target = DeployTargetV1(app_instance_id=uuid4(), zone_id=None, domain_id=None) + assert target.zone_id is None + assert target.domain_id is None + + def test_network_capability_activate_v1_rejects_domain_pin_without_zone(self) -> None: + payload = _envelope( + service_specification_id=str(uuid4()), + domain_id=str(uuid4()), + network_capability={ + "capability_type": "qod_session", + "target": _capability_target().model_dump(), + "parameters": _capability_parameters().model_dump(), + }, + ) + + with pytest.raises(ValidationError, match="domain_id requires zone_id"): + SrmNetworkCapabilityActivateV1.model_validate(payload) + + def test_update_rejects_activate_only_pin_fields(self) -> None: + """§B.6 update identifies an existing realization; zone/domain pins belong + only to activate and are rejected as unknown command fields.""" + payload = _envelope( + domain_id=str(uuid4()), + network_capability={ + "capability_type": "qod_session", + "external_ref": "sess-123", + "parameters": _capability_parameters().model_dump(), + }, + ) + + with pytest.raises(ValidationError, match="domain_id"): + SrmNetworkCapabilityUpdateV1.model_validate(payload) + + +def test_scale_payload_requires_replicas() -> None: + with pytest.raises(ValidationError): + ScalePayloadV1() # type: ignore[call-arg] + + +def test_scale_payload_requires_non_negative_replicas() -> None: + with pytest.raises(ValidationError): + ScalePayloadV1(replicas=-1) + + +def test_scale_v1_allows_omitted_service_specification_id() -> None: + payload = _envelope( + service_instance_id=str(uuid4()), + scale={"replicas": 3}, + ) + + command = SrmServiceScaleV1.model_validate(payload) + + assert command.service_specification_id is None + assert command.scale.replicas == 3 + + +def test_terminate_payload_defaults_grace_period_to_zero() -> None: + payload = _envelope( + service_instance_id=str(uuid4()), + terminate={}, + ) + + command = SrmServiceTerminateV1.model_validate(payload) + + assert command.terminate == TerminatePayloadV1(grace_period_seconds=0) + + +def test_terminate_payload_rejects_negative_grace_period() -> None: + with pytest.raises(ValidationError): + TerminatePayloadV1(grace_period_seconds=-1) + + +def _capability_target() -> CapabilityTarget: + return CapabilityTarget() + + +def _capability_parameters() -> CapabilityParameters: + return CapabilityParameters() + + +def test_network_capability_activate_v1_parses_minimal_valid_payload() -> None: + payload = _envelope( + service_specification_id=str(uuid4()), + network_capability={ + "capability_type": "qod_session", + "target": _capability_target().model_dump(), + "parameters": _capability_parameters().model_dump(), + }, + ) + + command = SrmNetworkCapabilityActivateV1.model_validate(payload) + + assert command.zone_id is None + assert command.domain_id is None + assert command.network_capability.capability_type == "qod_session" + + +def test_network_capability_activate_v1_accepts_zone_and_domain_pin() -> None: + zone_id, domain_id = uuid4(), uuid4() + payload = _envelope( + service_specification_id=str(uuid4()), + zone_id=str(zone_id), + domain_id=str(domain_id), + network_capability={ + "capability_type": "qod_session", + "target": _capability_target().model_dump(), + "parameters": _capability_parameters().model_dump(), + }, + ) + + command = SrmNetworkCapabilityActivateV1.model_validate(payload) + + assert command.zone_id == zone_id + assert command.domain_id == domain_id + + +def test_network_capability_payload_requires_capability_type() -> None: + with pytest.raises(ValidationError): + NetworkCapabilityPayloadV1( # type: ignore[call-arg] + target=_capability_target(), + parameters=_capability_parameters(), + ) + + +class TestNetworkCapabilityRealizationRef: + def test_accepts_external_ref_alone(self) -> None: + ref = NetworkCapabilityUpdatePayloadV1( + capability_type="qod_session", + external_ref="sess-123", + parameters=_capability_parameters(), + ) + assert ref.external_ref == "sess-123" + assert ref.service_instance_id is None + + def test_accepts_service_instance_id_alone(self) -> None: + instance_id = uuid4() + ref = NetworkCapabilityUpdatePayloadV1( + capability_type="qod_session", + service_instance_id=instance_id, + parameters=_capability_parameters(), + ) + assert ref.service_instance_id == instance_id + assert ref.external_ref is None + + def test_rejects_neither_present(self) -> None: + with pytest.raises(ValidationError, match="exactly one"): + NetworkCapabilityUpdatePayloadV1( + capability_type="qod_session", + parameters=_capability_parameters(), + ) + + def test_rejects_both_present(self) -> None: + with pytest.raises(ValidationError, match="exactly one"): + NetworkCapabilityUpdatePayloadV1( + capability_type="qod_session", + external_ref="sess-123", + service_instance_id=uuid4(), + parameters=_capability_parameters(), + ) + + def test_requires_capability_type(self) -> None: + with pytest.raises(ValidationError): + NetworkCapabilityUpdatePayloadV1( # type: ignore[call-arg] + external_ref="sess-123", + parameters=_capability_parameters(), + ) + + def test_deactivate_payload_shares_the_same_rule(self) -> None: + with pytest.raises(ValidationError, match="exactly one"): + NetworkCapabilityDeactivatePayloadV1(capability_type="qod_session") + + +def test_network_capability_update_v1_parses_valid_payload() -> None: + payload = _envelope( + network_capability={ + "capability_type": "qod_session", + "external_ref": "sess-123", + "parameters": _capability_parameters().model_dump(), + }, + ) + + command = SrmNetworkCapabilityUpdateV1.model_validate(payload) + + assert command.service_specification_id is None + assert command.network_capability.external_ref == "sess-123" + assert command.network_capability.target is None + assert command.network_capability.profile_ref is None + + +def test_network_capability_update_v1_accepts_retarget_and_profile_ref() -> None: + payload = _envelope( + service_specification_id=str(uuid4()), + network_capability={ + "capability_type": "qod_session", + "external_ref": "sess-123", + "target": _capability_target().model_dump(), + "profile_ref": "profile-1", + "parameters": _capability_parameters().model_dump(), + }, + ) + + command = SrmNetworkCapabilityUpdateV1.model_validate(payload) + + assert command.network_capability.profile_ref == "profile-1" + assert command.network_capability.target is not None + + +def test_network_capability_deactivate_v1_defaults_grace_period_to_zero() -> None: + payload = _envelope( + network_capability={ + "capability_type": "qod_session", + "external_ref": "sess-123", + }, + ) + + command = SrmNetworkCapabilityDeactivateV1.model_validate(payload) + + assert command.service_specification_id is None + assert command.network_capability.grace_period_seconds == 0 diff --git a/tests/api/fakes.py b/tests/api/fakes.py new file mode 100644 index 0000000000000000000000000000000000000000..605bacd54cf246258765fb53368b063b458f914d --- /dev/null +++ b/tests/api/fakes.py @@ -0,0 +1,27 @@ +from types import TracebackType + + +class FakeConnection: + async def __aenter__(self) -> "FakeConnection": + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + return None + + async def execute(self, statement: object) -> None: + return None + + +class FakeEngine: + def connect(self) -> FakeConnection: + return FakeConnection() + + +class FakeDatabusConnectionManager: + def __init__(self, *, is_connected: bool) -> None: + self.is_connected = is_connected diff --git a/tests/api/middlewares/__init__.py b/tests/api/middlewares/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/api/middlewares/test_logging.py b/tests/api/middlewares/test_logging.py new file mode 100644 index 0000000000000000000000000000000000000000..ff5fca60bec8831ccf4fc251831668083d45b662 --- /dev/null +++ b/tests/api/middlewares/test_logging.py @@ -0,0 +1,80 @@ +from contextlib import AbstractContextManager + +import structlog.testing +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from structlog.contextvars import bind_contextvars, merge_contextvars +from structlog.types import EventDict + + +# merge_contextvars is passed explicitly because capture_logs() clears all +# processors by default — without it bind_contextvars fields would not appear. +def _capture_logs() -> AbstractContextManager[list[EventDict]]: + return structlog.testing.capture_logs(processors=[merge_contextvars]) + + +async def test_logs_request_received(client: AsyncClient) -> None: + with _capture_logs() as logs: + await client.get("/healthz") + assert any(entry["event"] == "Request Received" for entry in logs) + + +async def test_logs_request_processed(client: AsyncClient) -> None: + with _capture_logs() as logs: + await client.get("/healthz") + processed = next((entry for entry in logs if entry["event"] == "Request Processed"), None) + assert processed is not None, "'Request Processed' log entry was not emitted" + assert isinstance(processed["duration"], float) + assert processed["duration"] >= 0 + + +async def test_request_received_contains_metadata(client: AsyncClient) -> None: + with _capture_logs() as logs: + await client.get("/healthz") + received = next((entry for entry in logs if entry["event"] == "Request Received"), None) + assert received is not None, "'Request Received' log entry was not emitted" + assert received["http_method"] == "GET" + assert received["path"] == "/healthz" + assert "request_id" in received + + +async def test_generated_request_id_matches_log(client: AsyncClient) -> None: + with _capture_logs() as logs: + response = await client.get("/healthz") + received = next((entry for entry in logs if entry["event"] == "Request Received"), None) + assert received is not None, "'Request Received' log entry was not emitted" + assert received["request_id"] == response.headers["X-Request-ID"] + + +async def test_log_context_does_not_bleed_between_requests(app: FastAPI) -> None: + @app.get("/test/leaky") + async def leaky() -> None: + bind_contextvars(canary="do_not_carry_over") + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + with _capture_logs() as logs: + await c.get("/test/leaky") + await c.get("/healthz") + + received = [entry for entry in logs if entry["event"] == "Request Received"] + assert len(received) == 2 + assert "canary" not in received[1] + + +async def test_request_processed_is_logged_on_500(app: FastAPI) -> None: + @app.get("/test/error") + async def raise_error() -> None: + raise RuntimeError("simulated handler failure") + + async with AsyncClient( + transport=ASGITransport(app=app, raise_app_exceptions=False), + base_url="http://test", + ) as c: + with _capture_logs() as logs: + response = await c.get("/test/error") + + assert response.status_code == 500 + processed = next((entry for entry in logs if entry["event"] == "Request Processed"), None) + assert processed is not None, "'Request Processed' was not emitted on 500" + assert isinstance(processed["duration"], float) + assert processed["status_code"] is None diff --git a/tests/api/middlewares/test_request_id.py b/tests/api/middlewares/test_request_id.py new file mode 100644 index 0000000000000000000000000000000000000000..ad07f76f0707da41c5ec4e52cf5f8ef462c4fb71 --- /dev/null +++ b/tests/api/middlewares/test_request_id.py @@ -0,0 +1,79 @@ +import uuid +from uuid import UUID, uuid4 + +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from srm.api.context import originating_request_id + + +async def test_generates_uuid4_when_header_absent(client: AsyncClient) -> None: + response = await client.get("/healthz") + assert UUID(response.headers["X-Request-ID"]).version == 4 + + +async def test_passes_through_valid_uuid4(client: AsyncClient) -> None: + original = str(uuid4()) + response = await client.get("/healthz", headers={"X-Request-ID": original}) + assert response.headers["X-Request-ID"] == original + + +async def test_regenerates_for_invalid_string(client: AsyncClient) -> None: + response = await client.get("/healthz", headers={"X-Request-ID": "not-a-uuid"}) + new_id = response.headers["X-Request-ID"] + assert new_id != "not-a-uuid" + assert UUID(new_id).version == 4 + + +async def test_regenerates_for_non_v4_uuid(client: AsyncClient) -> None: + uuid1_val = str(uuid.uuid1()) + response = await client.get("/healthz", headers={"X-Request-ID": uuid1_val}) + new_id = response.headers["X-Request-ID"] + assert new_id != uuid1_val + assert UUID(new_id).version == 4 + + +async def test_each_request_gets_unique_id(client: AsyncClient) -> None: + r1 = await client.get("/healthz") + r2 = await client.get("/healthz") + assert r1.headers["X-Request-ID"] != r2.headers["X-Request-ID"] + + +async def test_context_var_is_reset_to_default_after_request(client: AsyncClient) -> None: + await client.get("/healthz") + assert originating_request_id.get() == "" + + +async def test_regenerates_for_empty_string(client: AsyncClient) -> None: + response = await client.get("/healthz", headers={"X-Request-ID": ""}) + assert UUID(response.headers["X-Request-ID"]).version == 4 + + +async def test_context_var_is_reset_even_when_handler_raises(app: FastAPI) -> None: + @app.get("/test/error") + async def raise_error() -> None: + raise RuntimeError("simulated handler failure") + + # raise_server_exceptions=False makes httpx return the 500 instead of re-raising, + # so we can reach the assertion. The middleware finally block still runs before + # ServerErrorMiddleware converts the exception to a 500 response. + async with AsyncClient( + transport=ASGITransport(app=app, raise_app_exceptions=False), + base_url="http://test", + ) as c: + response = await c.get("/test/error") + + assert response.status_code == 500 + assert originating_request_id.get() == "" + + +async def test_context_var_holds_correct_value_during_request(app: FastAPI) -> None: + @app.get("/test/context") + async def context_echo() -> str: + return originating_request_id.get() + + sent_id = str(uuid4()) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + response = await c.get("/test/context", headers={"X-Request-ID": sent_id}) + + assert response.json() == sent_id diff --git a/tests/api/test_app.py b/tests/api/test_app.py new file mode 100644 index 0000000000000000000000000000000000000000..8d302e8fda368ab9bf1c078cf753711fb5f74226 --- /dev/null +++ b/tests/api/test_app.py @@ -0,0 +1,105 @@ +from collections.abc import Iterator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI + +from srm.config import get_settings +from srm.main import lifespan + + +def test_create_app_uses_settings(app: FastAPI) -> None: + settings = get_settings() + assert app.title == settings.app_name + assert app.version == settings.app_version + assert app.description == settings.app_description + + +@pytest.fixture +def stub_database() -> Iterator[AsyncMock]: + """Lifespan builds the DB engine before touching NATS; stubbing it keeps these tests + focused on the DataBus stage and off Docker.""" + engine = AsyncMock() + with ( + patch( + "srm.main.build_engine_and_session_maker", + new_callable=AsyncMock, + return_value=(engine, MagicMock()), + ), + patch("srm.main.schema_initialization", new_callable=AsyncMock), + ): + yield engine + + +class TestLifespanDatabusInit: + async def test_startup_fails_when_databus_connection_fails( + self, app: FastAPI, stub_database: AsyncMock + ) -> None: + with patch( + "srm.main.init_databus_manager", + new_callable=AsyncMock, + side_effect=OSError("nats unavailable"), + ): + with pytest.raises(OSError, match="nats unavailable"): + async with lifespan(app): + pass + + stub_database.dispose.assert_awaited_once() + + async def test_startup_fails_when_subject_subscription_fails( + self, app: FastAPI, stub_database: AsyncMock + ) -> None: + manager = AsyncMock() + + with ( + patch("srm.main.init_databus_manager", new_callable=AsyncMock, return_value=manager), + patch( + "srm.main.subscribe_to_subjects", + new_callable=AsyncMock, + side_effect=OSError("subscription refused"), + ), + ): + with pytest.raises(OSError, match="subscription refused"): + async with lifespan(app): + pass + + manager.close.assert_awaited_once() + stub_database.dispose.assert_awaited_once() + + async def test_successful_startup_exposes_databus_state( + self, app: FastAPI, stub_database: AsyncMock + ) -> None: + manager, subscribers = AsyncMock(), [MagicMock(), MagicMock()] + + with ( + patch("srm.main.init_databus_manager", new_callable=AsyncMock, return_value=manager), + patch( + "srm.main.subscribe_to_subjects", + new_callable=AsyncMock, + return_value=subscribers, + ), + ): + async with lifespan(app): + assert app.state.databus_connection_manager is manager + assert app.state.databus_subscribers == subscribers + + manager.close.assert_awaited_once() + assert app.state.databus_subscribers == [] + + async def test_shutdown_drains_databus_before_disposing_the_engine( + self, app: FastAPI, stub_database: AsyncMock + ) -> None: + """Draining delivers in-flight messages to handlers, which still need the engine.""" + order: list[str] = [] + manager = AsyncMock() + manager.close.side_effect = lambda: order.append("databus_closed") + stub_database.dispose.side_effect = lambda: order.append("engine_disposed") + + with ( + patch("srm.main.init_databus_manager", new_callable=AsyncMock, return_value=manager), + patch("srm.main.subscribe_to_subjects", new_callable=AsyncMock, return_value=[]), + ): + async with lifespan(app): + pass + + assert order == ["databus_closed", "engine_disposed"] diff --git a/tests/api/test_health.py b/tests/api/test_health.py new file mode 100644 index 0000000000000000000000000000000000000000..e7f14deaffd519074f1f169397c0da85e09e8bc8 --- /dev/null +++ b/tests/api/test_health.py @@ -0,0 +1,47 @@ +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from srm.api.health import health_router +from tests.api.fakes import FakeDatabusConnectionManager, FakeEngine + + +def make_health_app(*, nats_connected: bool) -> FastAPI: + app = FastAPI() + app.include_router(health_router) + app.state.db_engine = FakeEngine() + app.state.databus_connection_manager = FakeDatabusConnectionManager(is_connected=nats_connected) + return app + + +async def test_healthz_returns_200(client: AsyncClient) -> None: + response = await client.get("/healthz") + assert response.status_code == 200 + assert response.json() is True + + +@pytest.mark.integration +async def test_readyz_returns_200(client_with_db: AsyncClient) -> None: + response = await client_with_db.get("/readyz") + assert response.status_code == 200 + assert response.json() is True + + +async def test_readyz_returns_200_when_nats_connected() -> None: + app = make_health_app(nats_connected=True) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + response = await c.get("/readyz") + + assert response.status_code == 200 + assert response.json() is True + + +async def test_readyz_returns_503_when_nats_disconnected() -> None: + app = make_health_app(nats_connected=False) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + response = await c.get("/readyz") + + assert response.status_code == 503 + assert response.json() == {"detail": "NATS not ready"} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..dbc6a8676ff8e683e90c69c249b819c59a2779f7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,130 @@ +from collections.abc import AsyncIterator, Generator, Iterator + +import pytest +import pytest_asyncio +from asgi_lifespan import LifespanManager +from docker.errors import DockerException +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine +from testcontainers.nats import NatsContainer +from testcontainers.postgres import PostgresContainer + +from srm.adapters.database.sql import get_metadata +from srm.config import get_settings +from srm.main import create_app + +TEST_SETTINGS_ENV = { + "APP_NAME": "test-srm", + "APP_VERSION": "0.0.1", + "APP_DESCRIPTION": "Test SRM", + "POSTGRES_SETTINGS__ECHO": "true", + "POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP": "true", + "NATS_SETTINGS__URL": "nats://localhost:4222", + "NATS_SETTINGS__CONNECT_TIMEOUT": "10", + "NATS_SETTINGS__MAX_RECONNECT_ATTEMPTS": "3", +} + + +@pytest.fixture(autouse=True) +def clear_settings_cache() -> Generator[None, None, None]: + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +def _as_asyncpg_url(url: str) -> str: + if url.startswith("postgresql+psycopg2://"): + return url.replace("postgresql+psycopg2://", "postgresql+asyncpg://", 1) + if url.startswith("postgresql://"): + return url.replace("postgresql://", "postgresql+asyncpg://", 1) + raise ValueError(f"Unsupported postgres URL: {url}") + + +def _set_test_settings_env( + monkeypatch: pytest.MonkeyPatch, *, postgres_url: str, nats_url: str | None = None +) -> None: + for key, value in TEST_SETTINGS_ENV.items(): + monkeypatch.setenv(key, value) + monkeypatch.setenv("POSTGRES_SETTINGS__URL", postgres_url) + if nats_url is not None: + monkeypatch.setenv("NATS_SETTINGS__URL", nats_url) + + +@pytest.fixture(scope="session") +def postgres_container() -> Iterator[PostgresContainer]: + try: + with PostgresContainer("postgres:16-alpine") as container: + yield container + except DockerException as exc: + pytest.skip(f"Docker is not available for testcontainers: {exc}") + + +@pytest.fixture(scope="session") +def nats_container() -> Iterator[NatsContainer]: + try: + with NatsContainer() as container: + yield container + except DockerException as exc: + pytest.skip(f"Docker is not available for testcontainers: {exc}") + + +@pytest.fixture +def app_with_db( + monkeypatch: pytest.MonkeyPatch, + postgres_container: PostgresContainer, + nats_container: NatsContainer, + clean_db: None, +) -> FastAPI: + _set_test_settings_env( + monkeypatch, + postgres_url=_as_asyncpg_url(postgres_container.get_connection_url()), + nats_url=nats_container.nats_uri(), + ) + return create_app() + + +@pytest_asyncio.fixture +async def clean_db(postgres_container: PostgresContainer) -> AsyncIterator[None]: + engine = create_async_engine( + _as_asyncpg_url(postgres_container.get_connection_url()), + echo=False, + ) + table_names = list(get_metadata().tables) + + async with engine.begin() as conn: + await conn.run_sync(get_metadata().create_all) + if table_names: + joined = ", ".join(table_names) + await conn.execute(text(f"TRUNCATE TABLE {joined} RESTART IDENTITY CASCADE")) + + try: + yield + finally: + await engine.dispose() + + +@pytest.fixture +def app(monkeypatch: pytest.MonkeyPatch) -> FastAPI: + _set_test_settings_env( + monkeypatch, + postgres_url="postgresql+asyncpg://postgres:postgres@localhost:5432/srm", + ) + return create_app() + + +@pytest.fixture +async def client_with_db(app_with_db: FastAPI) -> AsyncIterator[AsyncClient]: + async with LifespanManager(app_with_db): + async with AsyncClient( + transport=ASGITransport(app=app_with_db), + base_url="http://test", + ) as c: + yield c + + +@pytest.fixture +async def client(app: FastAPI) -> AsyncIterator[AsyncClient]: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..87e37d983252c593e40e9004a1e8eded99bf8807 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator + +import pytest +import pytest_asyncio +from pytest import Item +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from srm.adapters.database.sql import get_metadata +from tests.conftest import _as_asyncpg_url + + +def pytest_collection_modifyitems(items: list[Item]) -> None: + for item in items: + if "tests/integration" in str(item.fspath): + item.add_marker(pytest.mark.integration) + + +@pytest_asyncio.fixture +async def db_engine(postgres_container: object) -> AsyncIterator[AsyncEngine]: + engine = create_async_engine( + _as_asyncpg_url(postgres_container.get_connection_url()), # type: ignore[attr-defined] + echo=True, + ) + async with engine.begin() as conn: + await conn.run_sync(get_metadata().create_all) + try: + yield engine + finally: + await engine.dispose() + + +@pytest_asyncio.fixture +async def db_session(db_engine: AsyncEngine) -> AsyncIterator[AsyncSession]: + async with db_engine.connect() as connection: + transaction = await connection.begin() + session_factory = async_sessionmaker(bind=connection, expire_on_commit=False) + session = session_factory() + try: + yield session + except Exception: + await session.rollback() + raise + finally: + await session.close() + if transaction.is_active: + await transaction.rollback() diff --git a/tests/integration/test_constraints.py b/tests/integration/test_constraints.py new file mode 100644 index 0000000000000000000000000000000000000000..ae049fe22751166ad523d6191f89ac26a08c6169 --- /dev/null +++ b/tests/integration/test_constraints.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from srm.adapters.database.repos.catalog import SqlServiceSpecificationRepository +from srm.adapters.database.repos.runtime_inventory import ( + SqlServiceInstanceRepository, + SqlServiceOrderRepository, +) +from srm.adapters.database.repos.topology import ( + SqlCapabilityRepository, + SqlDomainRepository, + SqlZoneRepository, +) +from srm.domain.models.catalog import ServiceSpecification +from srm.domain.models.runtime_inventory import ServiceInstance, ServiceOrder +from srm.domain.models.runtime_inventory.enums import ( + ServiceInstanceState, + ServiceOrderState, + ServiceOrderType, +) +from srm.domain.models.topology import Capability, Domain, Zone +from srm.domain.models.topology.enums import ( + CapabilityKind, + CapabilityState, + DomainKind, + DomainState, + ZoneKind, + ZoneState, +) + + +def _now() -> datetime: + return datetime(2026, 7, 7, 12, 0, tzinfo=timezone.utc) + + +def _zone(ref: str = "zone-1") -> Zone: + return Zone( + id=uuid4(), + platform_ref="edge-platform", + ref=ref, + name="Zone", + kind=ZoneKind.RESOURCE, + state=ZoneState.ACTIVE, + metadata={}, + domains=[], + created_at=_now(), + updated_at=_now(), + ) + + +def _domain(zone_id: UUID, ref: str) -> Domain: + return Domain( + id=uuid4(), + zone_id=zone_id, + ref=ref, + name="Domain", + kind=DomainKind.COMPUTE, + state=DomainState.ACTIVE, + metadata={}, + capabilities=[], + created_at=_now(), + updated_at=_now(), + ) + + +def _capability(domain_id: UUID, ref: str) -> Capability: + return Capability( + id=uuid4(), + domain_id=domain_id, + ref=ref, + name="Capability", + kind=CapabilityKind.DEPLOY_WORKLOAD, + state=CapabilityState.ACTIVE, + metadata={}, + control_path_bindings=[], + created_at=_now(), + updated_at=_now(), + ) + + +def _service_specification() -> ServiceSpecification: + return ServiceSpecification( + id=uuid4(), + app_provider_id=str(uuid4()), + ref="spec-1", + name="Spec", + version="1.0.0", + created_at=_now(), + updated_at=_now(), + ) + + +def _service_instance( + spec_id: UUID, zone_id: UUID, order_id: UUID, app_provider_id: str, ref: str +) -> ServiceInstance: + return ServiceInstance( + id=uuid4(), + zone_id=zone_id, + service_specification_id=spec_id, + ref=ref, + originating_service_order_id=order_id, + state=ServiceInstanceState.CREATING, + app_provider_id=app_provider_id, + capabilities=[], + created_at=_now(), + updated_at=_now(), + ) + + +def _service_order(operation_id: UUID, zone_id: UUID) -> ServiceOrder: + return ServiceOrder( + id=uuid4(), + operation_id=operation_id, + order_type=ServiceOrderType.DEPLOY_SERVICE, + state=ServiceOrderState.ACCEPTED, + payload_snapshot={"target_pins": {"zone_id": str(zone_id)}}, + app_provider_id=str(uuid4()), + created_at=_now(), + updated_at=_now(), + ) + + +class TestCapabilityReferenceUniqueness: + """capability.ref is unique within its domain, not across the whole table.""" + + async def test_same_ref_is_allowed_under_different_domains( + self, db_session: AsyncSession + ) -> None: + zone = await SqlZoneRepository(db_session).create(_zone()) + domains = SqlDomainRepository(db_session) + first_domain = await domains.create(_domain(zone.id, ref="compute-a")) + second_domain = await domains.create(_domain(zone.id, ref="compute-b")) + + capabilities = SqlCapabilityRepository(db_session) + await capabilities.create(_capability(first_domain.id, ref="deploy")) + try: + saved = await capabilities.create(_capability(second_domain.id, ref="deploy")) + except IntegrityError as exc: + pytest.fail( + "capability.ref must be unique per domain, not globally: " + f"a second domain cannot reuse 'deploy' ({exc.orig})" + ) + assert isinstance(saved.id, UUID) + + async def test_same_ref_is_rejected_within_one_domain(self, db_session: AsyncSession) -> None: + zone = await SqlZoneRepository(db_session).create(_zone()) + domain = await SqlDomainRepository(db_session).create(_domain(zone.id, ref="compute")) + capabilities = SqlCapabilityRepository(db_session) + + await capabilities.create(_capability(domain.id, ref="deploy")) + with pytest.raises(IntegrityError): + await capabilities.create(_capability(domain.id, ref="deploy")) + + +class TestServiceInstanceReferenceUniqueness: + """service_instance.ref (the instance name) is unique per tenant.""" + + async def test_same_ref_is_allowed_across_tenants(self, db_session: AsyncSession) -> None: + spec = await SqlServiceSpecificationRepository(db_session).create(_service_specification()) + zone = await SqlZoneRepository(db_session).create(_zone()) + order = await SqlServiceOrderRepository(db_session).create( + _service_order(operation_id=uuid4(), zone_id=zone.id) + ) + instances = SqlServiceInstanceRepository(db_session) + + await instances.create( + _service_instance( + spec_id=spec.id, + zone_id=zone.id, + order_id=order.id, + app_provider_id=str(uuid4()), + ref="prod", + ) + ) + try: + saved = await instances.create( + _service_instance( + spec_id=spec.id, + zone_id=zone.id, + order_id=order.id, + app_provider_id=str(uuid4()), + ref="prod", + ) + ) + except IntegrityError as exc: + pytest.fail( + "service_instance.ref must be unique per tenant, not globally: " + f"a second tenant cannot reuse the name 'prod' ({exc.orig})" + ) + assert saved.id is not None + + async def test_same_ref_is_rejected_within_one_tenant(self, db_session: AsyncSession) -> None: + spec = await SqlServiceSpecificationRepository(db_session).create(_service_specification()) + zone = await SqlZoneRepository(db_session).create(_zone()) + order = await SqlServiceOrderRepository(db_session).create( + _service_order(operation_id=uuid4(), zone_id=zone.id) + ) + instances = SqlServiceInstanceRepository(db_session) + tenant = str(uuid4()) + + await instances.create( + _service_instance( + spec_id=spec.id, + zone_id=zone.id, + order_id=order.id, + app_provider_id=tenant, + ref="prod", + ) + ) + with pytest.raises(IntegrityError): + await instances.create( + _service_instance( + spec_id=spec.id, + zone_id=zone.id, + order_id=order.id, + app_provider_id=tenant, + ref="prod", + ) + ) + + +class TestServiceOrderIdempotency: + """A redelivered command (same operation_id) must not create a second order.""" + + async def test_duplicate_operation_id_is_rejected(self, db_session: AsyncSession) -> None: + operation_id = uuid4() + orders = SqlServiceOrderRepository(db_session) + + zone = await SqlZoneRepository(db_session).create(_zone()) + await orders.create(_service_order(operation_id, zone.id)) + with pytest.raises(IntegrityError): + await orders.create(_service_order(operation_id, zone.id)) diff --git a/tests/integration/test_databus.py b/tests/integration/test_databus.py new file mode 100644 index 0000000000000000000000000000000000000000..82569c00d51f5f41e64513649a1aaee89b92d628 --- /dev/null +++ b/tests/integration/test_databus.py @@ -0,0 +1,119 @@ +import asyncio +import json +from collections.abc import AsyncIterator, Generator + +import nats +import pytest +import pytest_asyncio +from nats.aio.client import Client +from testcontainers.nats import NatsContainer + +from srm.adapters.databus.nats_connection_manager import NatsConnectionManager +from srm.adapters.databus.nats_publisher import NatsPublisher +from srm.api.databus.nats_subscriber import NatsSubscriber, subscribe_to_subjects +from srm.api.databus.schemas import InboundMessage +from srm.config import NatsSettings +from tests.api.databus.test_nats_subscriber import EXPECTED_COMMAND_SUBJECTS + + +@pytest.fixture(scope="session") +def nats_url() -> Generator[str, None, None]: + with NatsContainer(image="nats:2.10-alpine") as container: + yield container.nats_uri() + + +@pytest_asyncio.fixture +async def connection_manager(nats_url: str) -> AsyncIterator[NatsConnectionManager]: + manager = NatsConnectionManager( + settings=NatsSettings(url=nats_url, connect_timeout=5, max_reconnect_attempts=3) + ) + await manager.connect() + try: + yield manager + finally: + await manager.close() + + +@pytest_asyncio.fixture +async def raw_client(nats_url: str) -> AsyncIterator[Client]: + client = await nats.connect(nats_url) + try: + yield client + finally: + await client.drain() + + +async def test_connection_manager_connects(connection_manager: NatsConnectionManager) -> None: + assert connection_manager.is_connected is True + + +async def test_connection_manager_close_disconnects(nats_url: str) -> None: + manager = NatsConnectionManager( + settings=NatsSettings(url=nats_url, connect_timeout=5, max_reconnect_attempts=3) + ) + await manager.connect() + + await manager.close() + + assert manager.is_connected is False + + +async def test_publisher_publishes_json_message_to_subject( + connection_manager: NatsConnectionManager, + raw_client: Client, +) -> None: + publisher = NatsPublisher(connection_manager) + received: list[dict[str, object]] = [] + ready = asyncio.Event() + + async def handler(msg: object) -> None: + received.append(json.loads(msg.data)) # type: ignore[attr-defined] + ready.set() + + sub = await raw_client.subscribe("command.srm.service.deploy", cb=handler) + await raw_client.flush() + await publisher.publish("command.srm.service.deploy", {"operation_id": "abc-123"}) + await asyncio.wait_for(ready.wait(), timeout=2.0) + await sub.unsubscribe() + + assert received == [{"operation_id": "abc-123"}] + + +async def test_subscriber_invokes_router_when_message_arrives( + connection_manager: NatsConnectionManager, + raw_client: Client, +) -> None: + received: list[InboundMessage] = [] + ready = asyncio.Event() + + async def router(message: InboundMessage) -> None: + received.append(message) + ready.set() + + subscriber = NatsSubscriber( + connection_manager=connection_manager, + subject="command.srm.service.deploy", + router=router, + ) + await subscriber.start() + await connection_manager.client.flush() + + await raw_client.publish( + "command.srm.service.deploy", + json.dumps({"operation_id": "abc-123"}).encode("utf-8"), + ) + await asyncio.wait_for(ready.wait(), timeout=2.0) + + assert len(received) == 1 + assert received[0].subject == "command.srm.service.deploy" + assert json.loads(received[0].payload) == {"operation_id": "abc-123"} + + +async def test_subscribe_to_subjects_registers_all_command_subjects( + connection_manager: NatsConnectionManager, +) -> None: + subscribers = await subscribe_to_subjects(connection_manager) + + assert sorted(sub._subject for sub in subscribers) == sorted(EXPECTED_COMMAND_SUBJECTS) + for sub in subscribers: + assert sub._subscription is not None diff --git a/tests/integration/test_postgres.py b/tests/integration/test_postgres.py new file mode 100644 index 0000000000000000000000000000000000000000..e857216e872dd3ec65a50f8394f62fa4daaa7113 --- /dev/null +++ b/tests/integration/test_postgres.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +from srm.adapters.database.repos.catalog import SqlServiceSpecificationRepository +from srm.adapters.database.repos.topology import SqlZoneRepository +from srm.adapters.errors import DuplicateServiceSpecificationError +from srm.domain.models.canonical_parameters.compute import ( + ComputeRequirements, + ComputeResources, + InterfaceVisibility, + NetworkInterface, + TopologyConstraints, +) +from srm.domain.models.canonical_parameters.parameters import ( + CapabilityParameters, + CapabilityTarget, + Parameters, + SourceSpecification, + SourceSpecificationFamily, +) +from srm.domain.models.catalog import ( + RuntimeKind, + ServiceCapabilityRequirement, + ServiceDeploymentUnit, + ServiceSpecification, + ServiceSpecificationState, +) +from srm.domain.models.common import TransportProtocol +from srm.domain.models.topology import CapabilityKind, DomainKind, Zone +from srm.domain.models.topology.enums import ZoneKind, ZoneState + + +def _timestamp() -> datetime: + return datetime(2026, 7, 7, 12, 0, tzinfo=timezone.utc) + + +def _compute_requirements() -> ComputeRequirements: + return ComputeRequirements( + compute=ComputeResources(cpu_millicores=500, memory_mb=1024), + topology=TopologyConstraints(min_nodes=1), + interfaces=[ + NetworkInterface( + component="api", + interface_id="http", + protocol=TransportProtocol.TCP, + port=8080, + visibility=InterfaceVisibility.EXTERNAL, + ) + ], + standalone=True, + ) + + +def _parameters() -> Parameters: + return Parameters( + target=CapabilityTarget(), + parameters=CapabilityParameters(profile_ref="gold", duration_seconds=300), + source_spec=SourceSpecification( + family=SourceSpecificationFamily.INTERNAL, + api="service.deploy", + version="1.0.0", + ), + ) + + +def _service_specification() -> ServiceSpecification: + now = _timestamp() + specification_id = uuid4() + return ServiceSpecification( + id=specification_id, + app_provider_id=str(uuid4()), + ref="video-analytics", + name="Video Analytics", + version="1.2.3", + state=ServiceSpecificationState.ACTIVE, + descriptor={"category": "edge-app"}, + metadata={"tenant": "demo"}, + deployment_units=[ + ServiceDeploymentUnit( + id=uuid4(), + service_specification_id=specification_id, + ref="main", + name="Main Unit", + runtime_kind=RuntimeKind.HELM, + artifact_ref="oci://example/video-analytics", + resource_requirements=_compute_requirements(), + parameters_schema={"type": "object"}, + metadata={"tier": "gold"}, + created_at=now, + updated_at=now, + ) + ], + capability_requirements=[ + ServiceCapabilityRequirement( + id=uuid4(), + service_specification_id=specification_id, + deployment_unit_id=None, + ref="compute-capability", + capability_kind=CapabilityKind.DEPLOY_WORKLOAD, + domain_kind=DomainKind.COMPUTE, + is_required=True, + selector={"zone": "edge-a"}, + parameters=_parameters(), + policy={"capacity_check": "preferred"}, + metadata={"scope": "main"}, + created_at=now, + updated_at=now, + ) + ], + created_at=now, + updated_at=now, + ) + + +def _zone() -> Zone: + now = _timestamp() + return Zone( + id=uuid4(), + platform_ref="edge-platform-a", + ref="zone-athens-1", + name="Athens Zone", + kind=ZoneKind.RESOURCE, + state=ZoneState.ACTIVE, + metadata={"region": "gr"}, + domains=[], + created_at=now, + updated_at=now, + ) + + +async def test_service_specification_repo_persists_and_loads_aggregate( + db_session: AsyncSession, +) -> None: + repo = SqlServiceSpecificationRepository(db_session) + specification = _service_specification() + + saved = await repo.create(specification) + + assert isinstance(saved.id, UUID) + assert len(saved.deployment_units) == 1 + assert isinstance(saved.deployment_units[0].id, UUID) + assert len(saved.capability_requirements) == 1 + + reloaded = await repo.get_by_id(saved.id) + + assert reloaded is not None + assert reloaded == saved + assert reloaded.id == specification.id + assert reloaded.app_provider_id == specification.app_provider_id + assert reloaded.descriptor == specification.descriptor + assert len(reloaded.deployment_units) == 1 + assert reloaded.deployment_units[0].resource_requirements == _compute_requirements() + assert len(reloaded.capability_requirements) == 1 + assert reloaded.capability_requirements[0].deployment_unit_id is None + assert reloaded.capability_requirements[0].parameters == _parameters() + + +async def test_service_specification_duplicate_entry( + db_session: AsyncSession, +) -> None: + repo = SqlServiceSpecificationRepository(db_session) + specification = _service_specification() + specification_dupl = _service_specification() + specification_dupl.app_provider_id = specification.app_provider_id + + _ = await repo.create(specification) + with pytest.raises(DuplicateServiceSpecificationError): + _ = await repo.create(specification_dupl) + + +async def test_service_specification_duplicate_id_is_conflict_without_overwrite( + db_engine: AsyncEngine, +) -> None: + session_factory = async_sessionmaker(bind=db_engine, expire_on_commit=False) + + async with session_factory() as session: + repo = SqlServiceSpecificationRepository(session) + saved = await repo.create(_service_specification()) + await session.commit() + + duplicate = _service_specification() + duplicate.id = saved.id + duplicate.app_provider_id = "attacker" + duplicate.ref = "hijacked" + duplicate.name = "Hijacked" + duplicate.created_at = None + duplicate.updated_at = None + + async with session_factory() as session: + repo = SqlServiceSpecificationRepository(session) + with pytest.raises(DuplicateServiceSpecificationError): + await repo.create(duplicate) + await session.rollback() + + async with session_factory() as session: + repo = SqlServiceSpecificationRepository(session) + reloaded = await repo.get_by_id(saved.id) + + assert reloaded is not None + assert reloaded.name == saved.name + assert reloaded.app_provider_id == saved.app_provider_id + assert reloaded.ref == saved.ref + + +async def test_zone_repo_duplicate_id_is_conflict_without_overwrite( + db_engine: AsyncEngine, +) -> None: + session_factory = async_sessionmaker(bind=db_engine, expire_on_commit=False) + + async with session_factory() as session: + repo = SqlZoneRepository(session) + saved = await repo.create(_zone()) + await session.commit() + + async with session_factory() as session: + repo = SqlZoneRepository(session) + saved.name = "Athens Zone Updated" + with pytest.raises(IntegrityError): + await repo.create(saved) + await session.rollback() + + async with session_factory() as session: + repo = SqlZoneRepository(session) + reloaded = await repo.get_by_id(saved.id) + + assert reloaded is not None + assert reloaded.created_at == saved.created_at + assert reloaded.updated_at == saved.updated_at + assert reloaded.name == "Athens Zone" diff --git a/tests/integration/test_repositories.py b/tests/integration/test_repositories.py new file mode 100644 index 0000000000000000000000000000000000000000..b5e59ff3784872daf00a4a0782057f03c9fd9ae0 --- /dev/null +++ b/tests/integration/test_repositories.py @@ -0,0 +1,453 @@ +"""Round-trip coverage for every SQL repository against real PostgreSQL. + +The core contract of every repository is the same: ``create()`` persists an entity, +assigns a surrogate id, and ``get_by_id()`` returns an equal aggregate. This is +asserted uniformly across all repositories so each one has baseline coverage. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from datetime import datetime, timezone +from typing import TypeVar, cast +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from srm.adapters.database.repos.catalog import ( + SqlServiceCapabilityRequirementRepository, + SqlServiceDeploymentUnitRepository, + SqlServiceSpecificationRepository, +) +from srm.adapters.database.repos.runtime_inventory import ( + SqlCapabilityInstanceRepository, + SqlServiceInstanceRepository, + SqlServiceOrderRepository, +) +from srm.adapters.database.repos.topology import ( + SqlCapabilityRepository, + SqlControlPathBindingRepository, + SqlDomainRepository, + SqlZoneRepository, +) +from srm.domain.models.canonical_parameters.compute import ( + ComputeRequirements, + ComputeResources, + TopologyConstraints, +) +from srm.domain.models.canonical_parameters.parameters import ( + CapabilityParameters, + CapabilityTarget, + Parameters, + SourceSpecification, + SourceSpecificationFamily, +) +from srm.domain.models.canonical_parameters.result import Result, ResultStatus +from srm.domain.models.catalog import ( + RuntimeKind, + ServiceCapabilityRequirement, + ServiceDeploymentUnit, + ServiceSpecification, +) +from srm.domain.models.common import IdentifiedModel +from srm.domain.models.runtime_inventory import ( + CapabilityInstance, + ServiceInstance, + ServiceOrder, +) +from srm.domain.models.runtime_inventory.enums import ( + CapabilityInstanceKind, + CapabilityInstanceState, + ServiceInstanceState, + ServiceOrderState, + ServiceOrderType, +) +from srm.domain.models.topology import Capability, ControlPathBinding, Domain, Zone +from srm.domain.models.topology.enums import ( + CapabilityKind, + CapabilityState, + ControlPathBindingState, + DomainKind, + DomainState, + ZoneKind, + ZoneState, +) + + +def _now() -> datetime: + return datetime(2026, 7, 7, 12, 0, tzinfo=timezone.utc) + + +def _parameters() -> Parameters: + return Parameters( + target=CapabilityTarget(), + parameters=CapabilityParameters(profile_ref="gold", duration_seconds=300), + source_spec=SourceSpecification( + family=SourceSpecificationFamily.INTERNAL, + api="service.deploy", + version="1.0.0", + ), + ) + + +def _compute_requirements() -> ComputeRequirements: + return ComputeRequirements( + compute=ComputeResources(cpu_millicores=500, memory_mb=1024), + topology=TopologyConstraints(min_nodes=1), + ) + + +def _zone() -> Zone: + return Zone( + id=uuid4(), + platform_ref="edge-platform", + ref="zone-1", + name="Zone", + kind=ZoneKind.RESOURCE, + state=ZoneState.ACTIVE, + domains=[], + created_at=_now(), + updated_at=_now(), + ) + + +def _domain(zone_id: UUID) -> Domain: + return Domain( + id=uuid4(), + zone_id=zone_id, + ref="compute", + name="Domain", + kind=DomainKind.COMPUTE, + state=DomainState.ACTIVE, + capabilities=[], + created_at=_now(), + updated_at=_now(), + ) + + +def _capability(domain_id: UUID) -> Capability: + return Capability( + id=uuid4(), + domain_id=domain_id, + ref="deploy", + name="Capability", + kind=CapabilityKind.DEPLOY_WORKLOAD, + state=CapabilityState.ACTIVE, + control_path_bindings=[], + created_at=_now(), + updated_at=_now(), + ) + + +def _control_path_binding(capability_id: UUID) -> ControlPathBinding: + return ControlPathBinding( + id=uuid4(), + capability_id=capability_id, + ref="primary", + control_path_ref="cp://k8s-athens", + supported_runtime_kinds=["helm"], + supported_actions=["deploy"], + state=ControlPathBindingState.ACTIVE, + created_at=_now(), + updated_at=_now(), + ) + + +def _service_specification() -> ServiceSpecification: + return ServiceSpecification( + id=uuid4(), + app_provider_id=str(uuid4()), + ref="spec-1", + name="Spec", + version="1.0.0", + created_at=_now(), + updated_at=_now(), + ) + + +def _service_specification_with_children() -> ServiceSpecification: + spec = _service_specification() + spec.deployment_units = [ + ServiceDeploymentUnit( + id=uuid4(), + service_specification_id=spec.id, + ref="main", + name="Main", + runtime_kind=RuntimeKind.HELM, + artifact_ref="oci://example/app", + resource_requirements=_compute_requirements(), + created_at=_now(), + updated_at=_now(), + ) + ] + spec.capability_requirements = [ + ServiceCapabilityRequirement( + id=uuid4(), + service_specification_id=spec.id, + ref="compute-capability", + capability_kind=CapabilityKind.DEPLOY_WORKLOAD, + domain_kind=DomainKind.COMPUTE, + parameters=_parameters(), + created_at=_now(), + updated_at=_now(), + ) + ] + return spec + + +def _service_instance(spec_id: UUID, orig_so_id: UUID, zone_id: UUID) -> ServiceInstance: + return ServiceInstance( + id=uuid4(), + service_specification_id=spec_id, + originating_service_order_id=orig_so_id, + zone_id=zone_id, + ref="prod", + state=ServiceInstanceState.CREATING, + app_provider_id=str(uuid4()), + capabilities=[], + created_at=_now(), + updated_at=_now(), + ) + + +def _service_order(zone_id: UUID) -> ServiceOrder: + return ServiceOrder( + id=uuid4(), + operation_id=uuid4(), + order_type=ServiceOrderType.DEPLOY_SERVICE, + state=ServiceOrderState.ACCEPTED, + payload_snapshot={"target_pins": {"zone_id": str(zone_id)}}, + app_provider_id=str(uuid4()), + created_at=_now(), + updated_at=_now(), + ) + + +def _service_order_with_type(zone_id: UUID, order_type: ServiceOrderType) -> ServiceOrder: + order = _service_order(zone_id) + order.order_type = order_type + return order + + +def _capability_instance( + service_instance_id: UUID, capability_id: UUID, originating_service_order_id: UUID +) -> CapabilityInstance: + return CapabilityInstance( + id=uuid4(), + capability_id=capability_id, + service_instance_id=service_instance_id, + originating_service_order_id=originating_service_order_id, + ref="deploy", + kind=CapabilityInstanceKind.ACTIVATION, + state=CapabilityInstanceState.CREATING, + parameters_snapshot=_parameters(), + result_summary=Result(status=ResultStatus.ACTIVE), + app_provider_id=str(uuid4()), + created_at=_now(), + updated_at=_now(), + ) + + +# Each case sets up the required parent rows, saves one entity, and returns +# (repo, saved_entity) so the round-trip assertion is identical for all repos. +Case = Callable[[AsyncSession], Awaitable[tuple[object, IdentifiedModel]]] +TestFunction = TypeVar("TestFunction", bound=Callable[..., object]) + + +async def _case_service_specification(session: AsyncSession) -> tuple[object, IdentifiedModel]: + repo = SqlServiceSpecificationRepository(session) + return repo, await repo.create(_service_specification()) + + +async def _case_zone(session: AsyncSession) -> tuple[object, IdentifiedModel]: + repo = SqlZoneRepository(session) + return repo, await repo.create(_zone()) + + +async def _case_domain(session: AsyncSession) -> tuple[object, IdentifiedModel]: + zone = await SqlZoneRepository(session).create(_zone()) + repo = SqlDomainRepository(session) + return repo, await repo.create(_domain(zone.id)) + + +async def _case_capability(session: AsyncSession) -> tuple[object, IdentifiedModel]: + zone = await SqlZoneRepository(session).create(_zone()) + domain = await SqlDomainRepository(session).create(_domain(zone.id)) + repo = SqlCapabilityRepository(session) + return repo, await repo.create(_capability(domain.id)) + + +async def _case_control_path_binding(session: AsyncSession) -> tuple[object, IdentifiedModel]: + zone = await SqlZoneRepository(session).create(_zone()) + domain = await SqlDomainRepository(session).create(_domain(zone.id)) + capability = await SqlCapabilityRepository(session).create(_capability(domain.id)) + repo = SqlControlPathBindingRepository(session) + return repo, await repo.create(_control_path_binding(capability.id)) + + +async def _case_service_order(session: AsyncSession) -> tuple[object, IdentifiedModel]: + zone = await SqlZoneRepository(session).create(_zone()) + repo = SqlServiceOrderRepository(session) + return repo, await repo.create(_service_order(zone_id=zone.id)) + + +async def _case_service_instance(session: AsyncSession) -> tuple[object, IdentifiedModel]: + spec = await SqlServiceSpecificationRepository(session).create(_service_specification()) + zone = await SqlZoneRepository(session).create(_zone()) + order = await SqlServiceOrderRepository(session).create(_service_order(zone_id=zone.id)) + repo = SqlServiceInstanceRepository(session) + return repo, await repo.create( + _service_instance(spec_id=spec.id, orig_so_id=order.id, zone_id=zone.id) + ) + + +async def _case_capability_instance(session: AsyncSession) -> tuple[object, IdentifiedModel]: + spec = await SqlServiceSpecificationRepository(session).create(_service_specification()) + zone = await SqlZoneRepository(session).create(_zone()) + order = await SqlServiceOrderRepository(session).create(_service_order(zone_id=zone.id)) + instance = await SqlServiceInstanceRepository(session).create( + _service_instance(spec_id=spec.id, orig_so_id=order.id, zone_id=zone.id) + ) + domain = await SqlDomainRepository(session).create(_domain(zone.id)) + capability = await SqlCapabilityRepository(session).create(_capability(domain.id)) + repo = SqlCapabilityInstanceRepository(session) + return repo, await repo.create(_capability_instance(instance.id, capability.id, order.id)) + + +async def _case_service_deployment_unit(session: AsyncSession) -> tuple[object, IdentifiedModel]: + spec = await SqlServiceSpecificationRepository(session).create(_service_specification()) + repo = SqlServiceDeploymentUnitRepository(session) + return ( + repo, + await repo.create( + ServiceDeploymentUnit( + id=uuid4(), + service_specification_id=spec.id, + ref="main", + name="Main", + runtime_kind=RuntimeKind.HELM, + artifact_ref="oci://example/app", + resource_requirements=_compute_requirements(), + created_at=_now(), + updated_at=_now(), + ) + ), + ) + + +async def _case_service_capability_requirement( + session: AsyncSession, +) -> tuple[object, IdentifiedModel]: + spec = await SqlServiceSpecificationRepository(session).create(_service_specification()) + repo = SqlServiceCapabilityRequirementRepository(session) + return ( + repo, + await repo.create( + ServiceCapabilityRequirement( + id=uuid4(), + service_specification_id=spec.id, + ref="compute-capability", + capability_kind=CapabilityKind.DEPLOY_WORKLOAD, + domain_kind=DomainKind.COMPUTE, + parameters=_parameters(), + created_at=_now(), + updated_at=_now(), + ) + ), + ) + + +repository_case_parametrize = cast( + Callable[[TestFunction], TestFunction], + pytest.mark.parametrize( + "case", + [ + _case_service_specification, + _case_zone, + _case_domain, + _case_capability, + _case_control_path_binding, + _case_service_order, + _case_service_instance, + _case_capability_instance, + _case_service_deployment_unit, + _case_service_capability_requirement, + ], + ids=[ + "service_specification", + "zone", + "domain", + "capability", + "control_path_binding", + "service_order", + "service_instance", + "capability_instance", + "service_deployment_unit", + "service_capability_requirement", + ], + ), +) + + +@repository_case_parametrize +async def test_create_assigns_id_and_get_by_id_round_trips( + db_session: AsyncSession, case: Case +) -> None: + repo, saved = await case(db_session) + + assert saved.id + reloaded = await repo.get_by_id(saved.id) # type: ignore[attr-defined] + assert reloaded == saved + + +def test_service_order_type_values_match_persistence_model() -> None: + assert {order_type.value for order_type in ServiceOrderType} == { + "deploy_service", + "scale_service", + "terminate_service", + "activate_capability", + "update_capability", + "deactivate_capability", + "reconcile_service", + } + + +async def test_service_order_create_accepts_deactivate_capability( + db_session: AsyncSession, +) -> None: + zone = await SqlZoneRepository(db_session).create(_zone()) + repo = SqlServiceOrderRepository(db_session) + saved = await repo.create( + _service_order_with_type(zone.id, ServiceOrderType.DEACTIVATE_CAPABILITY) + ) + + reloaded = await repo.get_by_id(saved.id) + + assert reloaded is not None + assert reloaded.order_type == ServiceOrderType.DEACTIVATE_CAPABILITY + + +class TestChildRepositoriesGetById: + """Deployment units and capability requirements are written via the aggregate + root, so only their get_by_id is exercised directly.""" + + async def test_service_deployment_unit_get_by_id(self, db_session: AsyncSession) -> None: + saved = await SqlServiceSpecificationRepository(db_session).create( + _service_specification_with_children() + ) + unit = saved.deployment_units[0] + + reloaded = await SqlServiceDeploymentUnitRepository(db_session).get_by_id(unit.id) + assert reloaded == unit + + async def test_service_capability_requirement_get_by_id(self, db_session: AsyncSession) -> None: + saved = await SqlServiceSpecificationRepository(db_session).create( + _service_specification_with_children() + ) + requirement = saved.capability_requirements[0] + + reloaded = await SqlServiceCapabilityRequirementRepository(db_session).get_by_id( + requirement.id + ) + assert reloaded == requirement diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000000000000000000000000000000000000..b1f8a58c74f532c13b0d81e5aaf02cb6c0b6eedf --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,72 @@ +import pytest +from pydantic import ValidationError + +from srm.config import Settings, get_settings + +VALID_ENV = { + "APP_NAME": "my-srm", + "APP_VERSION": "1.2.3", + "APP_DESCRIPTION": "a description", + "POSTGRES_SETTINGS__URL": "postgresql://localhost:5432/srm", + "POSTGRES_SETTINGS__ECHO": "true", + "POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP": "true", + "NATS_SETTINGS__URL": "nats://localhost:4222", + "NATS_SETTINGS__CONNECT_TIMEOUT": "10", + "NATS_SETTINGS__MAX_RECONNECT_ATTEMPTS": "3", +} + + +def set_valid_env(monkeypatch: pytest.MonkeyPatch) -> None: + for key, value in VALID_ENV.items(): + monkeypatch.setenv(key, value) + + +def test_loads_all_fields_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + set_valid_env(monkeypatch) + s = Settings(_env_file=None) # type: ignore[call-arg] + assert s.app_name == "my-srm" + assert s.app_version == "1.2.3" + assert s.app_description == "a description" + assert s.postgres_settings.url == "postgresql://localhost:5432/srm" + assert s.postgres_settings.echo + assert s.postgres_settings.create_schema_on_startup + assert s.nats_settings.url == "nats://localhost:4222" + assert s.nats_settings.connect_timeout == 10 + assert s.nats_settings.max_reconnect_attempts == 3 + + +@pytest.mark.parametrize( + "missing_key", + [ + "APP_NAME", + "APP_VERSION", + "APP_DESCRIPTION", + "POSTGRES_SETTINGS__URL", + "POSTGRES_SETTINGS__ECHO", + "POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP", + "NATS_SETTINGS__URL", + "NATS_SETTINGS__CONNECT_TIMEOUT", + "NATS_SETTINGS__MAX_RECONNECT_ATTEMPTS", + ], +) +def test_raises_if_required_setting_missing( + monkeypatch: pytest.MonkeyPatch, + missing_key: str, +) -> None: + set_valid_env(monkeypatch) + monkeypatch.delenv(missing_key, raising=False) + with pytest.raises(ValidationError): + Settings(_env_file=None) # type: ignore[call-arg] + + +def test_loads_nested_nats_fields(monkeypatch: pytest.MonkeyPatch) -> None: + set_valid_env(monkeypatch) + s = Settings(_env_file=None) # type: ignore[call-arg] + assert s.nats_settings.url == "nats://localhost:4222" + assert s.nats_settings.connect_timeout == 10 + assert s.nats_settings.max_reconnect_attempts == 3 + + +def test_get_settings_returns_same_instance(monkeypatch: pytest.MonkeyPatch) -> None: + set_valid_env(monkeypatch) + assert get_settings() is get_settings() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py new file mode 100644 index 0000000000000000000000000000000000000000..c3f7140754056cfa0f318c8061a4b02d7648c0ba --- /dev/null +++ b/tests/unit/fakes.py @@ -0,0 +1,95 @@ +"""In-memory fakes for SRM's repository ports. + +A fake is a small but real implementation of a port that keeps its state in a +plain dict instead of talking to PostgreSQL, so tests can drive domain and +(future) application logic without a database and assert on outcomes rather +than on ORM calls. +""" + +from __future__ import annotations + +from uuid import UUID + +from srm.adapters.errors import DuplicateEntryError, DuplicateServiceSpecificationError +from srm.domain.models.catalog import ServiceSpecification +from srm.domain.models.runtime_inventory import ServiceInstance +from srm.domain.models.topology import Zone +from srm.domain.ports.database.catalog import ServiceSpecificationRepository +from srm.domain.ports.database.runtime_inventory import ServiceInstanceRepository +from srm.domain.ports.database.topology import ZoneRepository + + +class DuplicateServiceInstanceError(DuplicateEntryError): + entity_name = "Service Instance" + + +class InMemoryServiceSpecificationRepository(ServiceSpecificationRepository): + def __init__(self) -> None: + self.rows: dict[UUID, ServiceSpecification] = {} + + async def get_by_id(self, id: UUID) -> ServiceSpecification | None: + found = self.rows.get(id) + return found.model_copy(deep=True) if found is not None else None + + async def create(self, service_specification: ServiceSpecification) -> ServiceSpecification: + if service_specification.id in self.rows: + raise DuplicateServiceSpecificationError() + identity = ( + service_specification.app_provider_id, + service_specification.ref, + service_specification.version, + ) + for existing in self.rows.values(): + if existing.id != service_specification.id and identity == ( + existing.app_provider_id, + existing.ref, + existing.version, + ): + raise DuplicateServiceSpecificationError() + stored = service_specification.model_copy(deep=True) + self.rows[stored.id] = stored + return stored.model_copy(deep=True) + + +class InMemoryZoneRepository(ZoneRepository): + def __init__(self) -> None: + self.rows: dict[UUID, Zone] = {} + + async def get_by_id(self, id: UUID) -> Zone | None: + found = self.rows.get(id) + return found.model_copy(deep=True) if found is not None else None + + async def create(self, zone: Zone) -> Zone: + if zone.id in self.rows: + raise DuplicateEntryError() + stored = zone.model_copy(deep=True) + self.rows[stored.id] = stored + return stored.model_copy(deep=True) + + +class InMemoryServiceInstanceRepository(ServiceInstanceRepository): + def __init__(self) -> None: + self.rows: dict[UUID, ServiceInstance] = {} + + async def get_by_id(self, id: UUID) -> ServiceInstance | None: + found = self.rows.get(id) + return found.model_copy(deep=True) if found is not None else None + + async def create(self, service_instance: ServiceInstance) -> ServiceInstance: + if service_instance.id in self.rows: + raise DuplicateServiceInstanceError() + tenant_ref_zone = ( + service_instance.app_provider_id, + service_instance.ref, + service_instance.zone_id, + ) + for existing in self.rows.values(): + if existing.id != service_instance.id and tenant_ref_zone == ( + existing.app_provider_id, + existing.ref, + existing.zone_id, + ): + raise DuplicateServiceInstanceError() + stored = service_instance.model_copy(deep=True) + self.rows[stored.id] = stored + return stored.model_copy(deep=True) diff --git a/tests/unit/test_in_memory_repositories.py b/tests/unit/test_in_memory_repositories.py new file mode 100644 index 0000000000000000000000000000000000000000..5344f35ffbdf917b0bc02e5f53af829ae11c3504 --- /dev/null +++ b/tests/unit/test_in_memory_repositories.py @@ -0,0 +1,106 @@ +"""Contract tests for the in-memory repository fakes (test.unit.fakes).""" + +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +import pytest + +from srm.adapters.errors import DuplicateServiceSpecificationError +from srm.domain.models.catalog import ServiceSpecification, ServiceSpecificationState +from srm.domain.models.runtime_inventory import ServiceInstance +from srm.domain.models.runtime_inventory.enums import ServiceInstanceState +from tests.unit.fakes import ( + DuplicateServiceInstanceError, + InMemoryServiceInstanceRepository, + InMemoryServiceSpecificationRepository, +) + + +def _now() -> datetime: + return datetime(2026, 7, 7, 12, 0, tzinfo=timezone.utc) + + +def _service_specification(ref: str = "video-analytics") -> ServiceSpecification: + return ServiceSpecification( + id=uuid4(), + app_provider_id=str(uuid4()), + ref=ref, + name="Video Analytics", + version="1.2.3", + state=ServiceSpecificationState.ACTIVE, + descriptor={}, + metadata={}, + created_at=_now(), + updated_at=_now(), + ) + + +def _service_instance(app_provider_id: str, ref: str = "prod") -> ServiceInstance: + return ServiceInstance( + id=uuid4(), + zone_id=uuid4(), + service_specification_id=uuid4(), + originating_service_order_id=uuid4(), + ref=ref, + state=ServiceInstanceState.CREATING, + metadata={}, + app_provider_id=app_provider_id, + capabilities=[], + created_at=_now(), + updated_at=_now(), + ) + + +class TestInMemoryServiceSpecificationRepository: + async def test_create_round_trips_by_id(self) -> None: + repo = InMemoryServiceSpecificationRepository() + + saved = await repo.create(_service_specification()) + + assert isinstance(saved.id, UUID) + assert await repo.get_by_id(saved.id) == saved + + async def test_get_by_id_returns_none_for_unknown(self) -> None: + assert await InMemoryServiceSpecificationRepository().get_by_id(uuid4()) is None + + async def test_duplicate_provider_ref_version_is_rejected(self) -> None: + repo = InMemoryServiceSpecificationRepository() + first = _service_specification() + duplicate = _service_specification() + duplicate.app_provider_id = first.app_provider_id + + await repo.create(first) + with pytest.raises(DuplicateServiceSpecificationError): + await repo.create(duplicate) + + async def test_returned_objects_are_isolated_copies(self) -> None: + repo = InMemoryServiceSpecificationRepository() + saved = await repo.create(_service_specification()) + + saved.name = "mutated after create" + + reloaded = await repo.get_by_id(saved.id) + assert reloaded is not None + assert reloaded.name == "Video Analytics" + + +class TestInMemoryServiceInstanceRepository: + async def test_same_name_different_tenant_is_allowed(self) -> None: + repo = InMemoryServiceInstanceRepository() + + await repo.create(_service_instance(app_provider_id="tenant-a", ref="prod")) + saved = await repo.create(_service_instance(app_provider_id="tenant-b", ref="prod")) + + assert isinstance(saved.id, UUID) + + async def test_same_name_same_tenant_same_zone_is_rejected(self) -> None: + repo = InMemoryServiceInstanceRepository() + first = _service_instance(app_provider_id="tenant-a", ref="prod") + await repo.create(first) + + duplicate = _service_instance(app_provider_id="tenant-a", ref="prod") + duplicate.zone_id = first.zone_id + with pytest.raises(DuplicateServiceInstanceError): + await repo.create(duplicate) diff --git a/tests/unit/test_mappers.py b/tests/unit/test_mappers.py new file mode 100644 index 0000000000000000000000000000000000000000..5d7848b370f826f16e404bba85281cba011e76e9 --- /dev/null +++ b/tests/unit/test_mappers.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest + +from srm.adapters.database.mappers import ( + CapabilityInstanceMapper, + ControlPathBindingMapper, + ServiceInstanceMapper, + ServiceOrderMapper, + ZoneMapper, +) +from srm.domain.models.canonical_parameters.parameters import ( + CapabilityParameters, + CapabilityTarget, + Parameters, + SourceSpecification, + SourceSpecificationFamily, +) +from srm.domain.models.canonical_parameters.result import Result, ResultStatus +from srm.domain.models.runtime_inventory import ( + CapabilityInstance, + ServiceInstance, + ServiceOrder, +) +from srm.domain.models.runtime_inventory.enums import ( + CapabilityInstanceKind, + CapabilityInstanceState, + ServiceInstanceState, + ServiceOrderState, + ServiceOrderType, +) +from srm.domain.models.topology import ControlPathBinding, Zone +from srm.domain.models.topology.enums import ControlPathBindingState, ZoneKind, ZoneState + + +def _parameters() -> Parameters: + return Parameters( + target=CapabilityTarget(), + parameters=CapabilityParameters(profile_ref="gold", duration_seconds=300), + source_spec=SourceSpecification( + family=SourceSpecificationFamily.INTERNAL, + api="service.deploy", + version="1.0.0", + ), + ) + + +def _result() -> Result: + return Result(status=ResultStatus.ACTIVE) + + +def _now() -> datetime: + return datetime(2026, 7, 7, 12, 0, tzinfo=timezone.utc) + + +def _zone() -> Zone: + return Zone( + id=uuid4(), + platform_ref="edge-platform-a", + ref="zone-athens-1", + name="Athens Zone", + kind=ZoneKind.RESOURCE, + state=ZoneState.ACTIVE, + metadata={"region": "gr"}, + domains=[], + created_at=_now(), + updated_at=_now(), + ) + + +def _control_path_binding() -> ControlPathBinding: + capability_id = uuid4() + return ControlPathBinding( + id=uuid4(), + capability_id=capability_id, + ref="primary", + control_path_ref="cp://k8s-athens", + supported_runtime_kinds=["helm", "k8s_manifest"], + supported_actions=["deploy", "scale"], + priority=100, + is_default=True, + state=ControlPathBindingState.ACTIVE, + metadata={"note": "default"}, + created_at=_now(), + updated_at=_now(), + ) + + +def _service_order() -> ServiceOrder: + return ServiceOrder( + id=uuid4(), + operation_id=uuid4(), + correlation_id="corr-1", + source_component="oeg", + source_protocol="databus", + order_type=ServiceOrderType.DEPLOY_SERVICE, + service_specification_id=uuid4(), + state=ServiceOrderState.ACCEPTED, + payload_snapshot={"targets": []}, + failure_detail=None, + app_provider_id=str(uuid4()), + federation_partner_ref=None, + completed_at=None, + created_at=_now(), + updated_at=_now(), + ) + + +def _service_instance(app_provider_id: str) -> ServiceInstance: + return ServiceInstance( + id=uuid4(), + zone_id=uuid4(), + service_specification_id=uuid4(), + originating_service_order_id=uuid4(), + ref="prod", + state=ServiceInstanceState.CREATING, + metadata={}, + app_provider_id=app_provider_id, + federation_partner_ref=None, + capabilities=[], + terminated_at=None, + created_at=_now(), + updated_at=_now(), + ) + + +class TestTopologyMappers: + def test_zone_round_trips(self) -> None: + zone = _zone() + assert ZoneMapper.to_domain(ZoneMapper.to_row(zone)) == zone + + def test_control_path_binding_round_trips(self) -> None: + binding = _control_path_binding() + restored = ControlPathBindingMapper.to_domain(ControlPathBindingMapper.to_row(binding)) + assert restored == binding + + +def _capability_instance() -> CapabilityInstance: + return CapabilityInstance( + id=uuid4(), + capability_id=uuid4(), + service_instance_id=uuid4(), + originating_service_order_id=uuid4(), + service_capability_requirement_id=None, + control_path_binding_id=None, + control_path_ref_snapshot="cp://k8s-athens", + ref="deploy", + kind=CapabilityInstanceKind.ACTIVATION, + state=CapabilityInstanceState.ACTIVE, + external_id="release-xyz", + external_ref="app-instance-1", + parameters_snapshot=_parameters(), + result_summary=_result(), + failure_detail=None, + metadata={"note": "x"}, + app_provider_id=str(uuid4()), + federation_partner_ref=None, + terminated_at=None, + created_at=_now(), + updated_at=_now(), + ) + + +class TestRuntimeInventoryMappers: + def test_service_order_round_trips(self) -> None: + order = _service_order() + assert ServiceOrderMapper.to_domain(ServiceOrderMapper.to_row(order)) == order + + def test_service_instance_round_trips(self) -> None: + instance = _service_instance(app_provider_id=str(uuid4())) + restored = ServiceInstanceMapper.to_domain(ServiceInstanceMapper.to_row(instance)) + assert restored == instance + + def test_service_instance_round_trips_originating_order_fk_pair(self) -> None: + instance = _service_instance(app_provider_id=str(uuid4())) + instance.originating_service_order_id = uuid4() + + restored = ServiceInstanceMapper.to_domain(ServiceInstanceMapper.to_row(instance)) + + assert restored.originating_service_order_id == instance.originating_service_order_id + + def test_capability_instance_round_trips(self) -> None: + capability_instance = _capability_instance() + restored = CapabilityInstanceMapper.to_domain( + CapabilityInstanceMapper.to_row(capability_instance) + ) + assert restored == capability_instance + + def test_result_status_is_closed_to_known_values(self) -> None: + assert Result.model_validate({"status": "active"}).status == ResultStatus.ACTIVE + + with pytest.raises(ValueError): + Result.model_validate({"status": "unknown"}) + + +class TestTenantIdentity: + def test_service_instance_mapper_accepts_non_uuid_tenant(self) -> None: + instance = _service_instance(app_provider_id="acme-corp") + try: + row = ServiceInstanceMapper.to_row(instance) + except ValueError as exc: + pytest.fail(f"non-UUID app_provider_id crashes ServiceInstanceMapper.to_row: {exc!r}") + assert row.app_provider_id == "acme-corp" diff --git a/tests/unit/test_nats_connection_manager.py b/tests/unit/test_nats_connection_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..90d2e007ef408569002933db1e9a9951fae1ad53 --- /dev/null +++ b/tests/unit/test_nats_connection_manager.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import asyncio +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +import structlog.testing + +from srm.adapters.databus.nats_connection_manager import ( + NatsConnectionManager, + init_databus_manager, +) +from srm.config import NatsSettings + + +@pytest.fixture +def settings() -> NatsSettings: + return NatsSettings( + url="nats://broker:4222", + connect_timeout=3, + max_reconnect_attempts=2, + drain_timeout=7, + ) + + +def test_drain_timeout_defaults_when_unset() -> None: + settings = NatsSettings(url="nats://broker:4222", connect_timeout=3, max_reconnect_attempts=2) + assert settings.drain_timeout == 30 + + +def test_is_connected_false_when_no_client(settings: NatsSettings) -> None: + manager = NatsConnectionManager(settings=settings) + assert manager.is_connected is False + + +def test_client_raises_when_not_connected(settings: NatsSettings) -> None: + manager = NatsConnectionManager(settings=settings) + with pytest.raises(RuntimeError, match="not connected"): + _ = manager.client + + +async def test_connect_uses_settings(settings: NatsSettings) -> None: + mock_client = AsyncMock() + with patch( + "srm.adapters.databus.nats_connection_manager.nats.connect", + new_callable=AsyncMock, + return_value=mock_client, + ) as connect: + manager = NatsConnectionManager(settings=settings) + await manager.connect() + + call_kwargs = connect.call_args.kwargs + assert call_kwargs["servers"] == ["nats://broker:4222"] + assert call_kwargs["connect_timeout"] == 3 + assert call_kwargs["max_reconnect_attempts"] == 2 + assert call_kwargs["drain_timeout"] == 7 + assert callable(call_kwargs["error_cb"]) + assert callable(call_kwargs["disconnected_cb"]) + assert callable(call_kwargs["reconnected_cb"]) + assert manager.client is mock_client + + +class TestConnectionCallbacks: + @staticmethod + async def _connect_and_capture_callbacks(settings: NatsSettings) -> dict[str, Any]: + with patch( + "srm.adapters.databus.nats_connection_manager.nats.connect", + new_callable=AsyncMock, + return_value=AsyncMock(), + ) as connect: + await NatsConnectionManager(settings=settings).connect() + + return dict(connect.call_args.kwargs) + + async def test_error_cb_logs_the_error(self, settings: NatsSettings) -> None: + callbacks = await self._connect_and_capture_callbacks(settings) + + with structlog.testing.capture_logs() as logs: + await callbacks["error_cb"](OSError("stale connection")) + + assert [(e["event"], e["error"], e["log_level"]) for e in logs] == [ + ("nats_error", "stale connection", "error") + ] + + async def test_disconnected_cb_logs_the_url(self, settings: NatsSettings) -> None: + callbacks = await self._connect_and_capture_callbacks(settings) + + with structlog.testing.capture_logs() as logs: + await callbacks["disconnected_cb"]() + + assert [(e["event"], e["url"], e["log_level"]) for e in logs] == [ + ("nats_disconnected", "nats://broker:4222", "warning") + ] + + async def test_reconnected_cb_logs_the_url(self, settings: NatsSettings) -> None: + callbacks = await self._connect_and_capture_callbacks(settings) + + with structlog.testing.capture_logs() as logs: + await callbacks["reconnected_cb"]() + + assert [(e["event"], e["url"], e["log_level"]) for e in logs] == [ + ("nats_reconnected", "nats://broker:4222", "info") + ] + + +async def test_connect_is_noop_when_already_connected(settings: NatsSettings) -> None: + mock_client = AsyncMock() + mock_client.is_connected = True + + with patch( + "srm.adapters.databus.nats_connection_manager.nats.connect", + new_callable=AsyncMock, + return_value=mock_client, + ) as connect: + manager = NatsConnectionManager(settings=settings) + await manager.connect() + await manager.connect() + + connect.assert_awaited_once() + + +async def test_concurrent_connect_dials_only_once(settings: NatsSettings) -> None: + """Without the lock both callers see is_connected == False and dial, leaking a client.""" + mock_client = AsyncMock() + mock_client.is_connected = True + + async def slow_connect(**kwargs: Any) -> AsyncMock: + await asyncio.sleep(0) # hand control back so the second caller can interleave + return mock_client + + with patch( + "srm.adapters.databus.nats_connection_manager.nats.connect", + new_callable=AsyncMock, + side_effect=slow_connect, + ) as connect: + manager = NatsConnectionManager(settings=settings) + await asyncio.gather(manager.connect(), manager.connect()) + + connect.assert_awaited_once() + assert manager.client is mock_client + + +async def test_connect_propagates_and_logs_connection_errors(settings: NatsSettings) -> None: + with patch( + "srm.adapters.databus.nats_connection_manager.nats.connect", + new_callable=AsyncMock, + side_effect=OSError("connection refused"), + ): + manager = NatsConnectionManager(settings=settings) + with pytest.raises(OSError, match="connection refused"): + await manager.connect() + + assert manager.is_connected is False + + +async def test_close_drains_client_and_clears_state(settings: NatsSettings) -> None: + mock_client = AsyncMock() + manager = NatsConnectionManager(settings=settings) + manager._client = mock_client + + await manager.close() + + mock_client.drain.assert_awaited_once() + assert manager.is_connected is False + + +async def test_close_force_closes_when_drain_fails(settings: NatsSettings) -> None: + """A failed drain must not leave the connection open or block shutdown.""" + mock_client = AsyncMock() + mock_client.drain.side_effect = RuntimeError("drain exploded") + manager = NatsConnectionManager(settings=settings) + manager._client = mock_client + + with structlog.testing.capture_logs() as logs: + await manager.close() + + mock_client.close.assert_awaited_once() + assert manager.is_connected is False + assert [(e["event"], e["error"], e["log_level"]) for e in logs] == [ + ("nats_drain_failed", "drain exploded", "warning") + ] + + +async def test_close_when_not_connected_is_noop(settings: NatsSettings) -> None: + manager = NatsConnectionManager(settings=settings) + await manager.close() + assert manager.is_connected is False + + +async def test_init_databus_manager_connects_and_returns_manager(settings: NatsSettings) -> None: + mock_client = AsyncMock() + mock_client.is_connected = True + with patch( + "srm.adapters.databus.nats_connection_manager.nats.connect", + new_callable=AsyncMock, + return_value=mock_client, + ): + manager = await init_databus_manager(settings) + + assert isinstance(manager, NatsConnectionManager) + assert manager.is_connected is True diff --git a/tests/unit/test_nats_publisher.py b/tests/unit/test_nats_publisher.py new file mode 100644 index 0000000000000000000000000000000000000000..e6b06620ecc40e8a28d2ef3c7ced2be9e14d5c14 --- /dev/null +++ b/tests/unit/test_nats_publisher.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from srm.adapters.databus.nats_connection_manager import NatsConnectionManager +from srm.adapters.databus.nats_publisher import NatsPublisher + + +@pytest.fixture +def connection_manager() -> MagicMock: + manager = MagicMock(spec=NatsConnectionManager) + manager.client = AsyncMock() + return manager + + +async def test_publish_serializes_json_payload(connection_manager: MagicMock) -> None: + publisher = NatsPublisher(connection_manager) + + await publisher.publish("command.srm.service.deploy", {"operation_id": "abc-123"}) + + connection_manager.client.publish.assert_awaited_once_with( + "command.srm.service.deploy", + json.dumps({"operation_id": "abc-123"}).encode("utf-8"), + headers=None, + ) + + +async def test_publish_passes_headers(connection_manager: MagicMock) -> None: + publisher = NatsPublisher(connection_manager) + + await publisher.publish( + "command.srm.service.deploy", + {"operation_id": "abc-123"}, + headers={"x-correlation-id": "corr-1"}, + ) + + connection_manager.client.publish.assert_awaited_once_with( + "command.srm.service.deploy", + json.dumps({"operation_id": "abc-123"}).encode("utf-8"), + headers={"x-correlation-id": "corr-1"}, + ) + + +async def test_publish_rejects_non_json_serializable_payload(connection_manager: MagicMock) -> None: + publisher = NatsPublisher(connection_manager) + + with pytest.raises(TypeError): + await publisher.publish("command.srm.service.deploy", {"bad": object()}) + + connection_manager.client.publish.assert_not_awaited() diff --git a/tests/unit/test_service_specification_mapper.py b/tests/unit/test_service_specification_mapper.py new file mode 100644 index 0000000000000000000000000000000000000000..ee94de6fa437e1a9c68ae56d5299aa1c23467042 --- /dev/null +++ b/tests/unit/test_service_specification_mapper.py @@ -0,0 +1,205 @@ +from datetime import datetime, timezone +from uuid import uuid4 + +from srm.adapters.database.mappers import ServiceSpecificationMapper +from srm.adapters.database.sql import ( + ServiceCapabilityRequirementRow, + ServiceDeploymentUnitRow, + ServiceSpecificationRow, +) +from srm.domain.models.canonical_parameters.compute import ( + ComputeRequirements, + ComputeResources, + InterfaceVisibility, + NetworkInterface, + TopologyConstraints, +) +from srm.domain.models.canonical_parameters.parameters import ( + CapabilityParameters, + CapabilityTarget, + Parameters, + SourceSpecification, + SourceSpecificationFamily, +) +from srm.domain.models.catalog import ( + RuntimeKind, + ServiceCapabilityRequirement, + ServiceDeploymentUnit, + ServiceSpecification, + ServiceSpecificationState, +) +from srm.domain.models.common import TransportProtocol +from srm.domain.models.topology import CapabilityKind, DomainKind + + +def _compute_requirements() -> ComputeRequirements: + return ComputeRequirements( + compute=ComputeResources(cpu_millicores=500, memory_mb=1024), + topology=TopologyConstraints(min_nodes=1), + interfaces=[ + NetworkInterface( + component="api", + interface_id="http", + protocol=TransportProtocol.TCP, + port=8080, + visibility=InterfaceVisibility.EXTERNAL, + ) + ], + standalone=True, + ) + + +def _parameters() -> Parameters: + return Parameters( + target=CapabilityTarget(), + parameters=CapabilityParameters(profile_ref="gold", duration_seconds=300), + source_spec=SourceSpecification( + family=SourceSpecificationFamily.INTERNAL, + api="service.deploy", + version="1.0.0", + ), + ) + + +def _timestamp() -> datetime: + return datetime(2026, 7, 7, 12, 0, tzinfo=timezone.utc) + + +def _service_specification_row() -> ServiceSpecificationRow: + now = _timestamp() + spec_id = uuid4() + deployment_unit_id = uuid4() + return ServiceSpecificationRow( + id=spec_id, + app_provider_id=str(uuid4()), + ref="video-analytics", + name="Video Analytics", + version="1.2.3", + state=ServiceSpecificationState.ACTIVE, + descriptor={"category": "edge-app"}, + service_spec_metadata={"tenant": "demo"}, + deployment_units=[ + ServiceDeploymentUnitRow( + id=deployment_unit_id, + service_specification_id=spec_id, + ref="main", + name="Main Unit", + runtime_kind=RuntimeKind.HELM, + artifact_ref="oci://example/video-analytics", + resource_requirements=_compute_requirements().model_dump(mode="python"), + parameters_schema={"type": "object"}, + deployment_unit_metadata={"tier": "gold"}, + created_at=now, + updated_at=now, + ) + ], + capability_requirements=[ + ServiceCapabilityRequirementRow( + id=uuid4(), + service_specification_id=spec_id, + deployment_unit_id=deployment_unit_id, + ref="compute-capability", + capability_kind=CapabilityKind.DEPLOY_WORKLOAD, + domain_kind=DomainKind.COMPUTE, + is_required=True, + selector={"zone": "edge-a"}, + parameters=_parameters().model_dump(mode="python"), + policy={"capacity_check": "preferred"}, + capability_requirement_metadata={"scope": "main"}, + created_at=now, + updated_at=now, + ) + ], + created_at=now, + updated_at=now, + ) + + +def _service_specification_domain() -> ServiceSpecification: + now = _timestamp() + spec_id = uuid4() + deployment_unit_id = uuid4() + return ServiceSpecification( + id=spec_id, + app_provider_id=str(uuid4()), + ref="video-analytics", + name="Video Analytics", + version="1.2.3", + state=ServiceSpecificationState.ACTIVE, + descriptor={"category": "edge-app"}, + metadata={"tenant": "demo"}, + deployment_units=[ + ServiceDeploymentUnit( + id=deployment_unit_id, + service_specification_id=spec_id, + ref="main", + name="Main Unit", + runtime_kind=RuntimeKind.HELM, + artifact_ref="oci://example/video-analytics", + resource_requirements=_compute_requirements(), + parameters_schema={"type": "object"}, + metadata={"tier": "gold"}, + created_at=now, + updated_at=now, + ) + ], + capability_requirements=[ + ServiceCapabilityRequirement( + id=uuid4(), + service_specification_id=spec_id, + deployment_unit_id=deployment_unit_id, + ref="compute-capability", + capability_kind=CapabilityKind.DEPLOY_WORKLOAD, + domain_kind=DomainKind.COMPUTE, + is_required=True, + selector={"zone": "edge-a"}, + parameters=_parameters(), + policy={"capacity_check": "preferred"}, + metadata={"scope": "main"}, + created_at=now, + updated_at=now, + ) + ], + created_at=now, + updated_at=now, + ) + + +def test_to_domain_maps_service_specification_aggregate() -> None: + row = _service_specification_row() + + domain = ServiceSpecificationMapper.to_domain(row) + + assert domain.id == row.id + assert domain.app_provider_id == row.app_provider_id + assert len(domain.deployment_units) == 1 + assert domain.deployment_units[0].service_specification_id == row.id + assert domain.deployment_units[0].resource_requirements == _compute_requirements() + assert len(domain.capability_requirements) == 1 + assert domain.capability_requirements[0].service_specification_id == row.id + assert ( + domain.capability_requirements[0].deployment_unit_id + == row.capability_requirements[0].deployment_unit_id + ) + assert domain.capability_requirements[0].parameters == _parameters() + + +def test_to_row_serializes_service_specification_aggregate() -> None: + domain = _service_specification_domain() + + row = ServiceSpecificationMapper.to_row(domain) + + assert row.id == domain.id + assert row.app_provider_id == domain.app_provider_id + assert len(row.deployment_units) == 1 + assert row.deployment_units[0].service_specification_id == domain.id + assert row.deployment_units[0].resource_requirements == _compute_requirements().model_dump( + mode="python" + ) + assert len(row.capability_requirements) == 1 + assert row.capability_requirements[0].service_specification_id == domain.id + assert ( + row.capability_requirements[0].deployment_unit_id + == domain.capability_requirements[0].deployment_unit_id + ) + assert row.capability_requirements[0].parameters == _parameters().model_dump(mode="python") diff --git a/tests/unit/test_service_specification_repo.py b/tests/unit/test_service_specification_repo.py new file mode 100644 index 0000000000000000000000000000000000000000..4f4e6cbcbc08b86be5bc6bf8a53b5f169a96869e --- /dev/null +++ b/tests/unit/test_service_specification_repo.py @@ -0,0 +1,182 @@ +from datetime import datetime, timezone +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from srm.adapters.database.mappers import ServiceSpecificationMapper +from srm.adapters.database.repos.catalog import SqlServiceSpecificationRepository +from srm.adapters.database.sql import ( + ServiceCapabilityRequirementRow, + ServiceDeploymentUnitRow, + ServiceSpecificationRow, +) +from srm.adapters.errors import DuplicateServiceSpecificationError +from srm.domain.models.canonical_parameters.compute import ( + ComputeRequirements, + ComputeResources, + TopologyConstraints, +) +from srm.domain.models.canonical_parameters.parameters import ( + CapabilityParameters, + CapabilityTarget, + Parameters, + SourceSpecification, + SourceSpecificationFamily, +) +from srm.domain.models.catalog import RuntimeKind, ServiceSpecification, ServiceSpecificationState +from srm.domain.models.topology import CapabilityKind, DomainKind + + +class PgCodeError(Exception): + def __init__(self, message: str, pgcode: str) -> None: + super().__init__(message) + self.pgcode = pgcode + + +def _timestamp() -> datetime: + return datetime(2026, 7, 7, 12, 0, tzinfo=timezone.utc) + + +def _service_specification_row() -> ServiceSpecificationRow: + now = _timestamp() + spec_id = uuid4() + deployment_unit_id = uuid4() + return ServiceSpecificationRow( + id=spec_id, + app_provider_id=str(uuid4()), + ref="video-analytics", + name="Video Analytics", + version="1.2.3", + state=ServiceSpecificationState.ACTIVE, + descriptor={"category": "edge-app"}, + service_spec_metadata={"tenant": "demo"}, + deployment_units=[ + ServiceDeploymentUnitRow( + id=deployment_unit_id, + service_specification_id=spec_id, + ref="main", + name="Main Unit", + runtime_kind=RuntimeKind.HELM, + artifact_ref="oci://example/video-analytics", + resource_requirements=ComputeRequirements( + compute=ComputeResources(cpu_millicores=500, memory_mb=1024), + topology=TopologyConstraints(min_nodes=1), + ).model_dump(mode="python"), + parameters_schema={"type": "object"}, + deployment_unit_metadata={"tier": "gold"}, + created_at=now, + updated_at=now, + ) + ], + capability_requirements=[ + ServiceCapabilityRequirementRow( + id=uuid4(), + service_specification_id=spec_id, + deployment_unit_id=deployment_unit_id, + ref="compute-capability", + capability_kind=CapabilityKind.DEPLOY_WORKLOAD, + domain_kind=DomainKind.COMPUTE, + is_required=True, + selector={"zone": "edge-a"}, + parameters=Parameters( + target=CapabilityTarget(), + parameters=CapabilityParameters(profile_ref="gold"), + source_spec=SourceSpecification( + family=SourceSpecificationFamily.INTERNAL, + api="service.deploy", + version="1.0.0", + ), + ).model_dump(mode="python"), + policy={"capacity_check": "preferred"}, + capability_requirement_metadata={"scope": "main"}, + created_at=now, + updated_at=now, + ) + ], + created_at=now, + updated_at=now, + ) + + +def _service_specification_domain() -> ServiceSpecification: + now = _timestamp() + return ServiceSpecification( + id=uuid4(), + app_provider_id=str(uuid4()), + ref="video-analytics", + name="Video Analytics", + version="1.2.3", + state=ServiceSpecificationState.ACTIVE, + descriptor={"category": "edge-app"}, + metadata={"tenant": "demo"}, + created_at=now, + updated_at=now, + ) + + +async def test_get_by_id_returns_mapped_service_specification() -> None: + row = _service_specification_row() + session = AsyncMock(spec=AsyncSession) + session.scalar.return_value = row + repo = SqlServiceSpecificationRepository(session) + + result = await repo.get_by_id(row.id) + + assert result is not None + assert result.id == row.id + assert len(result.deployment_units) == 1 + session.scalar.assert_awaited_once() + + +async def test_create_flushes_and_reloads_service_specification() -> None: + domain = _service_specification_domain() + session = AsyncMock(spec=AsyncSession) + reloaded = _service_specification_row() + reloaded.id = domain.id + session.scalar.return_value = reloaded + repo = SqlServiceSpecificationRepository(session) + + result = await repo.create(domain) + + expected = ServiceSpecificationMapper.to_domain(reloaded) + assert result == expected + session.add.assert_called_once() + session.flush.assert_awaited_once() + session.commit.assert_not_awaited() + session.scalar.assert_awaited_once() + + +async def test_create_raises_duplicate_service_specification_error_on_integrity_error() -> None: + domain = _service_specification_domain() + session = AsyncMock(spec=AsyncSession) + orig = PgCodeError("duplicate key value violates unique constraint", "23505") + session.flush.side_effect = IntegrityError("duplicate", params=None, orig=orig) + repo = SqlServiceSpecificationRepository(session) + + with pytest.raises(DuplicateServiceSpecificationError): + await repo.create(domain) + + +async def test_create_propagates_non_unique_integrity_errors() -> None: + domain = _service_specification_domain() + session = AsyncMock(spec=AsyncSession) + orig = PgCodeError("null value in column violates not-null constraint", "23502") + session.flush.side_effect = IntegrityError("insert", params=None, orig=orig) + repo = SqlServiceSpecificationRepository(session) + + with pytest.raises(IntegrityError): + await repo.create(domain) + + +async def test_create_propagates_check_violation_integrity_errors() -> None: + domain = _service_specification_domain() + session = AsyncMock(spec=AsyncSession) + orig = PgCodeError("new row violates check constraint", "23514") + session.flush.side_effect = IntegrityError("insert", params=None, orig=orig) + repo = SqlServiceSpecificationRepository(session) + + with pytest.raises(IntegrityError): + await repo.create(domain) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000000000000000000000000000000000..19113912835f94ab68290d99d08c02e8ea80392b --- /dev/null +++ b/uv.lock @@ -0,0 +1,1468 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[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 = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "asgi-lifespan" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/da/e7908b54e0f8043725a990bf625f2041ecf6bfe8eb7b19407f1c00b630f7/asgi-lifespan-2.1.0.tar.gz", hash = "sha256:5e2effaf0bfe39829cf2d64e7ecc47c7d86d676a6599f7afba378c31f5e3a308", size = 15627, upload-time = "2023-03-28T17:35:49.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/f5/c36551e93acba41a59939ae6a0fb77ddb3f2e8e8caa716410c65f7341f72/asgi_lifespan-2.1.0-py3-none-any.whl", hash = "sha256:ed840706680e28428c01e14afb3875d7d76d3206f3d5b2f2294e059b5c23804f", size = 10895, upload-time = "2023-03-28T17:35:47.772Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, +] + +[[package]] +name = "auto-mix-prep" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/8d/94fade74736b4e94dd8dd45c2a2d12fc4863f3ffe8decc26abc7353be21d/auto-mix-prep-0.2.0.tar.gz", hash = "sha256:5e7d9c6e3fd73c49bf22a216e04d7249d529d8822f5ee3d25f3dcf6c982a92d6", size = 2901, upload-time = "2015-05-23T18:20:59.465Z" } + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[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 = "colorlog" +version = "6.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/38/2992ff192eaa7dd5a793f8b6570d6bbe887c4fbbf7e72702eb0a693a01c8/colorlog-6.8.2.tar.gz", hash = "sha256:3e3e079a41feb5a1b64f978b5ea4f46040a94f11f0e8bbb8261e3dbbeca64d44", size = 16529, upload-time = "2024-01-26T13:59:28.628Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/18/3e867ab37a24fdf073c1617b9c7830e06ec270b1ea4694a624038fc40a03/colorlog-6.8.2-py3-none-any.whl", hash = "sha256:4dcbb62368e2800cb3c5abd348da7e53f6c362dda502ec27c560b2e58a66bd33", size = 11357, upload-time = "2024-01-26T13:59:27.064Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, +] + +[[package]] +name = "detect-installer" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/ce/6897d812825e9d4c53e3c7112726e800cc5231b013b2223bf64f653ff362/detect_installer-0.1.0.tar.gz", hash = "sha256:00ad7ba0a36e3cf7d08a40d3643011746dbc112597c7d475cc91c416710ca4e7", size = 3049, upload-time = "2026-02-23T10:40:22.567Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/34/8cc73273414405086c58852916e4031812a6a30fe04c057e37ad99397b7f/detect_installer-0.1.0-py3-none-any.whl", hash = "sha256:034fb20fd665c36e6ba52b8821525ea07fb4f7f938cac459df889fb33801528a", size = 4539, upload-time = "2026-02-23T10:40:23.807Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "docker" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/7f/731ff914b0255d3d065f45fd4e626d4b8c95dbcbaada049f337a6ac16410/docker-7.2.0.tar.gz", hash = "sha256:cebb93773d334f778e023a7ee352a8d6e13ab1bd3b863a4d4a59dec897df43ac", size = 118731, upload-time = "2026-07-09T14:53:46.39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/23/529140fe1aab80fc6992f93a706deec709140a6397439139a054e1515c45/docker-7.2.0-py3-none-any.whl", hash = "sha256:a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f", size = 148775, upload-time = "2026-07-09T14:53:45.224Z" }, +] + +[[package]] +name = "durationpy" +version = "0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335, upload-time = "2025-05-17T13:52:37.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "fastapi" +version = "0.139.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/71/f4cfcd72fd94af40d1c76141b69c3d27acaa3641e8fd64872929ff6998b9/fastapi-0.139.1.tar.gz", hash = "sha256:99461bde7ac3fc34c78443da1f4dad3ca8f3182580029a2827692db216a8d7ae", size = 422782, upload-time = "2026-07-16T09:18:34.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/4d/73d2e5e891d56722f5b6def27f67e91332342a18468f2ea20aa5da9ff64d/fastapi-0.139.1-py3-none-any.whl", hash = "sha256:17faa81907751a8a85cd44c46f37fb576bde0078cb37de40bf1cd55de7104d87", size = 130147, upload-time = "2026-07-16T09:18:32.723Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "email-validator" }, + { name = "fastapi-cli", extra = ["standard"] }, + { name = "fastar" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "pydantic-extra-types" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cli" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich-toolkit" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/eb/3b534c6f8e157f9ddbf2a153512307c886cad0b258739c200dd8ff8c4452/fastapi_cli-0.0.32.tar.gz", hash = "sha256:38024d2345275e1b37ce8848727a580d84901b570e96b3256d9d36a9a5039424", size = 26636, upload-time = "2026-07-16T12:16:58.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/53/56ae5ae17bb0a5d89d1d31e5320eb1865553ebbfbde91cdc4c221245f2a8/fastapi_cli-0.0.32-py3-none-any.whl", hash = "sha256:8dcc286fa32f01bbd3f65dd09cfd5a2540ed5f2230b77db7fd30978d6165f3c4", size = 14670, upload-time = "2026-07-16T12:16:57.297Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "fastapi-cloud-cli" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cloud-cli" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "detect-installer" }, + { name = "fastar" }, + { name = "httpx" }, + { name = "pydantic", extra = ["email"] }, + { name = "rich-toolkit" }, + { name = "rignore" }, + { name = "sentry-sdk" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/f2/36bfe990baa656de89a2b98a77a15dcd018474f7245c8e4a10cada0553c5/fastapi_cloud_cli-0.22.2.tar.gz", hash = "sha256:7ec78c1fed58f578af5eb1fb54ec4b456eba4dc1eaca1c3a93cf499a7cbc7ab3", size = 94480, upload-time = "2026-07-14T09:27:01.349Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/25/e01631a63a5213fc783e5b84e8a27eca800f21549aa414644a0a29181045/fastapi_cloud_cli-0.22.2-py3-none-any.whl", hash = "sha256:37c6b05adb94a4c9f59916a559d041c565db36b5f3dddeed420bf0a51182c363", size = 77744, upload-time = "2026-07-14T09:27:02.571Z" }, +] + +[[package]] +name = "fastar" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/0f/0aeb3fc50046617702acc0078b277b58367fd62eb727b9ec733ae0e8bbcc/fastar-0.11.0.tar.gz", hash = "sha256:aa7f100f7313c03fdb20f1385927ba95671071ba308ad0c1763fef295e1895ce", size = 70238, upload-time = "2026-04-13T17:11:17.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/06/a5773706afc8bd496769786590bbc56d2d0ee419a299cc12ea3f5717fcf3/fastar-0.11.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3c51f1c2cdddbd1420d2897ace7738e36c65e17f6ae84e0bfe763f8d1068bb97", size = 708394, upload-time = "2026-04-13T17:09:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a6/d5e2a4e48495616440a21eed07558219ca90243ad00b0502586f95bd4833/fastar-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0d9d6b052baf5380baea866675dab6ccd04ec2460d12b1c46f10ce3f4ee6a820", size = 628417, upload-time = "2026-04-13T17:09:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/ab/69/9816d69ac8265c9e50456637a487ccfb7a9c566efd9dbcd673df9c2558c2/fastar-0.11.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd2f05666d4df7e14885b5c38fefd92a785917387513d33d837ff42ec143a22f", size = 863950, upload-time = "2026-04-13T17:09:11.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/0d/f88daad53aff2e754b6b5ff2a7113f72447a34f6ef17cc23ca99988117b7/fastar-0.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e6e74aba1ae77ca4aedcaf1697cd413319f4c88a5ccbe5b42c709517c5097e", size = 760737, upload-time = "2026-04-13T17:07:55.958Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a6/82ef4ecd969d50d92ed3ed9dbd8fe77faa24be5e5736f716edc9f4ce8d62/fastar-0.11.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:38ef77fe940bbc9b37a98bd838727f844b11731cd39358a2640ff864fb385086", size = 757603, upload-time = "2026-04-13T17:08:10.623Z" }, + { url = "https://files.pythonhosted.org/packages/03/35/50249f0d827251f8ac511495e2eacccebda80a00a0ad73e9615b8113b84f/fastar-0.11.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8955e61b32d6aff82c983217abf80933fd823b0e727586fc72f08043d996fd59", size = 923952, upload-time = "2026-04-13T17:08:25.526Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d8/faee41659e9c379d906d24eaee6d6833ac8cfef0a5df480e5c2a8d3efb33/fastar-0.11.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:483532442cdb08fbff0169510224eae0836f2f672cea6aacb52847d90fefdc46", size = 816574, upload-time = "2026-04-13T17:08:56.076Z" }, + { url = "https://files.pythonhosted.org/packages/22/47/0448ea7992b997dad2bf004bfd98eca74b5858630eae080b50c7b17d9ddc/fastar-0.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef5a6071121e05d8287fc75bccb054bcbac8bb0501200a0c0a8feeace5303ea4", size = 819382, upload-time = "2026-04-13T17:09:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/33/ef/0d63eb43586831b7a6f8b22c4d77125a7c594423af1f4f090fa9541b9b40/fastar-0.11.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e45e598af5afe8412197d4786efd6cf29be02e7d3d4f6a3461149eae5d7e94f1", size = 885254, upload-time = "2026-04-13T17:08:40.9Z" }, + { url = "https://files.pythonhosted.org/packages/01/25/edd584675d69e49a165052c3ee886df1c5d574f3e7d813c990306387c623/fastar-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2e160919b1c47ddb8538e7e8eb4cd527281b40f0bf75110a75993838ef61f286", size = 971239, upload-time = "2026-04-13T17:10:12.997Z" }, + { url = "https://files.pythonhosted.org/packages/a5/37/e8bb24f506ba2b08fbaf36c5800e843bd4d542954e9331f00418e2d23349/fastar-0.11.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4bb4dc0fc8f7a6807febcebce8a2f3626ba4955a9263d81ecc630aad83be84c0", size = 1035185, upload-time = "2026-04-13T17:10:30.207Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bf/be753736296338149ee4cb3e92e2b5423d6ba17c7b951d15218fd7e99bbf/fastar-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4ec95af56aa173f6e320e1183001bf108ba59beaf13edd1fc8200648db203588", size = 1072191, upload-time = "2026-04-13T17:10:47.072Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cd/a81c1aaafb5a22ce57c98ae22f39c89413ed53e4ee6e1b1444b0bd666a6c/fastar-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:136cf342735464091c39dc3708168f9fdeb9ebea40b1ead937c61afaf46143d9", size = 1028054, upload-time = "2026-04-13T17:11:04.293Z" }, + { url = "https://files.pythonhosted.org/packages/ec/88/1ce4eed3d70627c95f49ca017f6bbbf2ddcc4b0c601d293259de7689bc20/fastar-0.11.0-cp312-cp312-win32.whl", hash = "sha256:35f23c11b556cc4d3704587faacbc0037f7bdf6c4525cd1d09c70bda4b1c6809", size = 454198, upload-time = "2026-04-13T17:11:45.168Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1d/26ce92f4331cd61a69840db9ca6115829805eec24f285481a854f578e917/fastar-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:920bc56c3c0b8a8ca492904941d1883c1c947c858cd93343356c29122a38f44c", size = 486697, upload-time = "2026-04-13T17:11:31.084Z" }, + { url = "https://files.pythonhosted.org/packages/ed/96/e6eda4480559c69b05d466e7b5ea9170e81fef3795a73e059959a3258319/fastar-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:395248faf89e8a6bd5dc1fd544c8465113b627cb6d7c8b296796b60ebea33593", size = 462591, upload-time = "2026-04-13T17:11:20.577Z" }, +] + +[[package]] +name = "filelock" +version = "3.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2b/8b6480a70a647035334a604d0931926de4b5cd1f57835d45ad5eed2b1a1e/filelock-3.30.0.tar.gz", hash = "sha256:1774e682dbe443bd60f9609162fc596e2c80dc84ffc2957068953406d0520090", size = 174927, upload-time = "2026-07-16T03:53:58.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/af/9b01bcf5c91e81899bb890b87bd9077732a9b3365c098e67fe77958c39ed/filelock-3.30.0-py3-none-any.whl", hash = "sha256:40632998f0772e64183bb819f086a1b9def6be1090cf1dcb9d45f46806ef279b", size = 93131, upload-time = "2026-07-16T03:53:56.727Z" }, +] + +[[package]] +name = "google-auth" +version = "2.56.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/66/b4ba60005743e01933e22b4f62313e063f7460458b7d8a358427b4930013/google_auth-2.56.0.tar.gz", hash = "sha256:f90fa030b569a92654b9d690665a073841df33d57487be53db583a9a0867a553", size = 364629, upload-time = "2026-07-13T19:09:57.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/7d/cd3e187f14ce832e419e70709bfcc40cb0dc11517d5d03c9d3919bcc3101/google_auth-2.56.0-py3-none-any.whl", hash = "sha256:6e88c10217e07a92bfd01cac8ee99e32ccfb08414c3102e6c5b8d58f37a0d1e0", size = 257976, upload-time = "2026-07-13T19:09:42.685Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, + { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, + { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, + { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" }, +] + +[[package]] +name = "grimp" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/73/ce58881177b003def779c87b5e10f396deef068933c97d6d206bd46d4cb7/grimp-3.15.tar.gz", hash = "sha256:91b57d4d801dc107ebfb5a7040d4777a152c579b5dc202426e1185e50931fe1e", size = 831734, upload-time = "2026-07-03T12:09:36.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/66/621abde26d8ece0d34ba611ca94bc62bf8c9c4389760d8909b0d96964878/grimp-3.15-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:915ea140cf55107fd6825c3e9eae2c4fda18aa19b87e8eee05d510b4d44ab928", size = 2143914, upload-time = "2026-07-03T12:08:50.423Z" }, + { url = "https://files.pythonhosted.org/packages/c8/76/a27fff8de84dbf46db9d6da937fe772f04a0e21e057a4863fd30e6fcaa55/grimp-3.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ae2d4d958d871792a9686ad51845e9e1e0886e9db13ecc47a9475899f4b27db", size = 2089296, upload-time = "2026-07-03T12:08:42.802Z" }, + { url = "https://files.pythonhosted.org/packages/e1/3d/fe38a0881ce7e00ef8590745853bccff5d337a943dc0d1d0735b0eb605f9/grimp-3.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19a1039d50ed6a9b221f44b32c7b07cb43dda70971e892fe8149995c0e9c1840", size = 2254829, upload-time = "2026-07-03T12:07:34.365Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a2/ef18989048e8f0c92171eabb15dffe9cd72de6404b86e3a37553f7d16dd6/grimp-3.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5b7b649f2a34278897237670b6650073c9ab0fc1abf56821301bda28cb5c4256", size = 2193553, upload-time = "2026-07-03T12:07:45.06Z" }, + { url = "https://files.pythonhosted.org/packages/85/22/82303539d21068021cc28c526be5e1b1cc0b7a61704c1663909497dd9b8a/grimp-3.15-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:020a8875c0cb67f407eb019b7be65b574d49071653f155c243f801aa87a1fd4d", size = 2345941, upload-time = "2026-07-03T12:08:18.082Z" }, + { url = "https://files.pythonhosted.org/packages/bd/20/3c66e7c814ba2b6b0cd230ca2445825c605f28242f4f3a658e5bb9adda73/grimp-3.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f0a0705cf9a10648c4aea71edde8fe3dfbf1cf05bd434ef38c813ad7c544886", size = 2604369, upload-time = "2026-07-03T12:07:55.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/2f642969950e096a67a43909ba66f33cb4750974e30c2c771e293aeb787c/grimp-3.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c36373cd0a2d4c9b53fabefbaa9edcc4c511ad2d335fb1296484f6ca550e4f82", size = 2326619, upload-time = "2026-07-03T12:08:05.931Z" }, + { url = "https://files.pythonhosted.org/packages/a1/99/98d39545e54e239a52a54d8e96752780778b11b5ebc78096dfa090f9d2ac/grimp-3.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:477ac0abcd12c697a0bd01b40875605f7f0db97332df6148e4d4057ee3ad199d", size = 2272955, upload-time = "2026-07-03T12:08:30.488Z" }, + { url = "https://files.pythonhosted.org/packages/d6/02/fabd5ae2b12276530f4bae038ffcf3a556ac2c9b9fa271f83fbeb4036a08/grimp-3.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:017e493fee9962d50db6f7a8b5d49ad2ae508484a06078d700adbef30f1ff1f0", size = 2431271, upload-time = "2026-07-03T12:08:57.606Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c8/fa3ec84df9c3ccc2b08177be41a48b76178e9e5773f4471f1caff4fc5c46/grimp-3.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e77982dd5b0977945034fa328f65cfb62ff589cb0da97a0f6042de78525c5c73", size = 2466937, upload-time = "2026-07-03T12:09:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/52d3acd2bbd6cebdf2ee6546c334f50f6358c25ae58624ae63d2ec3ad30b/grimp-3.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d129a8c57b7a19a44e8da94caf38a02753f1c9953aaba8c1a4633747f097164f", size = 2504734, upload-time = "2026-07-03T12:09:17.998Z" }, + { url = "https://files.pythonhosted.org/packages/33/53/ad27750eb8b4a0c3ecb5ca7d78c7230f0f5e814515ed6f8986be527117ff/grimp-3.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba25002e92b1792f13391295a1f805d2e09781b846d92ec1a791ff9ed89298b6", size = 2514448, upload-time = "2026-07-03T12:09:28.401Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c0/8cc474a24198c1c2936269c5854be92af41787bd76d3190af584a9cebca7/grimp-3.15-cp312-cp312-win32.whl", hash = "sha256:232bf7a4c7536f62a99478eeb01de63c455261add35ee00c6ec9f04f280f853e", size = 1855806, upload-time = "2026-07-03T12:09:48.396Z" }, + { url = "https://files.pythonhosted.org/packages/91/11/e46139fd43dd5714fae93f09f1c858fb2dc83a575d5f8cb1daf8a15a261b/grimp-3.15-cp312-cp312-win_amd64.whl", hash = "sha256:13be2285e358a7687c0f3b798ac9d4819f275976ad8d651297966e5a75bfafb9", size = 1985556, upload-time = "2026-07-03T12:09:40.786Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "import-linter" +version = "2.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "grimp" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/c6/42962eb043df4d6984c1540220735b20442572fe37dab5e65ac807c939b9/import_linter-2.13.tar.gz", hash = "sha256:13af4a1d6b06044c58ea784e8732fd7fe48eec821a75feb4d6a1a2de36dd5c27", size = 1279761, upload-time = "2026-07-03T14:00:31.285Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/13/e7725e6eb32607fd4af51ccf3edfe835826e5cdf783b4d7fdc8f459196ae/import_linter-2.13-py3-none-any.whl", hash = "sha256:c0372e7ee5e15657bc06a8e841445e13237afd738a672d26863dc927af9f0bf5", size = 638185, upload-time = "2026-07-03T14:00:29.676Z" }, +] + +[[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 = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "kubernetes" +version = "33.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "durationpy" }, + { name = "google-auth" }, + { name = "oauthlib" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "six" }, + { name = "urllib3" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/52/19ebe8004c243fdfa78268a96727c71e08f00ff6fe69a301d0b7fcbce3c2/kubernetes-33.1.0.tar.gz", hash = "sha256:f64d829843a54c251061a8e7a14523b521f2dc5c896cf6d65ccf348648a88993", size = 1036779, upload-time = "2025-06-09T21:57:58.521Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/43/d9bebfc3db7dea6ec80df5cb2aad8d274dd18ec2edd6c4f21f32c237cbbb/kubernetes-33.1.0-py2.py3-none-any.whl", hash = "sha256:544de42b24b64287f7e0aa9513c93cb503f7f40eea39b20f66810011a86eabc5", size = 1941335, upload-time = "2025-06-09T21:57:56.327Z" }, +] + +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nats-py" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/f0/fc5e93f2b0dd14a202590ad9d30eda1955ea872039b5204357348d0f4b1e/nats_py-2.15.0.tar.gz", hash = "sha256:6622c547d9a7d2313d9c147d46c386188f4ec2c7b5c9f9a0438a4d1b55f54a93", size = 75995, upload-time = "2026-06-05T07:34:03.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/a8/b55606c7c621fb813c8ec78baf201d2c78bf6051091ec0c7ada572999e95/nats_py-2.15.0-py3-none-any.whl", hash = "sha256:9f8d36aa52a9926a88b8f1d70cf1fdce0ad387941479b500ee9ab3e51073cefd", size = 90334, upload-time = "2026-06-05T07:34:02.81Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[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 = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[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 = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.3" +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/10/2e/ca897f093ee6c5f3b0bee123ee4465c50e75431c3d5b6a3b44a47134e891/pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3", size = 785513, upload-time = "2025-04-08T13:27:06.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/1d/407b29780a289868ed696d1616f4aad49d6388e5a77f567dcd2629dcd7b8/pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f", size = 443591, upload-time = "2025-04-08T13:27:03.789Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/19/ed6a078a5287aea7922de6841ef4c06157931622c89c2a47940837b5eecd/pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df", size = 434395, upload-time = "2025-04-02T09:49:41.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/ce/3cb22b07c29938f97ff5f5bb27521f95e2ebec399b882392deb68d6c440e/pydantic_core-2.33.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8", size = 2026640, upload-time = "2025-04-02T09:47:25.394Z" }, + { url = "https://files.pythonhosted.org/packages/19/78/f381d643b12378fee782a72126ec5d793081ef03791c28a0fd542a5bee64/pydantic_core-2.33.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498", size = 1852649, upload-time = "2025-04-02T09:47:27.417Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2b/98a37b80b15aac9eb2c6cfc6dbd35e5058a352891c5cce3a8472d77665a6/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939", size = 1892472, upload-time = "2025-04-02T09:47:29.006Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d4/3c59514e0f55a161004792b9ff3039da52448f43f5834f905abef9db6e4a/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d", size = 1977509, upload-time = "2025-04-02T09:47:33.464Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b6/c2c7946ef70576f79a25db59a576bce088bdc5952d1b93c9789b091df716/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e", size = 2128702, upload-time = "2025-04-02T09:47:34.812Z" }, + { url = "https://files.pythonhosted.org/packages/88/fe/65a880f81e3f2a974312b61f82a03d85528f89a010ce21ad92f109d94deb/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3", size = 2679428, upload-time = "2025-04-02T09:47:37.315Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ff/4459e4146afd0462fb483bb98aa2436d69c484737feaceba1341615fb0ac/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d", size = 2008753, upload-time = "2025-04-02T09:47:39.013Z" }, + { url = "https://files.pythonhosted.org/packages/7c/76/1c42e384e8d78452ededac8b583fe2550c84abfef83a0552e0e7478ccbc3/pydantic_core-2.33.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b", size = 2114849, upload-time = "2025-04-02T09:47:40.427Z" }, + { url = "https://files.pythonhosted.org/packages/00/72/7d0cf05095c15f7ffe0eb78914b166d591c0eed72f294da68378da205101/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39", size = 2069541, upload-time = "2025-04-02T09:47:42.01Z" }, + { url = "https://files.pythonhosted.org/packages/b3/69/94a514066bb7d8be499aa764926937409d2389c09be0b5107a970286ef81/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a", size = 2239225, upload-time = "2025-04-02T09:47:43.425Z" }, + { url = "https://files.pythonhosted.org/packages/84/b0/e390071eadb44b41f4f54c3cef64d8bf5f9612c92686c9299eaa09e267e2/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db", size = 2248373, upload-time = "2025-04-02T09:47:44.979Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/288b3579ffc07e92af66e2f1a11be3b056fe1214aab314748461f21a31c3/pydantic_core-2.33.1-cp312-cp312-win32.whl", hash = "sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda", size = 1907034, upload-time = "2025-04-02T09:47:46.843Z" }, + { url = "https://files.pythonhosted.org/packages/02/28/58442ad1c22b5b6742b992ba9518420235adced665513868f99a1c2638a5/pydantic_core-2.33.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4", size = 1956848, upload-time = "2025-04-02T09:47:48.404Z" }, + { url = "https://files.pythonhosted.org/packages/a1/eb/f54809b51c7e2a1d9f439f158b8dd94359321abcc98767e16fc48ae5a77e/pydantic_core-2.33.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea", size = 1903986, upload-time = "2025-04-02T09:47:49.839Z" }, +] + +[[package]] +name = "pydantic-extra-types" +version = "2.10.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/fa/6b268a47839f8af46ffeb5bb6aee7bded44fbad54e6bf826c11f17aef91a/pydantic_extra_types-2.10.3.tar.gz", hash = "sha256:dcc0a7b90ac9ef1b58876c9b8fdede17fbdde15420de9d571a9fccde2ae175bb", size = 95128, upload-time = "2025-03-11T13:00:42.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0a/f6f8e5f79d188e2f3fa9ecfccfa72538b685985dd5c7c2886c67af70e685/pydantic_extra_types-2.10.3-py3-none-any.whl", hash = "sha256:e8b372752b49019cd8249cc192c62a820d8019f5382a8789d0f887338a59c0f3", size = 37175, upload-time = "2025-03-11T13:00:40.919Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pydub" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326, upload-time = "2021-03-10T02:09:54.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" }, +] + +[[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 = "pymongo" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/5a/d664298bf54762f0c89b8aa2c276868070e06afb853b4a8837de5741e5f9/pymongo-4.13.2.tar.gz", hash = "sha256:0f64c6469c2362962e6ce97258ae1391abba1566a953a492562d2924b44815c2", size = 2167844, upload-time = "2025-06-16T18:16:30.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e0/0e187750e23eed4227282fcf568fdb61f2b53bbcf8cbe3a71dde2a860d12/pymongo-4.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ec89516622dfc8b0fdff499612c0bd235aa45eeb176c9e311bcc0af44bf952b6", size = 912004, upload-time = "2025-06-16T18:15:14.299Z" }, + { url = "https://files.pythonhosted.org/packages/57/c2/9b79795382daaf41e5f7379bffdef1880d68160adea352b796d6948cb5be/pymongo-4.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f30eab4d4326df54fee54f31f93e532dc2918962f733ee8e115b33e6fe151d92", size = 911698, upload-time = "2025-06-16T18:15:16.334Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e4/f04dc9ed5d1d9dbc539dc2d8758dd359c5373b0e06fcf25418b2c366737c/pymongo-4.13.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cce9428d12ba396ea245fc4c51f20228cead01119fcc959e1c80791ea45f820", size = 1690357, upload-time = "2025-06-16T18:15:18.358Z" }, + { url = "https://files.pythonhosted.org/packages/bb/de/41478a7d527d38f1b98b084f4a78bbb805439a6ebd8689fbbee0a3dfacba/pymongo-4.13.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac9241b727a69c39117c12ac1e52d817ea472260dadc66262c3fdca0bab0709b", size = 1754593, upload-time = "2025-06-16T18:15:20.096Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/8fa2eb110291e154f4312779b1a5b815090b8b05a59ecb4f4a32427db1df/pymongo-4.13.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3efc4c515b371a9fa1d198b6e03340985bfe1a55ae2d2b599a714934e7bc61ab", size = 1723637, upload-time = "2025-06-16T18:15:22.048Z" }, + { url = "https://files.pythonhosted.org/packages/27/7b/9863fa60a4a51ea09f5e3cd6ceb231af804e723671230f2daf3bd1b59c2b/pymongo-4.13.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f57a664aa74610eb7a52fa93f2cf794a1491f4f76098343485dd7da5b3bcff06", size = 1693613, upload-time = "2025-06-16T18:15:24.866Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/a42efa07820a59089836f409a63c96e7a74e33313e50dc39c554db99ac42/pymongo-4.13.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3dcb0b8cdd499636017a53f63ef64cf9b6bd3fd9355796c5a1d228e4be4a4c94", size = 1652745, upload-time = "2025-06-16T18:15:27.078Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/2c77d1acda61d281edd3e3f00d5017d3fac0c29042c769efd3b8018cb469/pymongo-4.13.2-cp312-cp312-win32.whl", hash = "sha256:bf43ae07804d7762b509f68e5ec73450bb8824e960b03b861143ce588b41f467", size = 883232, upload-time = "2025-06-16T18:15:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4f/727f59156e3798850c3c2901f106804053cb0e057ed1bd9883f5fa5aa8fa/pymongo-4.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:812a473d584bcb02ab819d379cd5e752995026a2bb0d7713e78462b6650d3f3a", size = 903304, upload-time = "2025-06-16T18:15:31.346Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/81/58c70036dffeccb7fe7d79d6260c69f7a28272bbd3909c29a01ea9422744/python_discovery-1.4.4.tar.gz", hash = "sha256:5cad33982d412c1f3ffb8f9ca4ea292c9680bca3942451d30b69c37fce53a4a3", size = 72212, upload-time = "2026-07-08T23:06:50.691Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/ae/84bc0d2440c95772272bb6f4b3d09ccf08b2898fce89b3d4f969a9fc74e9/python_discovery-1.4.4-py3-none-any.whl", hash = "sha256:abebe9120b43453b68c908acfb1e72a19d1a959ed2cb620ad38fc57d08056dbe", size = 34181, upload-time = "2026-07-08T23:06:49.402Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, +] + +[[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/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" }, +] + +[[package]] +name = "requests" +version = "2.32.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rich-toolkit" +version = "0.20.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/3a/a258c2fbc6c6bdf428611388f5698ba5d57ffdf0755e1cab474d9cc47813/rich_toolkit-0.20.3.tar.gz", hash = "sha256:223dd2cfba325ed55e94933b9e53f3aca13e9fdf76622bd564c18109a2273c1b", size = 205355, upload-time = "2026-07-13T14:38:06.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/ce/639d0d0ce3d25c5edbd1afecd308bb35dc04883a45ac9f0855c8aee4e919/rich_toolkit-0.20.3-py3-none-any.whl", hash = "sha256:419aa87516d5f3849cca553c6dcf707c02a36d508fcf996946606725d34a3002", size = 36195, upload-time = "2026-07-13T14:38:05.687Z" }, +] + +[[package]] +name = "rignore" +version = "0.7.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/0e/012556ef3047a2628842b44e753bb15f4dc46806780ff090f1e8fe4bf1eb/rignore-0.7.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:03e82348cb7234f8d9b2834f854400ddbbd04c0f8f35495119e66adbd37827a8", size = 883488, upload-time = "2025-11-05T20:42:41.359Z" }, + { url = "https://files.pythonhosted.org/packages/93/b0/d4f1f3fe9eb3f8e382d45ce5b0547ea01c4b7e0b4b4eb87bcd66a1d2b888/rignore-0.7.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9e624f6be6116ea682e76c5feb71ea91255c67c86cb75befe774365b2931961", size = 820411, upload-time = "2025-11-05T20:42:24.782Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c8/dea564b36dedac8de21c18e1851789545bc52a0c22ece9843444d5608a6a/rignore-0.7.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bda49950d405aa8d0ebe26af807c4e662dd281d926530f03f29690a2e07d649a", size = 897821, upload-time = "2025-11-05T20:40:52.613Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/ee96db17ac1835e024c5d0742eefb7e46de60020385ac883dd3d1cde2c1f/rignore-0.7.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5fd5ab3840b8c16851d327ed06e9b8be6459702a53e5ab1fc4073b684b3789e", size = 873963, upload-time = "2025-11-05T20:41:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8c/ad5a57bbb9d14d5c7e5960f712a8a0b902472ea3f4a2138cbf70d1777b75/rignore-0.7.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ced2a248352636a5c77504cb755dc02c2eef9a820a44d3f33061ce1bb8a7f2d2", size = 1169216, upload-time = "2025-11-05T20:41:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/5b00bc2a6bc1701e6878fca798cf5d9125eb3113193e33078b6fc0d99123/rignore-0.7.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a04a3b73b75ddc12c9c9b21efcdaab33ca3832941d6f1d67bffd860941cd448a", size = 942942, upload-time = "2025-11-05T20:41:39.393Z" }, + { url = "https://files.pythonhosted.org/packages/85/e5/7f99bd0cc9818a91d0e8b9acc65b792e35750e3bdccd15a7ee75e64efca4/rignore-0.7.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d24321efac92140b7ec910ac7c53ab0f0c86a41133d2bb4b0e6a7c94967f44dd", size = 959787, upload-time = "2025-11-05T20:42:09.765Z" }, + { url = "https://files.pythonhosted.org/packages/55/54/2ffea79a7c1eabcede1926347ebc2a81bc6b81f447d05b52af9af14948b9/rignore-0.7.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c7aa109d41e593785c55fdaa89ad80b10330affa9f9d3e3a51fa695f739b20", size = 984245, upload-time = "2025-11-05T20:41:54.062Z" }, + { url = "https://files.pythonhosted.org/packages/41/f7/e80f55dfe0f35787fa482aa18689b9c8251e045076c35477deb0007b3277/rignore-0.7.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1734dc49d1e9501b07852ef44421f84d9f378da9fbeda729e77db71f49cac28b", size = 1078647, upload-time = "2025-11-05T21:40:13.463Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cf/2c64f0b6725149f7c6e7e5a909d14354889b4beaadddaa5fff023ec71084/rignore-0.7.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5719ea14ea2b652c0c0894be5dfde954e1853a80dea27dd2fbaa749618d837f5", size = 1139186, upload-time = "2025-11-05T21:40:31.27Z" }, + { url = "https://files.pythonhosted.org/packages/75/95/a86c84909ccc24af0d094b50d54697951e576c252a4d9f21b47b52af9598/rignore-0.7.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e23424fc7ce35726854f639cb7968151a792c0c3d9d082f7f67e0c362cfecca", size = 1117604, upload-time = "2025-11-05T21:40:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/13b249613fd5d18d58662490ab910a9f0be758981d1797789913adb4e918/rignore-0.7.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3efdcf1dd84d45f3e2bd2f93303d9be103888f56dfa7c3349b5bf4f0657ec696", size = 1127725, upload-time = "2025-11-05T21:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/c7/28/fa5dcd1e2e16982c359128664e3785f202d3eca9b22dd0b2f91c4b3d242f/rignore-0.7.6-cp312-cp312-win32.whl", hash = "sha256:ccca9d1a8b5234c76b71546fc3c134533b013f40495f394a65614a81f7387046", size = 646145, upload-time = "2025-11-05T21:41:51.096Z" }, + { url = "https://files.pythonhosted.org/packages/26/87/69387fb5dd81a0f771936381431780b8cf66fcd2cfe9495e1aaf41548931/rignore-0.7.6-cp312-cp312-win_amd64.whl", hash = "sha256:c96a285e4a8bfec0652e0bfcf42b1aabcdda1e7625f5006d188e3b1c87fdb543", size = 726090, upload-time = "2025-11-05T21:41:36.485Z" }, + { url = "https://files.pythonhosted.org/packages/24/5f/e8418108dcda8087fb198a6f81caadbcda9fd115d61154bf0df4d6d3619b/rignore-0.7.6-cp312-cp312-win_arm64.whl", hash = "sha256:a64a750e7a8277a323f01ca50b7784a764845f6cce2fe38831cb93f0508d0051", size = 656317, upload-time = "2025-11-05T21:41:25.305Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.66.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/ff/670abe04c5072719b5060ed93851d0d69525d60f8f2c5810f8becd58f9c1/sentry_sdk-2.66.0.tar.gz", hash = "sha256:9727d35aa83c56cd53294676fe65b96296a334c9ce107fa2142bd70f47acb265", size = 935745, upload-time = "2026-07-16T12:42:04.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/bb/49b10783f29067da2eec179320617e94faf63196609de47aeab3c26c3325/sentry_sdk-2.66.0-py3-none-any.whl", hash = "sha256:096136c214c602be2b323524d30755dc5b30ec5a218a206207f33b12c05c6f11", size = 504769, upload-time = "2026-07-16T12:42:02.919Z" }, +] + +[[package]] +name = "service-resource-manager" +source = { editable = "." } +dependencies = [ + { name = "asyncpg" }, + { name = "fastapi", extra = ["standard"] }, + { name = "nats-py" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "sqlalchemy" }, + { name = "structlog" }, + { name = "sunrise6g-opensdk" }, +] + +[package.optional-dependencies] +dev = [ + { name = "asgi-lifespan" }, + { name = "import-linter" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "testcontainers" }, +] + +[package.metadata] +requires-dist = [ + { name = "asgi-lifespan", marker = "extra == 'dev'", specifier = ">=2.1.0" }, + { name = "asyncpg", specifier = ">=0.31.0" }, + { name = "fastapi", extras = ["standard"], specifier = ">=0.135.1" }, + { name = "import-linter", marker = "extra == 'dev'", specifier = ">=2.11" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.1" }, + { name = "nats-py", specifier = ">=2.10.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.5.1" }, + { name = "pydantic-settings", specifier = ">=2.13.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.0.0" }, + { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.6" }, + { name = "sqlalchemy", specifier = ">=2.0.48" }, + { name = "structlog", specifier = ">=25.5.0" }, + { name = "sunrise6g-opensdk", specifier = "==2.0.0" }, + { name = "testcontainers", extras = ["postgres"], marker = "extra == 'dev'", specifier = ">=4.13.2" }, +] +provides-extras = ["dev"] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "structlog" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" }, +] + +[[package]] +name = "sunrise6g-opensdk" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "auto-mix-prep" }, + { name = "colorlog" }, + { name = "kubernetes" }, + { name = "pydantic" }, + { name = "pydantic-extra-types" }, + { name = "pymongo" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/49/ccb3ad1b7381123c64ddd160c4a708382fc4a46ec85043f40020432b596a/sunrise6g_opensdk-2.0.0.tar.gz", hash = "sha256:b57ca5e7d51c5670aed65a7ab42e3ca6914ec57c6ca7f2f0b1755e5cd8982cf3", size = 88604, upload-time = "2025-10-08T09:53:35.12Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/12/a84ca974dfd0ba03f093212780bf54dc15b028bd168fe471343995a3de84/sunrise6g_opensdk-2.0.0-py3-none-any.whl", hash = "sha256:aec1b9b2e968f7f3e28646fe56ff49de6dcfe28c9e9d42004d653f0a122679d8", size = 127729, upload-time = "2025-10-08T09:53:33.481Z" }, +] + +[[package]] +name = "testcontainers" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/ac/a597c3a0e02b26cbed6dd07df68be1e57684766fd1c381dee9b170a99690/testcontainers-4.14.2.tar.gz", hash = "sha256:1340ccf16fe3acd9389a6c9e1d9ab21d9fe99a8afdf8165f89c3e69c1967d239", size = 166841, upload-time = "2026-03-18T05:19:16.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl", hash = "sha256:0d0522c3cd8f8d9627cda41f7a6b51b639fa57bdc492923c045117933c668d68", size = 125712, upload-time = "2026-03-18T05:19:15.29Z" }, +] + +[[package]] +name = "typer" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[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.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/d9/b477fddb68840b570af8b22afe9b035cbc277b5fb7b33dea390617a8b10f/virtualenv-21.6.1.tar.gz", hash = "sha256:15f978b7cd329f24855ff4a0c4b4899cc7678589f49adbdcbbb4d3232e641128", size = 5526620, upload-time = "2026-07-10T19:33:53.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/7c/4e7225d46d634a0d8d534dd8a6ce0c319d09b4d0cf0337eb314ca4789d8c/virtualenv-21.6.1-py3-none-any.whl", hash = "sha256:afe991df855715a2b2f60edfcc0107ef95a79fdfd8cb4cdaa71603d1c12e463b", size = 5506392, upload-time = "2026-07-10T19:33:51.629Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "websockets" +version = "16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/02/b9a097e1e16fee4e2fd1ec8c39f6a9c5d6257bae8fa12640caf869f54436/websockets-16.1.tar.gz", hash = "sha256:299468cbe42e2b9981134c7c51d99387d8a7bf562b00183b3eec53f882846dad", size = 182530, upload-time = "2026-07-10T06:32:57.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/52/748c014f07f4e0e170c8932de7e647a1511d5ab3049cd978797136aee577/websockets-16.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b6aa3f7ad345cf3862c21f4fbf2ef5e14d911348476c2845e137c091fe3a3f0b", size = 179798, upload-time = "2026-07-10T06:31:09.664Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5e/2a2e64d977d084e49d37c187c26c056daaff41965be7300cd5dbde6f8b07/websockets-16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b43fcfb521ac2f34ba80b7b8ea16303e4ad82dd8af667bf40839ad3a5d37b164", size = 177478, upload-time = "2026-07-10T06:31:11.072Z" }, + { url = "https://files.pythonhosted.org/packages/aa/12/5b85b4e75d697e548a94962ce5c036b05dd21cb9545759d555c5586422fc/websockets-16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bd3e12cd9afbe2baedae0b1eeade8ba64329b60fe2f9abdc966bd10fd2c2ef5", size = 177746, upload-time = "2026-07-10T06:31:12.386Z" }, + { url = "https://files.pythonhosted.org/packages/9d/62/79b1c8f0cee0da648b4899e1c5b0dbd3aa59846985136a54854db6827ab4/websockets-16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f41979c8623df9bd30d949d82010a8fda5c56ff12cd8508a5b7272b6d4b53a", size = 187345, upload-time = "2026-07-10T06:31:13.754Z" }, + { url = "https://files.pythonhosted.org/packages/25/34/b7c5c52c2f24280e1c017acb7ad491a566750a5cceca7f3cf999373bba21/websockets-16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a24d1f35aef07d794a16c853c688e74956c50239bec37b4f2de080056046419b", size = 188581, upload-time = "2026-07-10T06:31:15.075Z" }, + { url = "https://files.pythonhosted.org/packages/bc/37/604193bebcbeffe96fdf795960b83a15d600880c64dc17ec9c31c5b3427d/websockets-16.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c64c024ddf7a35331b21fcddb562a039c275d2c82e8c2d12939e7da23997270", size = 191362, upload-time = "2026-07-10T06:31:16.395Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b4/5ee27575b367d7110d4d13945e2a9de067ec84dc71e54b87f01e38550d9a/websockets-16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e99757f5baafe20fc598e202ea6f5b0b265186ad38d0a17bd8beca16296955", size = 189216, upload-time = "2026-07-10T06:31:17.776Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/3e2dcc78d85fc5d9d814895ce6d07d0dfacc0f6aaa1d151f2b8c8d772299/websockets-16.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:353f3bc6e058ac1ccab4b3588e8598837a8c04cfc8351233e6d523be675d844c", size = 187971, upload-time = "2026-07-10T06:31:19.152Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2f/cd271717b93d5ee19626cb5e38a85baab745c86e33db7c31a3ac729b31b8/websockets-16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0352f5b38b40e857b6428d468fa21dbb4dd4a567d933c26d9831b4efe1b92f43", size = 185381, upload-time = "2026-07-10T06:31:20.665Z" }, + { url = "https://files.pythonhosted.org/packages/78/91/6ad6f2f1426317b5001bd490534208c7360636b35bac1dec2e0c22bfc40e/websockets-16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70bd789afab579602968c39f21cb925466505f3edff22f0ae852bca54978a4f9", size = 188015, upload-time = "2026-07-10T06:31:22.024Z" }, + { url = "https://files.pythonhosted.org/packages/c7/6d/533733132ab4c07540efd4a8f0b9a435d3a5059b2f26cc476ace1abf7f45/websockets-16.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d0fb4b46f121eccd539353baebd1083a8767a9a351109453d1d1caecd1ba40c2", size = 186619, upload-time = "2026-07-10T06:31:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/08/73/16c059f3d73b3331eba10793704afa4faa9939234fb08ef7dca35794e8f0/websockets-16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c14b6634af01541e4efe2954fd8f263386f7aa6d37c01e55dd8109fd17661452", size = 188497, upload-time = "2026-07-10T06:31:25.024Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/9a8fae7dd2acdcfb1a8844c29fe42b518a04b64fce38a0923b6290e452f1/websockets-16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a58532c49a851bcb481e58c1be23b315c17fe2fbbed509d75aeea12f543d2c15", size = 186051, upload-time = "2026-07-10T06:31:26.291Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/b240c7dd6a0e0c59c1f68377cc3015263521080c327c15f5e753c1f6d378/websockets-16.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4e969170c3b08e1d8dabd990fef1fa702c4233aeaabec33f871806e444f6a0e4", size = 187029, upload-time = "2026-07-10T06:31:27.605Z" }, + { url = "https://files.pythonhosted.org/packages/50/35/524e3fac40e47d6fdcf6c4b2c95ef1bc8a97e01593c90eff86621df7b716/websockets-16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff9b000064b88787ba9f7a3cb2af2b68a658ca5aad76458a46469e7124b678a0", size = 187308, upload-time = "2026-07-10T06:31:28.927Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/56840cf62c8859af6ba22b9529da937332468c80f32b598753e8a66d3990/websockets-16.1-cp312-cp312-win32.whl", hash = "sha256:b9f5d83f80f4d7c4bba6d97f3755ac05850c784dce0fd2ab371c4e41172f53ff", size = 180161, upload-time = "2026-07-10T06:31:30.316Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ff/87eb9eb44cb62424a8d729834f2b0515a47e2669fabec29820268f4d50a1/websockets-16.1-cp312-cp312-win_amd64.whl", hash = "sha256:6852c9f653966c16109d3b6f31181fd734f7914927e3f0fa1117af7a18c9aa21", size = 180462, upload-time = "2026-07-10T06:31:31.708Z" }, + { url = "https://files.pythonhosted.org/packages/66/58/bd83247f39ddc26ffc2c24eb05087a3b749e00cb4509fc6d19daa23c8495/websockets-16.1-py3-none-any.whl", hash = "sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81", size = 174031, upload-time = "2026-07-10T06:32:56.079Z" }, +] + +[[package]] +name = "wrapt" +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/85/180b40628b23772692a0c76e8030114e1c0ae068470ed531919f0a5f2a4a/wrapt-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8417fd3c674d3c8023d080292d29301531a12daf8bd938dd419710dd2f464f2b", size = 81484, upload-time = "2026-06-20T23:47:59.924Z" }, + { url = "https://files.pythonhosted.org/packages/94/f2/21c90f2a16689702e2aaff45795b11018dff2c9b1242bac10d225483f676/wrapt-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7070c7472582e31af3dfc2622b2381a0df7435110a9388ed8db5ffbce67efb", size = 82151, upload-time = "2026-06-20T23:48:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b3/7e6e9fcf4fe7e1b69a49fe6cc5a44e8224bab6283c5233c97e132f14908e/wrapt-2.2.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a", size = 169828, upload-time = "2026-06-20T23:48:02.719Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/894f132d857ed5a9904d937baf368badcbe5ea9e436e2f1930fe21c9f1f0/wrapt-2.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d1a6050405bf334be33bf66296f113563622972a34900ae6fa60fd283a1a900", size = 171544, upload-time = "2026-06-20T23:48:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/29/de/3c833e03725b477e9ea34028224dd21a48781830101e4e036f77e8b6b102/wrapt-2.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10adb01371408c6de504a6658b9886480f1a4919a83752748a387a504a21df79", size = 160663, upload-time = "2026-06-20T23:48:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/33/be/27edce350b24e3054d9d047f65f16d4c4d4c1f3f31c4278a1f8a95c723c8/wrapt-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3442eee2a5798f9b451f1b2cd7518ce8b7e28a2a364696c414460a0e295c012a", size = 169387, upload-time = "2026-06-20T23:48:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c4/9fd9679af8bf38e146652c7f47b6b352c3e5795b4ad1c0b7f94e15ac2aa7/wrapt-2.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6c99012a22f735a85eed7c4b86a3e99c30fdd57d9e115b2b45f796264b58d0bf", size = 158849, upload-time = "2026-06-20T23:48:08.91Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c2/aa6c0c2206803068c6859dabe01f8c84c43744da93d4c67b8946d21655ee/wrapt-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b686cfc008776a3952d6213cb296ed7f45d782a8453936406faa89eac0835ab", size = 168147, upload-time = "2026-06-20T23:48:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/42/63/3eb25da41049d20ae18fcab2dd8b056e02387c4bfa626cbdfb7c3b872e4f/wrapt-2.2.2-cp312-cp312-win32.whl", hash = "sha256:ef2cce266b5b0b07e19fa82e59673b81142b7a3607c8ed1254113d048ed668da", size = 77734, upload-time = "2026-06-20T23:48:11.769Z" }, + { url = "https://files.pythonhosted.org/packages/da/09/0390e008a305360948fa9ce69507d041ac12cb2ee5d28e34467e2ee79391/wrapt-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:abf8c20a2d72ee69e16328b3c91342c446e723bfe48bfcc4dded3b9722ac027f", size = 80585, upload-time = "2026-06-20T23:48:13.117Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b3/84c445c66969f2d3457276b183a48c91097d59bbef9af6c075366b0f8c36/wrapt-2.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:c6c64c5d02578bc4c4bca4f0aef1504de933c1d5b4ac2710b9131111459506c8", size = 79553, upload-time = "2026-06-20T23:48:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload-time = "2026-06-20T23:49:42.966Z" }, +]