diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 6c80814..2def546 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -10,6 +10,5 @@ build: python: "3.11" commands: - pip install --upgrade pip - - pip install -r requirements.txt - - pip install -r docs-requirements.txt - - sphinx-build -b html docs _readthedocs/html/ + - pip install uv + - uv run sphinx-build -b html docs _readthedocs/html/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e251c34..39c9f0d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,6 +34,21 @@ And then uninstall using pip: - pip uninstall im-squonk2-client -y +With a suitable Squonk installation you should be able to run the basic test module, +which exercises a number of API methods. You will need to define a number of variables, +but one done the test is expected to work: - + + export SQUONK2_DMAPI_URL=https://data-manager-test.example.com/data-manager-api + export SQUONK2_KEYCLOAK_URL=https://keycloak-test.example.com/auth + export SQUONK2_KEYCLOAK_REALM=squonk + export SQUONK2_KEYCLOAK_DM_CLIENT_ID=data-manager-api-test + export SQUONK2_KEYCLOAK_USER=dmit-user-admin + export SQUONK2_KEYCLOAK_USER_PASSWORD=password1234 + + uv run test.py + +Feel free to extend the test module as you add API methods. + --- [ruff]: https://docs.astral.sh/ruff diff --git a/README.rst b/README.rst index 3dc3c52..4291638 100644 --- a/README.rst +++ b/README.rst @@ -23,6 +23,25 @@ Data Manager, Account Server and UI REST interfaces. The functions provide access to some of the key API methods, implemented initially to support execution of Jobs from a Fragalysis stack `backend`_. +API compatibility matrix +------------------------ +The following table displays recent Python Client releases and the corresponding +Squonk2 component API versions (Account Server, Data Manager, User Interface) +that are compatible with them, starting with client ``6.x``. + +====== ====== ====== ====== ========= +Client AS DM UI Supported +====== ====== ====== ====== ========= +7.x 4.x 5.x 6.x ✅ +6.x 4.x 4.x 6.x ✖️ +====== ====== ====== ====== ========= + +As an example, the above table illustrates th§at full compatibility with DM ``5.x`` +will require client version ``7``. Client version ``6``, although it remains published, +is no longer supported. + +The Python client is only supported for component installation versions we manage. + Simplified Authentication ========================= The following Squonk2 Authentication functions are available: - @@ -38,23 +57,35 @@ The following Squonk2 Data Manager API functions are available: - - ``DmApi.ping()`` +- ``DmApi.acknowledge_service_error()`` - ``DmApi.add_project_editor()`` - ``DmApi.add_project_observer()`` +- ``DmApi.apply_workflow_version()`` - ``DmApi.create_project()`` +- ``DmApi.create_project_path()`` +- ``DmApi.create_workflow()`` - ``DmApi.delete_instance()`` - ``DmApi.delete_instance_token()`` - ``DmApi.delete_project()`` +- ``DmApi.delete_project_path()`` +- ``DmApi.delete_service_error()`` - ``DmApi.delete_unmanaged_project_files()`` +- ``DmApi.delete_workflow()`` +- ``DmApi.delete_running_workflow()`` - ``DmApi.dry_run_job_instance()`` - ``DmApi.get_account_server_namespace()`` - ``DmApi.get_account_server_registration()`` +- ``DmApi.get_application()`` +- ``DmApi.get_applications()`` - ``DmApi.get_available_instances()`` - ``DmApi.get_available_datasets()`` - ``DmApi.get_available_jobs()`` - ``DmApi.get_available_projects()`` - ``DmApi.get_available_tasks()`` +- ``DmApi.get_dataset_for_digest()`` - ``DmApi.get_job()`` - ``DmApi.get_job_definition_schema_version()`` +- ``DmApi.get_job_exchange_rate()`` - ``DmApi.get_job_exchange_rates()`` - ``DmApi.get_job_by_version()`` - ``DmApi.get_input_handler()`` @@ -62,21 +93,33 @@ The following Squonk2 Data Manager API functions are available: - - ``DmApi.get_mode()`` - ``DmApi.get_project()`` - ``DmApi.get_project_instances()`` +- ``DmApi.get_running_workflow()`` +- ``DmApi.get_running_workflows()`` +- ``DmApi.get_running_workflow_steps()`` - ``DmApi.get_service_errors()`` - ``DmApi.get_task()`` - ``DmApi.get_tasks()`` - ``DmApi.get_unmanaged_project_file()`` - ``DmApi.get_unmanaged_project_file_with_token()`` +- ``DmApi.get_user_inventory()`` - ``DmApi.get_version()`` +- ``DmApi.get_workflows()`` +- ``DmApi.get_workflow()`` +- ``DmApi.get_workflow_definition()`` - ``DmApi.get_workflow_engine_version()`` - ``DmApi.list_project_files()`` +- ``DmApi.move_project_path()`` - ``DmApi.put_unmanaged_project_files()`` - ``DmApi.put_job_manifest()`` - ``DmApi.remove_project_editor()`` - ``DmApi.remove_project_observer()`` +- ``DmApi.run_workflow()`` - ``DmApi.set_admin_state()`` - ``DmApi.set_job_exchange_rates()`` - ``DmApi.start_job_instance()`` +- ``DmApi.stop_running_workflow()`` +- ``DmApi.update_project()`` +- ``DmApi.update_workflow()`` A ``dataclass`` defined in ``api`` is used as the return value for many of the methods: - @@ -197,7 +240,7 @@ e.g. ``export SQUONK2_ENVIRONMENTS_FILE=~/my-env'`` --- - # An example Squeck environments file. + # An example environments file. # # It provides all the connection details for one or more Squonk2 environments. # It is expected to be found in the user's home directory diff --git a/examples/test-workflow.yaml b/examples/test-workflow.yaml new file mode 100644 index 0000000..085c40c --- /dev/null +++ b/examples/test-workflow.yaml @@ -0,0 +1,12 @@ +--- +kind: DataManagerWorkflow +kind-version: "2025.2" +name: python-client-example +description: >- + The simplest of workflow files used by the test framework in our Python client. +steps: +- name: coin + specification: + collection: im-test + job: coin-test + version: "1.0.0" diff --git a/pyproject.toml b/pyproject.toml index 70543a7..e89cccb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,10 +30,10 @@ maintainers = [ requires-python = ">=3.11" dependencies = [ "authlib>=1.0.1,<2.0", - "munch>=4.0.0,<5.0", - "pyyaml>=5.2,<7.0", - "requests>=2.22.0", - "wrapt>=1.14.1,< 2.0", + "munch>=4.0.0,<5", + "pyyaml>=5.2,<7", + "requests>=2.22.0,<3", + "wrapt>=2.0.0,<3", ] [project.urls] @@ -41,6 +41,7 @@ Repository = "https://github.com/InformaticsMatters/squonk2-python-client.git" [dependency-groups] dev = [ + "packaging>=26.0", "pre-commit>=4.5.1", "pyroma>=5.0.1", "ruff>=0.15.6", @@ -48,6 +49,7 @@ dev = [ "sphinx-rtd-theme>=3.1.0", "sphinx-toolbox>=4.1.2", "ty>=0.0.23", + "typer>=0.24.1", ] [tool.ruff] diff --git a/src/squonk2/as_api.py b/src/squonk2/as_api.py index 1078202..0bac0ce 100644 --- a/src/squonk2/as_api.py +++ b/src/squonk2/as_api.py @@ -10,7 +10,6 @@ import contextlib from datetime import date -from enum import Enum import logging import os from pathlib import Path @@ -24,32 +23,7 @@ import requests from .api import ApiRv - - -class EventStreamFormat(Enum): - """Enumeration of EventStream formats""" - - JSON_STRING = 1 - PROTOCOL_STRING = 2 - - -class AssetScopeEnum(Enum): - """Enumeration of Asset scopes""" - - USER = 1 - PRODUCT = 2 - UNIT = 3 - ORGANISATION = 4 - GLOBAL = 5 - - -class DefaultProductPrivacyEnum(Enum): - """Enumeration of Default product Privacy""" - - ALWAYS_PRIVATE = 1 - ALWAYS_PUBLIC = 2 - DEFAULT_PRIVATE = 3 - DEFAULT_PUBLIC = 4 +from .enumerations import DefaultProductPrivacyEnum, EventStreamFormat, ScopeEnum # The Account Server API URL environment variable, @@ -455,7 +429,7 @@ def create_asset( *, name: str, description: str, - scope: AssetScopeEnum, + scope: ScopeEnum, content_string: Optional[str] = None, content_file: Optional[Path] = None, scope_id: Optional[str] = None, diff --git a/src/squonk2/dm_api.py b/src/squonk2/dm_api.py index f511d3a..7238635 100644 --- a/src/squonk2/dm_api.py +++ b/src/squonk2/dm_api.py @@ -22,6 +22,7 @@ import requests from .api import ApiRv +from .enumerations import ScopeEnum TEST_PRODUCT_ID: str = "product-11111111-1111-1111-1111-111111111111" """A test Account Server (AS) Product ID. This ID does not actually exist in the AS @@ -470,6 +471,41 @@ def create_project( timeout=timeout_s, )[0] + @classmethod + @synchronized + def update_project( + cls, + access_token: str, + *, + project_id: str, + project_name: str | None = None, + private: bool | None = None, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Updates a Project,. + + :param access_token: A valid DM API access token. + :param project_name: A unique name. + :param private: True or False + :param timeout_s: The API request timeout + """ + assert access_token + + data: dict[str, Any] = {} + if project_name: + data["name"] = project_name + if private is not None: + data["private"] = private + + return DmApi.__request( + "PATCH", + f"/project/{project_id}", + access_token=access_token, + data=data, + error_message="Failed creating project", + timeout=timeout_s, + )[0] + @classmethod @synchronized def delete_project( @@ -1573,6 +1609,57 @@ def get_service_errors( timeout=timeout_s, )[0] + @classmethod + @synchronized + def acknowledge_service_error( + cls, + access_token: str, + *, + error_id: int, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Patches a service error. You need admin rights to use this method. + + :param access_token: A valid DM API access token + :param error_id: The error identity + :param timeout_s: The underlying request timeout + """ + assert access_token + + return DmApi.__request( + "PATCH", + f"/admin/service-error/{error_id}", + access_token=access_token, + expected_response_codes=[204], + error_message="Failed to patch service error", + timeout=timeout_s, + )[0] + + @classmethod + @synchronized + def delete_service_error( + cls, + access_token: str, + *, + error_id: int, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Deletes a service error. You need admin rights to use this method. + + :param access_token: A valid DM API access token + :param error_id: The error identity + :param timeout_s: The underlying request timeout + """ + assert access_token + + return DmApi.__request( + "DELETE", + f"/admin/service-error/{error_id}", + access_token=access_token, + error_message="Failed to delete service error", + timeout=timeout_s, + )[0] + @classmethod @synchronized def get_job_exchange_rates( @@ -1603,6 +1690,36 @@ def get_job_exchange_rates( timeout=timeout_s, )[0] + @classmethod + @synchronized + def get_job_exchange_rate( + cls, + access_token: str, + *, + job_id: int, + current: bool = False, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Gets exchange rates for a Job. + + :param access_token: A valid DM API access token + :param job_id: The Job + :param current: True to only get the current rate + :param timeout_s: The underlying request timeout + """ + assert access_token + + params: Dict[str, Any] = {"current": current} + + return DmApi.__request( + "GET", + f"/job/{job_id}/exchange-rate", + access_token=access_token, + params=params, + error_message="Failed to get exchange rates", + timeout=timeout_s, + )[0] + @classmethod @synchronized def set_job_exchange_rates( @@ -1754,3 +1871,637 @@ def get_account_server_namespace( error_message="Failed to get AS namespace", timeout=timeout_s, )[0] + + @classmethod + @synchronized + def get_user_inventory( + cls, + access_token: str, + *, + org_id: str | None = None, + unit_id: str | None = None, + usernames: list[str] | None = None, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Gets user inventory. + + :param access_token: A valid DM API access token + :param org_id: An Organisation UUID + :param unit_id: A Unit UUID + :param usernames: A list of usernames + :param timeout_s: The underlying request timeout + """ + assert access_token + + params: Dict[str, Any] = {} + if org_id: + params["org_id"] = org_id + if unit_id: + params["unit_id"] = unit_id + if usernames: + params["usernames"] = ",".join(usernames) + + return DmApi.__request( + "GET", + "/inventory/user", + access_token=access_token, + params=params, + error_message="Failed to get user inventory", + timeout=timeout_s, + )[0] + + @classmethod + @synchronized + def get_supported_file_types( + cls, + access_token: str, + *, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Gets the supported dataset file types. + + :param access_token: A valid DM API access token + :param timeout_s: The underlying request timeout + """ + assert access_token + + return DmApi.__request( + "GET", + "/type", + access_token=access_token, + error_message="Failed to get types", + timeout=timeout_s, + )[0] + + @classmethod + @synchronized + def get_dataset_for_digest( + cls, + access_token: str, + *, + digest: str, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Returns a dataset ID and version of a dataset that matches + the provided SHA256 digest. + + :param access_token: A valid DM API access token + :param digest: A SHA256 digest, a 64-character hex string + :param timeout_s: The underlying request timeout + """ + assert access_token + + params = {"dataset_digest": digest} + return DmApi.__request( + "GET", + "/type", + access_token=access_token, + params=params, + error_message="Failed to get dataset digest", + timeout=timeout_s, + )[0] + + @classmethod + @synchronized + def get_applications( + cls, + access_token: str, + *, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Gets known applications. + + :param access_token: A valid DM API access token + :param timeout_s: The underlying request timeout + """ + assert access_token + + return DmApi.__request( + "GET", + "/application", + access_token=access_token, + error_message="Failed to get applications", + timeout=timeout_s, + )[0] + + @classmethod + @synchronized + def get_application( + cls, + access_token: str, + *, + app_id: str, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Gets known applications. + + :param access_token: A valid DM API access token + :param app_id: An application ID + :param timeout_s: The underlying request timeout + """ + assert access_token + + return DmApi.__request( + "GET", + f"/application/{app_id}", + access_token=access_token, + error_message="Failed to get applications", + timeout=timeout_s, + )[0] + + @classmethod + @synchronized + def create_project_path( + cls, + access_token: str, + *, + project_id: str, + project_path: str, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Creates a Project path. + + :param access_token: A valid DM API access token + :param project_id: A Project ID + :param project_path: A path (a '/' prefix is added if not present) + :param timeout_s: The underlying request timeout + """ + assert access_token + + path_with_prefix = ( + project_path if project_path.startswith("/") else f"/{project_path}" + ) + data = { + "project_id": project_id, + "path": path_with_prefix, + } + return DmApi.__request( + "PUT", + "/path", + access_token=access_token, + data=data, + error_message="Failed to create project path", + timeout=timeout_s, + )[0] + + @classmethod + @synchronized + def delete_project_path( + cls, + access_token: str, + *, + project_id: str, + project_path: str, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Deleted a Project path. + + :param access_token: A valid DM API access token + :param project_id: A Project ID + :param project_path: A path (a '/' prefix is added if not present) + :param timeout_s: The underlying request timeout + """ + assert access_token + + path_with_prefix = ( + project_path if project_path.startswith("/") else f"/{project_path}" + ) + data = { + "project_id": project_id, + "path": path_with_prefix, + } + return DmApi.__request( + "DELETE", + "/path", + access_token=access_token, + data=data, + error_message="Failed to delete project path", + timeout=timeout_s, + )[0] + + @classmethod + @synchronized + def move_project_path( + cls, + access_token: str, + *, + project_id: str, + project_src_path: str, + project_dst_path: str, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Moves a Project path. + + :param access_token: A valid DM API access token + :param project_id: A Project ID + :param project_src_path: The path to rename (a '/' prefix is added if not present) + :param project_dst_path: The new path (a '/' prefix is added if not present) + :param timeout_s: The underlying request timeout + """ + assert access_token + + src_path_with_prefix = ( + project_src_path + if project_src_path.startswith("/") + else f"/{project_src_path}" + ) + dst_path_with_prefix = ( + project_dst_path + if project_dst_path.startswith("/") + else f"/{project_dst_path}" + ) + data = { + "project_id": project_id, + "src_path": src_path_with_prefix, + "dst_path": dst_path_with_prefix, + } + return DmApi.__request( + "PUT", + "/path/move", + access_token=access_token, + data=data, + error_message="Failed to move project path", + timeout=timeout_s, + )[0] + + @classmethod + @synchronized + def create_workflow( + cls, + access_token: str, + *, + name: str, + scope: ScopeEnum, + scope_id: str | None = None, + definition: str | None = None, + definition_file: str | None = None, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Create a Workflow. + + :param access_token: A valid DM API access token + :param timeout_s: The underlying request timeout + """ + assert access_token + + data: Dict[str, Any] = { + "name": name, + "scope": scope.name, + } + if scope != ScopeEnum.GLOBAL: + data["scope_id"] = scope_id + if definition: + data["definition"] = definition + # Has the user provided a file? + # We have to provide a file to avoid connexion's expected "['multipart/form-data']"" + # so we 'trick' the test by providing a 'content_file' (which gives us a RequestBody) + # but we do not give it a name, which our handler recognizes as 'no file'. + if definition_file: + files = {"definition_file": open(definition_file, "rb")} + else: + files = {"definition_file": ("", open(os.path.realpath(__file__), "rb"))} + + return DmApi.__request( + "POST", + "/workflow", + access_token=access_token, + data=data, + files=files, + expected_response_codes=[201], + error_message="Failed to create workflow", + timeout=timeout_s, + )[0] + + @classmethod + @synchronized + def update_workflow( + cls, + access_token: str, + *, + workflow_id: str, + name: str | None = None, + definition: str | None = None, + definition_file: str | None = None, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Patch (update) a Workflow. + + :param access_token: A valid DM API access token + :param timeout_s: The underlying request timeout + """ + assert access_token + + data: Dict[str, Any] = {} + if name: + data["name"] = name + if definition: + data["definition"] = definition + # Has the user provided a file? + # We have to provide a file to avoid connexion's expected "['multipart/form-data']"" + # so we 'trick' the test by providing a 'content_file' (which gives us a RequestBody) + # but we do not give it a name, which our handler recognizes as 'no file'. + if definition_file: + files = {"definition_file": open(definition_file, "rb")} + else: + files = {"definition_file": ("", open(os.path.realpath(__file__), "rb"))} + + return DmApi.__request( + "PATCH", + f"/workflow/{workflow_id}", + access_token=access_token, + data=data, + files=files, + error_message="Failed to update workflow", + timeout=timeout_s, + )[0] + + @classmethod + @synchronized + def get_workflows( + cls, + access_token: str, + *, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Get known Workflows. + + :param access_token: A valid DM API access token + :param timeout_s: The underlying request timeout + """ + assert access_token + + return DmApi.__request( + "GET", + "/workflow", + access_token=access_token, + error_message="Failed to get workflows", + timeout=timeout_s, + )[0] + + @classmethod + @synchronized + def get_workflow( + cls, + access_token: str, + *, + workflow_id: str, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Gets a specific Workflow. + + :param access_token: A valid DM API access token + :param workflow_id: A Workflow UUID + :param timeout_s: The underlying request timeout + """ + assert access_token + + return DmApi.__request( + "GET", + f"/workflow/{workflow_id}", + access_token=access_token, + error_message="Failed to get workflow", + timeout=timeout_s, + )[0] + + @classmethod + @synchronized + def get_workflow_definition( + cls, + access_token: str, + *, + workflow_id: str, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Gets the definition for a specific Workflow. + + :param access_token: A valid DM API access token + :param workflow_id: A Workflow UUID + :param timeout_s: The underlying request timeout + """ + assert access_token + + return DmApi.__request( + "GET", + f"/workflow/{workflow_id}/definition", + access_token=access_token, + error_message="Failed to get workflow definition", + timeout=timeout_s, + )[0] + + @classmethod + @synchronized + def run_workflow( + cls, + access_token: str, + *, + workflow_id: str, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Runs a Workflow. + + :param access_token: A valid DM API access token + :param workflow_id: A Workflow UUID + :param timeout_s: The underlying request timeout + """ + assert access_token + + return DmApi.__request( + "POST", + f"/workflow/{workflow_id}/run", + access_token=access_token, + expected_response_codes=[201], + error_message="Failed to run the workflow", + timeout=timeout_s, + )[0] + + @classmethod + @synchronized + def apply_workflow_version( + cls, + access_token: str, + *, + workflow_id: str, + version_str: str, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Applies a version number to a Workflow. + + :param access_token: A valid DM API access token + :param workflow_id: A Workflow UUID + :param timeout_s: The underlying request timeout + """ + assert access_token + + data = {"version": version_str} + + return DmApi.__request( + "PUT", + f"/workflow/{workflow_id}/version", + access_token=access_token, + data=data, + expected_response_codes=[201], + error_message="Failed to run the workflow", + timeout=timeout_s, + )[0] + + @classmethod + @synchronized + def delete_workflow( + cls, + access_token: str, + *, + workflow_id: str, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Deletes a specific Workflow. + + :param access_token: A valid DM API access token + :param workflow_id: A Workflow UUID + :param timeout_s: The underlying request timeout + """ + assert access_token + + return DmApi.__request( + "DELETE", + f"/workflow/{workflow_id}", + access_token=access_token, + expected_response_codes=[204], + error_message="Failed to delete workflow", + timeout=timeout_s, + )[0] + + @classmethod + @synchronized + def get_running_workflows( + cls, + access_token: str, + *, + workflow_id: str | None = None, + project_id: str | None = None, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Get known Workflows. + + :param access_token: A valid DM API access token + :param workflow_id: A Workflow UUID + :param project_id: A project UUID + :param timeout_s: The underlying request timeout + """ + assert access_token + + data = {} + if workflow_id: + data["workflow_id"] = workflow_id + if project_id: + data["project_id"] = project_id + + return DmApi.__request( + "GET", + "/running-workflow", + access_token=access_token, + data=data, + error_message="Failed to get the running workflows", + timeout=timeout_s, + )[0] + + @classmethod + @synchronized + def get_running_workflow( + cls, + access_token: str, + *, + running_workflow_id: str | None = None, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Gets known Running Workflows. + + :param access_token: A valid DM API access token + :param running_workflow_id: A Running Workflow UUID + :param timeout_s: The underlying request timeout + """ + assert access_token + + return DmApi.__request( + "GET", + f"/running-workflow/{running_workflow_id}", + access_token=access_token, + error_message="Failed to get the workflow", + timeout=timeout_s, + )[0] + + @classmethod + @synchronized + def delete_running_workflow( + cls, + access_token: str, + *, + running_workflow_id: str | None = None, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Get a Running Workflow. + + :param access_token: A valid DM API access token + :param running_workflow_id: A Running Workflow UUID + :param timeout_s: The underlying request timeout + """ + assert access_token + + return DmApi.__request( + "DELETE", + f"/running-workflow/{running_workflow_id}", + access_token=access_token, + expected_response_codes=[204], + error_message="Failed to delete running workflow", + timeout=timeout_s, + )[0] + + @classmethod + @synchronized + def stop_running_workflow( + cls, + access_token: str, + *, + running_workflow_id: str | None = None, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Stops a Running Workflows. + + :param access_token: A valid DM API access token + :param running_workflow_id: A Running Workflow UUID + :param timeout_s: The underlying request timeout + """ + assert access_token + + return DmApi.__request( + "PUT", + f"/running-workflow/{running_workflow_id}/stop", + access_token=access_token, + expected_response_codes=[204], + error_message="Failed to stop running workflow", + timeout=timeout_s, + )[0] + + @classmethod + @synchronized + def get_running_workflow_steps( + cls, + access_token: str, + *, + running_workflow_id: str | None = None, + timeout_s: int = _READ_TIMEOUT_S, + ) -> ApiRv: + """Stops a Running Workflows. + + :param access_token: A valid DM API access token + :param running_workflow_id: A Running Workflow UUID + :param timeout_s: The underlying request timeout + """ + assert access_token + + return DmApi.__request( + "GET", + f"/running-workflow/{running_workflow_id}/steps", + access_token=access_token, + error_message="Failed to get running workflow steps", + timeout=timeout_s, + )[0] diff --git a/src/squonk2/enumerations.py b/src/squonk2/enumerations.py new file mode 100644 index 0000000..349fd30 --- /dev/null +++ b/src/squonk2/enumerations.py @@ -0,0 +1,29 @@ +"""Enumerations, available to AS, DM and UI.""" + +from enum import Enum + + +class EventStreamFormat(Enum): + """Enumeration of EventStream formats""" + + JSON_STRING = 1 + PROTOCOL_STRING = 2 + + +class ScopeEnum(Enum): + """Enumeration of Scopes""" + + USER = 1 + PRODUCT = 2 + UNIT = 3 + ORGANISATION = 4 + GLOBAL = 5 + + +class DefaultProductPrivacyEnum(Enum): + """Enumeration of Default product Privacy""" + + ALWAYS_PRIVATE = 1 + ALWAYS_PUBLIC = 2 + DEFAULT_PRIVATE = 3 + DEFAULT_PUBLIC = 4 diff --git a/src/squonk2/examples/data_manager/job_chain.py b/src/squonk2/examples/data_manager/job_chain.py index d43dc4e..9f71b44 100755 --- a/src/squonk2/examples/data_manager/job_chain.py +++ b/src/squonk2/examples/data_manager/job_chain.py @@ -199,9 +199,11 @@ def run( # This gives us many things, like the DM API URL _ = Environment.load() env: Environment = Environment(environment) + assert env.dm_api DmApi.set_api_url(env.dm_api) # Now get a DM API access token, using the environment material + assert env.keycloak_dm_client_id api_token = Auth.get_access_token( keycloak_url=env.keycloak_url, keycloak_realm=env.keycloak_realm, @@ -212,6 +214,7 @@ def run( else: # Given a raw token (and DM API) api_token = token + assert dm_api_url DmApi.set_api_url(dm_api_url) assert api_token diff --git a/test.py b/test.py index 1295f63..c922ee2 100755 --- a/test.py +++ b/test.py @@ -1,22 +1,42 @@ #!/usr/bin/env python -"""A simple developer-centric test script.""" -import argparse +from importlib.metadata import version import os import sys import time -from typing import NoReturn, Optional +from typing import Annotated, NoReturn + +from packaging.version import Version +import typer from squonk2.api import ApiRv from squonk2.dm_api import DmApi, TEST_PRODUCT_ID from squonk2.auth import Auth +from squonk2.enumerations import ScopeEnum + +# Tests require a specific Squonk client version +REQUIRED_CLIENT_MAJOR_VERSION: int = 7 +CLIENT_VERSION: str = version("im-squonk2-client") +if CLIENT_VERSION != "0.0.0" and ( + Version(CLIENT_VERSION) < Version(f"{REQUIRED_CLIENT_MAJOR_VERSION}.0.0") + or Version(CLIENT_VERSION) >= Version(f"{REQUIRED_CLIENT_MAJOR_VERSION + 1}.0.0") +): + assert False, ( + f"The client must be version {REQUIRED_CLIENT_MAJOR_VERSION}, not {CLIENT_VERSION}" + ) # Get configuration from the environment. # All the expected variables must be defined... +# +# - https://data-manager-test.example.com/data-manager-api +# - https://keycloak.example.com/auth +# - squonk +# - data-manager-api-test DMAPI_URL: str = os.environ["SQUONK2_DMAPI_URL"] KEYCLOAK_URL: str = os.environ["SQUONK2_KEYCLOAK_URL"] KEYCLOAK_REALM: str = os.environ["SQUONK2_KEYCLOAK_REALM"] KEYCLOAK_CLIENT_ID: str = os.environ["SQUONK2_KEYCLOAK_DM_CLIENT_ID"] +# And User credentials... KEYCLOAK_USER: str = os.environ["SQUONK2_KEYCLOAK_USER"] KEYCLOAK_USER_PASSWORD: str = os.environ["SQUONK2_KEYCLOAK_USER_PASSWORD"] # Optional @@ -28,9 +48,14 @@ # # export SSL_CERT_FILE=$(python -m certifi) +# Name we'll give to the Project we'll create +TEST_PROJECT_NAME: str = "DmApi Test Project" + +TEST_DEFINITION_FILE = "examples/test-workflow.yaml" -def fail(msg: str, retval: Optional[ApiRv] = None) -> NoReturn: - """Issues a failure message then sies a sys.exit(1).""" + +def fail(msg: str, retval: ApiRv | None = None) -> NoReturn: + """Issues a failure message then issues a sys.exit(1).""" err_msg = f"FAILED {msg}" if retval: assert not retval.success @@ -39,23 +64,15 @@ def fail(msg: str, retval: Optional[ApiRv] = None) -> NoReturn: sys.exit(1) -def main(): - """The test entrypoint.""" +def main(project: Annotated[str, typer.Option(help="An existing Project UUID")] = ""): + """A simple developer test script. - # Prepare arg-parser and parse the command-line... - arg_parser: argparse.ArgumentParser = argparse.ArgumentParser( - description="Squonk2 Data Manager API Tester", - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - ) - arg_parser.add_argument( - "-p", - "--project-id", - help="An optional pre-existing DM Project." - " If one is not provided a new one is created" - " (and then deleted)", - type=str, - ) - args: argparse.Namespace = arg_parser.parse_args() + It runs a number of API methods to create a project, upload files, + run some tests, an then delete the project + + If you provide a Project it is used (and not deleted)""" + + print(f"Client API version={CLIENT_VERSION}") # Configure the URL. # Depending on keycloak configuration @@ -65,7 +82,7 @@ def main(): assert url == DMAPI_URL print(f"DM-API url={url}") - first_token = Auth.get_access_token( + first_token: str | None = Auth.get_access_token( keycloak_url=KEYCLOAK_URL, keycloak_realm=KEYCLOAK_REALM, keycloak_client_id=KEYCLOAK_CLIENT_ID, @@ -75,11 +92,11 @@ def main(): if not first_token: fail(f"Failed to get token from {KEYCLOAK_URL} for '{KEYCLOAK_USER}'") - print(f"DM-API authorised as '{KEYCLOAK_USER}' ({DMAPI_URL})") + print(f"DM-API authorised as '{KEYCLOAK_USER}'") # Get another token using the existing token. # Just tests that prior tokens are given back. - token = Auth.get_access_token( + token: str | None = Auth.get_access_token( keycloak_url=KEYCLOAK_URL, keycloak_realm=KEYCLOAK_REALM, keycloak_client_id=KEYCLOAK_CLIENT_ID, @@ -88,6 +105,7 @@ def main(): prior_token=first_token, ) assert token == first_token + assert token # Basic ping/version api_rv: ApiRv = DmApi.ping(token) @@ -95,11 +113,75 @@ def main(): fail("ping()", api_rv) print("DM-API ping() (SUCCESS)") - api_rv = DmApi.get_version(token) + api_rv = DmApi.get_version() if not api_rv.success: fail("get_version()", api_rv) print(f"DM-API version='{api_rv.msg['version']}'") + # Some more simple methods + + api_rv = DmApi.get_workflow_engine_version() + if not api_rv.success: + fail("get_workflow_engine_version()", api_rv) + print(f"DM-API workflow engine version='{api_rv.msg['version']}'") + + api_rv = DmApi.get_mode() + if not api_rv.success: + fail("mode()", api_rv) + print(f"DM-API mode='{api_rv.msg['mode']}'") + + # Use the workflow endpoints to create, update and delete a workflow. + # We create using a definition and a file + + api_rv = DmApi.create_workflow( + token, + name="Test A", + scope=ScopeEnum.GLOBAL, + definition_file=TEST_DEFINITION_FILE, + ) + if not api_rv.success: + fail("create_workflow()", api_rv) + workflow_id = api_rv.msg["id"] + validated = api_rv.msg["validated"] + print(f"DM-API create workflow (from file) (SUCCESS) (validated={validated})") + + api_rv = DmApi.update_workflow(token, workflow_id=workflow_id, name="Test B") + if not api_rv.success: + fail("update_workflow()", api_rv) + print("DM-API updated workflow (SUCCESS)") + + api_rv = DmApi.get_workflow_definition(token, workflow_id=workflow_id) + if not api_rv.success: + fail("get_workflow_definition()", api_rv) + print("DM-API got workflow definition (SUCCESS)") + + api_rv = DmApi.delete_workflow(token, workflow_id=workflow_id) + if not api_rv.success: + fail("delete_workflow()", api_rv) + print("DM-API delete workflow (SUCCESS)") + + # Create a workflow by content and delete it + + with open(TEST_DEFINITION_FILE, "r", encoding="utf8") as file: + definition = file.read() + + api_rv = DmApi.create_workflow( + token, + name="Test A", + scope=ScopeEnum.GLOBAL, + definition=definition, + ) + if not api_rv.success: + fail("create_workflow()", api_rv) + workflow_id = api_rv.msg["id"] + validated = api_rv.msg["validated"] + print(f"DM-API create workflow (from definition) (SUCCESS) (validated={validated})") + + api_rv = DmApi.delete_workflow(token, workflow_id=workflow_id) + if not api_rv.success: + fail("delete_workflow()", api_rv) + print("DM-API delete workflow (SUCCESS)") + # Get existing projects rv_projects = DmApi.get_available_projects(token) if not rv_projects.success: @@ -108,30 +190,30 @@ def main(): # If given a project ID find it, # otherwise create one using the '1111' codes, # assuming admin user (because we have no Product, Unit, Organisation atm) - project_id = "" - if args.project_id: + using_project_id: str = "" + if project: found = False - for project in rv_projects.msg["projects"]: - if project["project_id"] == args.project_id: + for existing_project in rv_projects.msg["projects"]: + if existing_project["project_id"] == project: print( - f"Found project (product_id={project['product_id']} size={project['size']})" + f"Found project (product_id={project} size={existing_project['size']})" ) found = True break if not found: - fail(f"Project does not exist ({args.project_id})") - project_id = args.project_id + fail(f"Project does not exist ({project})") + using_project_id = project else: - # Crete a new project, using the one we're about to - # create if it already exists on the server. - new_project_name = "DmApi Test Project" + # Not given a project (ID). + # Crete a new project, unless one exists with the same name. + new_project_name: str = TEST_PROJECT_NAME project_exists = False - for project in rv_projects.msg["projects"]: - if project["name"] == new_project_name: + for existing_project in rv_projects.msg["projects"]: + if existing_project["name"] == new_project_name: print( - f"Found existing test project '{new_project_name}' ({project['project_id']})" + f"Found existing test project '{new_project_name}' ({existing_project['project_id']})" ) - project_id = project["project_id"] + using_project_id = existing_project["project_id"] project_exists = True break if project_exists: @@ -146,15 +228,19 @@ def main(): ) if not api_rv.success: fail("create_project()", api_rv) - project_id = api_rv.msg["project_id"] - print(f"Created project_id={project_id}") - assert project_id + using_project_id = api_rv.msg["project_id"] + print(f"Created project_id={using_project_id}") + # Whether we created one (or the test project already existed) + # we must have set using_project_id + assert using_project_id # Get a list of project files on the root print("Listing project files") - api_rv = DmApi.list_project_files(token, project_id=project_id, project_path="/") + api_rv = DmApi.list_project_files( + token, project_id=using_project_id, project_path="/" + ) if not api_rv.success: - fail(f"list_project_files({project_id}, '/')", api_rv) + fail(f"list_project_files({using_project_id}, '/')", api_rv) num_project_files = 0 if api_rv.msg["files"]: for project_file in api_rv.msg["files"]: @@ -169,31 +255,31 @@ def main(): # Put a simple file into the project, get it back and delete it local_file = "LICENSE" project_path = "/license" - api_rv = DmApi.put_unmanaged_project_files( + api_rv: ApiRv = DmApi.put_unmanaged_project_files( token, - project_id=project_id, + project_id=using_project_id, project_files=local_file, project_path=project_path, ) if not api_rv.success: - fail(f"put_unmanaged_project_files({project_id})", api_rv) + fail(f"put_unmanaged_project_files({using_project_id})", api_rv) api_rv = DmApi.get_unmanaged_project_file( token, - project_id=project_id, + project_id=using_project_id, project_file=local_file, project_path=project_path, local_file=local_file, ) if not api_rv.success: - fail(f"get_unmanaged_project_file({project_id})", api_rv) + fail(f"get_unmanaged_project_file({using_project_id})", api_rv) api_rv = DmApi.delete_unmanaged_project_files( token, - project_id=project_id, + project_id=using_project_id, project_files=local_file, project_path=project_path, ) if not api_rv.success: - fail(f"delete_unmanaged_project_files({project_id})", api_rv) + fail(f"delete_unmanaged_project_files({using_project_id})", api_rv) # Run a test job print("Starting Job...") @@ -208,10 +294,10 @@ def main(): spec = {"collection": job_collection, "job": job_job, "version": job_version} job_name: str = "DmApi Test Job" api_rv = DmApi.start_job_instance( - token, project_id=project_id, name=job_name, specification=spec + token, project_id=using_project_id, name=job_name, specification=spec ) if not api_rv.success: - fail(f"start_job_instance({project_id})", api_rv) + fail(f"start_job_instance({using_project_id})", api_rv) job_task_id = api_rv.msg["task_id"] job_instance_id = api_rv.msg["instance_id"] print(f"Started (task_id={job_task_id} instance_id={job_instance_id})") @@ -248,16 +334,16 @@ def main(): print("Deleted") # Finally, if we created a project, delete it. - if not args.project_id: + if not project: print("Deleting project I created...") - print(f"Deleting project_id={project_id}...") - api_rv = DmApi.delete_project(token, project_id=project_id) + print(f"Deleting project_id={using_project_id}...") + api_rv = DmApi.delete_project(token, project_id=using_project_id) if not api_rv.success: - fail(f"delete_project({project_id})", api_rv) + fail(f"delete_project({using_project_id})", api_rv) print("Deleted") print("Done") if __name__ == "__main__": - main() + typer.run(main) diff --git a/uv.lock b/uv.lock index ecbe07b..130c275 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] +[[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 = "apeye" version = "1.4.1" @@ -297,6 +306,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, ] +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -475,6 +496,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "packaging" }, { name = "pre-commit" }, { name = "pyroma" }, { name = "ruff" }, @@ -483,19 +505,21 @@ dev = [ { name = "sphinx-rtd-theme" }, { name = "sphinx-toolbox" }, { name = "ty" }, + { name = "typer" }, ] [package.metadata] requires-dist = [ { name = "authlib", specifier = ">=1.0.1,<2.0" }, - { name = "munch", specifier = ">=4.0.0,<5.0" }, - { name = "pyyaml", specifier = ">=5.2,<7.0" }, - { name = "requests", specifier = ">=2.22.0" }, - { name = "wrapt", specifier = ">=1.14.1,<2.0" }, + { name = "munch", specifier = ">=4.0.0,<5" }, + { name = "pyyaml", specifier = ">=5.2,<7" }, + { name = "requests", specifier = ">=2.22.0,<3" }, + { name = "wrapt", specifier = ">=2.0.0,<3" }, ] [package.metadata.requires-dev] dev = [ + { name = "packaging", specifier = ">=26.0" }, { name = "pre-commit", specifier = ">=4.5.1" }, { name = "pyroma", specifier = ">=5.0.1" }, { name = "ruff", specifier = ">=0.15.6" }, @@ -503,6 +527,7 @@ dev = [ { name = "sphinx-rtd-theme", specifier = ">=3.1.0" }, { name = "sphinx-toolbox", specifier = ">=4.1.2" }, { name = "ty", specifier = ">=0.0.23" }, + { name = "typer", specifier = ">=0.24.1" }, ] [[package]] @@ -526,6 +551,18 @@ 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 = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -600,6 +637,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[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 = "more-itertools" version = "10.8.0" @@ -851,6 +897,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + [[package]] name = "roman" version = "5.2" @@ -963,6 +1022,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, ] +[[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" @@ -1297,6 +1365,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/29/b32cb7b4c7d56b9ed50117f8ad6e45834aec293e4cb14749daab4e9236d5/ty-0.0.23-py3-none-win_arm64.whl", hash = "sha256:a0399bd13fd2cd6683fd0a2d59b9355155d46546d8203e152c556ddbdeb20842", size = 10155890, upload-time = "2026-03-13T12:34:48.082Z" }, ] +[[package]] +name = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1341,59 +1424,75 @@ wheels = [ [[package]] name = "wrapt" -version = "1.17.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, - { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, - { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, - { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, - { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, - { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, - { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, - { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, - { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, - { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, - { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, - { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, - { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, - { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, - { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, - { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, - { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, - { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, - { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, - { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, - { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, - { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, - { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, - { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, - { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, - { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, - { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, - { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, - { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, - { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, - { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, - { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, - { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, - { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, - { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, - { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, - { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, - { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, - { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/81/60c4471fce95afa5922ca09b88a25f03c93343f759aae0f31fb4412a85c7/wrapt-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96159a0ee2b0277d44201c3b5be479a9979cf154e8c82fa5df49586a8e7679bb", size = 60666, upload-time = "2026-03-06T02:52:58.934Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/80e80e39e7cb90b006a0eaf11c73ac3a62bbfb3068469aec15cc0bc795de/wrapt-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98ba61833a77b747901e9012072f038795de7fc77849f1faa965464f3f87ff2d", size = 61601, upload-time = "2026-03-06T02:53:00.487Z" }, + { url = "https://files.pythonhosted.org/packages/b0/be/d7c88cd9293c859fc74b232abdc65a229bb953997995d6912fc85af18323/wrapt-2.1.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:767c0dbbe76cae2a60dd2b235ac0c87c9cccf4898aef8062e57bead46b5f6894", size = 114057, upload-time = "2026-03-06T02:52:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/ea/25/36c04602831a4d685d45a93b3abea61eca7fe35dab6c842d6f5d570ef94a/wrapt-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c691a6bc752c0cc4711cc0c00896fcd0f116abc253609ef64ef930032821842", size = 116099, upload-time = "2026-03-06T02:54:56.74Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4e/98a6eb417ef551dc277bec1253d5246b25003cf36fdf3913b65cb7657a56/wrapt-2.1.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f3b7d73012ea75aee5844de58c88f44cf62d0d62711e39da5a82824a7c4626a8", size = 112457, upload-time = "2026-03-06T02:53:52.842Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a6/a6f7186a5297cad8ec53fd7578533b28f795fdf5372368c74bd7e6e9841c/wrapt-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:577dff354e7acd9d411eaf4bfe76b724c89c89c8fc9b7e127ee28c5f7bcb25b6", size = 115351, upload-time = "2026-03-06T02:53:32.684Z" }, + { url = "https://files.pythonhosted.org/packages/97/6f/06e66189e721dbebd5cf20e138acc4d1150288ce118462f2fcbff92d38db/wrapt-2.1.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d7b6fd105f8b24e5bd23ccf41cb1d1099796524bcc6f7fbb8fe576c44befbc9", size = 111748, upload-time = "2026-03-06T02:53:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/ef/43/4808b86f499a51370fbdbdfa6cb91e9b9169e762716456471b619fca7a70/wrapt-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:866abdbf4612e0b34764922ef8b1c5668867610a718d3053d59e24a5e5fcfc15", size = 113783, upload-time = "2026-03-06T02:53:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/91/2c/a3f28b8fa7ac2cefa01cfcaca3471f9b0460608d012b693998cd61ef43df/wrapt-2.1.2-cp311-cp311-win32.whl", hash = "sha256:5a0a0a3a882393095573344075189eb2d566e0fd205a2b6414e9997b1b800a8b", size = 57977, upload-time = "2026-03-06T02:53:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c3/2b1c7bd07a27b1db885a2fab469b707bdd35bddf30a113b4917a7e2139d2/wrapt-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:64a07a71d2730ba56f11d1a4b91f7817dc79bc134c11516b75d1921a7c6fcda1", size = 60336, upload-time = "2026-03-06T02:54:28.104Z" }, + { url = "https://files.pythonhosted.org/packages/ec/5c/76ece7b401b088daa6503d6264dd80f9a727df3e6042802de9a223084ea2/wrapt-2.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:b89f095fe98bc12107f82a9f7d570dc83a0870291aeb6b1d7a7d35575f55d98a", size = 58756, upload-time = "2026-03-06T02:53:16.319Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" }, + { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" }, + { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" }, + { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, + { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, + { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, + { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, + { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, + { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, + { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, + { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" }, + { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" }, + { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" }, + { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" }, + { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" }, + { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" }, + { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" }, + { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, ]