From 6e4909400a121dc98894849fb53fbff69c1c6a3b Mon Sep 17 00:00:00 2001 From: stentoumis Date: Wed, 29 Jul 2026 11:13:11 +0300 Subject: [PATCH 01/18] feat: deleted old files --- .gitlab-ci.yml | 30 - LICENSE | 201 --- README.md | 128 -- .../.dockerignore | 72 - .../.gitignore | 64 - .../Dockerfile | 25 - .../README.md | 68 - .../requirements.txt | 8 - .../setup.py | 37 - .../src/__init__.py | 0 .../src/__main__.py | 26 - .../src/controllers/__init__.py | 2 - .../controllers/app_instance_controller.py | 65 - .../src/controllers/artifact_controller.py | 38 - .../edge_cloud_management_controller.py | 216 --- .../network_functions_controller.py | 187 --- .../src/controllers/nodes_controller.py | 45 - .../src/controllers/operations_controller.py | 44 - .../src/encoder.py | 19 - .../src/models/__init__.py | 11 - .../src/models/artifact_exists_model.py | 154 -- .../src/models/base_model_.py | 69 - .../src/models/copy_artifact_model.py | 250 --- .../src/models/helm_install_model.py | 134 -- .../src/properties.conf | 5 - .../src/services/artifact_service.py | 20 - .../src/services/edge_cloud_service.py | 55 - .../src/services/network_function_service.py | 0 .../src/swagger/swagger.yaml | 1401 ----------------- .../src/type_util.py | 32 - .../src/util.py | 142 -- .../src/utils/__init__.py | 0 .../src/utils/artifact_connector.py | 20 - .../src/utils/auxiliary_functions.py | 41 - 34 files changed, 3609 deletions(-) delete mode 100644 .gitlab-ci.yml delete mode 100644 LICENSE delete mode 100644 README.md delete mode 100644 service-resource-manager-implementation/.dockerignore delete mode 100644 service-resource-manager-implementation/.gitignore delete mode 100644 service-resource-manager-implementation/Dockerfile delete mode 100644 service-resource-manager-implementation/README.md delete mode 100644 service-resource-manager-implementation/requirements.txt delete mode 100644 service-resource-manager-implementation/setup.py delete mode 100644 service-resource-manager-implementation/src/__init__.py delete mode 100644 service-resource-manager-implementation/src/__main__.py delete mode 100644 service-resource-manager-implementation/src/controllers/__init__.py delete mode 100644 service-resource-manager-implementation/src/controllers/app_instance_controller.py delete mode 100644 service-resource-manager-implementation/src/controllers/artifact_controller.py delete mode 100644 service-resource-manager-implementation/src/controllers/edge_cloud_management_controller.py delete mode 100644 service-resource-manager-implementation/src/controllers/network_functions_controller.py delete mode 100644 service-resource-manager-implementation/src/controllers/nodes_controller.py delete mode 100644 service-resource-manager-implementation/src/controllers/operations_controller.py delete mode 100644 service-resource-manager-implementation/src/encoder.py delete mode 100644 service-resource-manager-implementation/src/models/__init__.py delete mode 100644 service-resource-manager-implementation/src/models/artifact_exists_model.py delete mode 100644 service-resource-manager-implementation/src/models/base_model_.py delete mode 100644 service-resource-manager-implementation/src/models/copy_artifact_model.py delete mode 100644 service-resource-manager-implementation/src/models/helm_install_model.py delete mode 100644 service-resource-manager-implementation/src/properties.conf delete mode 100644 service-resource-manager-implementation/src/services/artifact_service.py delete mode 100644 service-resource-manager-implementation/src/services/edge_cloud_service.py delete mode 100644 service-resource-manager-implementation/src/services/network_function_service.py delete mode 100644 service-resource-manager-implementation/src/swagger/swagger.yaml delete mode 100644 service-resource-manager-implementation/src/type_util.py delete mode 100644 service-resource-manager-implementation/src/util.py delete mode 100644 service-resource-manager-implementation/src/utils/__init__.py delete mode 100644 service-resource-manager-implementation/src/utils/artifact_connector.py delete mode 100644 service-resource-manager-implementation/src/utils/auxiliary_functions.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index c93f541..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,30 +0,0 @@ -stages: - - build - - push - -variables: - IMAGE_NAME: $CI_REGISTRY/$CI_PROJECT_PATH - IMAGE_TAG: latest - -before_script: - - docker info - -build: - tags: - - "shell" - stage: build - 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 diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 261eeb9..0000000 --- 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/README.md b/README.md deleted file mode 100644 index 79c5ed4..0000000 --- a/README.md +++ /dev/null @@ -1,128 +0,0 @@ -# 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/) - -## 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 diff --git a/service-resource-manager-implementation/.dockerignore b/service-resource-manager-implementation/.dockerignore deleted file mode 100644 index cdd823e..0000000 --- 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 a655050..0000000 --- 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 0d3a85e..0000000 --- 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 f5a11c6..0000000 --- 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/requirements.txt b/service-resource-manager-implementation/requirements.txt deleted file mode 100644 index bd15bd2..0000000 --- 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 8a20a01..0000000 --- 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/__init__.py b/service-resource-manager-implementation/src/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/service-resource-manager-implementation/src/__main__.py b/service-resource-manager-implementation/src/__main__.py deleted file mode 100644 index da8b42e..0000000 --- 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 0791ded..0000000 --- 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 db1a110..0000000 --- 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 ea47af5..0000000 --- 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 5b6267e..0000000 --- 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 cd95b55..0000000 --- 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 c7713c8..0000000 --- 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 528d97b..0000000 --- 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 1de7e66..0000000 --- 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 8b9ed33..0000000 --- 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 96401fa..0000000 --- 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 f168c28..0000000 --- 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 057aad3..0000000 --- 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 66fada0..0000000 --- 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 cf9e4dc..0000000 --- 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 406dbbf..0000000 --- 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 73ab19f..0000000 --- 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/services/network_function_service.py b/service-resource-manager-implementation/src/services/network_function_service.py deleted file mode 100644 index e69de29..0000000 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 a5a7645..0000000 --- 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 4b74a08..0000000 --- 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 79f19f3..0000000 --- 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/__init__.py b/service-resource-manager-implementation/src/utils/__init__.py deleted file mode 100644 index e69de29..0000000 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 861971f..0000000 --- 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 cf9224b..0000000 --- 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 -- GitLab From c9474c3d08e56b10e57cc4ec37c70067f2d5a076 Mon Sep 17 00:00:00 2001 From: stentoumis Date: Wed, 29 Jul 2026 12:23:21 +0300 Subject: [PATCH 02/18] feat: refactored srm with postgres adapter and fastapi with 2 health endpoints --- .env.example | 7 + .gitignore | 31 + .gitlab-ci.yml | 84 + .pre-commit-config.yaml | 36 + Dockerfile | 33 + Makefile | 61 + README.md | 6 + pyproject.toml | 103 ++ src/srm/__init__.py | 24 + src/srm/adapters/database/core.py | 28 + src/srm/adapters/database/mappers.py | 449 +++++ src/srm/adapters/database/repos/__init__.py | 29 + src/srm/adapters/database/repos/catalog.py | 132 ++ .../database/repos/runtime_inventory.py | 117 ++ src/srm/adapters/database/repos/topology.py | 152 ++ src/srm/adapters/database/sql.py | 372 +++++ src/srm/adapters/databus/.gitkeep | 0 src/srm/adapters/errors.py | 12 + src/srm/api/context.py | 3 + src/srm/api/databus/.gitkeep | 0 src/srm/api/dependencies.py | 23 + src/srm/api/health.py | 22 + src/srm/api/middlewares/logging.py | 36 + src/srm/api/middlewares/middlewares.py | 13 + src/srm/api/middlewares/request_id.py | 41 + src/srm/api/rest/.gitkeep | 0 src/srm/app_state.py | 8 + src/srm/application/__init__.py | 0 src/srm/application/use_cases/__init__.py | 0 src/srm/config.py | 46 + src/srm/domain/__init__.py | 107 ++ src/srm/domain/models/__init__.py | 113 ++ .../models/canonical_parameters/__init__.py | 48 + .../models/canonical_parameters/compute.py | 86 + .../models/canonical_parameters/parameters.py | 81 + .../models/canonical_parameters/result.py | 36 + src/srm/domain/models/catalog/__init__.py | 16 + src/srm/domain/models/catalog/enums.py | 17 + src/srm/domain/models/catalog/models.py | 58 + src/srm/domain/models/common.py | 31 + .../models/runtime_inventory/__init__.py | 25 + .../domain/models/runtime_inventory/enums.py | 52 + .../domain/models/runtime_inventory/models.py | 88 + src/srm/domain/models/topology/__init__.py | 26 + src/srm/domain/models/topology/enums.py | 52 + src/srm/domain/models/topology/models.py | 74 + src/srm/domain/ports/database/__init__.py | 31 + src/srm/domain/ports/database/catalog.py | 43 + .../ports/database/runtime_inventory.py | 40 + src/srm/domain/ports/database/topology.py | 51 + src/srm/main.py | 75 + tests/__init__.py | 0 tests/api/__init__.py | 0 tests/api/middlewares/__init__.py | 0 tests/api/middlewares/test_logging.py | 80 + tests/api/middlewares/test_request_id.py | 79 + tests/api/test_app.py | 10 + tests/api/test_health.py | 13 + tests/conftest.py | 100 ++ tests/integration/conftest.py | 53 + tests/integration/test_constraints.py | 238 +++ tests/integration/test_postgres.py | 204 +++ tests/integration/test_repositories.py | 420 +++++ tests/test_config.py | 96 ++ tests/unit/__init__.py | 0 tests/unit/fakes.py | 89 + tests/unit/test_in_memory_repositories.py | 106 ++ tests/unit/test_mappers.py | 200 +++ .../unit/test_service_specification_mapper.py | 205 +++ tests/unit/test_service_specification_repo.py | 188 +++ uv.lock | 1457 +++++++++++++++++ 71 files changed, 6556 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 .pre-commit-config.yaml create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/srm/__init__.py create mode 100644 src/srm/adapters/database/core.py create mode 100644 src/srm/adapters/database/mappers.py create mode 100644 src/srm/adapters/database/repos/__init__.py create mode 100644 src/srm/adapters/database/repos/catalog.py create mode 100644 src/srm/adapters/database/repos/runtime_inventory.py create mode 100644 src/srm/adapters/database/repos/topology.py create mode 100644 src/srm/adapters/database/sql.py create mode 100644 src/srm/adapters/databus/.gitkeep create mode 100644 src/srm/adapters/errors.py create mode 100644 src/srm/api/context.py create mode 100644 src/srm/api/databus/.gitkeep create mode 100644 src/srm/api/dependencies.py create mode 100644 src/srm/api/health.py create mode 100644 src/srm/api/middlewares/logging.py create mode 100644 src/srm/api/middlewares/middlewares.py create mode 100644 src/srm/api/middlewares/request_id.py create mode 100644 src/srm/api/rest/.gitkeep create mode 100644 src/srm/app_state.py create mode 100644 src/srm/application/__init__.py create mode 100644 src/srm/application/use_cases/__init__.py create mode 100644 src/srm/config.py create mode 100644 src/srm/domain/__init__.py create mode 100644 src/srm/domain/models/__init__.py create mode 100644 src/srm/domain/models/canonical_parameters/__init__.py create mode 100644 src/srm/domain/models/canonical_parameters/compute.py create mode 100644 src/srm/domain/models/canonical_parameters/parameters.py create mode 100644 src/srm/domain/models/canonical_parameters/result.py create mode 100644 src/srm/domain/models/catalog/__init__.py create mode 100644 src/srm/domain/models/catalog/enums.py create mode 100644 src/srm/domain/models/catalog/models.py create mode 100644 src/srm/domain/models/common.py create mode 100644 src/srm/domain/models/runtime_inventory/__init__.py create mode 100644 src/srm/domain/models/runtime_inventory/enums.py create mode 100644 src/srm/domain/models/runtime_inventory/models.py create mode 100644 src/srm/domain/models/topology/__init__.py create mode 100644 src/srm/domain/models/topology/enums.py create mode 100644 src/srm/domain/models/topology/models.py create mode 100644 src/srm/domain/ports/database/__init__.py create mode 100644 src/srm/domain/ports/database/catalog.py create mode 100644 src/srm/domain/ports/database/runtime_inventory.py create mode 100644 src/srm/domain/ports/database/topology.py create mode 100644 src/srm/main.py create mode 100644 tests/__init__.py create mode 100644 tests/api/__init__.py create mode 100644 tests/api/middlewares/__init__.py create mode 100644 tests/api/middlewares/test_logging.py create mode 100644 tests/api/middlewares/test_request_id.py create mode 100644 tests/api/test_app.py create mode 100644 tests/api/test_health.py create mode 100644 tests/conftest.py create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/test_constraints.py create mode 100644 tests/integration/test_postgres.py create mode 100644 tests/integration/test_repositories.py create mode 100644 tests/test_config.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/fakes.py create mode 100644 tests/unit/test_in_memory_repositories.py create mode 100644 tests/unit/test_mappers.py create mode 100644 tests/unit/test_service_specification_mapper.py create mode 100644 tests/unit/test_service_specification_repo.py create mode 100644 uv.lock diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..06995e9 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ebfbaf6 --- /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 new file mode 100644 index 0000000..4c98851 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,84 @@ +default: + tags: + - docker + 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: + UV_CACHE_DIR: "$CI_PROJECT_DIR/.cache/uv" + +type: + stage: type + script: + - mypy src/ tests/ + +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: + stage: build + tags: + - shell + before_script: + - docker info + script: + - export TEST_IMAGE_TAG="ci-${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}" + - docker build --network=host -t "$CI_REGISTRY_IMAGE:$TEST_IMAGE_TAG" . + rules: + - if: '$CI_COMMIT_BRANCH' + +push: + stage: push + tags: + - shell + needs: + - build + before_script: + - docker info + script: + - 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 push "$CI_REGISTRY_IMAGE:$TEST_IMAGE_TAG" + - docker logout "$CI_REGISTRY" + rules: + - if: '$CI_COMMIT_BRANCH' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..df54d1b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +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" + - "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 0000000..40e9770 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# 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/ +COPY .env ./ + +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/Makefile b/Makefile new file mode 100644 index 0000000..50e3a40 --- /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 new file mode 100644 index 0000000..5fa2455 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# Service Resource Manager + +A Python, FastAPI-based microservice to work as the brains of ETSI SDG OpenOP. + +## WIP +WIP diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c99d698 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,103 @@ +[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" +] + +[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/src/srm/__init__.py b/src/srm/__init__.py new file mode 100644 index 0000000..fc1e41b --- /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 0000000..6a675ab --- /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 0000000..982dfe2 --- /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 0000000..69768f8 --- /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 0000000..21aa15c --- /dev/null +++ b/src/srm/adapters/database/repos/catalog.py @@ -0,0 +1,132 @@ +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 save(self, service_specification: ServiceSpecification) -> ServiceSpecification: + merged = await self._session.merge(ServiceSpecificationMapper.to_row(service_specification)) + try: + await self._session.flush() + except IntegrityError as exc: + _log_integrity_error(exc, entity="service_specification", operation="save") + if _pgcode(exc) == "23505": + raise DuplicateServiceSpecificationError() from exc + raise + specification_id = merged.id + saved = await self.get_by_id(specification_id) + if saved is None: + raise RuntimeError("Saved 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 save(self, deployment_unit: ServiceDeploymentUnit) -> ServiceDeploymentUnit: + merged = await self._session.merge(ServiceDeploymentUnitMapper.to_row(deployment_unit)) + try: + await self._session.flush() + except IntegrityError as exc: + _log_integrity_error(exc, entity="service_deployment_unit", operation="save") + raise + deployment_unit_id = merged.id + saved = await self.get_by_id(deployment_unit_id) + if saved is None: + raise RuntimeError("Saved 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 save( + self, + capability_requirement: ServiceCapabilityRequirement, + ) -> ServiceCapabilityRequirement: + merged = await self._session.merge( + ServiceCapabilityRequirementMapper.to_row(capability_requirement) + ) + try: + await self._session.flush() + except IntegrityError as exc: + _log_integrity_error(exc, entity="service_capability_requirement", operation="save") + raise + requirement_id = merged.id + saved = await self.get_by_id(requirement_id) + if saved is None: + raise RuntimeError("Saved 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 0000000..7e82ce7 --- /dev/null +++ b/src/srm/adapters/database/repos/runtime_inventory.py @@ -0,0 +1,117 @@ +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 save(self, service_order: ServiceOrder) -> ServiceOrder: + merged = await self._session.merge(ServiceOrderMapper.to_row(service_order)) + try: + await self._session.flush() + except IntegrityError as exc: + _log_integrity_error(exc, entity="service_order", operation="save") + raise + service_order_id = merged.id + saved = await self.get_by_id(service_order_id) + if saved is None: + raise RuntimeError("Saved 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 save(self, service_instance: ServiceInstance) -> ServiceInstance: + merged = await self._session.merge(ServiceInstanceMapper.to_row(service_instance)) + try: + await self._session.flush() + except IntegrityError as exc: + _log_integrity_error(exc, entity="service_instance", operation="save") + raise + service_instance_id = merged.id + saved = await self.get_by_id(service_instance_id) + if saved is None: + raise RuntimeError("Saved 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 save(self, capability_instance: CapabilityInstance) -> CapabilityInstance: + merged = await self._session.merge(CapabilityInstanceMapper.to_row(capability_instance)) + try: + await self._session.flush() + except IntegrityError as exc: + _log_integrity_error(exc, entity="capability_instance", operation="save") + raise + capability_instance_id = merged.id + saved = await self.get_by_id(capability_instance_id) + if saved is None: + raise RuntimeError("Saved 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 0000000..a8e1729 --- /dev/null +++ b/src/srm/adapters/database/repos/topology.py @@ -0,0 +1,152 @@ +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 save(self, zone: Zone) -> Zone: + merged = await self._session.merge(ZoneMapper.to_row(zone)) + try: + await self._session.flush() + except IntegrityError as exc: + _log_integrity_error(exc, entity="zone", operation="save") + raise + zone_id = merged.id + saved = await self.get_by_id(zone_id) + if saved is None: + raise RuntimeError("Saved 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 save(self, domain: Domain) -> Domain: + merged = await self._session.merge(DomainMapper.to_row(domain)) + try: + await self._session.flush() + except IntegrityError as exc: + _log_integrity_error(exc, entity="domain", operation="save") + raise + domain_id = merged.id + saved = await self.get_by_id(domain_id) + if saved is None: + raise RuntimeError("Saved 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 save(self, capability: Capability) -> Capability: + merged = await self._session.merge(CapabilityMapper.to_row(capability)) + try: + await self._session.flush() + except IntegrityError as exc: + _log_integrity_error(exc, entity="capability", operation="save") + raise + capability_id = merged.id + saved = await self.get_by_id(capability_id) + if saved is None: + raise RuntimeError("Saved 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 save(self, control_path_binding: ControlPathBinding) -> ControlPathBinding: + merged = await self._session.merge(ControlPathBindingMapper.to_row(control_path_binding)) + try: + await self._session.flush() + except IntegrityError as exc: + _log_integrity_error(exc, entity="control_path_binding", operation="save") + raise + binding_id = merged.id + saved = await self.get_by_id(binding_id) + if saved is None: + raise RuntimeError("Saved 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 0000000..063ff67 --- /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/.gitkeep b/src/srm/adapters/databus/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/srm/adapters/errors.py b/src/srm/adapters/errors.py new file mode 100644 index 0000000..cb3e5d1 --- /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 0000000..199cb39 --- /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/.gitkeep b/src/srm/api/databus/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/srm/api/dependencies.py b/src/srm/api/dependencies.py new file mode 100644 index 0000000..20b8b7d --- /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 0000000..d34ee2f --- /dev/null +++ b/src/srm/api/health.py @@ -0,0 +1,22 @@ +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("/health/livez") +async def liveness() -> bool: + return True + + +@health_router.get("/health/readyz") +async def readiness(request: Request) -> bool: + try: + async with get_app_state(request).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 + + return True diff --git a/src/srm/api/middlewares/logging.py b/src/srm/api/middlewares/logging.py new file mode 100644 index 0000000..a5a6e01 --- /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 0000000..07531b4 --- /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 0000000..cc09819 --- /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/src/srm/api/rest/.gitkeep b/src/srm/api/rest/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/srm/app_state.py b/src/srm/app_state.py new file mode 100644 index 0000000..a21baa6 --- /dev/null +++ b/src/srm/app_state.py @@ -0,0 +1,8 @@ +from typing import Protocol + +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + + +class AppState(Protocol): + db_engine: AsyncEngine + session_maker: async_sessionmaker[AsyncSession] diff --git a/src/srm/application/__init__.py b/src/srm/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/srm/application/use_cases/__init__.py b/src/srm/application/use_cases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/srm/config.py b/src/srm/config.py new file mode 100644 index 0000000..5a75a01 --- /dev/null +++ b/src/srm/config.py @@ -0,0 +1,46 @@ +""" +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 Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_nested_delimiter="__") + + app_name: str + app_version: str + app_description: str + + postgres_settings: PostgreSQLSettings + + +@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 0000000..e4c16fc --- /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, + 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", + "ComputeTransportProtocol", + "ControlPathBinding", + "ControlPathBindingState", + "DeviceTarget", + "Domain", + "DomainKind", + "DomainModel", + "DomainState", + "IdentifiedModel", + "InterfaceVisibility", + "NetworkInterface", + "ParameterTransportProtocol", + "Parameters", + "Result", + "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 0000000..f3c8525 --- /dev/null +++ b/src/srm/domain/models/__init__.py @@ -0,0 +1,113 @@ +"""Domain model public API.""" + +from srm.domain.models.canonical_parameters import ( + Accelerator, + AcceleratorType, + ApplicationReferenceTarget, + ApplicationServerTarget, + CapabilityParameters, + CapabilityTarget, + ComputeRequirements, + ComputeResources, + DeviceTarget, + InterfaceVisibility, + NetworkInterface, + Parameters, + Result, + 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", + "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 0000000..a3e07fc --- /dev/null +++ b/src/srm/domain/models/canonical_parameters/__init__.py @@ -0,0 +1,48 @@ +"""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, ServiceEndpoint + +__all__ = [ + "Accelerator", + "AcceleratorType", + "ApplicationReferenceTarget", + "ApplicationServerTarget", + "CapabilityParameters", + "CapabilityTarget", + "ComputeRequirements", + "ComputeResources", + "DeviceTarget", + "InterfaceVisibility", + "NetworkInterface", + "Parameters", + "Result", + "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 0000000..4a95454 --- /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 0000000..b9c5f73 --- /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 0000000..2de29b8 --- /dev/null +++ b/src/srm/domain/models/canonical_parameters/result.py @@ -0,0 +1,36 @@ +from typing import Any + +from pydantic import Field + +from srm.domain.models.common import DomainModel + + +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: str + + 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 0000000..3a37f7a --- /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 0000000..d294f77 --- /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 0000000..5530d0e --- /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 0000000..b36b59a --- /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 0000000..2b07722 --- /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 0000000..c1a693a --- /dev/null +++ b/src/srm/domain/models/runtime_inventory/enums.py @@ -0,0 +1,52 @@ +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" + UPDATE_SERVICE = "update_service" + + ACTIVATE_CAPABILITY = "activate_capability" + UPDATE_CAPABILITY = "update_capability" + TERMINATE_CAPABILITY = "terminate_capability" + + QUERY_CAPABILITY = "query_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 0000000..e75b636 --- /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 0000000..dd15c3d --- /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 0000000..815db8f --- /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 0000000..b30977d --- /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 0000000..1460f67 --- /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 0000000..acdef32 --- /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 save(self, service_specification: ServiceSpecification) -> ServiceSpecification: + pass + + +class ServiceDeploymentUnitRepository(ABC): + @abstractmethod + async def get_by_id(self, id: UUID) -> ServiceDeploymentUnit | None: + pass + + @abstractmethod + async def save(self, deployment_unit: ServiceDeploymentUnit) -> ServiceDeploymentUnit: + pass + + +class ServiceCapabilityRequirementRepository(ABC): + @abstractmethod + async def get_by_id(self, id: UUID) -> ServiceCapabilityRequirement | None: + pass + + @abstractmethod + async def save( + 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 0000000..258757e --- /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 save(self, service_order: ServiceOrder) -> ServiceOrder: + pass + + +class ServiceInstanceRepository(ABC): + @abstractmethod + async def get_by_id(self, id: UUID) -> ServiceInstance | None: + pass + + @abstractmethod + async def save(self, service_instance: ServiceInstance) -> ServiceInstance: + pass + + +class CapabilityInstanceRepository(ABC): + @abstractmethod + async def get_by_id(self, id: UUID) -> CapabilityInstance | None: + pass + + @abstractmethod + async def save(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 0000000..8ebbfbf --- /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 save(self, zone: Zone) -> Zone: + pass + + +class DomainRepository(ABC): + @abstractmethod + async def get_by_id(self, id: UUID) -> Domain | None: + pass + + @abstractmethod + async def save(self, domain: Domain) -> Domain: + pass + + +class CapabilityRepository(ABC): + @abstractmethod + async def get_by_id(self, id: UUID) -> Capability | None: + pass + + @abstractmethod + async def save(self, capability: Capability) -> Capability: + pass + + +class ControlPathBindingRepository(ABC): + @abstractmethod + async def get_by_id(self, id: UUID) -> ControlPathBinding | None: + pass + + @abstractmethod + async def save(self, control_path_binding: ControlPathBinding) -> ControlPathBinding: + pass diff --git a/src/srm/main.py b/src/srm/main.py new file mode 100644 index 0000000..6e56ba4 --- /dev/null +++ b/src/srm/main.py @@ -0,0 +1,75 @@ +""" +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.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 + + app.state.db_engine = engine + app.state.session_maker = session_maker + + yield + + logger.info("Shutting down application") + 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 0000000..e69de29 diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/middlewares/__init__.py b/tests/api/middlewares/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/middlewares/test_logging.py b/tests/api/middlewares/test_logging.py new file mode 100644 index 0000000..d8ac245 --- /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("/health/livez") + 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("/health/livez") + 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("/health/livez") + 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"] == "/health/livez" + 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("/health/livez") + 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("/health/livez") + + 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 0000000..4f74db1 --- /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("/health/livez") + 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("/health/livez", 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("/health/livez", 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("/health/livez", 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("/health/livez") + r2 = await client.get("/health/livez") + 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("/health/livez") + assert originating_request_id.get() == "" + + +async def test_regenerates_for_empty_string(client: AsyncClient) -> None: + response = await client.get("/health/livez", 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 0000000..d46ee3f --- /dev/null +++ b/tests/api/test_app.py @@ -0,0 +1,10 @@ +from fastapi import FastAPI + +from srm.config import get_settings + + +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 diff --git a/tests/api/test_health.py b/tests/api/test_health.py new file mode 100644 index 0000000..e079c35 --- /dev/null +++ b/tests/api/test_health.py @@ -0,0 +1,13 @@ +from httpx import AsyncClient + + +async def test_livez_returns_200(client: AsyncClient) -> None: + response = await client.get("/health/livez") + assert response.status_code == 200 + assert response.json() is True + + +async def test_readyz_returns_200(client_with_db: AsyncClient) -> None: + response = await client_with_db.get("/health/readyz") + assert response.status_code == 200 + assert response.json() is True diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4438ba1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,100 @@ +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.postgres import PostgresContainer + +from srm.adapters.database.sql import get_metadata +from srm.config import get_settings +from srm.main import create_app + + +@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}") + + +@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 +def app_with_db( + monkeypatch: pytest.MonkeyPatch, + postgres_container: PostgresContainer, + clean_db: None, +) -> FastAPI: + monkeypatch.setenv("APP_NAME", "test-srm") + monkeypatch.setenv("APP_VERSION", "0.0.1") + monkeypatch.setenv("APP_DESCRIPTION", "Test SRM") + monkeypatch.setenv( + "POSTGRES_SETTINGS__URL", _as_asyncpg_url(postgres_container.get_connection_url()) + ) + monkeypatch.setenv("POSTGRES_SETTINGS__ECHO", "true") + monkeypatch.setenv("POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP", "true") + 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: + monkeypatch.setenv("APP_NAME", "test-srm") + monkeypatch.setenv("APP_VERSION", "0.0.1") + monkeypatch.setenv("APP_DESCRIPTION", "Test 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 0000000..87e37d9 --- /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 0000000..93e50fa --- /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).save(_zone()) + domains = SqlDomainRepository(db_session) + first_domain = await domains.save(_domain(zone.id, ref="compute-a")) + second_domain = await domains.save(_domain(zone.id, ref="compute-b")) + + capabilities = SqlCapabilityRepository(db_session) + await capabilities.save(_capability(first_domain.id, ref="deploy")) + try: + saved = await capabilities.save(_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).save(_zone()) + domain = await SqlDomainRepository(db_session).save(_domain(zone.id, ref="compute")) + capabilities = SqlCapabilityRepository(db_session) + + await capabilities.save(_capability(domain.id, ref="deploy")) + with pytest.raises(IntegrityError): + await capabilities.save(_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).save(_service_specification()) + zone = await SqlZoneRepository(db_session).save(_zone()) + order = await SqlServiceOrderRepository(db_session).save( + _service_order(operation_id=uuid4(), zone_id=zone.id) + ) + instances = SqlServiceInstanceRepository(db_session) + + await instances.save( + _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.save( + _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).save(_service_specification()) + zone = await SqlZoneRepository(db_session).save(_zone()) + order = await SqlServiceOrderRepository(db_session).save( + _service_order(operation_id=uuid4(), zone_id=zone.id) + ) + instances = SqlServiceInstanceRepository(db_session) + tenant = str(uuid4()) + + await instances.save( + _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.save( + _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).save(_zone()) + await orders.save(_service_order(operation_id, zone.id)) + with pytest.raises(IntegrityError): + await orders.save(_service_order(operation_id, zone.id)) diff --git a/tests/integration/test_postgres.py b/tests/integration/test_postgres.py new file mode 100644 index 0000000..1f04778 --- /dev/null +++ b/tests/integration/test_postgres.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +import pytest +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.save(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 + + saved_requirement = saved.capability_requirements[0] + saved_deployment_unit = saved.deployment_units[0] + saved_requirement.deployment_unit_id = saved_deployment_unit.id + saved = await repo.save(saved) + + 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 == reloaded.deployment_units[0].id + 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.save(specification) + with pytest.raises(DuplicateServiceSpecificationError): + _ = await repo.save(specification_dupl) + + +async def test_zone_repo_updates_updated_at_on_second_save( + 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.save(_zone()) + await session.commit() + + await asyncio.sleep(0.01) + + async with session_factory() as session: + repo = SqlZoneRepository(session) + saved.name = "Athens Zone Updated" + updated = await repo.save(saved) + await session.commit() + + assert updated.created_at == saved.created_at + assert updated.created_at is not None + assert updated.updated_at is not None + assert updated.updated_at > updated.created_at + assert updated.name == "Athens Zone Updated" diff --git a/tests/integration/test_repositories.py b/tests/integration/test_repositories.py new file mode 100644 index 0000000..3311c85 --- /dev/null +++ b/tests/integration/test_repositories.py @@ -0,0 +1,420 @@ +"""Round-trip coverage for every SQL repository against real PostgreSQL. + +The core contract of every repository is the same: ``save()`` 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 +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 _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="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.save(_service_specification()) + + +async def _case_zone(session: AsyncSession) -> tuple[object, IdentifiedModel]: + repo = SqlZoneRepository(session) + return repo, await repo.save(_zone()) + + +async def _case_domain(session: AsyncSession) -> tuple[object, IdentifiedModel]: + zone = await SqlZoneRepository(session).save(_zone()) + repo = SqlDomainRepository(session) + return repo, await repo.save(_domain(zone.id)) + + +async def _case_capability(session: AsyncSession) -> tuple[object, IdentifiedModel]: + zone = await SqlZoneRepository(session).save(_zone()) + domain = await SqlDomainRepository(session).save(_domain(zone.id)) + repo = SqlCapabilityRepository(session) + return repo, await repo.save(_capability(domain.id)) + + +async def _case_control_path_binding(session: AsyncSession) -> tuple[object, IdentifiedModel]: + zone = await SqlZoneRepository(session).save(_zone()) + domain = await SqlDomainRepository(session).save(_domain(zone.id)) + capability = await SqlCapabilityRepository(session).save(_capability(domain.id)) + repo = SqlControlPathBindingRepository(session) + return repo, await repo.save(_control_path_binding(capability.id)) + + +async def _case_service_order(session: AsyncSession) -> tuple[object, IdentifiedModel]: + zone = await SqlZoneRepository(session).save(_zone()) + repo = SqlServiceOrderRepository(session) + return repo, await repo.save(_service_order(zone_id=zone.id)) + + +async def _case_service_instance(session: AsyncSession) -> tuple[object, IdentifiedModel]: + spec = await SqlServiceSpecificationRepository(session).save(_service_specification()) + zone = await SqlZoneRepository(session).save(_zone()) + order = await SqlServiceOrderRepository(session).save(_service_order(zone_id=zone.id)) + repo = SqlServiceInstanceRepository(session) + return repo, await repo.save( + _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).save(_service_specification()) + zone = await SqlZoneRepository(session).save(_zone()) + order = await SqlServiceOrderRepository(session).save(_service_order(zone_id=zone.id)) + instance = await SqlServiceInstanceRepository(session).save( + _service_instance(spec_id=spec.id, orig_so_id=order.id, zone_id=zone.id) + ) + domain = await SqlDomainRepository(session).save(_domain(zone.id)) + capability = await SqlCapabilityRepository(session).save(_capability(domain.id)) + repo = SqlCapabilityInstanceRepository(session) + return repo, await repo.save(_capability_instance(instance.id, capability.id, order.id)) + + +async def _case_service_deployment_unit(session: AsyncSession) -> tuple[object, IdentifiedModel]: + spec = await SqlServiceSpecificationRepository(session).save(_service_specification()) + repo = SqlServiceDeploymentUnitRepository(session) + return ( + repo, + await repo.save( + 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).save(_service_specification()) + repo = SqlServiceCapabilityRequirementRepository(session) + return ( + repo, + await repo.save( + 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_save_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 + + +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).save( + _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).save( + _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 0000000..12e37e0 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,96 @@ +import pytest +from pydantic import ValidationError + +from srm.config import Settings, get_settings + + +def test_loads_all_fields_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("APP_NAME", "my-srm") + monkeypatch.setenv("APP_VERSION", "1.2.3") + monkeypatch.setenv("APP_DESCRIPTION", "a description") + monkeypatch.setenv("POSTGRES_SETTINGS__URL", "postgresql://localhost:5432/srm") + monkeypatch.setenv("POSTGRES_SETTINGS__ECHO", "true") + monkeypatch.setenv("POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP", "true") + 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 + + +def test_raises_if_app_name_missing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("APP_NAME", raising=False) + monkeypatch.setenv("APP_VERSION", "1.0.0") + monkeypatch.setenv("APP_DESCRIPTION", "test") + monkeypatch.setenv("POSTGRES_SETTINGS__URL", "postgresql://localhost:5432/srm") + monkeypatch.setenv("POSTGRES_SETTINGS__ECHO", "true") + monkeypatch.setenv("POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP", "true") + with pytest.raises(ValidationError): + Settings(_env_file=None) # type: ignore[call-arg] + + +def test_raises_if_app_version_missing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("APP_NAME", "test") + monkeypatch.delenv("APP_VERSION", raising=False) + monkeypatch.setenv("APP_DESCRIPTION", "test") + monkeypatch.setenv("POSTGRES_SETTINGS__URL", "postgresql://localhost:5432/srm") + monkeypatch.setenv("POSTGRES_SETTINGS__ECHO", "true") + monkeypatch.setenv("POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP", "true") + with pytest.raises(ValidationError): + Settings(_env_file=None) # type: ignore[call-arg] + + +def test_raises_if_app_description_missing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("APP_NAME", "test") + monkeypatch.setenv("APP_VERSION", "1.0.0") + monkeypatch.delenv("APP_DESCRIPTION", raising=False) + monkeypatch.setenv("POSTGRES_SETTINGS__URL", "postgresql://localhost:5432/srm") + monkeypatch.setenv("POSTGRES_SETTINGS__ECHO", "true") + monkeypatch.setenv("POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP", "true") + with pytest.raises(ValidationError): + Settings(_env_file=None) # type: ignore[call-arg] + + +def test_raises_if_postgres_url_missing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("APP_NAME", "test") + monkeypatch.setenv("APP_VERSION", "1.0.0") + monkeypatch.setenv("APP_DESCRIPTION", "a description") + monkeypatch.delenv("POSTGRES_SETTINGS__URL", raising=False) + monkeypatch.setenv("POSTGRES_SETTINGS__ECHO", "true") + monkeypatch.setenv("POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP", "true") + with pytest.raises(ValidationError): + Settings(_env_file=None) # type: ignore[call-arg] + + +def test_raises_if_postgres_echo_missing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("APP_NAME", "test") + monkeypatch.setenv("APP_VERSION", "1.0.0") + monkeypatch.setenv("APP_DESCRIPTION", "a description") + monkeypatch.setenv("POSTGRES_SETTINGS__URL", "postgresql://localhost:5432/srm") + monkeypatch.delenv("POSTGRES_SETTINGS__ECHO", raising=False) + monkeypatch.setenv("POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP", "true") + with pytest.raises(ValidationError): + Settings(_env_file=None) # type: ignore[call-arg] + + +def test_raises_if_postgres_create_schema_missing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("APP_NAME", "test") + monkeypatch.setenv("APP_VERSION", "1.0.0") + monkeypatch.setenv("APP_DESCRIPTION", "a description") + monkeypatch.setenv("POSTGRES_SETTINGS__URL", "postgresql://localhost:5432/srm") + monkeypatch.setenv("POSTGRES_SETTINGS__ECHO", "true") + monkeypatch.delenv("POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP", raising=False) + with pytest.raises(ValidationError): + Settings(_env_file=None) # type: ignore[call-arg] + + +def test_get_settings_returns_same_instance(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("APP_NAME", "test") + monkeypatch.setenv("APP_VERSION", "1.0.0") + monkeypatch.setenv("APP_DESCRIPTION", "test") + monkeypatch.setenv("POSTGRES_SETTINGS__URL", "postgresql://localhost:5432/srm") + monkeypatch.setenv("POSTGRES_SETTINGS__ECHO", "true") + monkeypatch.setenv("POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP", "true") + assert get_settings() is get_settings() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py new file mode 100644 index 0000000..642c17d --- /dev/null +++ b/tests/unit/fakes.py @@ -0,0 +1,89 @@ +"""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 save(self, service_specification: ServiceSpecification) -> ServiceSpecification: + 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 save(self, zone: Zone) -> Zone: + 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 save(self, service_instance: ServiceInstance) -> ServiceInstance: + 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 0000000..74c818b --- /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_save_round_trips_by_id(self) -> None: + repo = InMemoryServiceSpecificationRepository() + + saved = await repo.save(_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.save(first) + with pytest.raises(DuplicateServiceSpecificationError): + await repo.save(duplicate) + + async def test_returned_objects_are_isolated_copies(self) -> None: + repo = InMemoryServiceSpecificationRepository() + saved = await repo.save(_service_specification()) + + saved.name = "mutated after save" + + 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.save(_service_instance(app_provider_id="tenant-a", ref="prod")) + saved = await repo.save(_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.save(first) + + duplicate = _service_instance(app_provider_id="tenant-a", ref="prod") + duplicate.zone_id = first.zone_id + with pytest.raises(DuplicateServiceInstanceError): + await repo.save(duplicate) diff --git a/tests/unit/test_mappers.py b/tests/unit/test_mappers.py new file mode 100644 index 0000000..dc5b4b7 --- /dev/null +++ b/tests/unit/test_mappers.py @@ -0,0 +1,200 @@ +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 +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="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 + + +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_service_specification_mapper.py b/tests/unit/test_service_specification_mapper.py new file mode 100644 index 0000000..ee94de6 --- /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 0000000..494d3e5 --- /dev/null +++ b/tests/unit/test_service_specification_repo.py @@ -0,0 +1,188 @@ +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_save_flushes_and_reloads_service_specification() -> None: + domain = _service_specification_domain() + session = AsyncMock(spec=AsyncSession) + merged = _service_specification_row() + merged.id = domain.id + reloaded = _service_specification_row() + reloaded.id = domain.id + session.merge.return_value = merged + session.scalar.return_value = reloaded + repo = SqlServiceSpecificationRepository(session) + + result = await repo.save(domain) + + expected = ServiceSpecificationMapper.to_domain(reloaded) + assert result == expected + session.merge.assert_awaited_once() + session.flush.assert_awaited_once() + session.commit.assert_not_awaited() + session.scalar.assert_awaited_once() + + +async def test_save_raises_duplicate_service_specification_error_on_integrity_error() -> None: + domain = _service_specification_domain() + session = AsyncMock(spec=AsyncSession) + session.merge.return_value = _service_specification_row() + 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.save(domain) + + +async def test_save_propagates_non_unique_integrity_errors() -> None: + domain = _service_specification_domain() + session = AsyncMock(spec=AsyncSession) + session.merge.return_value = _service_specification_row() + 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.save(domain) + + +async def test_save_propagates_check_violation_integrity_errors() -> None: + domain = _service_specification_domain() + session = AsyncMock(spec=AsyncSession) + session.merge.return_value = _service_specification_row() + 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.save(domain) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..80cae94 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1457 @@ +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 = "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 = "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 = "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" }, +] -- GitLab From 2784adf30c98570e153e117ec0534a3bd4474dc9 Mon Sep 17 00:00:00 2001 From: stentoumis Date: Wed, 29 Jul 2026 12:36:28 +0300 Subject: [PATCH 03/18] feat: deleted old file --- .../pytest.ini | 5 - .../tests/conftest.py | 137 ----------- .../tests/test_retrieve_location.py | 214 ------------------ 3 files changed, 356 deletions(-) delete mode 100644 service-resource-manager-implementation/pytest.ini delete mode 100644 service-resource-manager-implementation/tests/conftest.py delete mode 100644 service-resource-manager-implementation/tests/test_retrieve_location.py diff --git a/service-resource-manager-implementation/pytest.ini b/service-resource-manager-implementation/pytest.ini deleted file mode 100644 index 82e481d..0000000 --- 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/tests/conftest.py b/service-resource-manager-implementation/tests/conftest.py deleted file mode 100644 index 5a82fdb..0000000 --- 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 e4f5f8e..0000000 --- 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") -- GitLab From b42a55130ce544d9ec27305dafcc981954834212 Mon Sep 17 00:00:00 2001 From: stentoumis Date: Wed, 29 Jul 2026 12:46:49 +0300 Subject: [PATCH 04/18] fix: tests --- tests/conftest.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 4438ba1..3ec2a95 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -81,6 +81,9 @@ def app(monkeypatch: pytest.MonkeyPatch) -> FastAPI: monkeypatch.setenv("APP_NAME", "test-srm") monkeypatch.setenv("APP_VERSION", "0.0.1") monkeypatch.setenv("APP_DESCRIPTION", "Test SRM") + monkeypatch.setenv("POSTGRES_SETTINGS__URL", "url") + monkeypatch.setenv("POSTGRES_SETTINGS__ECHO", "true") + monkeypatch.setenv("POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP", "true") return create_app() -- GitLab From 40cd9a8b20ad65ba2db41da4c4cf36f71d28afc8 Mon Sep 17 00:00:00 2001 From: stentoumis Date: Wed, 29 Jul 2026 12:52:43 +0300 Subject: [PATCH 05/18] feat: setting envs in one place. removing .env from dockerfile --- Dockerfile | 1 - tests/conftest.py | 34 +++++++++++++++++++++------------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/Dockerfile b/Dockerfile index 40e9770..e0254be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,6 @@ WORKDIR /app COPY --from=builder /app/.venv /app/.venv COPY src/ ./src/ -COPY .env ./ ENV PATH="/app/.venv/bin:$PATH" ENV PYTHONPATH="/app/src" diff --git a/tests/conftest.py b/tests/conftest.py index 3ec2a95..4206cf1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,14 @@ 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", +} + @pytest.fixture(autouse=True) def clear_settings_cache() -> Generator[None, None, None]: @@ -30,6 +38,12 @@ def _as_asyncpg_url(url: str) -> str: raise ValueError(f"Unsupported postgres URL: {url}") +def _set_test_settings_env(monkeypatch: pytest.MonkeyPatch, *, postgres_url: str) -> None: + for key, value in TEST_SETTINGS_ENV.items(): + monkeypatch.setenv(key, value) + monkeypatch.setenv("POSTGRES_SETTINGS__URL", postgres_url) + + @pytest.fixture(scope="session") def postgres_container() -> Iterator[PostgresContainer]: try: @@ -45,14 +59,10 @@ def app_with_db( postgres_container: PostgresContainer, clean_db: None, ) -> FastAPI: - monkeypatch.setenv("APP_NAME", "test-srm") - monkeypatch.setenv("APP_VERSION", "0.0.1") - monkeypatch.setenv("APP_DESCRIPTION", "Test SRM") - monkeypatch.setenv( - "POSTGRES_SETTINGS__URL", _as_asyncpg_url(postgres_container.get_connection_url()) + _set_test_settings_env( + monkeypatch, + postgres_url=_as_asyncpg_url(postgres_container.get_connection_url()), ) - monkeypatch.setenv("POSTGRES_SETTINGS__ECHO", "true") - monkeypatch.setenv("POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP", "true") return create_app() @@ -78,12 +88,10 @@ async def clean_db(postgres_container: PostgresContainer) -> AsyncIterator[None] @pytest.fixture def app(monkeypatch: pytest.MonkeyPatch) -> FastAPI: - monkeypatch.setenv("APP_NAME", "test-srm") - monkeypatch.setenv("APP_VERSION", "0.0.1") - monkeypatch.setenv("APP_DESCRIPTION", "Test SRM") - monkeypatch.setenv("POSTGRES_SETTINGS__URL", "url") - monkeypatch.setenv("POSTGRES_SETTINGS__ECHO", "true") - monkeypatch.setenv("POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP", "true") + _set_test_settings_env( + monkeypatch, + postgres_url="postgresql+asyncpg://postgres:postgres@localhost:5432/srm", + ) return create_app() -- GitLab From 39ed61bc2d6f0785677926e503472591b94dd0c0 Mon Sep 17 00:00:00 2001 From: stentoumis Date: Wed, 29 Jul 2026 15:57:28 +0300 Subject: [PATCH 06/18] feat: nats adapter implementation. and some tests --- .env.example | 4 + pyproject.toml | 3 +- src/srm/adapters/databus/.gitkeep | 0 .../databus/nats_connection_manager.py | 60 +++++++++ src/srm/adapters/databus/nats_publisher.py | 20 +++ src/srm/api/databus/.gitkeep | 0 src/srm/api/databus/message_router.py | 22 ++++ src/srm/api/databus/nats_subscriber.py | 95 ++++++++++++++ src/srm/api/databus/schemas.py | 114 ++++++++++++++++ src/srm/api/health.py | 7 +- src/srm/app_state.py | 5 + src/srm/config.py | 7 + src/srm/domain/ports/databus/publisher.py | 10 ++ src/srm/main.py | 19 ++- tests/api/fakes.py | 27 ++++ tests/api/test_health.py | 34 ++++- tests/conftest.py | 3 + tests/test_config.py | 124 +++++++----------- uv.lock | 11 ++ 19 files changed, 487 insertions(+), 78 deletions(-) delete mode 100644 src/srm/adapters/databus/.gitkeep create mode 100644 src/srm/adapters/databus/nats_connection_manager.py create mode 100644 src/srm/adapters/databus/nats_publisher.py delete mode 100644 src/srm/api/databus/.gitkeep create mode 100644 src/srm/api/databus/message_router.py create mode 100644 src/srm/api/databus/nats_subscriber.py create mode 100644 src/srm/api/databus/schemas.py create mode 100644 src/srm/domain/ports/databus/publisher.py create mode 100644 tests/api/fakes.py diff --git a/.env.example b/.env.example index 06995e9..6722c82 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,7 @@ 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_ATTEMPS = 3 diff --git a/pyproject.toml b/pyproject.toml index c99d698..a7a3783 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,8 @@ dependencies = [ "structlog>=25.5.0", "sunrise6g-opensdk==2.0.0", "sqlalchemy>=2.0.48", - "asyncpg>=0.31.0" + "asyncpg>=0.31.0", + "nats-py>=2.10.0", ] [project.optional-dependencies] diff --git a/src/srm/adapters/databus/.gitkeep b/src/srm/adapters/databus/.gitkeep deleted file mode 100644 index e69de29..0000000 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 0000000..0f45d79 --- /dev/null +++ b/src/srm/adapters/databus/nats_connection_manager.py @@ -0,0 +1,60 @@ +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 + + @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) + + if not self.is_connected: + try: + self._client = await nats.connect( + servers=[self._settings.url], + connect_timeout=self._settings.connect_timeout, + max_reconnect_attempts=self._settings.max_reconnect_attempts, + error_cb=_on_error, + disconnected_cb=_on_disconnect, + reconnected_cb=_on_reconnect, + ) + except Exception as e: + logger.error("nats_error", error=str(e)) + raise + else: + logger.info("nats_already_connected") + + async def close(self) -> None: + if self._client is not None: + await self._client.drain() + 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 0000000..c03ec67 --- /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/api/databus/.gitkeep b/src/srm/api/databus/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/srm/api/databus/message_router.py b/src/srm/api/databus/message_router.py new file mode 100644 index 0000000..9990f17 --- /dev/null +++ b/src/srm/api/databus/message_router.py @@ -0,0 +1,22 @@ +from collections.abc import Awaitable, Callable + +from nats.aio.msg import Msg + + +class MessageRouter: + def __init__(self) -> None: + self._handlers: dict[str, Callable[[Msg], Awaitable[None]]] = {} + + def register_handler( + self, + subject: str, + handler: Callable[[Msg], Awaitable[None]], + ) -> None: + self._handlers[subject] = handler + + async def route(self, msg: Msg) -> None: + handler = self._handlers.get(msg.subject) + if handler is None: + raise RuntimeError(f"No handler registered for subject: {msg.subject}") + + await handler(msg) diff --git a/src/srm/api/databus/nats_subscriber.py b/src/srm/api/databus/nats_subscriber.py new file mode 100644 index 0000000..2809dd8 --- /dev/null +++ b/src/srm/api/databus/nats_subscriber.py @@ -0,0 +1,95 @@ +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__) + + +async def _noop_router(message: InboundMessage) -> None: + logger.info("Message Received", subject=message.subject, data=message.payload) + return None + + +async def subscribe_to_subjects( + connection_manager: NatsConnectionManager, +) -> list["NatsSubscriber"]: + subscribers: list[NatsSubscriber] = [] + + service_deploy_sub = NatsSubscriber( + connection_manager=connection_manager, + subject="command.srm.service.deploy", + router=_noop_router, + ) + subscribers.append(service_deploy_sub) + + service_scale_sub = NatsSubscriber( + connection_manager=connection_manager, + subject="command.srm.service.scale", + router=_noop_router, + ) + subscribers.append(service_scale_sub) + + service_terminate_sub = NatsSubscriber( + connection_manager=connection_manager, + subject="command.srm.service.terminate", + router=_noop_router, + ) + subscribers.append(service_terminate_sub) + + service_network_activate_sub = NatsSubscriber( + connection_manager=connection_manager, + subject="command.srm.network.capability.activate", + router=_noop_router, + ) + subscribers.append(service_network_activate_sub) + + service_network_update_sub = NatsSubscriber( + connection_manager=connection_manager, + subject="command.srm.network.capability.update", + router=_noop_router, + ) + subscribers.append(service_network_update_sub) + + service_network_deactivate_sub = NatsSubscriber( + connection_manager=connection_manager, + subject="command.srm.network.capability.deactivate", + router=_noop_router, + ) + subscribers.append(service_network_deactivate_sub) + + 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 {}, + ) + await self._router(inbound_message) diff --git a/src/srm/api/databus/schemas.py b/src/srm/api/databus/schemas.py new file mode 100644 index 0000000..e108750 --- /dev/null +++ b/src/srm/api/databus/schemas.py @@ -0,0 +1,114 @@ +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, Field, model_validator + +from srm.domain.models.canonical_parameters.parameters import ( + CapabilityParameters, + CapabilityTarget, +) + + +class InboundMessage(BaseModel): + subject: str + payload: bytes + headers: dict[str, str] + + +class CommandEnvelopeV1(BaseModel): + schema_version: str = "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"] + + +class PlacementConstraintsV1(BaseModel): + model_config = {"extra": "allow"} + + +class DeployPayloadV1(BaseModel): + instance_name: str | None = None + placement_constraints: PlacementConstraintsV1 | None = None + + +class DeployTargetV1(BaseModel): + app_instance_id: UUID + zone_id: UUID | None = None + + +class SrmServiceDeployV1(CommandEnvelopeV1): + service_specification_public_id: UUID + targets: list[DeployTargetV1] + deploy: DeployPayloadV1 + + +class ScalePayloadV1(BaseModel): + replicas: int | None = Field(default=None, ge=0) + + +class SrmServiceScaleV1(CommandEnvelopeV1): + service_instance_public_id: UUID + service_specification_public_id: UUID | None = None + scale: ScalePayloadV1 + + +class TerminatePayloadV1(BaseModel): + grace_period_seconds: int = Field(default=0, ge=0) + + +class SrmServiceTerminateV1(CommandEnvelopeV1): + service_instance_public_id: UUID + service_specification_public_id: UUID | None = None + terminate: TerminatePayloadV1 + + +class NetworkCapabilityPayloadV1(BaseModel): + capability_type: str + target: CapabilityTarget + profile_ref: str | None = None + parameters: CapabilityParameters + + +class SrmNetworkCapabilityActivateV1(CommandEnvelopeV1): + service_specification_public_id: UUID + zone_public_id: UUID | None = None + network_capability: NetworkCapabilityPayloadV1 + + +class NetworkCapabilityRealizationRefV1(BaseModel): + external_ref: str | None = None + service_instance_public_id: UUID | None = None + capability_type: str | None = None + + @model_validator(mode="after") + def validate_reference_shape(self) -> "NetworkCapabilityRealizationRefV1": + if self.external_ref is not None: + return self + + if self.service_instance_public_id is not None and self.capability_type is not None: + return self + + raise ValueError( + "network capability realization must be identified by external_ref " + "or by service_instance_public_id and capability_type" + ) + + +class NetworkCapabilityUpdatePayloadV1(NetworkCapabilityRealizationRefV1): + parameters: CapabilityParameters + + +class SrmNetworkCapabilityUpdateV1(CommandEnvelopeV1): + network_capability: NetworkCapabilityUpdatePayloadV1 + + +class NetworkCapabilityDeactivatePayloadV1(NetworkCapabilityRealizationRefV1): + grace_period_seconds: int = Field(default=0, ge=0) + + +class SrmNetworkCapabilityDeactivateV1(CommandEnvelopeV1): + network_capability: NetworkCapabilityDeactivatePayloadV1 diff --git a/src/srm/api/health.py b/src/srm/api/health.py index d34ee2f..f3f4412 100644 --- a/src/srm/api/health.py +++ b/src/srm/api/health.py @@ -13,10 +13,15 @@ async def liveness() -> bool: @health_router.get("/health/readyz") async def readiness(request: Request) -> bool: + app_state = get_app_state(request) + try: - async with get_app_state(request).db_engine.connect() as connection: + 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/app_state.py b/src/srm/app_state.py index a21baa6..799d9cb 100644 --- a/src/srm/app_state.py +++ b/src/srm/app_state.py @@ -2,7 +2,12 @@ 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/src/srm/config.py b/src/srm/config.py index 5a75a01..cb39787 100644 --- a/src/srm/config.py +++ b/src/srm/config.py @@ -29,6 +29,12 @@ class PostgreSQLSettings(BaseModel): create_schema_on_startup: bool +class NatsSettings(BaseModel): + url: str + connect_timeout: int + max_reconnect_attempts: int + + class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_nested_delimiter="__") @@ -37,6 +43,7 @@ class Settings(BaseSettings): app_description: str postgres_settings: PostgreSQLSettings + nats_settings: NatsSettings @lru_cache() diff --git a/src/srm/domain/ports/databus/publisher.py b/src/srm/domain/ports/databus/publisher.py new file mode 100644 index 0000000..613f70d --- /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 index 6e56ba4..bfa7973 100644 --- a/src/srm/main.py +++ b/src/srm/main.py @@ -17,7 +17,15 @@ 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.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 @@ -46,14 +54,23 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: except Exception as e: logger.error("Database engine init failed!", error=str(e)) raise + databus_manager: NatsConnectionManager = await init_databus_manager( + settings=settings.nats_settings + ) + + databus_subscribers: list[NatsSubscriber] = await subscribe_to_subjects(databus_manager) 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") await engine.dispose() + await app.state.databus_connection_manager.close() + app.state.databus_subscribers = [] def create_app() -> FastAPI: diff --git a/tests/api/fakes.py b/tests/api/fakes.py new file mode 100644 index 0000000..605bacd --- /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/test_health.py b/tests/api/test_health.py index e079c35..9476006 100644 --- a/tests/api/test_health.py +++ b/tests/api/test_health.py @@ -1,4 +1,16 @@ -from httpx import AsyncClient +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_livez_returns_200(client: AsyncClient) -> None: @@ -11,3 +23,23 @@ async def test_readyz_returns_200(client_with_db: AsyncClient) -> None: response = await client_with_db.get("/health/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("/health/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("/health/readyz") + + assert response.status_code == 503 + assert response.json() == {"detail": "NATS not ready"} diff --git a/tests/conftest.py b/tests/conftest.py index 4206cf1..62f3feb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,9 @@ TEST_SETTINGS_ENV = { "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", } diff --git a/tests/test_config.py b/tests/test_config.py index 12e37e0..b1f8a58 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,14 +3,26 @@ 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: - monkeypatch.setenv("APP_NAME", "my-srm") - monkeypatch.setenv("APP_VERSION", "1.2.3") - monkeypatch.setenv("APP_DESCRIPTION", "a description") - monkeypatch.setenv("POSTGRES_SETTINGS__URL", "postgresql://localhost:5432/srm") - monkeypatch.setenv("POSTGRES_SETTINGS__ECHO", "true") - monkeypatch.setenv("POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP", "true") + 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" @@ -18,79 +30,43 @@ def test_loads_all_fields_from_env(monkeypatch: pytest.MonkeyPatch) -> None: assert s.postgres_settings.url == "postgresql://localhost:5432/srm" assert s.postgres_settings.echo assert s.postgres_settings.create_schema_on_startup - - -def test_raises_if_app_name_missing(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("APP_NAME", raising=False) - monkeypatch.setenv("APP_VERSION", "1.0.0") - monkeypatch.setenv("APP_DESCRIPTION", "test") - monkeypatch.setenv("POSTGRES_SETTINGS__URL", "postgresql://localhost:5432/srm") - monkeypatch.setenv("POSTGRES_SETTINGS__ECHO", "true") - monkeypatch.setenv("POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP", "true") - with pytest.raises(ValidationError): - Settings(_env_file=None) # type: ignore[call-arg] - - -def test_raises_if_app_version_missing(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("APP_NAME", "test") - monkeypatch.delenv("APP_VERSION", raising=False) - monkeypatch.setenv("APP_DESCRIPTION", "test") - monkeypatch.setenv("POSTGRES_SETTINGS__URL", "postgresql://localhost:5432/srm") - monkeypatch.setenv("POSTGRES_SETTINGS__ECHO", "true") - monkeypatch.setenv("POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP", "true") - with pytest.raises(ValidationError): - Settings(_env_file=None) # type: ignore[call-arg] - - -def test_raises_if_app_description_missing(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("APP_NAME", "test") - monkeypatch.setenv("APP_VERSION", "1.0.0") - monkeypatch.delenv("APP_DESCRIPTION", raising=False) - monkeypatch.setenv("POSTGRES_SETTINGS__URL", "postgresql://localhost:5432/srm") - monkeypatch.setenv("POSTGRES_SETTINGS__ECHO", "true") - monkeypatch.setenv("POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP", "true") - with pytest.raises(ValidationError): - Settings(_env_file=None) # type: ignore[call-arg] - - -def test_raises_if_postgres_url_missing(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("APP_NAME", "test") - monkeypatch.setenv("APP_VERSION", "1.0.0") - monkeypatch.setenv("APP_DESCRIPTION", "a description") - monkeypatch.delenv("POSTGRES_SETTINGS__URL", raising=False) - monkeypatch.setenv("POSTGRES_SETTINGS__ECHO", "true") - monkeypatch.setenv("POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP", "true") - with pytest.raises(ValidationError): - Settings(_env_file=None) # type: ignore[call-arg] - - -def test_raises_if_postgres_echo_missing(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("APP_NAME", "test") - monkeypatch.setenv("APP_VERSION", "1.0.0") - monkeypatch.setenv("APP_DESCRIPTION", "a description") - monkeypatch.setenv("POSTGRES_SETTINGS__URL", "postgresql://localhost:5432/srm") - monkeypatch.delenv("POSTGRES_SETTINGS__ECHO", raising=False) - monkeypatch.setenv("POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP", "true") + 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_raises_if_postgres_create_schema_missing(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("APP_NAME", "test") - monkeypatch.setenv("APP_VERSION", "1.0.0") - monkeypatch.setenv("APP_DESCRIPTION", "a description") - monkeypatch.setenv("POSTGRES_SETTINGS__URL", "postgresql://localhost:5432/srm") - monkeypatch.setenv("POSTGRES_SETTINGS__ECHO", "true") - monkeypatch.delenv("POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP", 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: - monkeypatch.setenv("APP_NAME", "test") - monkeypatch.setenv("APP_VERSION", "1.0.0") - monkeypatch.setenv("APP_DESCRIPTION", "test") - monkeypatch.setenv("POSTGRES_SETTINGS__URL", "postgresql://localhost:5432/srm") - monkeypatch.setenv("POSTGRES_SETTINGS__ECHO", "true") - monkeypatch.setenv("POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP", "true") + set_valid_env(monkeypatch) assert get_settings() is get_settings() diff --git a/uv.lock b/uv.lock index 80cae94..1911391 100644 --- a/uv.lock +++ b/uv.lock @@ -689,6 +689,15 @@ 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" @@ -1135,6 +1144,7 @@ source = { editable = "." } dependencies = [ { name = "asyncpg" }, { name = "fastapi", extra = ["standard"] }, + { name = "nats-py" }, { name = "pydantic-settings" }, { name = "python-dotenv" }, { name = "sqlalchemy" }, @@ -1162,6 +1172,7 @@ requires-dist = [ { 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" }, -- GitLab From b8a20797adc60b0b9fa2b7ffa95c9409c04e012f Mon Sep 17 00:00:00 2001 From: dgogos Date: Sun, 2 Aug 2026 10:19:09 +0300 Subject: [PATCH 07/18] feat: refactor NATS subscriber and publisher, add tests for connection manager and publisher --- .env.example | 2 +- src/srm/api/databus/message_router.py | 22 -- src/srm/api/databus/nats_subscriber.py | 62 ++-- src/srm/api/databus/schemas.py | 58 ++-- tests/api/databus/__init__.py | 0 tests/api/databus/test_nats_subscriber.py | 134 +++++++++ tests/api/databus/test_schemas.py | 321 +++++++++++++++++++++ tests/conftest.py | 18 +- tests/integration/test_databus.py | 122 ++++++++ tests/unit/test_nats_connection_manager.py | 107 +++++++ tests/unit/test_nats_publisher.py | 53 ++++ 11 files changed, 812 insertions(+), 87 deletions(-) delete mode 100644 src/srm/api/databus/message_router.py create mode 100644 tests/api/databus/__init__.py create mode 100644 tests/api/databus/test_nats_subscriber.py create mode 100644 tests/api/databus/test_schemas.py create mode 100644 tests/integration/test_databus.py create mode 100644 tests/unit/test_nats_connection_manager.py create mode 100644 tests/unit/test_nats_publisher.py diff --git a/.env.example b/.env.example index 6722c82..ec81351 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,4 @@ POSTGRES_SETTINGS__CREATE_SCHEMA_ON_STARTUP = true NATS_SETTINGS__URL = "nats://localhost:4222" NATS_SETTINGS__CONNECT_TIMEOUT = 10 -NATS_SETTINGS__MAX_RECONNECT_ATTEMPS = 3 +NATS_SETTINGS__MAX_RECONNECT_ATTEMPTS = 3 diff --git a/src/srm/api/databus/message_router.py b/src/srm/api/databus/message_router.py deleted file mode 100644 index 9990f17..0000000 --- a/src/srm/api/databus/message_router.py +++ /dev/null @@ -1,22 +0,0 @@ -from collections.abc import Awaitable, Callable - -from nats.aio.msg import Msg - - -class MessageRouter: - def __init__(self) -> None: - self._handlers: dict[str, Callable[[Msg], Awaitable[None]]] = {} - - def register_handler( - self, - subject: str, - handler: Callable[[Msg], Awaitable[None]], - ) -> None: - self._handlers[subject] = handler - - async def route(self, msg: Msg) -> None: - handler = self._handlers.get(msg.subject) - if handler is None: - raise RuntimeError(f"No handler registered for subject: {msg.subject}") - - await handler(msg) diff --git a/src/srm/api/databus/nats_subscriber.py b/src/srm/api/databus/nats_subscriber.py index 2809dd8..f1d2d70 100644 --- a/src/srm/api/databus/nats_subscriber.py +++ b/src/srm/api/databus/nats_subscriber.py @@ -9,8 +9,18 @@ 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 @@ -18,49 +28,10 @@ async def _noop_router(message: InboundMessage) -> None: async def subscribe_to_subjects( connection_manager: NatsConnectionManager, ) -> list["NatsSubscriber"]: - subscribers: list[NatsSubscriber] = [] - - service_deploy_sub = NatsSubscriber( - connection_manager=connection_manager, - subject="command.srm.service.deploy", - router=_noop_router, - ) - subscribers.append(service_deploy_sub) - - service_scale_sub = NatsSubscriber( - connection_manager=connection_manager, - subject="command.srm.service.scale", - router=_noop_router, - ) - subscribers.append(service_scale_sub) - - service_terminate_sub = NatsSubscriber( - connection_manager=connection_manager, - subject="command.srm.service.terminate", - router=_noop_router, - ) - subscribers.append(service_terminate_sub) - - service_network_activate_sub = NatsSubscriber( - connection_manager=connection_manager, - subject="command.srm.network.capability.activate", - router=_noop_router, - ) - subscribers.append(service_network_activate_sub) - - service_network_update_sub = NatsSubscriber( - connection_manager=connection_manager, - subject="command.srm.network.capability.update", - router=_noop_router, - ) - subscribers.append(service_network_update_sub) - - service_network_deactivate_sub = NatsSubscriber( - connection_manager=connection_manager, - subject="command.srm.network.capability.deactivate", - router=_noop_router, - ) - subscribers.append(service_network_deactivate_sub) + subscribers = [ + NatsSubscriber(connection_manager=connection_manager, subject=subject, router=_noop_router) + for subject in COMMAND_SUBJECTS + ] for subscriber in subscribers: await subscriber.start() @@ -92,4 +63,7 @@ class NatsSubscriber: payload=msg.data, headers=dict(msg.headers) if msg.headers is not None else {}, ) - await self._router(inbound_message) + 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 index e108750..0da04d7 100644 --- a/src/srm/api/databus/schemas.py +++ b/src/srm/api/databus/schemas.py @@ -38,21 +38,29 @@ class DeployPayloadV1(BaseModel): class DeployTargetV1(BaseModel): 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_public_id: UUID - targets: list[DeployTargetV1] + service_specification_id: UUID + targets: list[DeployTargetV1] = Field(min_length=1) deploy: DeployPayloadV1 class ScalePayloadV1(BaseModel): - replicas: int | None = Field(default=None, ge=0) + replicas: int = Field(ge=0) class SrmServiceScaleV1(CommandEnvelopeV1): - service_instance_public_id: UUID - service_specification_public_id: UUID | None = None + service_instance_id: UUID + service_specification_id: UUID | None = None scale: ScalePayloadV1 @@ -61,8 +69,8 @@ class TerminatePayloadV1(BaseModel): class SrmServiceTerminateV1(CommandEnvelopeV1): - service_instance_public_id: UUID - service_specification_public_id: UUID | None = None + service_instance_id: UUID + service_specification_id: UUID | None = None terminate: TerminatePayloadV1 @@ -74,35 +82,46 @@ class NetworkCapabilityPayloadV1(BaseModel): class SrmNetworkCapabilityActivateV1(CommandEnvelopeV1): - service_specification_public_id: UUID - zone_public_id: UUID | None = None + 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(BaseModel): + capability_type: str external_ref: str | None = None - service_instance_public_id: UUID | None = None - capability_type: str | None = None + service_instance_id: UUID | None = None @model_validator(mode="after") def validate_reference_shape(self) -> "NetworkCapabilityRealizationRefV1": - if self.external_ref is not None: - return self + has_external_ref = self.external_ref is not None + has_service_instance_id = self.service_instance_id is not None - if self.service_instance_public_id is not None and self.capability_type is not None: - return self + 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" + ) - raise ValueError( - "network capability realization must be identified by external_ref " - "or by service_instance_public_id and capability_type" - ) + 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 @@ -111,4 +130,5 @@ class NetworkCapabilityDeactivatePayloadV1(NetworkCapabilityRealizationRefV1): class SrmNetworkCapabilityDeactivateV1(CommandEnvelopeV1): + service_specification_id: UUID | None = None network_capability: NetworkCapabilityDeactivatePayloadV1 diff --git a/tests/api/databus/__init__.py b/tests/api/databus/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/databus/test_nats_subscriber.py b/tests/api/databus/test_nats_subscriber.py new file mode 100644 index 0000000..33e35f5 --- /dev/null +++ b/tests/api/databus/test_nats_subscriber.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from unittest.mock import AsyncMock, MagicMock + +import pytest + +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_swallows_router_exceptions(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"{}") + + await subscriber._handle_message(msg) # type: ignore[arg-type] + + router.assert_awaited_once() + + +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(COMMAND_SUBJECTS) + assert connection_manager.client.subscribe.await_count == len(COMMAND_SUBJECTS) + + +class TestCommandDeliveryDurability: + @pytest.mark.skip( + reason="TODO: JetStream durable consumer not implemented yet — " + "NatsSubscriber still uses core-NATS subscribe() (at-most-once)." + ) + async def test_subscriber_subscribes_durably_via_jetstream( + self, connection_manager: MagicMock + ) -> None: + """SRM must consume command.srm.* durably, at-least-once (the OOP_TASKS WorkQueue + stream, interface-contract.md §D.1-D.2). A core-NATS subscribe() is non-durable and + at-most-once: every command published while SRM is down or slow is lost with no + redelivery, and OEG/FM never see a terminal event for it.""" + jetstream = MagicMock() + jetstream.subscribe = AsyncMock() + connection_manager.client.jetstream.return_value = jetstream + + subscriber, _ = make_subscriber(connection_manager) + await subscriber.start() + + connection_manager.client.subscribe.assert_not_awaited() + jetstream.subscribe.assert_awaited_once() + assert jetstream.subscribe.await_args is not None + assert jetstream.subscribe.await_args.kwargs.get("durable"), ( + "the contract requires a named durable consumer" + ) diff --git a/tests/api/databus/test_schemas.py b/tests/api/databus/test_schemas.py new file mode 100644 index 0000000..5268dec --- /dev/null +++ b/tests/api/databus/test_schemas.py @@ -0,0 +1,321 @@ +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from pydantic import ValidationError + +from srm.api.databus.schemas import ( + DeployPayloadV1, + DeployTargetV1, + NetworkCapabilityDeactivatePayloadV1, + NetworkCapabilityPayloadV1, + NetworkCapabilityUpdatePayloadV1, + 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] = { + "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 + + +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_ignores_pins_and_does_not_apply_the_rule(self) -> None: + """§B.6: zone_id/domain_id are ignored when identifying an existing realization, + so a bare domain_id must not be rejected on update.""" + payload = _envelope( + domain_id=str(uuid4()), + network_capability={ + "capability_type": "qod_session", + "external_ref": "sess-123", + "parameters": _capability_parameters().model_dump(), + }, + ) + + command = SrmNetworkCapabilityUpdateV1.model_validate(payload) + + assert command.network_capability.external_ref == "sess-123" + + +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/conftest.py b/tests/conftest.py index 62f3feb..dbc6a86 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,7 @@ 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 @@ -41,10 +42,14 @@ def _as_asyncpg_url(url: str) -> str: raise ValueError(f"Unsupported postgres URL: {url}") -def _set_test_settings_env(monkeypatch: pytest.MonkeyPatch, *, postgres_url: str) -> None: +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") @@ -56,15 +61,26 @@ def postgres_container() -> Iterator[PostgresContainer]: 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() diff --git a/tests/integration/test_databus.py b/tests/integration/test_databus.py new file mode 100644 index 0000000..7665151 --- /dev/null +++ b/tests/integration/test_databus.py @@ -0,0 +1,122 @@ +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 ( + COMMAND_SUBJECTS, + NatsSubscriber, + subscribe_to_subjects, +) +from srm.api.databus.schemas import InboundMessage +from srm.config import NatsSettings + + +@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(COMMAND_SUBJECTS) + for sub in subscribers: + assert sub._subscription is not None diff --git a/tests/unit/test_nats_connection_manager.py b/tests/unit/test_nats_connection_manager.py new file mode 100644 index 0000000..6326a56 --- /dev/null +++ b/tests/unit/test_nats_connection_manager.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +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) + + +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 callable(call_kwargs["error_cb"]) + assert callable(call_kwargs["disconnected_cb"]) + assert callable(call_kwargs["reconnected_cb"]) + assert manager.client is mock_client + + +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_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_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 0000000..e6b0662 --- /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() -- GitLab From b3407087c38403c072612c413f03c50783092e0d Mon Sep 17 00:00:00 2001 From: dgogos Date: Sun, 2 Aug 2026 11:06:30 +0300 Subject: [PATCH 08/18] feat: enforce schema_version validation in CommandEnvelopeV1 and add related tests --- src/srm/api/databus/schemas.py | 2 +- tests/api/databus/test_schemas.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/srm/api/databus/schemas.py b/src/srm/api/databus/schemas.py index 0da04d7..d31c93c 100644 --- a/src/srm/api/databus/schemas.py +++ b/src/srm/api/databus/schemas.py @@ -17,7 +17,7 @@ class InboundMessage(BaseModel): class CommandEnvelopeV1(BaseModel): - schema_version: str = "1.0" + schema_version: Literal["1.0"] operation_id: UUID correlation_id: str requested_at: datetime diff --git a/tests/api/databus/test_schemas.py b/tests/api/databus/test_schemas.py index 5268dec..5e9a66f 100644 --- a/tests/api/databus/test_schemas.py +++ b/tests/api/databus/test_schemas.py @@ -6,6 +6,7 @@ import pytest from pydantic import ValidationError from srm.api.databus.schemas import ( + CommandEnvelopeV1, DeployPayloadV1, DeployTargetV1, NetworkCapabilityDeactivatePayloadV1, @@ -28,6 +29,7 @@ from srm.domain.models.canonical_parameters.parameters import ( 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", @@ -38,6 +40,35 @@ def _envelope(**overrides: object) -> dict[str, object]: 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) + + def test_deploy_v1_parses_minimal_valid_payload() -> None: payload = _envelope( service_specification_id=str(uuid4()), -- GitLab From 6061d7f93c1b79349952dfcab236fa29b904180c Mon Sep 17 00:00:00 2001 From: dgogos Date: Mon, 3 Aug 2026 11:46:40 +0300 Subject: [PATCH 09/18] feat: add validation for federation context in CommandEnvelopeV1 and enhance related tests --- src/srm/api/databus/schemas.py | 7 +++ tests/api/databus/test_nats_subscriber.py | 42 +++++++++++-- tests/api/databus/test_schemas.py | 46 +++++++++++++- tests/api/test_app.py | 70 ++++++++++++++++++++++ tests/integration/test_databus.py | 10 ++-- tests/unit/test_nats_connection_manager.py | 46 ++++++++++++++ 6 files changed, 209 insertions(+), 12 deletions(-) diff --git a/src/srm/api/databus/schemas.py b/src/srm/api/databus/schemas.py index d31c93c..2072cca 100644 --- a/src/srm/api/databus/schemas.py +++ b/src/srm/api/databus/schemas.py @@ -25,6 +25,13 @@ class CommandEnvelopeV1(BaseModel): 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(BaseModel): model_config = {"extra": "allow"} diff --git a/tests/api/databus/test_nats_subscriber.py b/tests/api/databus/test_nats_subscriber.py index 33e35f5..07f9177 100644 --- a/tests/api/databus/test_nats_subscriber.py +++ b/tests/api/databus/test_nats_subscriber.py @@ -4,6 +4,7 @@ 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 ( @@ -88,14 +89,47 @@ async def test_handle_message_defaults_headers_to_empty_dict_when_none( assert inbound.headers == {} -async def test_handle_message_swallows_router_exceptions(connection_manager: MagicMock) -> None: +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"{}") - await subscriber._handle_message(msg) # type: ignore[arg-type] + 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_command_dropped", "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( @@ -103,8 +137,8 @@ async def test_subscribe_to_subjects_registers_all_command_subjects( ) -> None: subscribers = await subscribe_to_subjects(connection_manager) - assert sorted(sub._subject for sub in subscribers) == sorted(COMMAND_SUBJECTS) - assert connection_manager.client.subscribe.await_count == len(COMMAND_SUBJECTS) + assert sorted(sub._subject for sub in subscribers) == sorted(EXPECTED_COMMAND_SUBJECTS) + assert connection_manager.client.subscribe.await_count == len(EXPECTED_COMMAND_SUBJECTS) class TestCommandDeliveryDurability: diff --git a/tests/api/databus/test_schemas.py b/tests/api/databus/test_schemas.py index 5e9a66f..653ae0d 100644 --- a/tests/api/databus/test_schemas.py +++ b/tests/api/databus/test_schemas.py @@ -69,6 +69,50 @@ class TestSchemaVersion: 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) + + def test_deploy_v1_parses_minimal_valid_payload() -> None: payload = _envelope( service_specification_id=str(uuid4()), @@ -109,7 +153,6 @@ def test_deploy_target_accepts_zone_and_domain_pin() -> None: 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()) @@ -255,7 +298,6 @@ def test_network_capability_payload_requires_capability_type() -> None: class TestNetworkCapabilityRealizationRef: - def test_accepts_external_ref_alone(self) -> None: ref = NetworkCapabilityUpdatePayloadV1( capability_type="qod_session", diff --git a/tests/api/test_app.py b/tests/api/test_app.py index d46ee3f..4096bab 100644 --- a/tests/api/test_app.py +++ b/tests/api/test_app.py @@ -1,6 +1,11 @@ +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: @@ -8,3 +13,68 @@ def test_create_app_uses_settings(app: FastAPI) -> None: 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 + + async def test_startup_fails_when_subject_subscription_fails( + self, app: FastAPI, stub_database: AsyncMock + ) -> None: + with ( + patch("srm.main.init_databus_manager", new_callable=AsyncMock), + 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 + + 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 == [] diff --git a/tests/integration/test_databus.py b/tests/integration/test_databus.py index 7665151..fb581ed 100644 --- a/tests/integration/test_databus.py +++ b/tests/integration/test_databus.py @@ -10,14 +10,12 @@ 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 ( - COMMAND_SUBJECTS, - NatsSubscriber, - subscribe_to_subjects, -) +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]: @@ -117,6 +115,6 @@ async def test_subscribe_to_subjects_registers_all_command_subjects( ) -> None: subscribers = await subscribe_to_subjects(connection_manager) - assert sorted(sub._subject for sub in subscribers) == sorted(COMMAND_SUBJECTS) + 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/unit/test_nats_connection_manager.py b/tests/unit/test_nats_connection_manager.py index 6326a56..1f70a7f 100644 --- a/tests/unit/test_nats_connection_manager.py +++ b/tests/unit/test_nats_connection_manager.py @@ -1,8 +1,10 @@ from __future__ import annotations +from typing import Any from unittest.mock import AsyncMock, patch import pytest +import structlog.testing from srm.adapters.databus.nats_connection_manager import ( NatsConnectionManager, @@ -47,6 +49,50 @@ async def test_connect_uses_settings(settings: NatsSettings) -> None: 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 -- GitLab From e29de8ea8b94b99758f6dbfa8a7e3bdc884a0553 Mon Sep 17 00:00:00 2001 From: dgogos Date: Mon, 3 Aug 2026 13:05:28 +0300 Subject: [PATCH 10/18] feat: add drain_timeout to NATS settings and enhance connection management --- .env.example | 1 + .pre-commit-config.yaml | 1 + .../databus/nats_connection_manager.py | 26 +++++++-- src/srm/config.py | 1 + src/srm/main.py | 23 ++++++-- tests/api/databus/test_nats_subscriber.py | 3 +- tests/api/test_app.py | 27 +++++++++- tests/integration/test_databus.py | 1 - tests/unit/test_nats_connection_manager.py | 53 ++++++++++++++++++- 9 files changed, 121 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index ec81351..ca26bb5 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,4 @@ 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/.pre-commit-config.yaml b/.pre-commit-config.yaml index df54d1b..8220cac 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,6 +27,7 @@ repos: - "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" diff --git a/src/srm/adapters/databus/nats_connection_manager.py b/src/srm/adapters/databus/nats_connection_manager.py index 0f45d79..47693e0 100644 --- a/src/srm/adapters/databus/nats_connection_manager.py +++ b/src/srm/adapters/databus/nats_connection_manager.py @@ -1,3 +1,5 @@ +import asyncio + import nats import structlog from nats.aio.client import Client @@ -17,6 +19,7 @@ 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: @@ -38,12 +41,17 @@ class NatsConnectionManager: async def _on_reconnect() -> None: logger.info("nats_reconnected", url=self._settings.url) - if not self.is_connected: + 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, @@ -51,10 +59,18 @@ class NatsConnectionManager: except Exception as e: logger.error("nats_error", error=str(e)) raise - else: - logger.info("nats_already_connected") async def close(self) -> None: - if self._client is not None: - await self._client.drain() + 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/config.py b/src/srm/config.py index cb39787..20a4d87 100644 --- a/src/srm/config.py +++ b/src/srm/config.py @@ -33,6 +33,7 @@ class NatsSettings(BaseModel): url: str connect_timeout: int max_reconnect_attempts: int + drain_timeout: int = 30 class Settings(BaseSettings): diff --git a/src/srm/main.py b/src/srm/main.py index bfa7973..4e4e5f7 100644 --- a/src/srm/main.py +++ b/src/srm/main.py @@ -54,11 +54,22 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: except Exception as e: logger.error("Database engine init failed!", error=str(e)) raise - databus_manager: NatsConnectionManager = await init_databus_manager( - settings=settings.nats_settings - ) + 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 - databus_subscribers: list[NatsSubscriber] = await subscribe_to_subjects(databus_manager) + 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 @@ -68,9 +79,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: yield logger.info("Shutting down application") - await engine.dispose() + # 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: diff --git a/tests/api/databus/test_nats_subscriber.py b/tests/api/databus/test_nats_subscriber.py index 07f9177..f10bc96 100644 --- a/tests/api/databus/test_nats_subscriber.py +++ b/tests/api/databus/test_nats_subscriber.py @@ -115,9 +115,10 @@ async def test_handle_message_logs_dropped_command_when_router_fails( router.assert_awaited_once() assert [(entry["event"], entry["subject"], entry["log_level"]) for entry in logs] == [ - ("databus_command_dropped", "command.srm.service.deploy", "error") + ("databus_handler_failed", "command.srm.service.deploy", "error") ] + EXPECTED_COMMAND_SUBJECTS = [ "command.srm.service.deploy", "command.srm.service.scale", diff --git a/tests/api/test_app.py b/tests/api/test_app.py index 4096bab..8d302e8 100644 --- a/tests/api/test_app.py +++ b/tests/api/test_app.py @@ -44,11 +44,15 @@ class TestLifespanDatabusInit: 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), + patch("srm.main.init_databus_manager", new_callable=AsyncMock, return_value=manager), patch( "srm.main.subscribe_to_subjects", new_callable=AsyncMock, @@ -59,6 +63,9 @@ class TestLifespanDatabusInit: 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: @@ -78,3 +85,21 @@ class TestLifespanDatabusInit: 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/integration/test_databus.py b/tests/integration/test_databus.py index fb581ed..82569c0 100644 --- a/tests/integration/test_databus.py +++ b/tests/integration/test_databus.py @@ -13,7 +13,6 @@ 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 diff --git a/tests/unit/test_nats_connection_manager.py b/tests/unit/test_nats_connection_manager.py index 1f70a7f..90d2e00 100644 --- a/tests/unit/test_nats_connection_manager.py +++ b/tests/unit/test_nats_connection_manager.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from typing import Any from unittest.mock import AsyncMock, patch @@ -15,7 +16,17 @@ from srm.config import NatsSettings @pytest.fixture def settings() -> NatsSettings: - return NatsSettings(url="nats://broker:4222", connect_timeout=3, max_reconnect_attempts=2) + 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: @@ -43,6 +54,7 @@ async def test_connect_uses_settings(settings: NatsSettings) -> None: 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"]) @@ -50,7 +62,6 @@ async def test_connect_uses_settings(settings: NatsSettings) -> None: class TestConnectionCallbacks: - @staticmethod async def _connect_and_capture_callbacks(settings: NatsSettings) -> dict[str, Any]: with patch( @@ -109,6 +120,27 @@ async def test_connect_is_noop_when_already_connected(settings: NatsSettings) -> 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", @@ -133,6 +165,23 @@ async def test_close_drains_client_and_clears_state(settings: NatsSettings) -> N 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() -- GitLab From e461bb2679e5fa684ccf1431b51abf94747de664 Mon Sep 17 00:00:00 2001 From: dgogos Date: Mon, 3 Aug 2026 18:20:32 +0300 Subject: [PATCH 11/18] fix: remove skipped test for durable NATS subscriber and clean up code --- tests/api/databus/test_nats_subscriber.py | 27 ----------------------- tests/api/test_health.py | 2 ++ 2 files changed, 2 insertions(+), 27 deletions(-) diff --git a/tests/api/databus/test_nats_subscriber.py b/tests/api/databus/test_nats_subscriber.py index f10bc96..65a97ef 100644 --- a/tests/api/databus/test_nats_subscriber.py +++ b/tests/api/databus/test_nats_subscriber.py @@ -140,30 +140,3 @@ async def test_subscribe_to_subjects_registers_all_command_subjects( assert sorted(sub._subject for sub in subscribers) == sorted(EXPECTED_COMMAND_SUBJECTS) assert connection_manager.client.subscribe.await_count == len(EXPECTED_COMMAND_SUBJECTS) - - -class TestCommandDeliveryDurability: - @pytest.mark.skip( - reason="TODO: JetStream durable consumer not implemented yet — " - "NatsSubscriber still uses core-NATS subscribe() (at-most-once)." - ) - async def test_subscriber_subscribes_durably_via_jetstream( - self, connection_manager: MagicMock - ) -> None: - """SRM must consume command.srm.* durably, at-least-once (the OOP_TASKS WorkQueue - stream, interface-contract.md §D.1-D.2). A core-NATS subscribe() is non-durable and - at-most-once: every command published while SRM is down or slow is lost with no - redelivery, and OEG/FM never see a terminal event for it.""" - jetstream = MagicMock() - jetstream.subscribe = AsyncMock() - connection_manager.client.jetstream.return_value = jetstream - - subscriber, _ = make_subscriber(connection_manager) - await subscriber.start() - - connection_manager.client.subscribe.assert_not_awaited() - jetstream.subscribe.assert_awaited_once() - assert jetstream.subscribe.await_args is not None - assert jetstream.subscribe.await_args.kwargs.get("durable"), ( - "the contract requires a named durable consumer" - ) diff --git a/tests/api/test_health.py b/tests/api/test_health.py index 9476006..c25e238 100644 --- a/tests/api/test_health.py +++ b/tests/api/test_health.py @@ -1,3 +1,4 @@ +import pytest from fastapi import FastAPI from httpx import ASGITransport, AsyncClient @@ -19,6 +20,7 @@ async def test_livez_returns_200(client: AsyncClient) -> None: 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("/health/readyz") assert response.status_code == 200 -- GitLab From 9e6aaab0ac72d10e1b4b207ba578af368436eb9c Mon Sep 17 00:00:00 2001 From: Dimitrios Gogos Date: Tue, 4 Aug 2026 07:29:01 +0000 Subject: [PATCH 12/18] fix: remove tags due to new gitlab runner --- .gitlab-ci.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4c98851..01c4d0b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,6 +1,4 @@ default: - tags: - - docker image: python:3.12-slim cache: paths: @@ -57,8 +55,6 @@ test: build: stage: build - tags: - - shell before_script: - docker info script: @@ -69,8 +65,6 @@ build: push: stage: push - tags: - - shell needs: - build before_script: -- GitLab From 62768689a008566cf2030380b0b4f255305dc3bf Mon Sep 17 00:00:00 2001 From: dgogos Date: Tue, 4 Aug 2026 12:58:45 +0300 Subject: [PATCH 13/18] refactor: remove push stage and consolidate build script in GitLab CI configuration --- .gitlab-ci.yml | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 01c4d0b..1e9ac23 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -17,7 +17,6 @@ stages: - format - test - build - - push variables: UV_CACHE_DIR: "$CI_PROJECT_DIR/.cache/uv" @@ -55,24 +54,19 @@ test: build: stage: build - before_script: - - docker info - script: - - export TEST_IMAGE_TAG="ci-${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHORT_SHA}" - - docker build --network=host -t "$CI_REGISTRY_IMAGE:$TEST_IMAGE_TAG" . - rules: - - if: '$CI_COMMIT_BRANCH' - -push: - stage: push - needs: - - build + image: docker:cli + variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + services: + - docker:dind before_script: - docker info script: - 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' + - if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "develop"' -- GitLab From aa04612669f7d79cae2c0392851c78ff7d9ab1eb Mon Sep 17 00:00:00 2001 From: stentoumis Date: Tue, 4 Aug 2026 23:16:55 +0300 Subject: [PATCH 14/18] fix: now changed every repo save function to create. the use of session.merge changed to session.add. changed it to every repo to be consistent and the save/update function will be added per use case --- src/srm/adapters/database/repos/catalog.py | 35 ++++----- .../database/repos/runtime_inventory.py | 33 +++++---- src/srm/adapters/database/repos/topology.py | 44 ++++++----- src/srm/domain/ports/database/catalog.py | 6 +- .../ports/database/runtime_inventory.py | 6 +- src/srm/domain/ports/database/topology.py | 8 +- tests/integration/test_constraints.py | 44 +++++------ tests/integration/test_postgres.py | 73 +++++++++++++------ tests/integration/test_repositories.py | 62 ++++++++-------- tests/unit/fakes.py | 12 ++- tests/unit/test_in_memory_repositories.py | 20 ++--- tests/unit/test_service_specification_repo.py | 24 +++--- 12 files changed, 203 insertions(+), 164 deletions(-) diff --git a/src/srm/adapters/database/repos/catalog.py b/src/srm/adapters/database/repos/catalog.py index 21aa15c..93d05b5 100644 --- a/src/srm/adapters/database/repos/catalog.py +++ b/src/srm/adapters/database/repos/catalog.py @@ -63,19 +63,20 @@ class SqlServiceSpecificationRepository(ServiceSpecificationRepository): row = await self._session.scalar(stmt) return ServiceSpecificationMapper.to_domain(row) if row is not None else None - async def save(self, service_specification: ServiceSpecification) -> ServiceSpecification: - merged = await self._session.merge(ServiceSpecificationMapper.to_row(service_specification)) + 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="save") + _log_integrity_error(exc, entity="service_specification", operation="create") if _pgcode(exc) == "23505": raise DuplicateServiceSpecificationError() from exc raise - specification_id = merged.id + specification_id = row.id saved = await self.get_by_id(specification_id) if saved is None: - raise RuntimeError("Saved service specification could not be reloaded") + raise RuntimeError("Created service specification could not be reloaded") return saved @@ -88,17 +89,18 @@ class SqlServiceDeploymentUnitRepository(ServiceDeploymentUnitRepository): row = await self._session.scalar(stmt) return ServiceDeploymentUnitMapper.to_domain(row) if row is not None else None - async def save(self, deployment_unit: ServiceDeploymentUnit) -> ServiceDeploymentUnit: - merged = await self._session.merge(ServiceDeploymentUnitMapper.to_row(deployment_unit)) + 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="save") + _log_integrity_error(exc, entity="service_deployment_unit", operation="create") raise - deployment_unit_id = merged.id + deployment_unit_id = row.id saved = await self.get_by_id(deployment_unit_id) if saved is None: - raise RuntimeError("Saved deployment unit could not be reloaded") + raise RuntimeError("Created deployment unit could not be reloaded") return saved @@ -113,20 +115,19 @@ class SqlServiceCapabilityRequirementRepository(ServiceCapabilityRequirementRepo row = await self._session.scalar(stmt) return ServiceCapabilityRequirementMapper.to_domain(row) if row is not None else None - async def save( + async def create( self, capability_requirement: ServiceCapabilityRequirement, ) -> ServiceCapabilityRequirement: - merged = await self._session.merge( - ServiceCapabilityRequirementMapper.to_row(capability_requirement) - ) + 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="save") + _log_integrity_error(exc, entity="service_capability_requirement", operation="create") raise - requirement_id = merged.id + requirement_id = row.id saved = await self.get_by_id(requirement_id) if saved is None: - raise RuntimeError("Saved capability requirement could not be reloaded") + 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 index 7e82ce7..50980f3 100644 --- a/src/srm/adapters/database/repos/runtime_inventory.py +++ b/src/srm/adapters/database/repos/runtime_inventory.py @@ -51,17 +51,18 @@ class SqlServiceOrderRepository(ServiceOrderRepository): row = await self._session.scalar(stmt) return ServiceOrderMapper.to_domain(row) if row is not None else None - async def save(self, service_order: ServiceOrder) -> ServiceOrder: - merged = await self._session.merge(ServiceOrderMapper.to_row(service_order)) + 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="save") + _log_integrity_error(exc, entity="service_order", operation="create") raise - service_order_id = merged.id + service_order_id = row.id saved = await self.get_by_id(service_order_id) if saved is None: - raise RuntimeError("Saved service order could not be reloaded") + raise RuntimeError("Created service order could not be reloaded") return saved @@ -80,17 +81,18 @@ class SqlServiceInstanceRepository(ServiceInstanceRepository): row = await self._session.scalar(stmt) return ServiceInstanceMapper.to_domain(row) if row is not None else None - async def save(self, service_instance: ServiceInstance) -> ServiceInstance: - merged = await self._session.merge(ServiceInstanceMapper.to_row(service_instance)) + 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="save") + _log_integrity_error(exc, entity="service_instance", operation="create") raise - service_instance_id = merged.id + service_instance_id = row.id saved = await self.get_by_id(service_instance_id) if saved is None: - raise RuntimeError("Saved service instance could not be reloaded") + raise RuntimeError("Created service instance could not be reloaded") return saved @@ -103,15 +105,16 @@ class SqlCapabilityInstanceRepository(CapabilityInstanceRepository): row = await self._session.scalar(stmt) return CapabilityInstanceMapper.to_domain(row) if row is not None else None - async def save(self, capability_instance: CapabilityInstance) -> CapabilityInstance: - merged = await self._session.merge(CapabilityInstanceMapper.to_row(capability_instance)) + 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="save") + _log_integrity_error(exc, entity="capability_instance", operation="create") raise - capability_instance_id = merged.id + capability_instance_id = row.id saved = await self.get_by_id(capability_instance_id) if saved is None: - raise RuntimeError("Saved capability instance could not be reloaded") + 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 index a8e1729..bfb0246 100644 --- a/src/srm/adapters/database/repos/topology.py +++ b/src/srm/adapters/database/repos/topology.py @@ -57,17 +57,18 @@ class SqlZoneRepository(ZoneRepository): row = await self._session.scalar(stmt) return ZoneMapper.to_domain(row) if row is not None else None - async def save(self, zone: Zone) -> Zone: - merged = await self._session.merge(ZoneMapper.to_row(zone)) + 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="save") + _log_integrity_error(exc, entity="zone", operation="create") raise - zone_id = merged.id + zone_id = row.id saved = await self.get_by_id(zone_id) if saved is None: - raise RuntimeError("Saved zone could not be reloaded") + raise RuntimeError("Created zone could not be reloaded") return saved @@ -88,17 +89,18 @@ class SqlDomainRepository(DomainRepository): row = await self._session.scalar(stmt) return DomainMapper.to_domain(row) if row is not None else None - async def save(self, domain: Domain) -> Domain: - merged = await self._session.merge(DomainMapper.to_row(domain)) + 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="save") + _log_integrity_error(exc, entity="domain", operation="create") raise - domain_id = merged.id + domain_id = row.id saved = await self.get_by_id(domain_id) if saved is None: - raise RuntimeError("Saved domain could not be reloaded") + raise RuntimeError("Created domain could not be reloaded") return saved @@ -115,17 +117,18 @@ class SqlCapabilityRepository(CapabilityRepository): row = await self._session.scalar(stmt) return CapabilityMapper.to_domain(row) if row is not None else None - async def save(self, capability: Capability) -> Capability: - merged = await self._session.merge(CapabilityMapper.to_row(capability)) + 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="save") + _log_integrity_error(exc, entity="capability", operation="create") raise - capability_id = merged.id + capability_id = row.id saved = await self.get_by_id(capability_id) if saved is None: - raise RuntimeError("Saved capability could not be reloaded") + raise RuntimeError("Created capability could not be reloaded") return saved @@ -138,15 +141,16 @@ class SqlControlPathBindingRepository(ControlPathBindingRepository): row = await self._session.scalar(stmt) return ControlPathBindingMapper.to_domain(row) if row is not None else None - async def save(self, control_path_binding: ControlPathBinding) -> ControlPathBinding: - merged = await self._session.merge(ControlPathBindingMapper.to_row(control_path_binding)) + 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="save") + _log_integrity_error(exc, entity="control_path_binding", operation="create") raise - binding_id = merged.id + binding_id = row.id saved = await self.get_by_id(binding_id) if saved is None: - raise RuntimeError("Saved control path binding could not be reloaded") + raise RuntimeError("Created control path binding could not be reloaded") return saved diff --git a/src/srm/domain/ports/database/catalog.py b/src/srm/domain/ports/database/catalog.py index acdef32..e7e0215 100644 --- a/src/srm/domain/ports/database/catalog.py +++ b/src/srm/domain/ports/database/catalog.py @@ -16,7 +16,7 @@ class ServiceSpecificationRepository(ABC): pass @abstractmethod - async def save(self, service_specification: ServiceSpecification) -> ServiceSpecification: + async def create(self, service_specification: ServiceSpecification) -> ServiceSpecification: pass @@ -26,7 +26,7 @@ class ServiceDeploymentUnitRepository(ABC): pass @abstractmethod - async def save(self, deployment_unit: ServiceDeploymentUnit) -> ServiceDeploymentUnit: + async def create(self, deployment_unit: ServiceDeploymentUnit) -> ServiceDeploymentUnit: pass @@ -36,7 +36,7 @@ class ServiceCapabilityRequirementRepository(ABC): pass @abstractmethod - async def save( + async def create( self, capability_requirement: ServiceCapabilityRequirement, ) -> ServiceCapabilityRequirement: diff --git a/src/srm/domain/ports/database/runtime_inventory.py b/src/srm/domain/ports/database/runtime_inventory.py index 258757e..bdf0834 100644 --- a/src/srm/domain/ports/database/runtime_inventory.py +++ b/src/srm/domain/ports/database/runtime_inventory.py @@ -16,7 +16,7 @@ class ServiceOrderRepository(ABC): pass @abstractmethod - async def save(self, service_order: ServiceOrder) -> ServiceOrder: + async def create(self, service_order: ServiceOrder) -> ServiceOrder: pass @@ -26,7 +26,7 @@ class ServiceInstanceRepository(ABC): pass @abstractmethod - async def save(self, service_instance: ServiceInstance) -> ServiceInstance: + async def create(self, service_instance: ServiceInstance) -> ServiceInstance: pass @@ -36,5 +36,5 @@ class CapabilityInstanceRepository(ABC): pass @abstractmethod - async def save(self, capability_instance: CapabilityInstance) -> CapabilityInstance: + 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 index 8ebbfbf..a208cff 100644 --- a/src/srm/domain/ports/database/topology.py +++ b/src/srm/domain/ports/database/topology.py @@ -17,7 +17,7 @@ class ZoneRepository(ABC): pass @abstractmethod - async def save(self, zone: Zone) -> Zone: + async def create(self, zone: Zone) -> Zone: pass @@ -27,7 +27,7 @@ class DomainRepository(ABC): pass @abstractmethod - async def save(self, domain: Domain) -> Domain: + async def create(self, domain: Domain) -> Domain: pass @@ -37,7 +37,7 @@ class CapabilityRepository(ABC): pass @abstractmethod - async def save(self, capability: Capability) -> Capability: + async def create(self, capability: Capability) -> Capability: pass @@ -47,5 +47,5 @@ class ControlPathBindingRepository(ABC): pass @abstractmethod - async def save(self, control_path_binding: ControlPathBinding) -> ControlPathBinding: + async def create(self, control_path_binding: ControlPathBinding) -> ControlPathBinding: pass diff --git a/tests/integration/test_constraints.py b/tests/integration/test_constraints.py index 93e50fa..ae049fe 100644 --- a/tests/integration/test_constraints.py +++ b/tests/integration/test_constraints.py @@ -132,15 +132,15 @@ class TestCapabilityReferenceUniqueness: async def test_same_ref_is_allowed_under_different_domains( self, db_session: AsyncSession ) -> None: - zone = await SqlZoneRepository(db_session).save(_zone()) + zone = await SqlZoneRepository(db_session).create(_zone()) domains = SqlDomainRepository(db_session) - first_domain = await domains.save(_domain(zone.id, ref="compute-a")) - second_domain = await domains.save(_domain(zone.id, ref="compute-b")) + 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.save(_capability(first_domain.id, ref="deploy")) + await capabilities.create(_capability(first_domain.id, ref="deploy")) try: - saved = await capabilities.save(_capability(second_domain.id, ref="deploy")) + 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: " @@ -149,27 +149,27 @@ class TestCapabilityReferenceUniqueness: 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).save(_zone()) - domain = await SqlDomainRepository(db_session).save(_domain(zone.id, ref="compute")) + zone = await SqlZoneRepository(db_session).create(_zone()) + domain = await SqlDomainRepository(db_session).create(_domain(zone.id, ref="compute")) capabilities = SqlCapabilityRepository(db_session) - await capabilities.save(_capability(domain.id, ref="deploy")) + await capabilities.create(_capability(domain.id, ref="deploy")) with pytest.raises(IntegrityError): - await capabilities.save(_capability(domain.id, ref="deploy")) + 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).save(_service_specification()) - zone = await SqlZoneRepository(db_session).save(_zone()) - order = await SqlServiceOrderRepository(db_session).save( + 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.save( + await instances.create( _service_instance( spec_id=spec.id, zone_id=zone.id, @@ -179,7 +179,7 @@ class TestServiceInstanceReferenceUniqueness: ) ) try: - saved = await instances.save( + saved = await instances.create( _service_instance( spec_id=spec.id, zone_id=zone.id, @@ -196,15 +196,15 @@ class TestServiceInstanceReferenceUniqueness: 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).save(_service_specification()) - zone = await SqlZoneRepository(db_session).save(_zone()) - order = await SqlServiceOrderRepository(db_session).save( + 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.save( + await instances.create( _service_instance( spec_id=spec.id, zone_id=zone.id, @@ -214,7 +214,7 @@ class TestServiceInstanceReferenceUniqueness: ) ) with pytest.raises(IntegrityError): - await instances.save( + await instances.create( _service_instance( spec_id=spec.id, zone_id=zone.id, @@ -232,7 +232,7 @@ class TestServiceOrderIdempotency: operation_id = uuid4() orders = SqlServiceOrderRepository(db_session) - zone = await SqlZoneRepository(db_session).save(_zone()) - await orders.save(_service_order(operation_id, zone.id)) + zone = await SqlZoneRepository(db_session).create(_zone()) + await orders.create(_service_order(operation_id, zone.id)) with pytest.raises(IntegrityError): - await orders.save(_service_order(operation_id, zone.id)) + await orders.create(_service_order(operation_id, zone.id)) diff --git a/tests/integration/test_postgres.py b/tests/integration/test_postgres.py index 1f04778..e857216 100644 --- a/tests/integration/test_postgres.py +++ b/tests/integration/test_postgres.py @@ -1,10 +1,10 @@ from __future__ import annotations -import asyncio 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 @@ -140,18 +140,13 @@ async def test_service_specification_repo_persists_and_loads_aggregate( repo = SqlServiceSpecificationRepository(db_session) specification = _service_specification() - saved = await repo.save(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 - saved_requirement = saved.capability_requirements[0] - saved_deployment_unit = saved.deployment_units[0] - saved_requirement.deployment_unit_id = saved_deployment_unit.id - saved = await repo.save(saved) - reloaded = await repo.get_by_id(saved.id) assert reloaded is not None @@ -162,7 +157,7 @@ async def test_service_specification_repo_persists_and_loads_aggregate( 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 == reloaded.deployment_units[0].id + assert reloaded.capability_requirements[0].deployment_unit_id is None assert reloaded.capability_requirements[0].parameters == _parameters() @@ -174,31 +169,67 @@ async def test_service_specification_duplicate_entry( specification_dupl = _service_specification() specification_dupl.app_provider_id = specification.app_provider_id - _ = await repo.save(specification) + _ = await repo.create(specification) with pytest.raises(DuplicateServiceSpecificationError): - _ = await repo.save(specification_dupl) + _ = await repo.create(specification_dupl) -async def test_zone_repo_updates_updated_at_on_second_save( +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 = SqlZoneRepository(session) - saved = await repo.save(_zone()) + repo = SqlServiceSpecificationRepository(session) + saved = await repo.create(_service_specification()) await session.commit() - await asyncio.sleep(0.01) + 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" - updated = await repo.save(saved) - await session.commit() + with pytest.raises(IntegrityError): + await repo.create(saved) + await session.rollback() - assert updated.created_at == saved.created_at - assert updated.created_at is not None - assert updated.updated_at is not None - assert updated.updated_at > updated.created_at - assert updated.name == "Athens Zone Updated" + 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 index 3311c85..9e463b1 100644 --- a/tests/integration/test_repositories.py +++ b/tests/integration/test_repositories.py @@ -251,70 +251,70 @@ TestFunction = TypeVar("TestFunction", bound=Callable[..., object]) async def _case_service_specification(session: AsyncSession) -> tuple[object, IdentifiedModel]: repo = SqlServiceSpecificationRepository(session) - return repo, await repo.save(_service_specification()) + return repo, await repo.create(_service_specification()) async def _case_zone(session: AsyncSession) -> tuple[object, IdentifiedModel]: repo = SqlZoneRepository(session) - return repo, await repo.save(_zone()) + return repo, await repo.create(_zone()) async def _case_domain(session: AsyncSession) -> tuple[object, IdentifiedModel]: - zone = await SqlZoneRepository(session).save(_zone()) + zone = await SqlZoneRepository(session).create(_zone()) repo = SqlDomainRepository(session) - return repo, await repo.save(_domain(zone.id)) + return repo, await repo.create(_domain(zone.id)) async def _case_capability(session: AsyncSession) -> tuple[object, IdentifiedModel]: - zone = await SqlZoneRepository(session).save(_zone()) - domain = await SqlDomainRepository(session).save(_domain(zone.id)) + zone = await SqlZoneRepository(session).create(_zone()) + domain = await SqlDomainRepository(session).create(_domain(zone.id)) repo = SqlCapabilityRepository(session) - return repo, await repo.save(_capability(domain.id)) + return repo, await repo.create(_capability(domain.id)) async def _case_control_path_binding(session: AsyncSession) -> tuple[object, IdentifiedModel]: - zone = await SqlZoneRepository(session).save(_zone()) - domain = await SqlDomainRepository(session).save(_domain(zone.id)) - capability = await SqlCapabilityRepository(session).save(_capability(domain.id)) + 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.save(_control_path_binding(capability.id)) + return repo, await repo.create(_control_path_binding(capability.id)) async def _case_service_order(session: AsyncSession) -> tuple[object, IdentifiedModel]: - zone = await SqlZoneRepository(session).save(_zone()) + zone = await SqlZoneRepository(session).create(_zone()) repo = SqlServiceOrderRepository(session) - return repo, await repo.save(_service_order(zone_id=zone.id)) + 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).save(_service_specification()) - zone = await SqlZoneRepository(session).save(_zone()) - order = await SqlServiceOrderRepository(session).save(_service_order(zone_id=zone.id)) + 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.save( + 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).save(_service_specification()) - zone = await SqlZoneRepository(session).save(_zone()) - order = await SqlServiceOrderRepository(session).save(_service_order(zone_id=zone.id)) - instance = await SqlServiceInstanceRepository(session).save( + 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).save(_domain(zone.id)) - capability = await SqlCapabilityRepository(session).save(_capability(domain.id)) + domain = await SqlDomainRepository(session).create(_domain(zone.id)) + capability = await SqlCapabilityRepository(session).create(_capability(domain.id)) repo = SqlCapabilityInstanceRepository(session) - return repo, await repo.save(_capability_instance(instance.id, capability.id, order.id)) + 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).save(_service_specification()) + spec = await SqlServiceSpecificationRepository(session).create(_service_specification()) repo = SqlServiceDeploymentUnitRepository(session) return ( repo, - await repo.save( + await repo.create( ServiceDeploymentUnit( id=uuid4(), service_specification_id=spec.id, @@ -333,11 +333,11 @@ async def _case_service_deployment_unit(session: AsyncSession) -> tuple[object, async def _case_service_capability_requirement( session: AsyncSession, ) -> tuple[object, IdentifiedModel]: - spec = await SqlServiceSpecificationRepository(session).save(_service_specification()) + spec = await SqlServiceSpecificationRepository(session).create(_service_specification()) repo = SqlServiceCapabilityRequirementRepository(session) return ( repo, - await repo.save( + await repo.create( ServiceCapabilityRequirement( id=uuid4(), service_specification_id=spec.id, @@ -385,7 +385,7 @@ repository_case_parametrize = cast( @repository_case_parametrize -async def test_save_assigns_id_and_get_by_id_round_trips( +async def test_create_assigns_id_and_get_by_id_round_trips( db_session: AsyncSession, case: Case ) -> None: repo, saved = await case(db_session) @@ -400,7 +400,7 @@ class TestChildRepositoriesGetById: 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).save( + saved = await SqlServiceSpecificationRepository(db_session).create( _service_specification_with_children() ) unit = saved.deployment_units[0] @@ -409,7 +409,7 @@ class TestChildRepositoriesGetById: assert reloaded == unit async def test_service_capability_requirement_get_by_id(self, db_session: AsyncSession) -> None: - saved = await SqlServiceSpecificationRepository(db_session).save( + saved = await SqlServiceSpecificationRepository(db_session).create( _service_specification_with_children() ) requirement = saved.capability_requirements[0] diff --git a/tests/unit/fakes.py b/tests/unit/fakes.py index 642c17d..c3f7140 100644 --- a/tests/unit/fakes.py +++ b/tests/unit/fakes.py @@ -31,7 +31,9 @@ class InMemoryServiceSpecificationRepository(ServiceSpecificationRepository): found = self.rows.get(id) return found.model_copy(deep=True) if found is not None else None - async def save(self, service_specification: ServiceSpecification) -> ServiceSpecification: + 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, @@ -57,7 +59,9 @@ class InMemoryZoneRepository(ZoneRepository): found = self.rows.get(id) return found.model_copy(deep=True) if found is not None else None - async def save(self, zone: Zone) -> Zone: + 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) @@ -71,7 +75,9 @@ class InMemoryServiceInstanceRepository(ServiceInstanceRepository): found = self.rows.get(id) return found.model_copy(deep=True) if found is not None else None - async def save(self, service_instance: ServiceInstance) -> ServiceInstance: + 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, diff --git a/tests/unit/test_in_memory_repositories.py b/tests/unit/test_in_memory_repositories.py index 74c818b..5344f35 100644 --- a/tests/unit/test_in_memory_repositories.py +++ b/tests/unit/test_in_memory_repositories.py @@ -54,10 +54,10 @@ def _service_instance(app_provider_id: str, ref: str = "prod") -> ServiceInstanc class TestInMemoryServiceSpecificationRepository: - async def test_save_round_trips_by_id(self) -> None: + async def test_create_round_trips_by_id(self) -> None: repo = InMemoryServiceSpecificationRepository() - saved = await repo.save(_service_specification()) + saved = await repo.create(_service_specification()) assert isinstance(saved.id, UUID) assert await repo.get_by_id(saved.id) == saved @@ -71,15 +71,15 @@ class TestInMemoryServiceSpecificationRepository: duplicate = _service_specification() duplicate.app_provider_id = first.app_provider_id - await repo.save(first) + await repo.create(first) with pytest.raises(DuplicateServiceSpecificationError): - await repo.save(duplicate) + await repo.create(duplicate) async def test_returned_objects_are_isolated_copies(self) -> None: repo = InMemoryServiceSpecificationRepository() - saved = await repo.save(_service_specification()) + saved = await repo.create(_service_specification()) - saved.name = "mutated after save" + saved.name = "mutated after create" reloaded = await repo.get_by_id(saved.id) assert reloaded is not None @@ -90,17 +90,17 @@ class TestInMemoryServiceInstanceRepository: async def test_same_name_different_tenant_is_allowed(self) -> None: repo = InMemoryServiceInstanceRepository() - await repo.save(_service_instance(app_provider_id="tenant-a", ref="prod")) - saved = await repo.save(_service_instance(app_provider_id="tenant-b", ref="prod")) + 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.save(first) + 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.save(duplicate) + await repo.create(duplicate) diff --git a/tests/unit/test_service_specification_repo.py b/tests/unit/test_service_specification_repo.py index 494d3e5..4f4e6cb 100644 --- a/tests/unit/test_service_specification_repo.py +++ b/tests/unit/test_service_specification_repo.py @@ -131,58 +131,52 @@ async def test_get_by_id_returns_mapped_service_specification() -> None: session.scalar.assert_awaited_once() -async def test_save_flushes_and_reloads_service_specification() -> None: +async def test_create_flushes_and_reloads_service_specification() -> None: domain = _service_specification_domain() session = AsyncMock(spec=AsyncSession) - merged = _service_specification_row() - merged.id = domain.id reloaded = _service_specification_row() reloaded.id = domain.id - session.merge.return_value = merged session.scalar.return_value = reloaded repo = SqlServiceSpecificationRepository(session) - result = await repo.save(domain) + result = await repo.create(domain) expected = ServiceSpecificationMapper.to_domain(reloaded) assert result == expected - session.merge.assert_awaited_once() + session.add.assert_called_once() session.flush.assert_awaited_once() session.commit.assert_not_awaited() session.scalar.assert_awaited_once() -async def test_save_raises_duplicate_service_specification_error_on_integrity_error() -> None: +async def test_create_raises_duplicate_service_specification_error_on_integrity_error() -> None: domain = _service_specification_domain() session = AsyncMock(spec=AsyncSession) - session.merge.return_value = _service_specification_row() 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.save(domain) + await repo.create(domain) -async def test_save_propagates_non_unique_integrity_errors() -> None: +async def test_create_propagates_non_unique_integrity_errors() -> None: domain = _service_specification_domain() session = AsyncMock(spec=AsyncSession) - session.merge.return_value = _service_specification_row() 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.save(domain) + await repo.create(domain) -async def test_save_propagates_check_violation_integrity_errors() -> None: +async def test_create_propagates_check_violation_integrity_errors() -> None: domain = _service_specification_domain() session = AsyncMock(spec=AsyncSession) - session.merge.return_value = _service_specification_row() 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.save(domain) + await repo.create(domain) -- GitLab From ea0427717206ca1ac77e0e39493ef11eb77af086 Mon Sep 17 00:00:00 2001 From: stentoumis Date: Tue, 4 Aug 2026 23:30:33 +0300 Subject: [PATCH 15/18] fix: aligned service order type with specs and added tests for enum values --- .../domain/models/runtime_inventory/enums.py | 4 +-- tests/integration/test_repositories.py | 35 ++++++++++++++++++- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/srm/domain/models/runtime_inventory/enums.py b/src/srm/domain/models/runtime_inventory/enums.py index c1a693a..d4c25c3 100644 --- a/src/srm/domain/models/runtime_inventory/enums.py +++ b/src/srm/domain/models/runtime_inventory/enums.py @@ -13,13 +13,11 @@ class ServiceOrderType(StrEnum): DEPLOY_SERVICE = "deploy_service" SCALE_SERVICE = "scale_service" TERMINATE_SERVICE = "terminate_service" - UPDATE_SERVICE = "update_service" ACTIVATE_CAPABILITY = "activate_capability" UPDATE_CAPABILITY = "update_capability" - TERMINATE_CAPABILITY = "terminate_capability" + DEACTIVATE_CAPABILITY = "deactivate_capability" - QUERY_CAPABILITY = "query_capability" RECONCILE_SERVICE = "reconcile_service" diff --git a/tests/integration/test_repositories.py b/tests/integration/test_repositories.py index 9e463b1..1b9c1f5 100644 --- a/tests/integration/test_repositories.py +++ b/tests/integration/test_repositories.py @@ -1,6 +1,6 @@ """Round-trip coverage for every SQL repository against real PostgreSQL. -The core contract of every repository is the same: ``save()`` persists an entity, +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. """ @@ -224,6 +224,12 @@ def _service_order(zone_id: UUID) -> ServiceOrder: ) +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: @@ -395,6 +401,33 @@ async def test_create_assigns_id_and_get_by_id_round_trips( 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.""" -- GitLab From 38872baa8da838c60a0c6daa446635d98ae86c46 Mon Sep 17 00:00:00 2001 From: stentoumis Date: Wed, 5 Aug 2026 00:56:30 +0300 Subject: [PATCH 16/18] fix: databus messages schemas are now more strict. no extra fields are allowed. more tests --- src/srm/api/databus/schemas.py | 24 ++++++++------ tests/api/databus/test_schemas.py | 54 +++++++++++++++++++++++++++---- 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/src/srm/api/databus/schemas.py b/src/srm/api/databus/schemas.py index 2072cca..6dbff88 100644 --- a/src/srm/api/databus/schemas.py +++ b/src/srm/api/databus/schemas.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import Literal from uuid import UUID -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from srm.domain.models.canonical_parameters.parameters import ( CapabilityParameters, @@ -10,13 +10,17 @@ from srm.domain.models.canonical_parameters.parameters import ( ) +class CommandSchema(BaseModel): + model_config = ConfigDict(extra="forbid") + + class InboundMessage(BaseModel): subject: str payload: bytes headers: dict[str, str] -class CommandEnvelopeV1(BaseModel): +class CommandEnvelopeV1(CommandSchema): schema_version: Literal["1.0"] operation_id: UUID correlation_id: str @@ -33,16 +37,16 @@ class CommandEnvelopeV1(BaseModel): return self -class PlacementConstraintsV1(BaseModel): - model_config = {"extra": "allow"} +class PlacementConstraintsV1(CommandSchema): + model_config = ConfigDict(extra="allow") -class DeployPayloadV1(BaseModel): +class DeployPayloadV1(CommandSchema): instance_name: str | None = None placement_constraints: PlacementConstraintsV1 | None = None -class DeployTargetV1(BaseModel): +class DeployTargetV1(CommandSchema): app_instance_id: UUID zone_id: UUID | None = None domain_id: UUID | None = None @@ -61,7 +65,7 @@ class SrmServiceDeployV1(CommandEnvelopeV1): deploy: DeployPayloadV1 -class ScalePayloadV1(BaseModel): +class ScalePayloadV1(CommandSchema): replicas: int = Field(ge=0) @@ -71,7 +75,7 @@ class SrmServiceScaleV1(CommandEnvelopeV1): scale: ScalePayloadV1 -class TerminatePayloadV1(BaseModel): +class TerminatePayloadV1(CommandSchema): grace_period_seconds: int = Field(default=0, ge=0) @@ -81,7 +85,7 @@ class SrmServiceTerminateV1(CommandEnvelopeV1): terminate: TerminatePayloadV1 -class NetworkCapabilityPayloadV1(BaseModel): +class NetworkCapabilityPayloadV1(CommandSchema): capability_type: str target: CapabilityTarget profile_ref: str | None = None @@ -102,7 +106,7 @@ class SrmNetworkCapabilityActivateV1(CommandEnvelopeV1): return self -class NetworkCapabilityRealizationRefV1(BaseModel): +class NetworkCapabilityRealizationRefV1(CommandSchema): capability_type: str external_ref: str | None = None service_instance_id: UUID | None = None diff --git a/tests/api/databus/test_schemas.py b/tests/api/databus/test_schemas.py index 653ae0d..dac5f7d 100644 --- a/tests/api/databus/test_schemas.py +++ b/tests/api/databus/test_schemas.py @@ -12,6 +12,7 @@ from srm.api.databus.schemas import ( NetworkCapabilityDeactivatePayloadV1, NetworkCapabilityPayloadV1, NetworkCapabilityUpdatePayloadV1, + PlacementConstraintsV1, ScalePayloadV1, SrmNetworkCapabilityActivateV1, SrmNetworkCapabilityDeactivateV1, @@ -113,6 +114,48 @@ class TestFederationContext: 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()), @@ -190,9 +233,9 @@ class TestPinShape: with pytest.raises(ValidationError, match="domain_id requires zone_id"): SrmNetworkCapabilityActivateV1.model_validate(payload) - def test_update_ignores_pins_and_does_not_apply_the_rule(self) -> None: - """§B.6: zone_id/domain_id are ignored when identifying an existing realization, - so a bare domain_id must not be rejected on update.""" + 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={ @@ -202,9 +245,8 @@ class TestPinShape: }, ) - command = SrmNetworkCapabilityUpdateV1.model_validate(payload) - - assert command.network_capability.external_ref == "sess-123" + with pytest.raises(ValidationError, match="domain_id"): + SrmNetworkCapabilityUpdateV1.model_validate(payload) def test_scale_payload_requires_replicas() -> None: -- GitLab From 7b8c5d5664f6fb8572b07341c5064668857e7994 Mon Sep 17 00:00:00 2001 From: stentoumis Date: Wed, 5 Aug 2026 09:33:34 +0300 Subject: [PATCH 17/18] fix: made result.status enum. removed deleted enums from init. changed health endpoints --- src/srm/api/health.py | 4 ++-- src/srm/domain/__init__.py | 2 -- .../domain/models/canonical_parameters/result.py | 8 +++++++- tests/api/middlewares/test_logging.py | 12 ++++++------ tests/api/middlewares/test_request_id.py | 16 ++++++++-------- tests/api/test_health.py | 10 +++++----- 6 files changed, 28 insertions(+), 24 deletions(-) diff --git a/src/srm/api/health.py b/src/srm/api/health.py index f3f4412..003d1e3 100644 --- a/src/srm/api/health.py +++ b/src/srm/api/health.py @@ -6,12 +6,12 @@ from srm.api.dependencies import get_app_state health_router = APIRouter(tags=["Platform Health"]) -@health_router.get("/health/livez") +@health_router.get("/healthz") async def liveness() -> bool: return True -@health_router.get("/health/readyz") +@health_router.get("/readyz") async def readiness(request: Request) -> bool: app_state = get_app_state(request) diff --git a/src/srm/domain/__init__.py b/src/srm/domain/__init__.py index e4c16fc..b4d3c3d 100644 --- a/src/srm/domain/__init__.py +++ b/src/srm/domain/__init__.py @@ -68,7 +68,6 @@ __all__ = [ "CapabilityTarget", "ComputeRequirements", "ComputeResources", - "ComputeTransportProtocol", "ControlPathBinding", "ControlPathBindingState", "DeviceTarget", @@ -79,7 +78,6 @@ __all__ = [ "IdentifiedModel", "InterfaceVisibility", "NetworkInterface", - "ParameterTransportProtocol", "Parameters", "Result", "RuntimeKind", diff --git a/src/srm/domain/models/canonical_parameters/result.py b/src/srm/domain/models/canonical_parameters/result.py index 2de29b8..86932dc 100644 --- a/src/srm/domain/models/canonical_parameters/result.py +++ b/src/srm/domain/models/canonical_parameters/result.py @@ -1,3 +1,4 @@ +from enum import StrEnum from typing import Any from pydantic import Field @@ -5,6 +6,11 @@ from pydantic import Field from srm.domain.models.common import DomainModel +class ResultStatus(StrEnum): + ACTIVE = "active" + FAILED = "failed" + + class ServiceEndpoint(DomainModel): interface_id: str @@ -25,7 +31,7 @@ class Result(DomainModel): frozen=True, ) - status: str + status: ResultStatus endpoints: list[ServiceEndpoint] = Field(default_factory=list) diff --git a/tests/api/middlewares/test_logging.py b/tests/api/middlewares/test_logging.py index d8ac245..ff5fca6 100644 --- a/tests/api/middlewares/test_logging.py +++ b/tests/api/middlewares/test_logging.py @@ -15,13 +15,13 @@ def _capture_logs() -> AbstractContextManager[list[EventDict]]: async def test_logs_request_received(client: AsyncClient) -> None: with _capture_logs() as logs: - await client.get("/health/livez") + 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("/health/livez") + 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) @@ -30,17 +30,17 @@ async def test_logs_request_processed(client: AsyncClient) -> None: async def test_request_received_contains_metadata(client: AsyncClient) -> None: with _capture_logs() as logs: - await client.get("/health/livez") + 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"] == "/health/livez" + 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("/health/livez") + 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"] @@ -54,7 +54,7 @@ async def test_log_context_does_not_bleed_between_requests(app: FastAPI) -> None 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("/health/livez") + await c.get("/healthz") received = [entry for entry in logs if entry["event"] == "Request Received"] assert len(received) == 2 diff --git a/tests/api/middlewares/test_request_id.py b/tests/api/middlewares/test_request_id.py index 4f74db1..ad07f76 100644 --- a/tests/api/middlewares/test_request_id.py +++ b/tests/api/middlewares/test_request_id.py @@ -8,18 +8,18 @@ from srm.api.context import originating_request_id async def test_generates_uuid4_when_header_absent(client: AsyncClient) -> None: - response = await client.get("/health/livez") + 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("/health/livez", headers={"X-Request-ID": original}) + 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("/health/livez", headers={"X-Request-ID": "not-a-uuid"}) + 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 @@ -27,25 +27,25 @@ async def test_regenerates_for_invalid_string(client: AsyncClient) -> None: async def test_regenerates_for_non_v4_uuid(client: AsyncClient) -> None: uuid1_val = str(uuid.uuid1()) - response = await client.get("/health/livez", headers={"X-Request-ID": uuid1_val}) + 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("/health/livez") - r2 = await client.get("/health/livez") + 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("/health/livez") + await client.get("/healthz") assert originating_request_id.get() == "" async def test_regenerates_for_empty_string(client: AsyncClient) -> None: - response = await client.get("/health/livez", headers={"X-Request-ID": ""}) + response = await client.get("/healthz", headers={"X-Request-ID": ""}) assert UUID(response.headers["X-Request-ID"]).version == 4 diff --git a/tests/api/test_health.py b/tests/api/test_health.py index c25e238..e7f14de 100644 --- a/tests/api/test_health.py +++ b/tests/api/test_health.py @@ -14,15 +14,15 @@ def make_health_app(*, nats_connected: bool) -> FastAPI: return app -async def test_livez_returns_200(client: AsyncClient) -> None: - response = await client.get("/health/livez") +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("/health/readyz") + response = await client_with_db.get("/readyz") assert response.status_code == 200 assert response.json() is True @@ -31,7 +31,7 @@ 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("/health/readyz") + response = await c.get("/readyz") assert response.status_code == 200 assert response.json() is True @@ -41,7 +41,7 @@ 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("/health/readyz") + response = await c.get("/readyz") assert response.status_code == 503 assert response.json() == {"detail": "NATS not ready"} -- GitLab From 1b860087662a78a0439f1985d330c5f0d4ef0d27 Mon Sep 17 00:00:00 2001 From: stentoumis Date: Wed, 5 Aug 2026 09:40:56 +0300 Subject: [PATCH 18/18] fix: typing error --- src/srm/domain/__init__.py | 2 ++ src/srm/domain/models/__init__.py | 2 ++ src/srm/domain/models/canonical_parameters/__init__.py | 3 ++- tests/integration/test_repositories.py | 4 ++-- tests/unit/test_mappers.py | 10 ++++++++-- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/srm/domain/__init__.py b/src/srm/domain/__init__.py index b4d3c3d..c52b344 100644 --- a/src/srm/domain/__init__.py +++ b/src/srm/domain/__init__.py @@ -28,6 +28,7 @@ from srm.domain.models import ( NetworkInterface, Parameters, Result, + ResultStatus, RuntimeKind, ServiceCapabilityRequirement, ServiceDeploymentUnit, @@ -80,6 +81,7 @@ __all__ = [ "NetworkInterface", "Parameters", "Result", + "ResultStatus", "RuntimeKind", "ServiceCapabilityRequirement", "ServiceDeploymentUnit", diff --git a/src/srm/domain/models/__init__.py b/src/srm/domain/models/__init__.py index f3c8525..5e91f33 100644 --- a/src/srm/domain/models/__init__.py +++ b/src/srm/domain/models/__init__.py @@ -14,6 +14,7 @@ from srm.domain.models.canonical_parameters import ( NetworkInterface, Parameters, Result, + ResultStatus, ServiceEndpoint, SourceSpecification, SourceSpecificationFamily, @@ -88,6 +89,7 @@ __all__ = [ "NetworkInterface", "Parameters", "Result", + "ResultStatus", "RuntimeKind", "ServiceCapabilityRequirement", "ServiceDeploymentUnit", diff --git a/src/srm/domain/models/canonical_parameters/__init__.py b/src/srm/domain/models/canonical_parameters/__init__.py index a3e07fc..8195557 100644 --- a/src/srm/domain/models/canonical_parameters/__init__.py +++ b/src/srm/domain/models/canonical_parameters/__init__.py @@ -22,7 +22,7 @@ from srm.domain.models.canonical_parameters.parameters import ( TrafficEndpoint, TrafficFilters, ) -from srm.domain.models.canonical_parameters.result import Result, ServiceEndpoint +from srm.domain.models.canonical_parameters.result import Result, ResultStatus, ServiceEndpoint __all__ = [ "Accelerator", @@ -38,6 +38,7 @@ __all__ = [ "NetworkInterface", "Parameters", "Result", + "ResultStatus", "ServiceEndpoint", "SourceSpecification", "SourceSpecificationFamily", diff --git a/tests/integration/test_repositories.py b/tests/integration/test_repositories.py index 1b9c1f5..b5e59ff 100644 --- a/tests/integration/test_repositories.py +++ b/tests/integration/test_repositories.py @@ -43,7 +43,7 @@ from srm.domain.models.canonical_parameters.parameters import ( SourceSpecification, SourceSpecificationFamily, ) -from srm.domain.models.canonical_parameters.result import Result +from srm.domain.models.canonical_parameters.result import Result, ResultStatus from srm.domain.models.catalog import ( RuntimeKind, ServiceCapabilityRequirement, @@ -242,7 +242,7 @@ def _capability_instance( kind=CapabilityInstanceKind.ACTIVATION, state=CapabilityInstanceState.CREATING, parameters_snapshot=_parameters(), - result_summary=Result(status="active"), + result_summary=Result(status=ResultStatus.ACTIVE), app_provider_id=str(uuid4()), created_at=_now(), updated_at=_now(), diff --git a/tests/unit/test_mappers.py b/tests/unit/test_mappers.py index dc5b4b7..5d7848b 100644 --- a/tests/unit/test_mappers.py +++ b/tests/unit/test_mappers.py @@ -19,7 +19,7 @@ from srm.domain.models.canonical_parameters.parameters import ( SourceSpecification, SourceSpecificationFamily, ) -from srm.domain.models.canonical_parameters.result import Result +from srm.domain.models.canonical_parameters.result import Result, ResultStatus from srm.domain.models.runtime_inventory import ( CapabilityInstance, ServiceInstance, @@ -49,7 +49,7 @@ def _parameters() -> Parameters: def _result() -> Result: - return Result(status="active") + return Result(status=ResultStatus.ACTIVE) def _now() -> datetime: @@ -189,6 +189,12 @@ class TestRuntimeInventoryMappers: ) 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: -- GitLab