From 8a1363a23cfa024e2325ca033fa62896b3b4f358 Mon Sep 17 00:00:00 2001 From: Colin Haywood Date: Tue, 21 Apr 2026 16:08:38 +1200 Subject: [PATCH 1/2] feat: Add all project code, tests, and documentation --- .editorconfig | 21 + .gitignore | 55 + CONTRIBUTING.rst | 166 +++ HISTORY.rst | 194 +++ MANIFEST.in | 11 + Makefile | 82 + NOTICE | 4 + README.rst | 81 + datamasque/client/__init__.py | 204 +++ datamasque/client/base.py | 304 ++++ datamasque/client/connections.py | 64 + datamasque/client/discovery.py | 286 ++++ datamasque/client/dmclient.py | 49 + datamasque/client/exceptions.py | 75 + datamasque/client/files.py | 92 ++ datamasque/client/ifm.py | 301 ++++ datamasque/client/license.py | 41 + datamasque/client/models/__init__.py | 0 datamasque/client/models/connection.py | 429 ++++++ datamasque/client/models/data_selection.py | 62 + datamasque/client/models/discovery.py | 229 +++ datamasque/client/models/dm_instance.py | 39 + datamasque/client/models/files.py | 89 ++ datamasque/client/models/ifm.py | 177 +++ datamasque/client/models/license.py | 60 + datamasque/client/models/pagination.py | 29 + datamasque/client/models/ruleset.py | 45 + datamasque/client/models/ruleset_library.py | 22 + datamasque/client/models/runs.py | 165 ++ datamasque/client/models/status.py | 68 + datamasque/client/models/user.py | 69 + datamasque/client/py.typed | 0 datamasque/client/ruleset_libraries.py | 164 ++ datamasque/client/rulesets.py | 57 + datamasque/client/runs.py | 189 +++ datamasque/client/settings.py | 76 + datamasque/client/users.py | 96 ++ docs/Makefile | 20 + docs/client.models.rst | 101 ++ docs/client.rst | 117 ++ docs/conf.py | 172 +++ docs/contributing.rst | 1 + docs/history.rst | 1 + docs/index.rst | 19 + docs/installation.rst | 38 + docs/make.bat | 36 + docs/modules.rst | 7 + docs/readme.rst | 1 + docs/usage.rst | 21 + pyproject.toml | 122 ++ setup.cfg | 8 + tests/__init__.py | 1 + tests/conftest.py | 74 + tests/helpers.py | 160 ++ tests/test_base.py | 321 ++++ tests/test_connections.py | 1158 +++++++++++++++ tests/test_discovery.py | 729 +++++++++ tests/test_files.py | 273 ++++ tests/test_ifm.py | 404 +++++ tests/test_license.py | 47 + tests/test_pagination.py | 153 ++ tests/test_ruleset_library.py | 673 +++++++++ tests/test_rulesets.py | 119 ++ tests/test_runs.py | 468 ++++++ tests/test_settings.py | 107 ++ tests/test_users.py | 399 +++++ uv.lock | 1486 +++++++++++++++++++ 67 files changed, 11331 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 CONTRIBUTING.rst create mode 100644 HISTORY.rst create mode 100644 MANIFEST.in create mode 100644 Makefile create mode 100644 NOTICE create mode 100644 README.rst create mode 100644 datamasque/client/__init__.py create mode 100644 datamasque/client/base.py create mode 100644 datamasque/client/connections.py create mode 100644 datamasque/client/discovery.py create mode 100644 datamasque/client/dmclient.py create mode 100644 datamasque/client/exceptions.py create mode 100644 datamasque/client/files.py create mode 100644 datamasque/client/ifm.py create mode 100644 datamasque/client/license.py create mode 100644 datamasque/client/models/__init__.py create mode 100644 datamasque/client/models/connection.py create mode 100644 datamasque/client/models/data_selection.py create mode 100644 datamasque/client/models/discovery.py create mode 100644 datamasque/client/models/dm_instance.py create mode 100644 datamasque/client/models/files.py create mode 100644 datamasque/client/models/ifm.py create mode 100644 datamasque/client/models/license.py create mode 100644 datamasque/client/models/pagination.py create mode 100644 datamasque/client/models/ruleset.py create mode 100644 datamasque/client/models/ruleset_library.py create mode 100644 datamasque/client/models/runs.py create mode 100644 datamasque/client/models/status.py create mode 100644 datamasque/client/models/user.py create mode 100644 datamasque/client/py.typed create mode 100644 datamasque/client/ruleset_libraries.py create mode 100644 datamasque/client/rulesets.py create mode 100644 datamasque/client/runs.py create mode 100644 datamasque/client/settings.py create mode 100644 datamasque/client/users.py create mode 100644 docs/Makefile create mode 100644 docs/client.models.rst create mode 100644 docs/client.rst create mode 100755 docs/conf.py create mode 100644 docs/contributing.rst create mode 100644 docs/history.rst create mode 100644 docs/index.rst create mode 100644 docs/installation.rst create mode 100644 docs/make.bat create mode 100644 docs/modules.rst create mode 100644 docs/readme.rst create mode 100644 docs/usage.rst create mode 100644 pyproject.toml create mode 100644 setup.cfg create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/helpers.py create mode 100644 tests/test_base.py create mode 100644 tests/test_connections.py create mode 100644 tests/test_discovery.py create mode 100644 tests/test_files.py create mode 100644 tests/test_ifm.py create mode 100644 tests/test_license.py create mode 100644 tests/test_pagination.py create mode 100644 tests/test_ruleset_library.py create mode 100644 tests/test_rulesets.py create mode 100644 tests/test_runs.py create mode 100644 tests/test_settings.py create mode 100644 tests/test_users.py create mode 100644 uv.lock diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d4a2c44 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +# http://editorconfig.org + +root = true + +[*] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true +charset = utf-8 +end_of_line = lf + +[*.bat] +indent_style = tab +end_of_line = crlf + +[LICENSE] +insert_final_newline = false + +[Makefile] +indent_style = tab diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2c0bf0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +coverage.xml +report.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Sphinx documentation +docs/_build/ + +# pyenv +.python-version + +# dotenv +.env + +# virtualenv +.venv +venv/ +ENV/ + +# mypy +.mypy_cache/ + +# IDE settings +.vscode/ +.idea/ diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 0000000..f24ed60 --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,166 @@ +============ +Contributing +============ + +Thanks for your interest in contributing to ``datamasque-python``! +Contributions, bug reports, and feature requests are all welcome. + +Reporting bugs +============== + +File an issue on the `GitHub issue tracker `_. +Please include: + +- the version of ``datamasque-python`` you're using (``pip show datamasque-python``); +- the Python version and operating system; +- a minimal reproducer if possible; +- the full traceback if the bug manifests as an exception. + +If the bug concerns a specific DataMasque server API response, +include the status code and (with any sensitive fields redacted) the response body. + +Feature requests +================ + +Open an issue describing what you'd like to do and why. +We're particularly interested in feedback on: + +- public API shape (method names, argument names, return types); +- endpoints not yet wrapped by the client; +- improvements to the typed return models. + +Development setup +================= + +The project uses `uv `_ for dependency management. +Install dependencies and set up a virtual environment: + +.. code-block:: console + + git clone https://github.com/datamasque/datamasque-python.git + cd datamasque-python + uv sync + +Running the tests +================= + +.. code-block:: console + + uv run pytest + +The test suite runs entirely against mocked HTTP responses (``requests_mock``), +so no DataMasque server is required. + +Linting and type-checking +========================= + +.. code-block:: console + + uv run ruff check datamasque tests + uv run ruff format --check datamasque tests + uv run mypy datamasque + +``ruff check`` enforces import order, +Python style, +and a set of pydocstyle rules (``D101``, ``D102``, ``D204``, ``D205``, ``D213``) +that require docstrings on all public classes and methods. +``ruff format`` applies the project's formatting style. +``mypy`` runs in strict mode with ``disallow_untyped_defs``. + +Code style +========== + +- **Line length:** + 120 characters. + Enforced by ``ruff format``. +- **Docstrings and comments:** + use `semantic line breaks `_ — + break at clause boundaries, not column widths. + This applies to text files (such as this file) as well as Python source. +- **Docstring content:** + + - Write for library consumers, not maintainers. + - Keep docstrings concise; no internal implementation notes. + - Multi-line docstrings start on the next line after the opening triple quotes. + - In Python docstrings, + use single backticks around anything code-like — + ``default_role = "any"`` in ``docs/conf.py`` makes Sphinx auto-link Python identifiers + and render everything else as monospace. + In top-level ``.rst`` files (``README.rst``, ``CONTRIBUTING.rst``, ``HISTORY.rst``) + use double backticks instead — + those are rendered directly by GitHub, + which doesn't honour the Sphinx role config. + +- **Enum member casing:** + enum members are ``lower_snake_case``, for example, ``DatabaseType.postgres``. +- **Enum comparisons:** + use ``is`` / ``is not`` when comparing against specific enum members, not ``==`` / ``!=``. +- **String formatting in messages:** + errors, log lines, and other user-facing messages follow a consistent quoting convention — + backticks around enum values and code identifiers, + double quotes around free-form string values. + Avoid ``!r`` in f-strings; + it produces Python's default single-quoted ``repr``, + which conflicts with the convention. + Use a single-quoted outer f-string + so double-quoted value literals don't need escaping: + + .. code-block:: python + + raise DataMasqueUserError( + f'The ruleset "{name}" is in `{state.value}` state.' + ) + + ``__str__`` follows this rule (it is a user-facing representation). + ``__repr__`` does not — + it follows Python's native ``repr`` convention, + where ``!r`` and single quotes are idiomatic. + +- **Identifier casing for initialisms:** + only the first letter of an initialism is capitalised in a camel-case identifier — + ``DataMasqueApiError``, not ``DataMasqueAPIError``. + The brand ``DataMasque`` is always spelled out in full. +- **Serialization conventions:** + API models subclass pydantic ``BaseModel``. + + - Serialise outgoing request bodies with ``model.model_dump(exclude_none=True, mode="json")``; + add ``by_alias=True`` when the model uses field aliases. + - Parse incoming responses with ``Model.model_validate(response.json())``. + - Use ``ConfigDict(extra="forbid")`` on outgoing request models + so a typo in a field name fails loudly. + - Use ``ConfigDict(extra="allow")`` on incoming response models + so unknown fields the server may add in future don't break deserialisation. +- **Imports:** + + - All imports at the top of the file; no inline imports. + - Absolute imports only; relative imports are not used. + +- **Formatting:** + run ``uv run ruff format`` before committing. + +Pull requests +============= + +1. Fork the repository and create a feature branch. +2. Add tests for any behavioural change. +3. Run ``uv run pytest``, ``uv run ruff check``, ``uv run ruff format --check``, and ``uv run mypy`` + locally before opening the PR. +4. Keep commits focused; one logical change per commit is easier to review. +5. Open a PR against ``main`` and describe what the change does and why. +6. The maintainers will review and either merge, request changes, or close with an explanation. + +Commit messages +=============== + +Use `conventional commits `_ format where practical: +``feat: add cancel_run method``, +``fix: handle 401 retry for multipart uploads``, +``docs: clarify make_request exception semantics``, +and so on. + +License +======= + +By contributing, +you agree that your contributions will be licensed under the Apache License 2.0, +the same license as the rest of the project. diff --git a/HISTORY.rst b/HISTORY.rst new file mode 100644 index 0000000..6a2a49c --- /dev/null +++ b/HISTORY.rst @@ -0,0 +1,194 @@ +======= +History +======= + +1.0.0 (2026-04-21) +------------------ + +* **First public open-source release.** +* All request and response types are now pydantic v2 models. +* Added support for many new APIs. +* Added ``DataMasqueIfmClient`` for the in-flight masking (IFM) API. +* Overhauled error handling and added new exception types. +* Certain request models now accept either a server-assigned ID or the corresponding object + (``ConnectionConfig``, ``Ruleset``) for entity-reference fields. +* Added ``token_source`` callable-based authentication + to both ``DataMasqueInstanceConfig`` and ``DataMasqueIfmInstanceConfig`` + as an alternative to ``password``. +* Ruleset is now mandatory on masking run requests. +* Fixed file data discovery API to accept both JSON path and standard locators. +* Replaced the CSV-only ``get_rulesets_generated_from_csv`` with ``get_generated_rulesets``, + which handles all three async-ruleset-generation flows (CSV, column selection, file selection). + +0.6.3 (2026-04-10) +------------------ + +* Added ``db2i`` to ``DatabaseType`` enum. + +0.6.2 (2026-03-17) +------------------ + +* Added ``RULESET_LIBRARY_MANAGER`` user role. +* Fixed superuser role value (``admin`` instead of empty string). +* Superusers can now be created via the users API. +* Fixed API field for user roles (``user_roles`` instead of ``roles``/``is_superuser``). + +0.6.1 (2026-03-16) +------------------ + +* Added ``InvalidLibraryError`` exception type. + +0.6.0 (2026-03-11) +------------------ + +* Added support for ruleset libraries. +* Removed ``too_big`` from ruleset validation statuses (no longer used). +* Migrated toolchain to ``uv`` with ``ruff``. +* Added support for ``validating`` run status. + +0.5.1 (2026-03-10) +------------------ + +* Added ``delete_user_by_id_if_exists`` and ``delete_user_by_username_if_exists``. + +0.4.12 (2026-01-29) +------------------- + +* Added support for downloading files. +* Fixed positional argument call in ``dmclient.py``. + +0.4.11 (2025-12-11) +------------------- + +* Fixed ``start_async_ruleset_generation_from_csv`` to use new file upload specification. + +0.4.10 (2025-12-10) +------------------- + +* Fixed issue with file uploads when request was retried after a 401 response. + +0.4.9 (2025-11-26) +------------------ + +* Added ``get_run_report`` and ``start_schema_discovery_run`` endpoints. + +0.4.8 (2025-09-19) +------------------ + +* Updated ``admin_install`` endpoint to support username parameter + +0.4.7 (2025-08-29) +------------------ + +* Added support for Redshift + +0.4.6 (2025-07-18) +------------------ + +* Added support for ``engine_options`` in database connection config +* Updated ``ruleset`` endpoint to use ``upsert`` behaviour +* Updated Snowflake connection handling for encrypted connection strings + +0.4.5 (2025-06-30) +------------------ + +* Added support for ``hash_columns`` in ruleset generator requests. + +0.4.4 (2025-06-09) +------------------ + +* Added support for Azure Blob Storage as a Snowflake staging platform. + +0.4.3 (2025-05-16) +------------------ + +* Added support for specifying Snowflake staging platform. + +0.4.2 (2025-04-03) +------------------ + +* Added support for Snowflake keypair authentication. + +0.4.1 (2025-03-25) +------------------ + +* Made snowflake role field optional. + +0.4.0 (2025-03-17) +------------------ + +* Added support for Snowflake connections. + +0.3.0 (2024-10-24) +------------------ + +* Added support for asynchronous ruleset generation with ``start_async_ruleset_generation``. +* Added support for CSV-based ruleset generation with ``start_async_ruleset_generation_from_csv`` and ``get_rulesets_generated_from_csv``. + +0.2.9 (2024-09-27) +------------------ + +* Added support for the ``dynamo_default_sse`` configuration option on DynamoDB connections. + +0.2.7 (2024-08-26) +------------------ + +* Fixed the user creation API. + +0.2.6 (2024-08-09) +------------------ + +* Removed the ``run_not_started`` pseudo-status from the ``MaskingRunStatus`` enum. +* Added support for the ``data_encoding`` connection parameter on MySQL and MariaDB. + +0.2.5 (2024-08-07) +------------------ + +* Added support for the ``finished_with_warnings`` run status. + +0.2.4 (2024-08-01) +------------------ + +* Added support for MSSQL Linked Server connections. + +0.2.3 (2024-07-30) +------------------ + +* Fixed ``set_locality`` passing in "locality" rather than "region". + +0.2.2 (2024-07-29) +------------------ + +* Add support for passing a filename or StringIO when uploading a license +* Add handling for HTTP 502 errors + +0.2.1 (2024-07-23) +------------------ + +* Add Ruleset model +* Fix numerous issues with the new Connection models +* Introduce a separate model for Dynamo connections + +0.2.0 (2024-07-22) +------------------ + +* Drastic simplification of the config models +* Add new features: + * file data discovery + * file ruleset generation + * locality + * seed file deletion + * list connections and delete connections + * user APIs +* Use v2 ruleset generation API + +0.1.2 (2024-01-22) +------------------ + +* Export RunID, remove RunFailureReason +* Run tests using Tox against Python 3.9 and above + +0.1.1 (2024-01-19) +------------------ + +* First release diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..bd8e33a --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,11 @@ +include CONTRIBUTING.rst +include HISTORY.rst +include LICENSE +include NOTICE +include README.rst + +recursive-include tests * +recursive-exclude * __pycache__ +recursive-exclude * *.py[co] + +recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d6b4496 --- /dev/null +++ b/Makefile @@ -0,0 +1,82 @@ +.PHONY: build clean clean-build clean-pyc clean-test coverage docs format help lint lint/ruff lint/format lint/mypy servedocs test +.DEFAULT_GOAL := help + +define BROWSER_PYSCRIPT +import os, webbrowser, sys + +from urllib.request import pathname2url + +webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) +endef +export BROWSER_PYSCRIPT + +define PRINT_HELP_PYSCRIPT +import re, sys + +for line in sys.stdin: + match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) + if match: + target, help = match.groups() + print("%-20s %s" % (target, help)) +endef +export PRINT_HELP_PYSCRIPT + +BROWSER := python3 -c "$$BROWSER_PYSCRIPT" + +help: + @python3 -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) + +clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts + +clean-build: ## remove build artifacts + rm -fr build/ + rm -fr dist/ + rm -fr .eggs/ + find . -name '*.egg-info' -exec rm -fr {} + + find . -name '*.egg' -exec rm -f {} + + +clean-pyc: ## remove Python file artifacts + find . -name '*.pyc' -exec rm -f {} + + find . -name '*.pyo' -exec rm -f {} + + find . -name '*~' -exec rm -f {} + + find . -name '__pycache__' -exec rm -fr {} + + +clean-test: ## remove test and coverage artifacts + rm -f .coverage + rm -fr htmlcov/ + rm -fr .pytest_cache + +lint/ruff: ## check style with ruff + uv run ruff check datamasque tests +lint/format: ## check formatting with ruff + uv run ruff format --check datamasque tests +lint/mypy: ## check types with mypy + uv run mypy datamasque + +lint: lint/ruff lint/format lint/mypy ## check style, formatting, and types + +format: ## autoformat with ruff + uv run ruff format datamasque tests + +test: ## run tests quickly with the default Python + uv run pytest + +build: ## build sdist and wheel into dist/ + uv build + +coverage: ## check code coverage quickly with the default Python + uv run pytest --cov=datamasque + uv run coverage report -m + uv run coverage html + $(BROWSER) htmlcov/index.html + +docs: ## generate Sphinx HTML documentation, including API docs + rm -f docs/client.rst + rm -f docs/modules.rst + uv run sphinx-apidoc -o docs/ datamasque + $(MAKE) -C docs clean + uv run $(MAKE) -C docs html + $(BROWSER) docs/_build/html/index.html + +servedocs: docs ## compile the docs watching for changes + uv run watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..0e412d5 --- /dev/null +++ b/NOTICE @@ -0,0 +1,4 @@ +DataMasque Python Client +Copyright 2026 DataMasque Ltd + +This product includes software developed at DataMasque Ltd (https://datamasque.com/). diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..ace7e0b --- /dev/null +++ b/README.rst @@ -0,0 +1,81 @@ +================= +datamasque-python +================= + +Official Python client for the `DataMasque `_ platform. + +DataMasque is a data masking platform that replaces sensitive data with realistic but non-production values, +so teams can use production-shaped data in non-production environments without exposing PII. +This package is a thin Python wrapper around the DataMasque server's HTTP API, +covering connection management, ruleset and ruleset-library CRUD, +masking run lifecycle, discovery results, user administration, and license management. + +Installation +============ + +.. code-block:: console + + pip install datamasque-python + +Python 3.9 or newer is required. + +Quickstart +========== + +.. code-block:: python + + from datamasque.client import DataMasqueClient + from datamasque.client.models.dm_instance import DataMasqueInstanceConfig + + config = DataMasqueInstanceConfig( + base_url="https://datamasque.example.com", + username="api_user", + password="api_password", + ) + client = DataMasqueClient(config) + client.authenticate() + + for connection in client.list_connections(): + print(connection.name) + +Authentication is performed on the first request if ``authenticate()`` is not called explicitly, +and is automatically retried once on a 401 response. +``client.healthcheck()`` is available as a lightweight readiness probe that does not consume credentials. + +Error handling +============== + +All methods raise subclasses of ``DataMasqueException`` on failure: + +- ``DataMasqueApiError`` — + the server responded with a non-2xx status (excluding 502). + The triggering ``Response`` is available on the ``.response`` attribute. +- ``DataMasqueNotReadyError`` — + the server responded with 502, + typically because it is still starting up. +- ``DataMasqueTransportError`` — + the request failed before any response was received + (connection refused, timeout, DNS failure, SSL handshake failure, etc.). +- ``FailedToStartError`` / ``InvalidRulesetError`` / ``InvalidLibraryError`` — + raised by ``start_masking_run`` when the server rejects the run. +- ``DataMasqueUserError`` — + raised by user-management methods when the input is invalid. + +Documentation +============= + +- All classes and functions have docstrings and type hints. +- Compiled docs are hosted at `Read the Docs: datamasque-python `_. +- Documentation for the DataMasque product, including a full API reference, + can be found on the `DataMasque portal `_. + +Contributing +============ + +See `CONTRIBUTING.rst `_ for development setup, testing, and the pull request flow. + +License +======= + +Apache License 2.0. +See `LICENSE `_. diff --git a/datamasque/client/__init__.py b/datamasque/client/__init__.py new file mode 100644 index 0000000..2f03777 --- /dev/null +++ b/datamasque/client/__init__.py @@ -0,0 +1,204 @@ +# Copyright 2026 DataMasque Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this library except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 + +from importlib.metadata import version + +from datamasque.client.dmclient import DataMasqueClient, FileOrContent +from datamasque.client.exceptions import ( + AsyncRulesetGenerationInProgressError, + DataMasqueApiError, + DataMasqueException, + DataMasqueIfmError, + DataMasqueNotReadyError, + DataMasqueTransportError, + DataMasqueUserError, + FailedToStartError, + IfmAuthError, + InvalidLibraryError, + InvalidRulesetError, + RunNotCancellableError, +) +from datamasque.client.ifm import DataMasqueIfmClient +from datamasque.client.models.connection import ( + AzureConnectionConfig, + ConnectionConfig, + ConnectionId, + DatabaseConnectionConfig, + DatabaseType, + DynamoConnectionConfig, + FileConnectionConfig, + MongoConnectionConfig, + MountedShareConnectionConfig, + MssqlLinkedServerConnectionConfig, + S3ConnectionConfig, + SnowflakeConnectionConfig, + SnowflakeStageLocation, + SseConfig, + SseSelection, +) +from datamasque.client.models.data_selection import ( + HashColumnsTableConfig, + JsonPath, + Locator, + SelectedColumns, + SelectedData, + SelectedFileData, + UserSelection, +) +from datamasque.client.models.discovery import ( + ConstraintColumns, + DiscoveryMatch, + FileDiscoveryFile, + FileDiscoveryLocatorResult, + FileDiscoveryMatch, + FileDiscoveryResult, + FileRulesetGenerationRequest, + ForeignKeyRef, + InDataDiscoveryConfig, + InDataDiscoveryRule, + ReferencingForeignKey, + RulesetGenerationRequest, + SchemaDiscoveryColumn, + SchemaDiscoveryPage, + SchemaDiscoveryRequest, + SchemaDiscoveryResult, + TableConstraints, +) +from datamasque.client.models.dm_instance import DataMasqueInstanceConfig +from datamasque.client.models.files import ( + DataMasqueFile, + FileId, + OracleWalletFile, + SeedFile, + SnowflakeKeyFile, + SslZipFile, +) +from datamasque.client.models.ifm import ( + DataMasqueIfmInstanceConfig, + IfmLog, + IfmMaskRequest, + IfmMaskResult, + IfmRulesetPlanRef, + IfmTokenInfo, + RulesetPlan, + RulesetPlanCreateRequest, + RulesetPlanOptions, + RulesetPlanPartialUpdateRequest, + RulesetPlanUpdateRequest, +) +from datamasque.client.models.license import LicenseInfo, SwitchableLicenseMetadata +from datamasque.client.models.ruleset import Ruleset, RulesetId, RulesetType +from datamasque.client.models.ruleset_library import RulesetLibrary, RulesetLibraryId +from datamasque.client.models.runs import ( + MaskingRunOptions, + MaskingRunRequest, + MaskType, + RunConnectionRef, + RunId, + RunInfo, + UnfinishedRun, +) +from datamasque.client.models.status import AsyncRulesetGenerationTaskStatus, MaskingRunStatus, ValidationStatus +from datamasque.client.models.user import User, UserId, UserRole + +__version__ = version("datamasque-python") + +__all__ = [ + "AsyncRulesetGenerationInProgressError", + "AsyncRulesetGenerationTaskStatus", + "AzureConnectionConfig", + "ConnectionConfig", + "ConnectionId", + "ConstraintColumns", + "DataMasqueApiError", + "DataMasqueClient", + "DataMasqueException", + "DataMasqueFile", + "DataMasqueIfmClient", + "DataMasqueIfmError", + "DataMasqueIfmInstanceConfig", + "DataMasqueInstanceConfig", + "DataMasqueNotReadyError", + "DataMasqueTransportError", + "DataMasqueUserError", + "DatabaseConnectionConfig", + "DatabaseType", + "DiscoveryMatch", + "DynamoConnectionConfig", + "FailedToStartError", + "FileConnectionConfig", + "FileDiscoveryFile", + "FileDiscoveryLocatorResult", + "FileDiscoveryMatch", + "FileDiscoveryResult", + "FileId", + "FileOrContent", + "FileRulesetGenerationRequest", + "ForeignKeyRef", + "HashColumnsTableConfig", + "IfmAuthError", + "IfmLog", + "IfmMaskRequest", + "IfmMaskResult", + "IfmRulesetPlanRef", + "IfmTokenInfo", + "InDataDiscoveryConfig", + "InDataDiscoveryRule", + "InvalidLibraryError", + "InvalidRulesetError", + "JsonPath", + "LicenseInfo", + "Locator", + "MaskType", + "MaskingRunOptions", + "MaskingRunRequest", + "MaskingRunStatus", + "MongoConnectionConfig", + "MountedShareConnectionConfig", + "MssqlLinkedServerConnectionConfig", + "OracleWalletFile", + "ReferencingForeignKey", + "Ruleset", + "RulesetGenerationRequest", + "RulesetId", + "RulesetLibrary", + "RulesetLibraryId", + "RulesetPlan", + "RulesetPlanCreateRequest", + "RulesetPlanOptions", + "RulesetPlanPartialUpdateRequest", + "RulesetPlanUpdateRequest", + "RulesetType", + "RunConnectionRef", + "RunId", + "RunInfo", + "RunNotCancellableError", + "S3ConnectionConfig", + "SchemaDiscoveryColumn", + "SchemaDiscoveryPage", + "SchemaDiscoveryRequest", + "SchemaDiscoveryResult", + "SeedFile", + "SelectedColumns", + "SelectedData", + "SelectedFileData", + "SnowflakeConnectionConfig", + "SnowflakeKeyFile", + "SnowflakeStageLocation", + "SseConfig", + "SseSelection", + "SslZipFile", + "SwitchableLicenseMetadata", + "TableConstraints", + "UnfinishedRun", + "User", + "UserId", + "UserRole", + "UserSelection", + "ValidationStatus", +] diff --git a/datamasque/client/base.py b/datamasque/client/base.py new file mode 100644 index 0000000..ca2ca15 --- /dev/null +++ b/datamasque/client/base.py @@ -0,0 +1,304 @@ +import logging +import warnings +from contextlib import contextmanager +from dataclasses import dataclass +from io import BufferedIOBase, BytesIO, TextIOBase +from pathlib import Path +from typing import Any, Callable, Iterator, Optional, Type, TypeVar, Union +from urllib.parse import urljoin + +import requests +from pydantic import BaseModel +from requests import Response +from urllib3.exceptions import InsecureRequestWarning + +from datamasque.client.exceptions import ( + DataMasqueApiError, + DataMasqueNotReadyError, + DataMasqueTransportError, +) +from datamasque.client.models.dm_instance import DataMasqueInstanceConfig + +logger = logging.getLogger(__name__) + +FileOrContent = Union[str, bytes, TextIOBase, BufferedIOBase, Path] +_T = TypeVar("_T", bound=BaseModel) + +# Substrings (case-insensitive) that mark a key whose value should be redacted +# before logging on an error path, so that passwords, API tokens, and similar secrets don't +# end up in user-visible logs when a request fails. +# Applied to both outgoing request bodies and incoming response bodies (if JSON-parseable to a dict). +SENSITIVE_DATA_KEYS = ("password", "secret", "token", "key", "credential") + + +def _redact_sensitive(value: Any) -> Any: + """Return `value` with sensitive keys redacted, if it's a dict; otherwise unchanged.""" + + if isinstance(value, dict): + return { + k: "" if any(word in str(k).lower() for word in SENSITIVE_DATA_KEYS) else v + for k, v in value.items() + } + + return value + + +@contextmanager +def suppress_insecure_warning_if_needed(verify_ssl: bool) -> Iterator[None]: + """Scope-limited suppression of `InsecureRequestWarning` when TLS verification is disabled.""" + + if verify_ssl: + yield + return + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=InsecureRequestWarning) + yield + + +@dataclass +class UploadFile: + """Represents a file to upload in a multipart form request.""" + + field_name: str + filename: str + content: BufferedIOBase + content_type: Optional[str] = None + + +class BaseClient: + """ + Shared state and HTTP plumbing for every feature client mixin. + + Holds the connection config, cached auth token, and the core `make_request` dispatcher + used by all per-feature mixins that compose `DataMasqueClient`. + """ + + token: str = "" + base_url: str + username: str + password: Optional[str] + verify_ssl: bool + token_source: Optional[Callable[[], str]] + + def __init__(self, connection_config: DataMasqueInstanceConfig) -> None: + self.base_url = connection_config.base_url + self.username = connection_config.username + self.password = connection_config.password + self.verify_ssl = connection_config.verify_ssl + self.token_source = connection_config.token_source + + @contextmanager + def _maybe_suppress_insecure_warning(self) -> Iterator[None]: + # `urllib3.disable_warnings` is global, + # so instead we scope the suppression to this single call via `warnings.catch_warnings`. + # Clients that leave `verify_ssl=True` never touch the warning filter at all. + with suppress_insecure_warning_if_needed(self.verify_ssl): + yield + + def authenticate(self) -> None: + """ + Authenticate against the DataMasque server and cache the resulting token. + + Called implicitly by `make_request` on the first request and on a 401 response, + so you generally do not need to call this yourself. + + When the client was constructed with a `token_source` callable, + the callable is invoked instead of POSTing to the login endpoint. + """ + + if self.token_source is not None: + self.token = f"Token {self.token_source()}" + logger.debug("Login Success via token_source") + return + + login_url = urljoin(self.base_url, "/api/auth/token/login/") + response = self.make_request( + method="POST", + path=login_url, + data={"username": self.username, "password": self.password}, + requires_authorization=False, + require_status_check=False, + ) + + if response.status_code == 200: + self.token = f"Token {response.json()['key']}" + logger.debug("Login Success: %s", self.token) + else: + logger.error("Login Failure") + raise DataMasqueApiError( + "Unable to login to DataMasque Client, please ensure that login credentials are correct", + response=response, + ) + + def healthcheck(self) -> None: + """ + Pings the server's unauthenticated healthcheck endpoint. + + Returns without error when the server is up and ready to accept requests. + """ + + self.make_request("GET", "/api/healthcheck/", requires_authorization=False) + + def make_request( + self, + method: str, + path: str, + *, + data: Optional[dict] = None, + params: Optional[dict] = None, + files: Optional[list[UploadFile]] = None, + requires_authorization: bool = True, + require_status_check: bool = True, + ) -> Response: + """ + Sends an HTTP request to the DataMasque server and returns the `Response`. + + When `requires_authorization` is true (the default), + the current auth token is sent in the request headers, + and a 401 response triggers one re-auth-and-retry. + + Args: + method: HTTP method (e.g. `"GET"`, `"POST"`). + path: URL path such as `/api/license/`. + Must include a trailing slash. + data: Request body. + Serialised as JSON for normal requests, + and as multipart form data when `files` is also provided. + params: Query string parameters, + merged into the URL as `?key=value&...`. + files: Multipart form uploads; + when set, the request is sent as `multipart/form-data` and `data` is sent alongside as form fields. + requires_authorization: When true (the default), + the current auth token is attached and a 401 triggers one re-auth-and-retry. + require_status_check: When true (the default), + a non-2xx response raises one of the exceptions below; + when false, the `Response` is returned regardless of status so the caller can inspect it directly. + + Raises: + DataMasqueApiError: When `require_status_check` is true (the default) and the response is non-2xx. + The response object is available on the `.response` attribute of the exception. + DataMasqueNotReadyError: When `require_status_check` is true and the response is 502. + 502 typically indicates the server is still starting up. + DataMasqueTransportError: When the request fails before any response is received + (connection refused, timeout, DNS failure, SSL handshake failure, etc.). + """ + + url = urljoin(self.base_url, path) + + def send() -> Response: + headers: Optional[dict] = {"Authorization": self.token} if requires_authorization else None + try: + with self._maybe_suppress_insecure_warning(): + if files: + files_payload = {f.field_name: (f.filename, f.content, f.content_type or "") for f in files} + return requests.request( + method, + url, + data=data, + params=params, + headers=headers, + files=files_payload, + verify=self.verify_ssl, + ) + return requests.request( + method, url, json=data, params=params, headers=headers, verify=self.verify_ssl + ) + except requests.RequestException as e: + raise DataMasqueTransportError(f"Failed to reach DataMasque server at {url}: {e}") from e + + response = send() + if response.status_code == 401: + logger.debug("Re-authenticating") + self.authenticate() + # Reset file pointers so the retry doesn't send empty files + if files: + for f in files: + f.content.seek(0) + response = send() + + if require_status_check: + self._raise_for_status(response, request_data=data) + + return response + + def _raise_for_status(self, response: Response, *, request_data: Optional[dict] = None) -> None: + if response.ok: + return + + if response.status_code == 502: + # Bad Gateway error returned when DM is still initializing + raise DataMasqueNotReadyError + + # Redact sensitive keys from the response body before logging, + # in case the server echoes back caller-supplied credentials in an error payload. + try: + response_body: Any = response.json() + except ValueError: + response_body = response.text or response.content + logger.error("Error when calling API: %s", _redact_sensitive(response_body)) + if isinstance(request_data, dict): + logger.error("Request data was: %s", _redact_sensitive(request_data)) + + raise DataMasqueApiError( + f"API request to {response.request.url} failed with status {response.status_code}", + response=response, + ) + + def _delete_if_exists(self, path: str, *, params: Optional[dict] = None) -> None: + response = self.make_request("DELETE", path, params=params, require_status_check=False) + if response.status_code == 404: + return + + self._raise_for_status(response) + + def _iter_paginated( + self, + path: str, + model: Type[_T], + *, + params: Optional[dict] = None, + page_size: int = 100, + ) -> Iterator[_T]: + """ + Iterate every `T` across all pages of an admin-server list endpoint. + + Opts into pagination by sending `limit`/`offset` on the first request, + then follows the absolute `next` URL returned by the server. + """ + + first_params = dict(params or {}) + first_params.setdefault("limit", page_size) + first_params.setdefault("offset", 0) + + url: Optional[str] = path + current_params: Optional[dict] = first_params + + while url: + response = self.make_request("GET", url, params=current_params) + data = response.json() + yield from (model.model_validate(item) for item in data["results"]) + url = data.get("next") + # The `next` URL is absolute and already contains the pagination cursor; + # do not re-send our initial params alongside it. + current_params = None + + +def read_file_or_content(file_or_content: FileOrContent, fallback_file_name: str) -> tuple[str, BufferedIOBase]: + """ + Takes either a filename (str), file path (Path), or some file content. + + Where content is provided, the filename is given by `fallback_file_name`. + Returns a tuple of the filename and a BytesIO containing the file content. + """ + + if isinstance(file_or_content, (str, Path)): + file_name = Path(file_or_content).name + with open(file_or_content, "rb") as file: + return file_name, BytesIO(file.read()) + + if isinstance(file_or_content, bytes): + file_or_content = BytesIO(file_or_content) + elif isinstance(file_or_content, TextIOBase): + file_or_content = BytesIO(file_or_content.read().encode()) + + return fallback_file_name, file_or_content diff --git a/datamasque/client/connections.py b/datamasque/client/connections.py new file mode 100644 index 0000000..e41e46b --- /dev/null +++ b/datamasque/client/connections.py @@ -0,0 +1,64 @@ +import logging + +from datamasque.client.base import BaseClient +from datamasque.client.exceptions import DataMasqueException +from datamasque.client.models.connection import ConnectionConfig, ConnectionId, validate_connection + +logger = logging.getLogger(__name__) + + +class ConnectionClient(BaseClient): + """Connection-related API methods. Mixed into `DataMasqueClient`.""" + + def list_connections(self) -> list[ConnectionConfig]: + """ + Lists all configured connections. + + Note that database passwords and connection strings are returned encrypted over the API + and so are `None` on the returned `ConnectionConfig` objects. + """ + + response = self.make_request("GET", "/api/connections/") + return [validate_connection(payload) for payload in response.json()] + + def create_or_update_connection(self, connection_config: ConnectionConfig) -> ConnectionConfig: + """Creates or updates the connection in DM, and sets the `id` field on the given `connection_config`.""" + + connection_id = connection_config.id + + all_connections = self.list_connections() + connections_matching_name = [ + connection for connection in all_connections if connection.name == connection_config.name + ] + if connections_matching_name: + connection_id = connections_matching_name[0].id + + data = { + "version": "1.0", + } | connection_config.model_dump(exclude_none=True, by_alias=True, mode="json") + if connection_id is None: + response = self.make_request("POST", "/api/connections/", data=data) + else: + response = self.make_request("PUT", f"/api/connections/{connection_id}/", data=data) + + connection_data = response.json() + server_connection_id = ConnectionId(connection_data["id"]) + logger.debug("%s creation successful", type(connection_config).__name__) + connection_config.id = server_connection_id + return connection_config + + def delete_connection_by_id_if_exists(self, connection_id: ConnectionId) -> None: + """Deletes the connection with the given ID. No-op if the connection does not exist.""" + + self._delete_if_exists(f"/api/connections/{connection_id}/") + + def delete_connection_by_name_if_exists(self, connection_name: str) -> None: + """Deletes the connection with the given name. No-op if the connection does not exist.""" + + all_connections = self.list_connections() + connections_matching_name = [connection for connection in all_connections if connection.name == connection_name] + for connection in connections_matching_name: + if connection.id is None: + raise DataMasqueException(f'Server returned a connection named "{connection.name}" without an `id`.') + + self.delete_connection_by_id_if_exists(connection.id) diff --git a/datamasque/client/discovery.py b/datamasque/client/discovery.py new file mode 100644 index 0000000..19b89f0 --- /dev/null +++ b/datamasque/client/discovery.py @@ -0,0 +1,286 @@ +import logging +import zipfile +from io import BufferedIOBase, BytesIO, TextIOBase +from pathlib import Path +from typing import Iterator, Optional, Union + +from datamasque.client.base import BaseClient, UploadFile +from datamasque.client.exceptions import ( + AsyncRulesetGenerationInProgressError, + DataMasqueException, + FailedToStartError, +) +from datamasque.client.models.connection import ConnectionId +from datamasque.client.models.data_selection import ( + SelectedColumns, + SelectedData, + SelectedFileData, +) +from datamasque.client.models.discovery import ( + FileDiscoveryResult, + FileRulesetGenerationRequest, + RulesetGenerationRequest, + SchemaDiscoveryPage, + SchemaDiscoveryRequest, + SchemaDiscoveryResult, +) +from datamasque.client.models.ruleset import Ruleset +from datamasque.client.models.runs import RunId +from datamasque.client.models.status import AsyncRulesetGenerationTaskStatus + +logger = logging.getLogger(__name__) + + +class DiscoveryClient(BaseClient): + """Schema-discovery and ruleset-generation API methods. Mixed into `DataMasqueClient`.""" + + def start_async_ruleset_generation(self, connection_id: ConnectionId, selected_data: SelectedData) -> None: + """ + Starts async ruleset generation using the most recent discovery results on the given connection. + + If the connection is a database connection, `selected_data` should be of type `SelectedColumns`. + If the connection is a file connection, `selected_data` should be of type `SelectedFileData`. + + Generation runs asynchronously on the server. + Poll `get_async_ruleset_generation_task_status` until it returns + `AsyncRulesetGenerationTaskStatus.finished`, + then call `get_generated_rulesets` to retrieve the resulting `Ruleset`. + """ + + if not selected_data: + raise ValueError("`selected_data` is a required argument to `start_async_ruleset_generation`.") + + data: dict = {} + if isinstance(selected_data, SelectedColumns): + data["selected_columns"] = selected_data.columns + if selected_data.hash_columns is not None: + data["hash_columns"] = { + schema: {table: cfg.model_dump(exclude_none=True) for table, cfg in tables.items()} + for schema, tables in selected_data.hash_columns.items() + } + elif isinstance(selected_data, SelectedFileData): + for user_selection in selected_data.user_selections: + if not (user_selection.locators and user_selection.files): + raise ValueError( + "Each `UserSelection` in `SelectedFileData.user_selections` " + "must have a non-null list of `locators` and `files` to be selected for." + ) + data["selected_data"] = [s.model_dump() for s in selected_data.user_selections] + else: + raise TypeError( + f"The argument `selected_data` to `start_async_ruleset_generation` was of an invalid type, " + f"expected `SelectedColumns` or `SelectedFileData`, got {type(selected_data)}." + ) + + self.make_request(method="POST", path=f"/api/async-generate-ruleset/{connection_id}/", data=data) + + def start_async_ruleset_generation_from_csv( + self, + connection_id: ConnectionId, + csv_content: Union[str, bytes, TextIOBase, BufferedIOBase], + target_size_bytes: Optional[int] = None, + ) -> None: + """ + Generate ruleset(s) from the schema discovery CSV file obtained from `get_db_discovery_result_report()`. + + `target_size_bytes` is an optional integer specifying the approximate size in bytes of each generated ruleset. + + `csv_content` can be: + - A string (e.g. from `get_db_discovery_result_report()`) + - Bytes + - A text file handle (e.g. `open(path)`) + - A binary file handle (e.g. `open(path, 'rb')`) + + Generation runs asynchronously on the server. + Poll `get_async_ruleset_generation_task_status` until it returns + `AsyncRulesetGenerationTaskStatus.finished`, + then call `get_generated_rulesets` to retrieve the resulting `Ruleset` objects. + """ + + content: BufferedIOBase + if isinstance(csv_content, str): + content = BytesIO(csv_content.encode()) + elif isinstance(csv_content, bytes): + content = BytesIO(csv_content) + elif isinstance(csv_content, TextIOBase): + content = BytesIO(csv_content.read().encode()) + else: + content = csv_content + + files = [ + UploadFile( + field_name="csv_or_zip_file", + filename="ruleset.csv", + content=content, + content_type="text/csv", + ), + ] + self.make_request( + method="POST", + path=f"/api/async-generate-ruleset/{connection_id}/from-csv/", + data={"target_size_bytes": target_size_bytes} if target_size_bytes is not None else None, + files=files, + ) + + def get_async_ruleset_generation_task_status(self, connection_id: ConnectionId) -> AsyncRulesetGenerationTaskStatus: + """Queries the status of an async ruleset generation task.""" + + response = self.make_request(method="GET", path=f"/api/async-generate-ruleset/{connection_id}/") + response_data = response.json() + status = response_data.get("status") + if not status: + raise DataMasqueException("Attempted to get an async ruleset generation task status but none was given.") + + return AsyncRulesetGenerationTaskStatus(status) + + def get_generated_rulesets(self, connection_id: ConnectionId) -> list[Ruleset]: + """ + Return the `Ruleset` objects produced by a previously-started async ruleset generation. + + Use for all three async-RG flows: + + - Database masking from a schema-discovery CSV (`start_async_ruleset_generation_from_csv`) - + returns one or more rulesets + - Database masking from a column selection (`start_async_ruleset_generation` with `SelectedColumns`) - + returns a list containing one ruleset + - File masking from a file/locator selection (`start_async_ruleset_generation` with `SelectedFileData`) - + returns a list containing one ruleset + + Raises `AsyncRulesetGenerationInProgressError` if the task hasn't finished yet, + and `DataMasqueException` if it failed. + + Note that the ruleset(s) have autogenerated names, which you may want to customize before uploading. + """ + + status = self.get_async_ruleset_generation_task_status(connection_id) + if status is AsyncRulesetGenerationTaskStatus.failed: + logger.error("Ruleset generation failed for connection: %s", connection_id) + raise DataMasqueException(f"Ruleset generation failed for connection: {connection_id}") + + if status is not AsyncRulesetGenerationTaskStatus.finished: + logger.error( + "Ruleset generation is still in progress for connection: %s. Status: `%s`", + connection_id, + status.value, + ) + raise AsyncRulesetGenerationInProgressError( + f"Ruleset generation in progress or not ready. Current status: `{status.value}`." + ) + + # The download-rulesets endpoint returns a ZIP attachment for the CSV flow, + # or issues a 303 redirect back to the task-status endpoint for the column / file flows + # (which carries the generated ruleset inline as `generated_ruleset`). + # `requests` follows the 303 transparently, so we distinguish by the presence of + # a `Content-Disposition: attachment` header, which Django's `FileResponse` sets on the ZIP response. + response = self.make_request( + method="GET", + path=f"/api/async-generate-ruleset/{connection_id}/download-rulesets/", + ) + + if "attachment" in response.headers.get("Content-Disposition", "").lower(): + rulesets = [] + with zipfile.ZipFile(BytesIO(response.content)) as zip_file: + for file_info in zip_file.infolist(): + if file_info.filename.endswith((".yml", ".yaml")): + with zip_file.open(file_info) as file: + yaml_content = file.read().decode("utf-8") + rulesets.append(Ruleset(name=Path(file_info.filename).stem, yaml=yaml_content)) + return rulesets + + generated = response.json().get("generated_ruleset") + if not generated: + raise DataMasqueException( + f"Ruleset generation for connection {connection_id} reported `finished` " + f"but no ruleset was returned on the task-status record." + ) + + return [Ruleset(name="generated_ruleset", yaml=generated)] + + def start_schema_discovery_run(self, discovery_config: SchemaDiscoveryRequest) -> RunId: + """ + Starts a schema discovery run with the given configuration. + + Args: + discovery_config: A `SchemaDiscoveryRequest` with connection ID and optional settings. + + Returns: + RunId: The ID of the started discovery run + + Raises: + FailedToStartError: If run fails to start + """ + + data = discovery_config.model_dump(exclude_none=True, mode="json") + response = self.make_request( + "POST", + "/api/schema-discovery/", + data=data, + require_status_check=False, + ) + run_data = response.json() + + if response.status_code == 201: + logger.info("Schema discovery run %s started successfully", run_data["id"]) + return RunId(run_data["id"]) + + logger.error("Schema discovery run failed to start: %s", run_data) + raise FailedToStartError( + f"Schema discovery run failed to start " + f"(server responded with status {response.status_code}: {response.text}).", + response=response, + ) + + def iter_schema_discovery_results(self, run_id: RunId) -> Iterator[SchemaDiscoveryResult]: + """Lazily iterate all schema discovery results for a run via the paginated v2 endpoint.""" + + return self._iter_paginated( + f"/api/schema-discovery/v2/{run_id}/", + model=SchemaDiscoveryResult, + ) + + def list_schema_discovery_results(self, run_id: RunId) -> list[SchemaDiscoveryResult]: + """Returns all schema discovery results for a run.""" + + return list(self.iter_schema_discovery_results(run_id)) + + def get_schema_discovery_page(self, run_id: RunId, *, limit: int = 50, offset: int = 0) -> SchemaDiscoveryPage: + """ + Returns a single page of schema discovery results including `table_metadata`. + + Use this when you need the table-constraint metadata alongside the results. + """ + + response = self.make_request( + "GET", + f"/api/schema-discovery/v2/{run_id}/", + params={"limit": limit, "offset": offset}, + ) + return SchemaDiscoveryPage.model_validate(response.json()) + + def generate_ruleset(self, generation_request: RulesetGenerationRequest) -> str: + """ + Generates database-masking ruleset YAML from the most recent discovery run on the given connection. + + `generation_request` is a `RulesetGenerationRequest`. + """ + + data = generation_request.model_dump(exclude_none=True, mode="json") + response = self.make_request("POST", "/api/generate-ruleset/v2/", data=data) + return response.content.decode("utf-8") + + def generate_file_ruleset(self, generation_request: FileRulesetGenerationRequest) -> str: + """ + Generates file-masking ruleset YAML from the most recent file-data-discovery run on the given connection. + + `generation_request` is a `FileRulesetGenerationRequest`. + """ + + data = generation_request.model_dump(exclude_none=True, mode="json") + response = self.make_request("POST", "/api/generate-file-ruleset/", data=data) + return response.content.decode("utf-8") + + def get_file_data_discovery_report(self, run_id: RunId) -> list[FileDiscoveryResult]: + """Returns the file-data-discovery results for the specified run.""" + + response = self.make_request("GET", f"api/runs/{run_id}/file-discovery-results/") + return [FileDiscoveryResult.model_validate(d) for d in response.json()] diff --git a/datamasque/client/dmclient.py b/datamasque/client/dmclient.py new file mode 100644 index 0000000..55cc6f3 --- /dev/null +++ b/datamasque/client/dmclient.py @@ -0,0 +1,49 @@ +from datamasque.client.base import FileOrContent, UploadFile +from datamasque.client.connections import ConnectionClient +from datamasque.client.discovery import DiscoveryClient +from datamasque.client.files import FileClient +from datamasque.client.license import LicenseClient +from datamasque.client.ruleset_libraries import RulesetLibraryClient +from datamasque.client.rulesets import RulesetClient +from datamasque.client.runs import RunClient +from datamasque.client.settings import SettingsClient +from datamasque.client.users import UserClient + +__all__ = ["DataMasqueClient", "FileOrContent", "UploadFile"] + + +class DataMasqueClient( + LicenseClient, + ConnectionClient, + RulesetClient, + RulesetLibraryClient, + FileClient, + RunClient, + DiscoveryClient, + UserClient, + SettingsClient, +): + """ + Client for a DataMasque server instance. + + Example usage: + + .. code-block:: python + + from datamasque.client import DataMasqueClient + from datamasque.client.models.dm_instance import DataMasqueInstanceConfig + + config = DataMasqueInstanceConfig( + base_url="https://datamasque.example.com", + username="api_user", + password="api_password", + ) + client = DataMasqueClient(config) + client.authenticate() + + for connection in client.list_connections(): + print(connection.name) + + Authentication is performed on the first request if `authenticate()` is not called explicitly, + and is automatically retried once on a 401 response. + """ diff --git a/datamasque/client/exceptions.py b/datamasque/client/exceptions.py new file mode 100644 index 0000000..05fe339 --- /dev/null +++ b/datamasque/client/exceptions.py @@ -0,0 +1,75 @@ +from requests import Response + + +class DataMasqueException(Exception): + """Generic exception base class.""" + + +class DataMasqueUserError(DataMasqueException): + """Raised when error occurs during user creation or configuration.""" + + +class DataMasqueApiError(DataMasqueException): + """ + Raised when the DataMasque server responds to a request with a non-2xx status code. + + The triggering `Response` is always available on the `.response` attribute, + so callers can inspect the status code, headers, and body for richer error handling. + + 502 Bad Gateway responses are raised as `DataMasqueNotReadyError` instead. + """ + + def __init__(self, message: str, *, response: Response) -> None: + super().__init__(message) + self.response = response + + +class FailedToStartError(DataMasqueApiError): + """ + Raised when `start_masking_run` fails to create the run. + + Inherits `.response` from `DataMasqueApiError`, + so callers can read the server's status code and error body directly. + """ + + +class InvalidRulesetError(FailedToStartError): + """Specific error for when runs fail to start due to having an invalid ruleset.""" + + +class InvalidLibraryError(FailedToStartError): + """Specific error for when runs fail to start due to having an invalid ruleset library.""" + + +class DataMasqueTransportError(DataMasqueException): + """ + Raised when a request to the DataMasque server fails before any response is received. + + Covers connection refused, timeout, DNS failure, SSL handshake failure, + and similar transport-layer errors. + The originating `requests` exception is chained via `__cause__`. + """ + + +class DataMasqueNotReadyError(DataMasqueException): + """Raised when the DataMasque server is not healthy, normally because it is still starting up.""" + + +class AsyncRulesetGenerationInProgressError(DataMasqueException): + """Raised when attempting to retrieve results from a ruleset generation request that has not yet completed.""" + + +class DataMasqueIfmError(DataMasqueException): + """Generic base exception for IFM (in-flight masking) client errors.""" + + +class IfmAuthError(DataMasqueIfmError): + """Raised when the IFM client cannot obtain or refresh a JWT (e.g. invalid credentials, missing scope).""" + + +class RunNotCancellableError(DataMasqueUserError): + """ + Raised when `cancel_run` is called against a run that is no longer eligible for cancellation. + + Typically this happens when the run is already finished, failed, or in the cancelling state itself. + """ diff --git a/datamasque/client/files.py b/datamasque/client/files.py new file mode 100644 index 0000000..2f476e6 --- /dev/null +++ b/datamasque/client/files.py @@ -0,0 +1,92 @@ +from pathlib import Path +from typing import Optional, Type, TypeVar, Union + +from datamasque.client.base import BaseClient, UploadFile, read_file_or_content +from datamasque.client.models.files import DataMasqueFile + +FileTypeT = TypeVar("FileTypeT", bound=DataMasqueFile) + + +class FileClient(BaseClient): + """File-upload API methods. Mixed into `DataMasqueClient`.""" + + def upload_file( + self, + file_type: Type[FileTypeT], + file_name: str, + file_path_or_content: Union[str, bytes, Path], + ) -> FileTypeT: + """ + Uploads a file of the given type to the DataMasque server. + + `file_type` must be a concrete subclass of `DataMasqueFile` + (`SeedFile`, `OracleWalletFile`, `SslZipFile`, `SnowflakeKeyFile`). + `file_path_or_content` may be a path (as `str` or `Path`), raw `bytes`, or a file-like object. + """ + + name, content = read_file_or_content(file_path_or_content, file_name) + content.seek(0) + + response = self.make_request( + "POST", + file_type.get_url(), + data={"name": file_name}, + files=[ + UploadFile( + field_name=file_type.get_content_param_name(), + filename=name, + content=content, + content_type="application/octet-stream", + ), + ], + ) + return file_type.model_validate(response.json()) + + def delete_file_if_exists(self, file: DataMasqueFile) -> None: + """ + Deletes a file. No-op if the file does not exist. + + `file` must be an instance of a concrete subclass of `DataMasqueFile`. + The `file` must have its ID set. + """ + + if file.id is None: + raise ValueError("File has not yet been created") + + # file.get_url() ends with a slash so no need to insert one before the id + self._delete_if_exists(f"{file.get_url()}{file.id}/") + + def list_files_of_type(self, file_type: Type[FileTypeT]) -> list[FileTypeT]: + """Returns all files of the given type (a concrete subclass of `DataMasqueFile`).""" + + response = self.make_request("GET", file_type.get_url()) + return [file_type.model_validate(file) for file in response.json()] + + def get_file_of_type_by_name(self, file_type: Type[FileTypeT], name: str) -> Optional[FileTypeT]: + """ + Looks for a file of the given type (a concrete subclass of `DataMasqueFile`) with the given `name`. + + Returns it if found, otherwise `None`. + """ + + matching_files = [f for f in self.list_files_of_type(file_type) if f.name == name] + return matching_files[0] if matching_files else None + + def upload_file_if_not_exists(self, file_type: Type[FileTypeT], file_path: Union[str, Path]) -> Optional[FileTypeT]: + """ + Upload a file only if one with the same name doesn't already exist. + + Args: + file_type: A concrete subclass of `DataMasqueFile` (e.g., SeedFile, OracleWalletFile). + file_path: Path to the file to upload. + + Returns: + The uploaded file object if a new file was uploaded, or None if a file + with the same name already exists. + """ + + file_path = Path(file_path) + if self.get_file_of_type_by_name(file_type, file_path.name) is not None: + return None + + return self.upload_file(file_type, file_path.name, file_path) diff --git a/datamasque/client/ifm.py b/datamasque/client/ifm.py new file mode 100644 index 0000000..374d46e --- /dev/null +++ b/datamasque/client/ifm.py @@ -0,0 +1,301 @@ +""" +Client for the DataMasque IFM (in-flight masking) HTTP API. + +`DataMasqueIfmClient` mirrors the public IFM endpoints in a typed Python interface. +Authentication is JWT-based: +the access token is obtained from the admin server's `/api/auth/jwt/login/` endpoint +and refreshed via `/api/auth/jwt/refresh/` on a 401. +Users may also supply a `token_source` callable in the connection config to bypass admin-server login entirely. +""" + +import logging +from contextlib import contextmanager +from typing import Callable, Iterator, Optional, Type, TypeVar, Union +from urllib.parse import urljoin + +import requests +from pydantic import BaseModel +from requests import Response + +from datamasque.client.base import suppress_insecure_warning_if_needed +from datamasque.client.exceptions import ( + DataMasqueApiError, + DataMasqueNotReadyError, + DataMasqueTransportError, + IfmAuthError, +) +from datamasque.client.models.ifm import ( + DataMasqueIfmInstanceConfig, + IfmMaskRequest, + IfmMaskResult, + IfmTokenInfo, + RulesetPlan, + RulesetPlanCreateRequest, + RulesetPlanPartialUpdateRequest, + RulesetPlanUpdateRequest, +) +from datamasque.client.models.pagination import IfmPage + +logger = logging.getLogger(__name__) + +_IfmT = TypeVar("_IfmT", bound=BaseModel) + + +class DataMasqueIfmClient: + """ + Client for a DataMasque IFM service. + + Example usage: + + .. code-block:: python + + from datamasque.client import DataMasqueIfmClient, DataMasqueIfmInstanceConfig + + config = DataMasqueIfmInstanceConfig( + admin_server_base_url="https://datamasque.example.com", + ifm_base_url="https://datamasque.example.com/ifm", + username="ifm_user", + password="ifm_password", + ) + client = DataMasqueIfmClient(config) + + for plan in client.list_ruleset_plans(): + print(plan.name) + + Authentication happens transparently on the first request, + with automatic token refresh on expiry. + """ + + access_token: str = "" + refresh_token: str = "" + admin_server_base_url: str + ifm_base_url: str + username: str + password: Optional[str] + verify_ssl: bool + token_source: Optional[Callable[[], str]] + + def __init__(self, connection_config: DataMasqueIfmInstanceConfig) -> None: + self.admin_server_base_url = connection_config.admin_server_base_url + self.ifm_base_url = connection_config.ifm_base_url + self.username = connection_config.username + self.password = connection_config.password + self.verify_ssl = connection_config.verify_ssl + self.token_source = connection_config.token_source + + def authenticate(self) -> None: + """Obtain an access (and refresh) token from the admin server, or via `token_source`.""" + + if self.token_source is not None: + self.access_token = self.token_source() + self.refresh_token = "" + logger.debug("IFM login success via token_source") + return + + login_url = urljoin(self.admin_server_base_url, "/api/auth/jwt/login/") + try: + with self._maybe_suppress_insecure_warning(): + response = requests.post( + login_url, + json={"username": self.username, "password": self.password}, + verify=self.verify_ssl, + ) + except requests.RequestException as e: + raise DataMasqueTransportError(f"Failed to reach admin server at {login_url}: {e}") from e + + if response.status_code != 200: + logger.error("IFM JWT login failed: status %s", response.status_code) + raise IfmAuthError(f"Unable to obtain IFM JWT from admin server (status {response.status_code}).") + + body = response.json() + self.access_token = body["access_token"] + self.refresh_token = body.get("refresh_token", "") + logger.debug("IFM JWT login success") + + def _refresh_or_reauth(self) -> None: + """Refresh the access token using the cached refresh token, or fall back to a full re-login.""" + + if self.token_source is not None or not self.refresh_token: + self.authenticate() + return + + refresh_url = urljoin(self.admin_server_base_url, "/api/auth/jwt/refresh/") + try: + with self._maybe_suppress_insecure_warning(): + response = requests.post( + refresh_url, + json={"refresh": self.refresh_token}, + verify=self.verify_ssl, + ) + except requests.RequestException as e: + raise DataMasqueTransportError(f"Failed to reach admin server at {refresh_url}: {e}") from e + + if response.status_code == 200: + self.access_token = response.json()["access_token"] + logger.debug("IFM JWT refresh success") + return + + # Refresh failed (probably expired) — fall back to a full login. + logger.debug("IFM JWT refresh failed (status %s); re-authenticating", response.status_code) + self.authenticate() + + @contextmanager + def _maybe_suppress_insecure_warning(self) -> Iterator[None]: + with suppress_insecure_warning_if_needed(self.verify_ssl): + yield + + def _iter_ifm_paginated( + self, + path: str, + model: Type[_IfmT], + *, + page_size: int = 100, + ) -> Iterator[_IfmT]: + """Iterate every `T` across all pages of an IFM list endpoint.""" + + offset = 0 + while True: + response = self._make_request("GET", path, params={"limit": page_size, "offset": offset}) + page = IfmPage[model].model_validate(response.json()) # type: ignore[valid-type] + yield from page.items + offset += len(page.items) + if not page.items or offset >= page.total: + return + + def _make_request( + self, + method: str, + path: str, + *, + json_body: Optional[Union[dict, list]] = None, + params: Optional[dict] = None, + require_status_check: bool = True, + ) -> Response: + """ + Send an authenticated HTTP request to the IFM service. + + Adds `Authorization: Bearer `, + triggers a refresh-and-retry on a 401, + and raises `DataMasqueApiError` on a non-2xx final response when `require_status_check` is true. + """ + + if not self.access_token: + self.authenticate() + + url = urljoin(self.ifm_base_url.rstrip("/") + "/", path.lstrip("/")) + + def send() -> Response: + try: + with self._maybe_suppress_insecure_warning(): + return requests.request( + method, + url, + json=json_body, + params=params, + headers={"Authorization": f"Bearer {self.access_token}"}, + verify=self.verify_ssl, + ) + except requests.RequestException as e: + raise DataMasqueTransportError(f"Failed to reach IFM server at {url}: {e}") from e + + response = send() + if response.status_code == 401: + logger.debug("IFM 401 — refreshing token and retrying") + self._refresh_or_reauth() + response = send() + + if require_status_check and not response.ok: + if response.status_code == 502: + raise DataMasqueNotReadyError + + raise DataMasqueApiError( + f"IFM API request to {response.request.url} failed with status {response.status_code}", + response=response, + ) + + return response + + def verify_token(self) -> IfmTokenInfo: + """`GET /verify-token/` — returns the list of scopes granted to the current JWT.""" + + return IfmTokenInfo.model_validate(self._make_request("GET", "verify-token/").json()) + + def iter_ruleset_plans(self) -> Iterator[RulesetPlan]: + """Lazily iterate all ruleset plans via the paginated IFM endpoint.""" + + return self._iter_ifm_paginated("ruleset-plans/", model=RulesetPlan) + + def list_ruleset_plans(self) -> list[RulesetPlan]: + """`GET /ruleset-plans/` — list every ruleset plan visible to the current JWT.""" + + return list(self.iter_ruleset_plans()) + + def get_ruleset_plan(self, plan_name: str) -> RulesetPlan: + """`GET /ruleset-plans/{plan_name}/` — fetch one plan including its ruleset YAML.""" + + return RulesetPlan.model_validate(self._make_request("GET", f"ruleset-plans/{plan_name}/").json()) + + def create_ruleset_plan(self, plan: RulesetPlanCreateRequest) -> RulesetPlan: + """`POST /ruleset-plans/` — create a new plan; returns the persisted view including its URL.""" + + data = plan.model_dump(exclude_none=True, mode="json") + return RulesetPlan.model_validate(self._make_request("POST", "ruleset-plans/", json_body=data).json()) + + def update_ruleset_plan(self, plan_name: str, plan: RulesetPlanUpdateRequest) -> RulesetPlan: + """`PUT /ruleset-plans/{plan_name}/` — full replace of an existing plan.""" + + data = plan.model_dump(exclude_none=True, mode="json") + return RulesetPlan.model_validate( + self._make_request("PUT", f"ruleset-plans/{plan_name}/", json_body=data).json() + ) + + def patch_ruleset_plan(self, plan_name: str, plan: RulesetPlanPartialUpdateRequest) -> RulesetPlan: + """`PATCH /ruleset-plans/{plan_name}/` — partial update; only fields set on `plan` are sent.""" + + data = plan.model_dump(exclude_none=True, mode="json") + return RulesetPlan.model_validate( + self._make_request("PATCH", f"ruleset-plans/{plan_name}/", json_body=data).json() + ) + + def delete_ruleset_plan(self, plan_name: str) -> None: + """`DELETE /ruleset-plans/{plan_name}/` — no-op on the client side; raises on non-2xx server response.""" + + self._make_request("DELETE", f"ruleset-plans/{plan_name}/") + + def mask(self, plan_name: str, request: IfmMaskRequest) -> IfmMaskResult: + """ + `POST /ruleset-plans/{plan_name}/mask/` — execute the named ruleset plan against `request.data`. + + Returns an `IfmMaskResult` with `success=True` when the server returns 2xx + (`data` carries the masked records), + or `success=False` when the server returns a soft failure + (HTTP 400 with the full mask-result shape — `data` omitted, `logs` populated). + Network, auth, and other hard errors still raise + `DataMasqueApiError` / `IfmAuthError` / `DataMasqueNotReadyError`. + """ + + data = request.model_dump(exclude_none=True, mode="json") + response = self._make_request( + "POST", + f"ruleset-plans/{plan_name}/mask/", + json_body=data, + require_status_check=False, + ) + body = response.json() if response.content else {} + + if response.ok: + return IfmMaskResult.model_validate(body | {"success": True}) + + # The server returns soft failures as HTTP 400 with the full IfmMaskResult body + # (`ruleset_plan` populated, `data` omitted, `logs` carries the detail). + # Any other 4xx/5xx is a hard error and still raises. + if response.status_code == 400 and isinstance(body, dict) and "ruleset_plan" in body: + return IfmMaskResult.model_validate(body | {"success": False}) + + if response.status_code == 502: + raise DataMasqueNotReadyError + + raise DataMasqueApiError( + f"IFM API request to {response.request.url} failed with status {response.status_code}", + response=response, + ) diff --git a/datamasque/client/license.py b/datamasque/client/license.py new file mode 100644 index 0000000..46b7de6 --- /dev/null +++ b/datamasque/client/license.py @@ -0,0 +1,41 @@ +import logging + +from datamasque.client.base import BaseClient, FileOrContent, UploadFile, read_file_or_content +from datamasque.client.models.license import LicenseInfo + +logger = logging.getLogger(__name__) + + +class LicenseClient(BaseClient): + """License management API methods. Mixed into `DataMasqueClient`.""" + + def upload_license_file(self, license_file: FileOrContent) -> None: + """ + Uploads a DataMasque license. + + Specify the path to a license (.dmlicense) filename, + or pass a `StringIO` or `BytesIO` containing the license content. + """ + + license_file_name, content = read_file_or_content(license_file, "license.lic") + content.seek(0) + + self.make_request( + method="POST", + path="/api/license-upload/", + files=[ + UploadFile( + field_name="license_file", + filename=license_file_name, + content=content, + content_type="application/octet-stream", + ), + ], + ) + logger.info("License upload successful.") + + def get_current_license_info(self) -> LicenseInfo: + """Returns information about the license currently installed on the server.""" + + response = self.make_request("GET", "/api/license/") + return LicenseInfo.model_validate(response.json()) diff --git a/datamasque/client/models/__init__.py b/datamasque/client/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/datamasque/client/models/connection.py b/datamasque/client/models/connection.py new file mode 100644 index 0000000..45f4780 --- /dev/null +++ b/datamasque/client/models/connection.py @@ -0,0 +1,429 @@ +"""Connection configuration models for the DataMasque API.""" + +from enum import Enum +from typing import Any, Callable, Literal, NewType, Optional + +from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator + +from datamasque.client.exceptions import DataMasqueException +from datamasque.client.models.files import FileId + +ConnectionId = NewType("ConnectionId", str) + + +def unwrap_connection_id(value: Any) -> Any: + """ + Coerce a `ConnectionConfig` to its `id`; pass other values through unchanged. + + Used by request-model validators that accept either a `ConnectionId` + or a full `ConnectionConfig` for user convenience. + Raises `ValueError` if the config has no `id` + (i.e. the caller hasn't yet created it on the server). + """ + + if isinstance(value, ConnectionConfig): + if value.id is None: + raise ValueError("Connection has not been created yet (id is None)") + return value.id + + return value + + +class DatabaseType(Enum): + """Supported database engines for `DatabaseConnectionConfig`.""" + + postgres = "postgres" + mysql = "mysql" + oracle = "oracle" + mariadb = "mariadb" + sql_server = "mssql" + redshift = "redshift" + dynamodb = "dynamo_db" + db2_luw = "db2_luw" + db2i = "db2i" + mssql_linked = "mssql_linked" + snowflake = "snowflake" + mongodb = "mongodb" + + +class SnowflakeStageLocation(str, Enum): + """Storage backend for a Snowflake connection's external stage.""" + + local = "local" # Not supported for production use + aws_s3 = "aws_s3" + azure_blob_storage = "azure_blob_storage" + + +class SseSelection(Enum): + """Mirrors the available options in the AWS console for DynamoDB Server-Side Encryption.""" + + dynamodb_owned = "dynamodb_owned" + aws_managed = "aws_managed" + account_managed = "account_managed" + use_source = "use_source" + + +class SseConfig(BaseModel): + """ + Server-side encryption configuration for a DynamoDB connection. + + `kms_key_id` is required when `selection` is `SseSelection.account_managed` + and must be `None` for every other selection. + """ + + model_config = ConfigDict(extra="forbid") + + selection: SseSelection + kms_key_id: Optional[str] = None # Required when `selection` is `account_managed`; must be None otherwise + + @model_validator(mode="after") + def _validate_kms_key(self) -> "SseConfig": + if self.selection is SseSelection.account_managed: + if self.kms_key_id is None: + raise ValueError( + "A KMS key ID must be specified when the SSE key is stored in your account, and owned " + "and managed by you." + ) + elif self.kms_key_id is not None: + raise ValueError( + "A KMS key ID can only be specified when the SSE key is stored in your account, and " + "owned and managed by you." + ) + return self + + +class ConnectionConfig(BaseModel): + """ + Base class for all connection configurations. + + Use `validate_connection(payload)` to deserialize an API response + into the appropriate concrete subclass. + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + name: str + id: Optional[ConnectionId] = None + + +class DynamoConnectionConfig(ConnectionConfig): + """Connection configuration for a DynamoDB table.""" + + s3_bucket_name: Optional[str] = None + dynamo_append_datetime: bool = False + dynamo_append_suffix: str = "-MASKED" + dynamo_replace_tables: bool = True + dynamo_default_region: Optional[str] = None + dynamo_default_sse: SseConfig = SseConfig(selection=SseSelection.dynamodb_owned, kms_key_id=None) + iam_role_arn: Optional[str] = None + export_s3_prefix: Optional[str] = None + + mask_type: Literal["database"] = "database" + db_type: Literal["dynamo_db"] = "dynamo_db" + + @property + def database_type(self) -> DatabaseType: + return DatabaseType.dynamodb + + @model_serializer(mode="wrap") + def _serialize(self, handler: Callable) -> dict: + d = handler(self) + # The admin server requires these placeholder fields for Dynamo connections. + d.setdefault("host", "") + d.setdefault("port", None) + d.setdefault("user", "") + d.setdefault("password", "") + d.setdefault("database", "") + d.setdefault("schema", "") + return d + + @model_validator(mode="before") + @classmethod + def _strip_server_only_fields(cls, data: dict) -> dict: + """Drop fields that come back from the server but aren't part of this model.""" + if isinstance(data, dict): + for key in ("password_encrypted", "dbpassword"): + data.pop(key, None) + return data + + +class MongoConnectionConfig(ConnectionConfig): + """Connection configuration for a MongoDB instance.""" + + host: str = "" + port: int = 27017 + database: str = "" + user: str = "" + password: Optional[str] = None + auth_source: str = "admin" + tls: bool = False + direct_connection: bool = False + replica_set: str = "" + is_read_only: bool = False + + mask_type: Literal["database"] = "database" + db_type: Literal["mongodb"] = "mongodb" + + @property + def database_type(self) -> DatabaseType: + return DatabaseType.mongodb + + @model_serializer(mode="wrap") + def _serialize(self, handler: Callable) -> dict: + d = handler(self) + # The server expects the password under the `dbpassword` key. + password = d.pop("password", None) + if password: + d["dbpassword"] = password + if not d.get("tls"): + d.pop("tls", None) + if not d.get("direct_connection"): + d.pop("direct_connection", None) + if not d.get("replica_set"): + d.pop("replica_set", None) + if not d.get("user"): + d.pop("user", None) + return d + + @model_validator(mode="before") + @classmethod + def _strip_encrypted_password(cls, data: dict) -> dict: + if isinstance(data, dict): + for key in ("password_encrypted", "dbpassword"): + data.pop(key, None) + return data + + +class SnowflakeConnectionConfig(ConnectionConfig): + """ + Connection configuration for a Snowflake database. + + Supports password authentication (`password`) + and key-pair authentication (`snowflake_private_key` + optional `snowflake_private_key_passphrase`). + """ + + database: str + user: str + snowflake_account_id: str + snowflake_warehouse: str + snowflake_storage_integration_name: str + host: str = "" + port: Optional[int] = None + db_schema: Optional[str] = Field(default=None, alias="schema") + snowflake_role: str = "" + is_read_only: bool = False + password: Optional[str] = None + snowflake_private_key: Optional[FileId] = None + snowflake_private_key_passphrase: Optional[str] = None + snowflake_stage_location: Optional[SnowflakeStageLocation] = None + s3_bucket_name: Optional[str] = None + iam_role_arn: Optional[str] = None + snowflake_azure_container_name: Optional[str] = None + snowflake_azure_connection_string: Optional[str] = None + snowflake_azure_connection_string_encrypted: Optional[str] = None + + mask_type: Literal["database"] = "database" + db_type: Literal["snowflake"] = "snowflake" + + @property + def database_type(self) -> DatabaseType: + return DatabaseType.snowflake + + @model_serializer(mode="wrap") + def _serialize(self, handler: Callable) -> dict: + d = handler(self) + # The server expects the password under the `dbpassword` key. + password = d.pop("password", None) + if password is not None: + d["dbpassword"] = password + # Snowflake requires `schema` even when the user hasn't set one. + if d.get("schema") is None: + d["schema"] = "" + return d + + @model_validator(mode="before") + @classmethod + def _strip_encrypted_password(cls, data: dict) -> dict: + if isinstance(data, dict): + for key in ("password_encrypted", "dbpassword"): + data.pop(key, None) + return data + + +class DatabaseConnectionConfig(ConnectionConfig): + """ + Connection configuration for a SQL database. + + Use `DynamoConnectionConfig` for DynamoDB, `SnowflakeConnectionConfig` for Snowflake, + and `MongoConnectionConfig` for MongoDB. + """ + + host: str + port: int + database: str + user: str + password: Optional[str] = None + database_type: DatabaseType + engine_options: Optional[dict] = None + db_schema: Optional[str] = Field(default=None, alias="schema") + data_encoding: Optional[str] = None + is_read_only: bool = False + s3_bucket_name: Optional[str] = None + s3_redshift_iam_role: Optional[str] = None + + @model_validator(mode="after") + def _reject_special_engines(self) -> "DatabaseConnectionConfig": + if self.database_type is DatabaseType.dynamodb: + raise ValueError("For DynamoDB, use the DynamoConnectionConfig class instead") + if self.database_type is DatabaseType.snowflake: + raise ValueError("For Snowflake, use the SnowflakeConnectionConfig class instead") + if self.database_type is DatabaseType.mongodb: + raise ValueError("For MongoDB, use the MongoConnectionConfig class instead") + return self + + mask_type: Literal["database"] = "database" + + @property + def db_type(self) -> str: + return self.database_type.value + + @model_serializer(mode="wrap") + def _serialize(self, handler: Callable) -> dict: + d = handler(self) + # The server expects the password under the `dbpassword` key. + password = d.pop("password", None) + if password is not None: + d["dbpassword"] = password + d.pop("database_type", None) + d["db_type"] = self.db_type + + # The server requires certain fields to be present or absent + # depending on the engine type. + db_type = self.database_type + if db_type in {DatabaseType.mysql, DatabaseType.mariadb} or d.get("schema") is None: + d["schema"] = "" + if db_type not in {DatabaseType.mysql, DatabaseType.mariadb, DatabaseType.oracle, DatabaseType.postgres}: + d.pop("data_encoding", None) + if db_type is not DatabaseType.redshift: + d.pop("s3_bucket_name", None) + d.pop("s3_redshift_iam_role", None) + if not d.get("engine_options"): + d.pop("engine_options", None) + return d + + @model_validator(mode="before") + @classmethod + def _normalize_incoming(cls, data: dict) -> dict: + if isinstance(data, dict): + for key in ("password_encrypted", "dbpassword"): + data.pop(key, None) + + # Determine the engine type from whichever key is present. + engine = data.get("database_type") or data.get("db_type", "") + if isinstance(engine, DatabaseType): + engine = engine.value + + # The API returns a `schema` value for engines that don't have schemas (MySQL/MariaDB). + # Drop it so the model accurately reflects "not applicable". + if engine in {DatabaseType.mysql.value, DatabaseType.mariadb.value}: + data.pop("schema", None) + + # Map `db_type` → `database_type` for incoming payloads. + if "db_type" in data and "database_type" not in data: + data["database_type"] = data.pop("db_type") + return data + + +class MssqlLinkedServerConnectionConfig(DatabaseConnectionConfig): + """Connection configuration for a Microsoft SQL Server linked-server setup.""" + + linked_server: str = "" + + +class FileConnectionConfig(ConnectionConfig): + """ + Abstract base for file-based connections. + + `is_file_mask_source` and `is_file_mask_destination` + control whether the connection can be used as the source, destination, or both of a masking run. + """ + + base_directory: str = "" + is_file_mask_source: bool = False + is_file_mask_destination: bool = False + + mask_type: Literal["file"] = "file" + + +class S3ConnectionConfig(FileConnectionConfig): + """Connection configuration for an S3 bucket.""" + + type: Literal["s3_connection"] = "s3_connection" + bucket: str = "" + iam_role_arn: Optional[str] = None + + +class AzureConnectionConfig(FileConnectionConfig): + """ + Connection configuration for an Azure Blob Storage container. + + `connection_string` comes back encrypted from `list_connections` + and is write-only in practice. + """ + + type: Literal["azure_blob_connection"] = "azure_blob_connection" + container: str = "" + connection_string: Optional[str] = None + + @model_validator(mode="before") + @classmethod + def _strip_encrypted_connection_string(cls, data: dict) -> dict: + if isinstance(data, dict): + # The API returns the encrypted form; drop it so `connection_string` stays None. + data.pop("connection_string_encrypted", None) + return data + + +class MountedShareConnectionConfig(FileConnectionConfig): + """Connection configuration for a mounted file share.""" + + type: Literal["mounted_share_connection"] = "mounted_share_connection" + + +FILE_TYPE_MAP: dict[str, type[FileConnectionConfig]] = { + "s3_connection": S3ConnectionConfig, + "azure_blob_connection": AzureConnectionConfig, + "mounted_share_connection": MountedShareConnectionConfig, +} + +DB_TYPE_MAP: dict[str, type[ConnectionConfig]] = { + DatabaseType.dynamodb.value: DynamoConnectionConfig, + DatabaseType.mongodb.value: MongoConnectionConfig, + DatabaseType.snowflake.value: SnowflakeConnectionConfig, + DatabaseType.mssql_linked.value: MssqlLinkedServerConnectionConfig, + # others use the default `DatabaseConnectionConfig` +} + + +def validate_connection(payload: dict) -> ConnectionConfig: + """ + Validate an API response payload into the appropriate concrete `ConnectionConfig` subclass. + + Dispatches on `mask_type`, then on `type` (file) or `db_type` (database). + """ + + mask_type = payload.get("mask_type") + + if mask_type == "file": + file_type = payload.get("type", "") + klass = FILE_TYPE_MAP.get(file_type) + if klass is None: + raise DataMasqueException(f"Unexpected file connection type: {file_type}") + return klass.model_validate(payload) + + if mask_type == "database": + db_type = payload.get("db_type", "") + db_klass = DB_TYPE_MAP.get(db_type, DatabaseConnectionConfig) + return db_klass.model_validate(payload) + + raise DataMasqueException(f"Unexpected connection mask_type: {mask_type}") diff --git a/datamasque/client/models/data_selection.py b/datamasque/client/models/data_selection.py new file mode 100644 index 0000000..54dfbb8 --- /dev/null +++ b/datamasque/client/models/data_selection.py @@ -0,0 +1,62 @@ +"""Models related to data selection in endpoints such as /api/async-generate-ruleset.""" + +from typing import Optional, Union + +from pydantic import BaseModel, ConfigDict + +JsonPath = list[Union[str, int]] +""" +A path into a JSON/structured document, +e.g. `["employees", 0, "firstName"]` or `["users", "*", "email"]`. +String elements are object keys (or the `*` wildcard), and integer elements are list indices. +""" + +Locator = Union[str, JsonPath] +""" +A locator identifying a masked value within a file. +- Tabular files (CSV, parquet, fixed-width) use a bare string column name, e.g. `"email"`. +- Structured files (JSON) use a :data:`JsonPath`, e.g. `["employees", "*", "email"]`. +""" + + +class UserSelection(BaseModel): + """Information about selected files and locators for file masking ruleset generation.""" + + model_config = ConfigDict(extra="forbid") + + files: list[str] + locators: list[Locator] + + +class HashColumnsTableConfig(BaseModel): + """ + Configuration for `hash_columns` at the table level. + + `table` contains table-level hash column defaults applied to all selected columns. + `columns` contains per-column overrides (`None` or `[]` disables hashing for that column). + """ + + model_config = ConfigDict(extra="forbid") + + table: Optional[list[str]] = None + columns: Optional[dict[str, Optional[list[str]]]] = None + + +class SelectedColumns(BaseModel): + """Selected columns and hash columns for database masking ruleset generation.""" + + model_config = ConfigDict(extra="forbid") + + columns: dict[str, dict[str, list[str]]] + hash_columns: Optional[dict[str, dict[str, HashColumnsTableConfig]]] = None + + +class SelectedFileData(BaseModel): + """Selected files and locators for file masking ruleset generation.""" + + model_config = ConfigDict(extra="forbid") + + user_selections: list[UserSelection] + + +SelectedData = Union[SelectedColumns, SelectedFileData] diff --git a/datamasque/client/models/discovery.py b/datamasque/client/models/discovery.py new file mode 100644 index 0000000..7532526 --- /dev/null +++ b/datamasque/client/models/discovery.py @@ -0,0 +1,229 @@ +"""Typed request and response shapes for schema-discovery and ruleset-generation endpoints.""" + +from typing import Any, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from datamasque.client.models.connection import ConnectionConfig, ConnectionId, unwrap_connection_id +from datamasque.client.models.data_selection import HashColumnsTableConfig, Locator, UserSelection +from datamasque.client.models.pagination import Page + + +class InDataDiscoveryRule(BaseModel): + """A single rule for in-data discovery.""" + + model_config = ConfigDict(extra="forbid") + + name: Optional[str] = None + pattern: str + + +class InDataDiscoveryConfig(BaseModel): + """In-data discovery configuration nested under `SchemaDiscoveryRequest.in_data_discovery`.""" + + model_config = ConfigDict(extra="forbid") + + enabled: Optional[bool] = None + row_sample_size: Optional[int] = None + custom_rules: Optional[list[InDataDiscoveryRule]] = None + non_sensitive_rules: Optional[list[InDataDiscoveryRule]] = None + force: Optional[bool] = None + + +class SchemaDiscoveryRequest(BaseModel): + """ + Request body for `POST /api/schema-discovery/`. + + `connection` accepts either a `ConnectionId` or a full `ConnectionConfig` returned by an earlier client call. + Every other field uses the server's default value when omitted. + """ + + model_config = ConfigDict(extra="forbid") + + connection: Union[ConnectionId, ConnectionConfig] + custom_keywords: list[str] = Field(default_factory=list) + ignored_keywords: list[str] = Field(default_factory=list) + schemas: list[str] = Field(default_factory=list) + in_data_discovery: Optional[InDataDiscoveryConfig] = None + disable_built_in_keywords: bool = False + disable_global_custom_keywords: bool = False + disable_global_ignored_keywords: bool = False + + @field_validator("connection", mode="before") + @classmethod + def _unwrap_connection(cls, value: Any) -> Any: + return unwrap_connection_id(value) + + +class RulesetGenerationRequest(BaseModel): + """ + Request body for `POST /api/generate-ruleset/v2/`. + + `connection` accepts either a `ConnectionId` or a full `ConnectionConfig` returned by an earlier client call. + `selected_columns` is the same nested `schema -> table -> [column, ...]` mapping + used by `SelectedColumns.columns`, + and `hash_columns` follows the `HashColumnsTableConfig` shape. + """ + + model_config = ConfigDict(extra="forbid") + + connection: Union[ConnectionId, ConnectionConfig] + selected_columns: dict[str, dict[str, list[str]]] + hash_columns: Optional[dict[str, dict[str, HashColumnsTableConfig]]] = None + + @field_validator("connection", mode="before") + @classmethod + def _unwrap_connection(cls, value: Any) -> Any: + return unwrap_connection_id(value) + + +class FileRulesetGenerationRequest(BaseModel): + """ + Request body for `POST /api/generate-file-ruleset/`. + + `connection` accepts either a `ConnectionId` or a full `ConnectionConfig` returned by an earlier client call. + """ + + model_config = ConfigDict(extra="forbid") + + connection: Union[ConnectionId, ConnectionConfig] + selected_data: list[UserSelection] + + @field_validator("connection", mode="before") + @classmethod + def _unwrap_connection(cls, value: Any) -> Any: + return unwrap_connection_id(value) + + +class DiscoveryMatch(BaseModel): + """A single match found by schema or file discovery.""" + + model_config = ConfigDict(extra="allow") + + label: str + categories: list[str] + flagged_by: str + description: str + hit_ratio: Optional[int] = None # None for metadata matches, percentage 0-100 for IDD matches. + + +class ForeignKeyRef(BaseModel): + """A foreign key declared on a column, pointing to another column it references.""" + + model_config = ConfigDict(extra="allow") + + name: str + referenced_column: str # Dotted path: "schema.table.column". + + +class ReferencingForeignKey(BaseModel): + """A foreign key declared on another column that points *at* this column.""" + + model_config = ConfigDict(extra="allow") + + name: str + referencing_column: str # Dotted path: "schema.table.column". + + +class SchemaDiscoveryColumn(BaseModel): + """Column-level data in a schema discovery result.""" + + model_config = ConfigDict(extra="allow") + + data_type: Optional[str] = None + max_length: Optional[int] = None + foreign_keys: list[ForeignKeyRef] + discovery_matches: list[DiscoveryMatch] + numeric_precision: Optional[int] = None + numeric_scale: Optional[int] = None + constraint_columns: list[str] + pk_constraint_name: Optional[str] = None + uk_constraint_name: Optional[str] = None + unique_index_names: list[str] + referencing_foreign_keys: list[ReferencingForeignKey] + constraint: str # Primary or Unique, or empty string if column does not participate in a PK/UK + + +class SchemaDiscoveryResult(BaseModel): + """A single row in the v2 schema discovery results.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: int + column: str + table: str + schema_name: Optional[str] = Field(default=None, alias="schema") # "schema" is a reserved word in Pydantic + data: SchemaDiscoveryColumn + + +class ConstraintColumns(BaseModel): + """A constraint's column list in table metadata.""" + + model_config = ConfigDict(extra="allow") + + columns: list[str] + + +class TableConstraints(BaseModel): + """Constraint metadata for a single table.""" + + model_config = ConfigDict(extra="allow") + + primary_keys: Optional[list[ConstraintColumns]] = None + unique_keys: Optional[list[ConstraintColumns]] = None + foreign_keys: Optional[list[ConstraintColumns]] = None + + +class SchemaDiscoveryPage(Page[SchemaDiscoveryResult]): + """ + Admin-server envelope for `GET /api/schema-discovery/v2/{run_id}/`. + + Extends the standard `Page` with `table_metadata`. + """ + + table_metadata: Optional[dict[str, dict[str, TableConstraints]]] = None + + +class FileDiscoveryMatch(BaseModel): + """A single match in a file discovery locator.""" + + model_config = ConfigDict(extra="allow") + + categories: Optional[list[str]] = None + flagged_by: Optional[str] = None + description: Optional[str] = None + label: Optional[str] = None + hit_ratio: Optional[int] = None + + +class FileDiscoveryLocatorResult(BaseModel): + """A locator (column/path) within a discovered file.""" + + model_config = ConfigDict(extra="allow") + + locator: Optional[Locator] = None + matches: Optional[list[FileDiscoveryMatch]] = None + data_types: Optional[list[str]] = None + + +class FileDiscoveryFile(BaseModel): + """A file entry in a file discovery result.""" + + model_config = ConfigDict(extra="allow") + + path: Optional[str] = None + file_type: Optional[str] = None + delimiter: Optional[str] = None + encoding: Optional[str] = None + + +class FileDiscoveryResult(BaseModel): + """A single record from `GET /api/runs/{run_id}/file-discovery-results/`.""" + + model_config = ConfigDict(extra="allow") + + id: Optional[int] = None + connection: Optional[Any] = None + file_type: Optional[str] = None + files: Optional[list[FileDiscoveryFile]] = None + results: Optional[list[FileDiscoveryLocatorResult]] = None diff --git a/datamasque/client/models/dm_instance.py b/datamasque/client/models/dm_instance.py new file mode 100644 index 0000000..5026134 --- /dev/null +++ b/datamasque/client/models/dm_instance.py @@ -0,0 +1,39 @@ +from typing import Callable, Optional + +from pydantic import BaseModel, ConfigDict, model_validator + +from datamasque.client.exceptions import DataMasqueUserError + + +class DataMasqueInstanceConfig(BaseModel): + """ + Connection configuration for `DataMasqueClient`. + + `base_url` is the root URL of the DataMasque admin server + (e.g. `https://datamasque.example.com/`). + Set `verify_ssl=False` to skip TLS certificate verification + (only use this with a self-signed certificate; + do not disable it otherwise). + Exactly one of `password` or `token_source` must be set. + `token_source` is a user-supplied callable that returns the bare API token string — + the hex value returned by `POST /api/auth/token/login/`; + the client prepends it with `Token ` when sending the `Authorization` header. + The client calls `token_source` on each authentication attempt, + so the callable is free to fetch and refresh tokens out-of-band (e.g. from a secrets manager). + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + + base_url: str + username: str + password: Optional[str] = None + verify_ssl: bool = True + token_source: Optional[Callable[[], str]] = None + + @model_validator(mode="after") + def _validate_auth_source(self) -> "DataMasqueInstanceConfig": + if (self.password is None) == (self.token_source is None): + raise DataMasqueUserError( + "Exactly one of `password` or `token_source` must be provided to `DataMasqueInstanceConfig`." + ) + return self diff --git a/datamasque/client/models/files.py b/datamasque/client/models/files.py new file mode 100644 index 0000000..59bfb7f --- /dev/null +++ b/datamasque/client/models/files.py @@ -0,0 +1,89 @@ +from abc import abstractmethod +from datetime import datetime +from typing import NewType, Optional + +from pydantic import BaseModel, ConfigDict, model_validator + +FileId = NewType("FileId", str) + + +class DataMasqueFile(BaseModel): + """Base class for the concrete file types (`SeedFile`, `OracleWalletFile`, `SslZipFile`, `SnowflakeKeyFile`).""" + + model_config = ConfigDict(extra="allow") + + name: str + created_date: datetime + modified_date: Optional[datetime] = None + id: Optional[FileId] = None + + @model_validator(mode="before") + @classmethod + def _promote_filename(cls, data: dict) -> dict: + """The API sometimes returns `filename` instead of `name`.""" + if isinstance(data, dict): + if "filename" in data and "name" not in data: + data["name"] = data["filename"] + return data + + @classmethod + @abstractmethod + def get_url(cls) -> str: + """Returns the API URL path for files of this type.""" + + raise NotImplementedError # pragma: no cover + + @classmethod + @abstractmethod + def get_content_param_name(cls) -> str: + """Returns the multipart form field name used when uploading files of this type.""" + + raise NotImplementedError # pragma: no cover + + +class SeedFile(DataMasqueFile): + """Represents a seed file (CSV file).""" + + @classmethod + def get_url(cls) -> str: + return "api/seeds/" + + @classmethod + def get_content_param_name(cls) -> str: + return "seed_file" + + +class OracleWalletFile(DataMasqueFile): + """Represents an Oracle wallet file (ZIP file).""" + + @classmethod + def get_url(cls) -> str: + return "api/oracle-wallets/" + + @classmethod + def get_content_param_name(cls) -> str: + return "zip_archive" + + +class SslZipFile(DataMasqueFile): + """Represents a ZIP file of SSL certificates used to establish secure database connections.""" + + @classmethod + def get_url(cls) -> str: + return "api/connection-filesets/" + + @classmethod + def get_content_param_name(cls) -> str: + return "zip_archive" + + +class SnowflakeKeyFile(DataMasqueFile): + """Represents a private SSH key file for Snowflake connections.""" + + @classmethod + def get_url(cls) -> str: + return "api/files/snowflake-keys/" + + @classmethod + def get_content_param_name(cls) -> str: + return "key_file" diff --git a/datamasque/client/models/ifm.py b/datamasque/client/models/ifm.py new file mode 100644 index 0000000..2673c30 --- /dev/null +++ b/datamasque/client/models/ifm.py @@ -0,0 +1,177 @@ +"""Typed request and response shapes for the IFM (in-flight masking) HTTP API.""" + +from datetime import datetime +from typing import Any, Callable, Optional + +from pydantic import BaseModel, ConfigDict, model_validator + +from datamasque.client.exceptions import DataMasqueUserError + + +class RulesetPlanOptions(BaseModel): + """ + Server-defined defaults applied when a mask request omits the corresponding fields. + + All keys are optional; + callers can supply any subset (or none) and the IFM server fills in remaining defaults. + """ + + model_config = ConfigDict(extra="forbid") + + enabled: Optional[bool] = None + # NB: Encoding and charset are not currently implemented for IFM. + # These fields are here just to ensure we can round-trip a `RulesetPlan` object. + default_encoding: Optional[str] = None + default_charset: Optional[str] = None + default_log_level: Optional[str] = None + + +class IfmLog(BaseModel): + """A single log entry produced by IFM during a mask call or a ruleset-plan validation.""" + + model_config = ConfigDict(extra="allow") + + log_level: str + timestamp: str + message: str + + +class IfmRulesetPlanRef(BaseModel): + """Reference to a ruleset plan embedded in a mask response.""" + + model_config = ConfigDict(extra="allow") + + name: str + serial: int + + +class RulesetPlan(BaseModel): + """ + Unified model for IFM ruleset plans. + + Collapses the list/detail/create/update response shapes into one model + with optional fields for parts that differ by endpoint. + """ + + model_config = ConfigDict(extra="allow") + + name: str + serial: int + created_time: datetime + modified_time: datetime + options: RulesetPlanOptions + ruleset_yaml: Optional[str] = None + logs: Optional[list[IfmLog]] = None + url: Optional[str] = None + + +class RulesetPlanCreateRequest(BaseModel): + """Request body for `POST /ifm/ruleset-plans/`.""" + + model_config = ConfigDict(extra="forbid") + + name: str + ruleset_yaml: str + options: Optional[RulesetPlanOptions] = None + + +class RulesetPlanUpdateRequest(BaseModel): + """Request body for `PUT /ifm/ruleset-plans/{name}/`.""" + + model_config = ConfigDict(extra="forbid") + + ruleset_yaml: str + options: Optional[RulesetPlanOptions] = None + + +class RulesetPlanPartialUpdateRequest(BaseModel): + """Request body for `PATCH /ifm/ruleset-plans/{name}/` — every field is optional.""" + + model_config = ConfigDict(extra="forbid") + + ruleset_yaml: Optional[str] = None + options: Optional[RulesetPlanOptions] = None + + +class IfmMaskRequest(BaseModel): + """ + Request body for `POST /ruleset-plans/{name}/mask/`. + + `data` is the list of records to be masked; + every other field overrides server defaults configured on the plan. + """ + + model_config = ConfigDict(extra="forbid") + + data: list[Any] + disable_instance_secret: Optional[bool] = None + run_secret: Optional[str] = None + hash_values: Optional[Any] = None + log_level: Optional[str] = None + request_id: Optional[str] = None + ai_engine_url: Optional[str] = None + + +class IfmMaskResult(BaseModel): + """ + Response shape for `POST /ruleset-plans/{name}/mask/`. + + `success` is populated by the client based on the HTTP status the server returned: + + - `True` — masking completed; + `data` carries the masked records (possibly an empty list if the request had no input). + - `False` — the server rejected the request with a soft failure + (e.g. a masking function received an unsupported value type); + `data` is omitted and details surface in `logs`. + + Hard failures (plan not found, auth, transport) still raise rather than producing an `IfmMaskResult`. + """ + + model_config = ConfigDict(extra="allow") + + success: bool + request_id: Optional[str] = None + ruleset_plan: Optional[IfmRulesetPlanRef] = None + logs: Optional[list[IfmLog]] = None + data: Optional[list[Any]] = None + + +class IfmTokenInfo(BaseModel): + """Response body for `GET /verify-token/` — the list of scopes granted to the current JWT.""" + + model_config = ConfigDict(extra="allow") + + scopes: list[str] + + +class DataMasqueIfmInstanceConfig(BaseModel): + """ + Connection configuration for `DataMasqueIfmClient`. + + `admin_server_base_url` is where JWTs are obtained and refreshed; + `ifm_base_url` is where the IFM API itself lives + (typically a separate hostname or the admin server with `/ifm` prefix). + Exactly one of `password` or `token_source` must be set. + `token_source` is a user-supplied callable that returns the bare JWT access token string — + the value issued by the admin server's `/api/auth/jwt/login/` endpoint; + the client prepends it with `Bearer ` when sending the `Authorization` header. + The client calls `token_source` on each authentication and refresh, + so the callable is free to fetch and refresh tokens out-of-band (e.g. from a secrets manager). + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + + admin_server_base_url: str + ifm_base_url: str + username: str + password: Optional[str] = None + verify_ssl: bool = True + token_source: Optional[Callable[[], str]] = None + + @model_validator(mode="after") + def _validate_auth_source(self) -> "DataMasqueIfmInstanceConfig": + if (self.password is None) == (self.token_source is None): + raise DataMasqueUserError( + "Exactly one of `password` or `token_source` must be provided to `DataMasqueIfmInstanceConfig`." + ) + return self diff --git a/datamasque/client/models/license.py b/datamasque/client/models/license.py new file mode 100644 index 0000000..9739507 --- /dev/null +++ b/datamasque/client/models/license.py @@ -0,0 +1,60 @@ +"""Typed response shape for the license endpoint.""" + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict + + +class SwitchableLicenseMetadata(BaseModel): + """Metadata for switchable license management (AWS Marketplace, etc.).""" + + model_config = ConfigDict(extra="allow") + + can_switch_license_source: Optional[bool] = None + license_source: Optional[str] = None + license_select_time: Optional[datetime] = None + aws_account_number: Optional[str] = None + last_checkout_success_time: Optional[datetime] = None + last_checkout_success_type: Optional[str] = None + last_checkout_error: Optional[str] = None + last_checkout_license_arn: Optional[str] = None + last_checkout_product_name: Optional[str] = None + last_checkout_contract_expiry: Optional[datetime] = None + last_checkout_agreement_id: Optional[str] = None + last_checkout_agreement_url: Optional[str] = None + checkout_mode: Optional[str] = None + selected_product_sku: Optional[str] = None + allow_fallback: Optional[bool] = None + last_checkout_success_license_count: Optional[int] = None + iam_role_arn: Optional[str] = None + + +class LicenseInfo(BaseModel): + """ + License information returned by `GET /api/license/`. + + Core fields (`uuid`, `name`, `type`, `is_expired`, `uploadable`) + are always present in the server response. + Other fields vary by license type and server version. + """ + + model_config = ConfigDict(extra="allow") + + uuid: str + name: str + type: str + is_expired: bool + uploadable: bool + version: Optional[str] = None + raw_type: Optional[str] = None + expiry_date: Optional[datetime] = None + quota_tb: Optional[float] = None + maximum_node_count: Optional[int] = None + row_limit: Optional[int] = None + platform_name: Optional[str] = None + platform_code: Optional[str] = None + days_until_expiry: Optional[int] = None + is_contract_product: Optional[bool] = None + contract_license_type: Optional[str] = None + switchable_license_metadata: Optional[SwitchableLicenseMetadata] = None diff --git a/datamasque/client/models/pagination.py b/datamasque/client/models/pagination.py new file mode 100644 index 0000000..04d4c1d --- /dev/null +++ b/datamasque/client/models/pagination.py @@ -0,0 +1,29 @@ +"""Pagination envelope models matching the DataMasque admin-server and IFM list-endpoint response shapes.""" + +from typing import Generic, Optional, TypeVar + +from pydantic import BaseModel, ConfigDict + +T = TypeVar("T") + + +class Page(BaseModel, Generic[T]): + """Admin-server paginated response envelope.""" + + model_config = ConfigDict(extra="allow") + + count: int + next: Optional[str] = None + previous: Optional[str] = None + results: list[T] + + +class IfmPage(BaseModel, Generic[T]): + """IFM paginated response envelope.""" + + model_config = ConfigDict(extra="allow") + + items: list[T] + total: int + limit: int + offset: int diff --git a/datamasque/client/models/ruleset.py b/datamasque/client/models/ruleset.py new file mode 100644 index 0000000..564b7c1 --- /dev/null +++ b/datamasque/client/models/ruleset.py @@ -0,0 +1,45 @@ +import enum +from typing import Any, NewType, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from datamasque.client.models.status import ValidationStatus + +RulesetId = NewType("RulesetId", str) + + +def unwrap_ruleset_id(value: Any) -> Any: + """ + Coerce a `Ruleset` to its `id`; pass other values through unchanged. + + Used by request-model validators that accept either a `RulesetId` + or a full `Ruleset` for user convenience. + Raises `ValueError` if the ruleset has no `id` + (i.e. the caller hasn't yet created it on the server). + """ + + if isinstance(value, Ruleset): + if value.id is None: + raise ValueError("Ruleset has not been created yet (id is None)") + return value.id + + return value + + +class RulesetType(enum.Enum): + """Ruleset type (database masking or file masking).""" + + file = "file" + database = "database" + + +class Ruleset(BaseModel): + """Represents a ruleset.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + name: str + yaml: str = Field(default="", alias="config_yaml") + ruleset_type: RulesetType = Field(default=RulesetType.database, alias="mask_type") + id: Optional[RulesetId] = None + is_valid: Optional[ValidationStatus] = None diff --git a/datamasque/client/models/ruleset_library.py b/datamasque/client/models/ruleset_library.py new file mode 100644 index 0000000..1ebe789 --- /dev/null +++ b/datamasque/client/models/ruleset_library.py @@ -0,0 +1,22 @@ +from datetime import datetime +from typing import NewType, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from datamasque.client.models.status import ValidationStatus + +RulesetLibraryId = NewType("RulesetLibraryId", str) + + +class RulesetLibrary(BaseModel): + """Represents a ruleset library.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + name: str + namespace: str = "" + yaml: Optional[str] = Field(default=None, alias="config_yaml") + id: Optional[RulesetLibraryId] = None + is_valid: Optional[ValidationStatus] = None + created: Optional[datetime] = None + modified: Optional[datetime] = None diff --git a/datamasque/client/models/runs.py b/datamasque/client/models/runs.py new file mode 100644 index 0000000..379f82a --- /dev/null +++ b/datamasque/client/models/runs.py @@ -0,0 +1,165 @@ +"""Typed request and response shapes for run-related API endpoints.""" + +import enum +from datetime import datetime +from typing import Any, NewType, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from datamasque.client.models.connection import ConnectionConfig, ConnectionId, unwrap_connection_id +from datamasque.client.models.ruleset import Ruleset, RulesetId, unwrap_ruleset_id +from datamasque.client.models.status import MaskingRunStatus + +RunId = NewType("RunId", int) + + +class MaskType(enum.Enum): + """Type of a masking run.""" + + database = "database" # Also used for schema discovery. + file = "file" + file_data_discovery = "file_data_discovery" + + +class MaskingRunOptions(BaseModel): + """ + Optional run-time overrides for `MaskingRunRequest.options`. + + All fields optional; server applies defaults when omitted. + `run_secret`, + if supplied, + must be 16–256 characters and is used as the per-run encryption key; + the server auto-generates one when omitted. + """ + + model_config = ConfigDict(extra="forbid") + + batch_size: Optional[int] = None + dry_run: Optional[bool] = None + continue_on_failure: Optional[bool] = None + max_rows: Optional[int] = None + diagnostic_logging: Optional[bool] = None + run_secret: Optional[str] = Field(default=None, min_length=16, max_length=256) + disable_instance_secret: Optional[bool] = None + + +class MaskingRunRequest(BaseModel): + """ + Request body for `POST /api/runs/`. + + `connection`, `destination_connection`, and `ruleset` accept either the server-assigned ID + or the corresponding object returned by an earlier client call (e.g. a `ConnectionConfig` + or `Ruleset`); the object's `id` is extracted at construction time. + """ + + model_config = ConfigDict(extra="forbid") + + connection: Union[ConnectionId, ConnectionConfig] + ruleset: Union[RulesetId, Ruleset] + mask_type: MaskType = MaskType.database + destination_connection: Optional[Union[ConnectionId, ConnectionConfig]] = None + options: MaskingRunOptions = Field(default_factory=MaskingRunOptions) + name: Optional[str] = None + + @field_validator("connection", "destination_connection", mode="before") + @classmethod + def _unwrap_connection(cls, value: Any) -> Any: + return unwrap_connection_id(value) + + @field_validator("ruleset", mode="before") + @classmethod + def _unwrap_ruleset(cls, value: Any) -> Any: + return unwrap_ruleset_id(value) + + +class RunConnectionRef(BaseModel): + """A reference to a connection used in a run — just the ID and display name.""" + + model_config = ConfigDict(extra="allow") + + id: Optional[ConnectionId] = None + name: str + + +def _collapse_flat_connection_fields(data: Any) -> Any: + """ + Collapse flat `*_connection` + `*_connection_name` pairs into nested `RunConnectionRef`s. + + The admin server sends connections as two parallel fields + (`source_connection` holding the ID and `source_connection_name` holding the display name); + the client surfaces them as a single nested object. + Leaves the input alone if the fields are already in nested form + (i.e. the caller constructed the model directly). + """ + + if not isinstance(data, dict): + return data + + data = dict(data) + + if "source_connection_name" in data and not isinstance(data.get("source_connection"), dict): + data["source_connection"] = { + "id": data.pop("source_connection", None), + "name": data.pop("source_connection_name"), + } + + dest_name = data.get("destination_connection_name") + if dest_name and not isinstance(data.get("destination_connection"), dict): + data["destination_connection"] = { + "id": data.pop("destination_connection", None), + "name": data.pop("destination_connection_name"), + } + elif "destination_connection_name" in data: + # Empty string or None — let the Optional default apply. + data.pop("destination_connection_name", None) + data.pop("destination_connection", None) + + return data + + +class RunInfo(BaseModel): + """Full record for a masking run.""" + + model_config = ConfigDict(extra="allow") + + id: int + status: MaskingRunStatus + mask_type: MaskType + source_connection: RunConnectionRef + ruleset_name: str + name: Optional[str] = None + destination_connection: Optional[RunConnectionRef] = None + ruleset: Optional[RulesetId] = None + start_time: Optional[datetime] = None + end_time: Optional[datetime] = None + options: Optional[dict[str, Any]] = None + + @model_validator(mode="before") + @classmethod + def _collapse_connection_fields(cls, data: Any) -> Any: + return _collapse_flat_connection_fields(data) + + +class UnfinishedRun(BaseModel): + """Represents a masking run that is queued, running, validating, or cancelling.""" + + model_config = ConfigDict(extra="allow") + + id: int + source_connection: RunConnectionRef + ruleset_name: str + status: MaskingRunStatus + destination_connection: Optional[RunConnectionRef] = None + + @model_validator(mode="before") + @classmethod + def _collapse_connection_fields(cls, data: Any) -> Any: + return _collapse_flat_connection_fields(data) + + def __str__(self) -> str: + if self.destination_connection is not None: + connection_part = f'"{self.source_connection.name}", "{self.destination_connection.name}"' + else: + connection_part = f'"{self.source_connection.name}"' + + return f'{connection_part}: Run ID {self.id} in status `{self.status.value}`, ruleset "{self.ruleset_name}"' diff --git a/datamasque/client/models/status.py b/datamasque/client/models/status.py new file mode 100644 index 0000000..b09e18f --- /dev/null +++ b/datamasque/client/models/status.py @@ -0,0 +1,68 @@ +import enum + + +class ValidationStatus(enum.Enum): + """Validation status of a ruleset or ruleset library.""" + + valid = "valid" + invalid = "invalid" + in_progress = "in_progress" + unknown = "unknown" + + +class MaskingRunStatus(enum.Enum): + """List of valid masking run statuses.""" + + finished = "finished" + finished_with_warnings = "finished_with_warnings" + queued = "queued" + running = "running" + failed = "failed" + validating = "validating" + cancelling = "cancelling" + cancelled = "cancelled" + + @classmethod + def get_final_states(cls) -> set["MaskingRunStatus"]: + """Returns the list of final statuses, i.e. the run is completed, successfully or otherwise.""" + + return {cls.finished, cls.finished_with_warnings, cls.cancelled, cls.failed} + + @classmethod + def get_finished_states(cls) -> set["MaskingRunStatus"]: + """Returns the list of statuses that indicate the run completed successfully.""" + + return {cls.finished, cls.finished_with_warnings} + + @property + def is_in_final_state(self) -> bool: + """Returns True if this status is a final status.""" + + return self in self.get_final_states() + + @property + def is_finished(self) -> bool: + """Returns True if this status is a finished status.""" + + return self in self.get_finished_states() + + +class AsyncRulesetGenerationTaskStatus(enum.Enum): + """List of statuses of async ruleset generation tasks.""" + + finished = "finished" + failed = "failed" + running = "running" + queued = "queued" + + @classmethod + def get_final_states(cls) -> set["AsyncRulesetGenerationTaskStatus"]: + """Returns the list of final statuses, i.e. the ruleset generation has completed, successfully or otherwise.""" + + return {cls.finished, cls.failed} + + @property + def is_in_final_state(self) -> bool: + """Returns True if this status is a final status.""" + + return self in self.get_final_states() diff --git a/datamasque/client/models/user.py b/datamasque/client/models/user.py new file mode 100644 index 0000000..7e970b4 --- /dev/null +++ b/datamasque/client/models/user.py @@ -0,0 +1,69 @@ +import secrets +import string +from enum import Enum +from typing import NewType, Optional + +from pydantic import BaseModel, ConfigDict, Field + +UserId = NewType("UserId", int) + +GENERATED_PASSWORD_LENGTH = 16 + + +class UserRole(Enum): + """ + List of supported user roles. + + `ruleset_library_manager` can be optionally included alongside `mask_builder`. + It is not valid as a standalone role. + """ + + superuser = "admin" + mask_builder = "mask_builder" + ruleset_library_manager = "ruleset_library_managers" + mask_runner = "mask_runner" + + +class User(BaseModel): + """Represents a DataMasque user account.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + username: str + email: str + roles: list[UserRole] = Field(alias="user_roles") + id: Optional[UserId] = None + password: Optional[str] = Field(default=None, exclude=True) + + @staticmethod + def generate_password() -> str: + """ + Generates a password suitable for DataMasque authentication. + + The password consists of 16 characters + without the same character occurring three times in a row + and without any three consecutive characters forming an increasing or decreasing sequence. + """ + + def is_sequential(s: str) -> bool: + """Check if the last three characters are in an increasing or decreasing sequence.""" + + if len(s) < 3: + return False + return (ord(s[-1]) == ord(s[-2]) + 1 == ord(s[-3]) + 2) or (ord(s[-1]) == ord(s[-2]) - 1 == ord(s[-3]) - 2) + + chars = string.ascii_letters + string.digits + result = secrets.choice(chars) + + while len(result) < GENERATED_PASSWORD_LENGTH: + next_char = secrets.choice(chars) + if len(result) >= 2 and next_char == result[-1] == result[-2]: + continue + if is_sequential(result + next_char): + continue + result += next_char + + return result + + def __str__(self) -> str: + return self.username diff --git a/datamasque/client/py.typed b/datamasque/client/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/datamasque/client/ruleset_libraries.py b/datamasque/client/ruleset_libraries.py new file mode 100644 index 0000000..81acb8f --- /dev/null +++ b/datamasque/client/ruleset_libraries.py @@ -0,0 +1,164 @@ +import logging +from typing import Iterator, Optional + +from datamasque.client.base import BaseClient +from datamasque.client.exceptions import DataMasqueApiError, DataMasqueException +from datamasque.client.models.pagination import Page +from datamasque.client.models.ruleset import Ruleset +from datamasque.client.models.ruleset_library import RulesetLibrary, RulesetLibraryId + +logger = logging.getLogger(__name__) + + +class RulesetLibraryClient(BaseClient): + """Ruleset library CRUD API methods. Mixed into `DataMasqueClient`.""" + + def iter_ruleset_libraries(self) -> Iterator[RulesetLibrary]: + """Lazily iterate all ruleset libraries via paginated endpoint.""" + + return self._iter_paginated("/api/ruleset-libraries/", model=RulesetLibrary) + + def list_ruleset_libraries(self) -> list[RulesetLibrary]: + """ + Lists all ruleset libraries. + + Note: The YAML content is not included in the list response for performance. + Use `get_ruleset_library` to retrieve the full library with YAML content. + """ + + return list(self.iter_ruleset_libraries()) + + def get_ruleset_library(self, library_id: RulesetLibraryId) -> RulesetLibrary: + """Retrieves a single ruleset library by ID, including its YAML content.""" + + response = self.make_request("GET", f"/api/ruleset-libraries/{library_id}/") + return RulesetLibrary.model_validate(response.json()) + + def get_ruleset_library_by_name(self, name: str, namespace: str = "") -> Optional[RulesetLibrary]: + """ + Looks for a ruleset library matching the given name and namespace (case-sensitive, exact match). + + Returns it (with full YAML content) if found, otherwise None. + """ + + response = self.make_request( + "GET", + "/api/ruleset-libraries/", + params={"name_exact": name, "namespace_exact": namespace, "limit": 1}, + ) + page = Page[RulesetLibrary].model_validate(response.json()) + if not page.results: + return None + + library_id = page.results[0].id + if library_id is None: + raise DataMasqueApiError( + "Server returned a ruleset library list entry without an `id`.", + response=response, + ) + + return self.get_ruleset_library(library_id) + + def create_ruleset_library(self, library: RulesetLibrary) -> RulesetLibrary: + """ + Creates a new ruleset library on the server. + + Sets the library's server-assigned fields (`id`, `is_valid`, `created`, `modified`) and returns the library. + """ + + data = library.model_dump(exclude_none=True, by_alias=True, mode="json") + response = self.make_request("POST", "/api/ruleset-libraries/", data=data) + created_library = RulesetLibrary.model_validate(response.json()) + library.id = created_library.id + library.is_valid = created_library.is_valid + library.created = created_library.created + library.modified = created_library.modified + logger.info('Creation of ruleset library "%s" successful', library.name) + return library + + def update_ruleset_library(self, library: RulesetLibrary) -> RulesetLibrary: + """ + Performs a full update of the ruleset library. + + The library must have its `id` set (i.e., it must have been previously created or retrieved from the server). + """ + + if library.id is None: + raise ValueError("Cannot update a library that has not been created yet (id is None)") + + data = library.model_dump(exclude_none=True, by_alias=True, mode="json") + response = self.make_request("PUT", f"/api/ruleset-libraries/{library.id}/", data=data) + updated_library = RulesetLibrary.model_validate(response.json()) + library.is_valid = updated_library.is_valid + library.modified = updated_library.modified + logger.debug('Update of ruleset library "%s" successful', library.name) + return library + + def create_or_update_ruleset_library(self, library: RulesetLibrary) -> RulesetLibrary: + """ + Creates the library if it doesn't exist, or updates it if a library with the same name already exists. + + Sets the library's `id` property. + """ + + existing = self.get_ruleset_library_by_name(library.name, library.namespace) + if existing is not None: + library.id = existing.id + return self.update_ruleset_library(library) + + return self.create_ruleset_library(library) + + def delete_ruleset_library_by_id_if_exists(self, library_id: RulesetLibraryId, *, force: bool = False) -> None: + """ + Deletes (archives) the ruleset library with the given ID. + + No-op if the library does not exist. + + If the library is imported by any rulesets, + the server will return 409 Conflict unless `force=True` is passed. + """ + + params = {"force": "true"} if force else None + self._delete_if_exists(f"/api/ruleset-libraries/{library_id}/", params=params) + + def delete_ruleset_library_by_name_if_exists( + self, library_name: str, namespace: str = "", *, force: bool = False + ) -> None: + """ + Deletes the ruleset library with the given name and namespace. + + No-op if the library does not exist. + """ + + all_libraries = self.list_ruleset_libraries() + matching = [lib for lib in all_libraries if lib.name == library_name and lib.namespace == namespace] + for lib in matching: + if lib.id is None: + raise DataMasqueException(f'Server returned a ruleset library named "{lib.name}" without an `id`.') + + self.delete_ruleset_library_by_id_if_exists(lib.id, force=force) + + def iter_rulesets_using_library(self, library_id: RulesetLibraryId) -> Iterator[Ruleset]: + """Lazily iterate non-archived rulesets that import the given library.""" + + return self._iter_paginated(f"/api/ruleset-libraries/{library_id}/rulesets/", model=Ruleset) + + def list_rulesets_using_library(self, library_id: RulesetLibraryId) -> list[Ruleset]: + """ + Lists non-archived rulesets that import the given library. + + Note: The YAML content is not included in the response for performance. + Each returned Ruleset will have an empty string for `yaml`. + """ + + return list(self.iter_rulesets_using_library(library_id)) + + def validate_ruleset_library(self, library_id: RulesetLibraryId) -> RulesetLibrary: + """ + Triggers re-validation of the ruleset library by performing a no-op update. + + Returns the updated library with the new validation status. + """ + + response = self.make_request("PATCH", f"/api/ruleset-libraries/{library_id}/", data={}) + return RulesetLibrary.model_validate(response.json()) diff --git a/datamasque/client/rulesets.py b/datamasque/client/rulesets.py new file mode 100644 index 0000000..4b9e038 --- /dev/null +++ b/datamasque/client/rulesets.py @@ -0,0 +1,57 @@ +import logging + +from datamasque.client.base import BaseClient +from datamasque.client.exceptions import DataMasqueException +from datamasque.client.models.ruleset import Ruleset, RulesetId +from datamasque.client.models.status import ValidationStatus + +logger = logging.getLogger(__name__) + + +class RulesetClient(BaseClient): + """Ruleset CRUD API methods. Mixed into `DataMasqueClient`.""" + + def list_rulesets(self) -> list[Ruleset]: + """Returns all rulesets configured on the server.""" + + response = self.make_request("GET", "/api/v2/rulesets/") + return [Ruleset.model_validate(payload) for payload in response.json()] + + def create_or_update_ruleset(self, ruleset: Ruleset) -> Ruleset: + """ + Creates or updates a ruleset. + + Populates the given ruleset's `id` and `is_valid` fields from the server response, + and returns the same ruleset instance for convenience. + """ + + data = ruleset.model_dump(exclude_none=True, by_alias=True, mode="json") + response = self.make_request("POST", "/api/rulesets/", data=data, params={"upsert": "true"}) + response_data = response.json() + ruleset.id = RulesetId(response_data["id"]) + is_valid = response_data.get("is_valid") + if is_valid is not None: + ruleset.is_valid = ValidationStatus(is_valid) + + if response.status_code == 201: + logger.info('Creation of ruleset "%s" successful', ruleset.name) + elif response.status_code == 200: + logger.debug('Update of ruleset "%s" successful', ruleset.name) + + return ruleset + + def delete_ruleset_by_id_if_exists(self, ruleset_id: RulesetId) -> None: + """Deletes the ruleset with the given ID. No-op if the ruleset does not exist.""" + + self._delete_if_exists(f"/api/rulesets/{ruleset_id}/") + + def delete_ruleset_by_name_if_exists(self, ruleset_name: str) -> None: + """Deletes the ruleset with the given name. No-op if the ruleset does not exist.""" + + all_rulesets = self.list_rulesets() + rulesets_matching_name = [ruleset for ruleset in all_rulesets if ruleset.name == ruleset_name] + for ruleset in rulesets_matching_name: + if ruleset.id is None: + raise DataMasqueException(f'Server returned a ruleset named "{ruleset.name}" without an `id`.') + + self.delete_ruleset_by_id_if_exists(ruleset.id) diff --git a/datamasque/client/runs.py b/datamasque/client/runs.py new file mode 100644 index 0000000..6b9a827 --- /dev/null +++ b/datamasque/client/runs.py @@ -0,0 +1,189 @@ +import logging +import re + +from datamasque.client.base import BaseClient +from datamasque.client.exceptions import ( + FailedToStartError, + InvalidLibraryError, + InvalidRulesetError, + RunNotCancellableError, +) +from datamasque.client.models.runs import MaskingRunRequest, RunId, RunInfo, UnfinishedRun +from datamasque.client.models.status import MaskingRunStatus + +logger = logging.getLogger(__name__) + + +class RunClient(BaseClient): + """Masking-run and run-report API methods. Mixed into `DataMasqueClient`.""" + + def get_run_log(self, run_id: RunId) -> str: + """Returns the full log output of the specified run.""" + + response = self.make_request("GET", f"api/runs/{run_id}/log/") + return response.text + + def get_sdd_report(self, run_id: RunId) -> str: + """Returns the sensitive-data-discovery report generated by the specified run.""" + + response = self.make_request("GET", f"api/runs/{run_id}/sdd-report/") + return response.text + + def get_run_report(self, run_id: RunId) -> str: + """ + Retrieves the run report for the specified run. + + Args: + run_id: The ID of the run + + Returns: + str: The run report content + """ + + response = self.make_request("GET", f"api/runs/{run_id}/run-report/") + return response.text + + def get_db_discovery_result_report(self, run_id: RunId, include_selection_column: bool = True) -> str: + """ + Returns the database-discovery result report for the specified run as CSV. + + When `include_selection_column` is true (the default), + the CSV includes a `selected` column suitable for feeding back into ruleset generation. + """ + + url = f"api/runs/{run_id}/db-discovery-results/report/" + params = None if include_selection_column else {"include_selection_column": "false"} + response = self.make_request("GET", url, params=params) + return response.text + + def get_unfinished_runs(self) -> dict[str, UnfinishedRun]: + """Queries the DM instance for unfinished runs, and returns them organised by connection name.""" + + unfinished_runs = {} + for status in ( + MaskingRunStatus.queued, + MaskingRunStatus.running, + MaskingRunStatus.validating, + MaskingRunStatus.cancelling, + ): + response = self.make_request( + "GET", + "api/runs/", + params={ + "connection_ruleset_name": "", + "ruleset_name": "", + "run_status": status.value, + "limit": 1, + "offset": 0, + }, + ) + data = response.json() + + for run in data.get("results", []): + unfinished_run = UnfinishedRun.model_validate(run) + + unfinished_runs[unfinished_run.source_connection.name] = unfinished_run + if unfinished_run.destination_connection is not None: + unfinished_runs[unfinished_run.destination_connection.name] = unfinished_run + + return unfinished_runs + + def start_masking_run(self, run_info: MaskingRunRequest) -> RunId: + """ + Starts a masking run with the given configuration and returns its run ID. + + Args: + run_info: A `MaskingRunRequest` describing the run configuration. + + Raises: + InvalidRulesetError: the run failed to start because the ruleset is invalid. + InvalidLibraryError: the run failed to start because a referenced library is invalid. + FailedToStartError: the run failed to start for any other reason. + """ + + data = run_info.model_dump(exclude_none=True, mode="json") + response = self.make_request("POST", "/api/runs/", data=data, require_status_check=False) + run_data = response.json() if response.content else {} + + if response.status_code == 201: + logger.info( + "Run %s started successfully using ruleset %s", + run_data["id"], + run_data["name"], + ) + return RunId(run_data["id"]) + + if isinstance(run_data, dict) and "ruleset" in run_data: + logger.error("Run failed to start: %s", run_data) + + try: + errors = run_data["ruleset"][0] + except (TypeError, IndexError, KeyError): + pass # fall through to generic FailedToStartError below + else: + if errors.lower().startswith("cannot start run"): + # Attempt to parse the library name out from a string like: + # `Library "abc" is invalid.` + # Trailing space is deliberate + # to match the end of the library name and the start of the error description that follows. + if matches := re.search(r'library "(.*)" ', errors, flags=re.IGNORECASE): + raise InvalidLibraryError( + f'Run failed to start due to invalid library named "{matches.group(1)}".', + response=response, + ) + elif "library" in errors.lower(): + raise InvalidLibraryError( + "Run failed to start due to invalid library.", + response=response, + ) + + raise InvalidRulesetError( + f'Run failed to start due to invalid ruleset named "{data.get("name")}".', + response=response, + ) + + raise FailedToStartError( + f'Run failed to start using ruleset named "{data.get("name")}" ' + f"(server responded with status {response.status_code}: {response.text}).", + response=response, + ) + + def get_run_info(self, run_id: RunId) -> RunInfo: + """Returns the full run record for the specified run ID.""" + + response = self.make_request("GET", f"/api/runs/{run_id}/") + return RunInfo.model_validate(response.json()) + + def cancel_run(self, run_id: RunId) -> RunInfo: + """ + Requests cancellation of the specified run and returns the updated run record. + + On success the run transitions to the `cancelling` status; + callers can poll `get_run_info` to observe the final `cancelled` status. + + Args: + run_id: The ID of the run to cancel. + + Returns: + The updated `RunInfo` for the run, + with `status` set to `cancelling`. + + Raises: + RunNotCancellableError: the run is not in a state that can be cancelled + (typically because it is already finished, failed, or cancelling). + DataMasqueApiError: any other non-2xx response. + """ + + response = self.make_request( + "POST", + f"/api/runs/{run_id}/cancel/", + require_status_check=False, + ) + + if response.status_code == 400: + # The admin server returns 400 with a `RunLifecycleError` payload + # when the run cannot be cancelled in its current state. + raise RunNotCancellableError(f"Run {run_id} cannot be cancelled in its current state.") + + self._raise_for_status(response) + return RunInfo.model_validate(response.json()) diff --git a/datamasque/client/settings.py b/datamasque/client/settings.py new file mode 100644 index 0000000..3b837c9 --- /dev/null +++ b/datamasque/client/settings.py @@ -0,0 +1,76 @@ +from pathlib import Path +from typing import Optional +from urllib.parse import urlparse + +from datamasque.client.base import BaseClient +from datamasque.client.exceptions import DataMasqueUserError + + +class SettingsClient(BaseClient): + """Server-wide settings, log retrieval, and admin-install bootstrap. Mixed into `DataMasqueClient`.""" + + def retrieve_application_logs(self, output_path: Path) -> None: + """Downloads the DataMasque application logs archive to `output_path`.""" + + response = self.make_request("GET", path="/api/logs/download/", params={"log_service": "application"}) + + with open(output_path, "wb") as application_logs_output: + for chunk in response.iter_content(chunk_size=4096): + application_logs_output.write(chunk) + + def set_locality(self, locality: str) -> None: + """Sets the server-wide locality used for ruleset generation and Jinja2 interpolation of ruleset YAML.""" + + self.make_request("PATCH", path="api/settings/", data={"locality": locality}) + + def admin_install( + self, + email: str, + username: str = "admin", + password: Optional[str] = None, + allowed_hosts: Optional[list[str]] = None, + ) -> None: + """ + Performs the first-time admin-install bootstrap on a fresh DataMasque server. + + Creates the initial admin account and configures the server's allowed-hosts list. + This endpoint is unauthenticated and can only be called once per server; + subsequent calls will fail. + + If `password` is not given, the client's configured password is used. + If `allowed_hosts` is not given, it defaults to the following list: + + - `localhost` + - `127.0.0.1` + - the client's configured hostname (from `base_url`). + """ + + if password is None: + password = self.password + if password is None: + # Clients constructed with `token_source` instead of a password + # have no fallback to use here; require an explicit `password` argument. + raise DataMasqueUserError( + "`admin_install` requires a `password` argument when the client was constructed without one." + ) + + if allowed_hosts is None: + allowed_hosts = ["localhost", "127.0.0.1"] + dm_hostname = urlparse(self.base_url).hostname + if dm_hostname and dm_hostname not in allowed_hosts: + allowed_hosts.append(dm_hostname) + + data = { + "email": email, + "username": username, + "password": password, + "re_password": password, + "allowed_hosts": allowed_hosts, + } + + self.make_request( + "POST", + "/api/users/admin-install/", + data=data, + requires_authorization=False, + ) diff --git a/datamasque/client/users.py b/datamasque/client/users.py new file mode 100644 index 0000000..15cd12b --- /dev/null +++ b/datamasque/client/users.py @@ -0,0 +1,96 @@ +from typing import Optional + +from datamasque.client.base import BaseClient +from datamasque.client.exceptions import DataMasqueException, DataMasqueUserError +from datamasque.client.models.user import User, UserId, UserRole + + +class UserClient(BaseClient): + """User CRUD API methods. Mixed into `DataMasqueClient`.""" + + def list_users(self) -> list[User]: + """Returns all active users configured on the server.""" + + users = [] + for user_data in self.make_request("GET", "/api/users/").json(): + if user_data.get("is_active", False): + users.append(User.model_validate(user_data)) + + return users + + def create_or_update_user(self, user: User, new_password: Optional[str] = None) -> User: + """ + Creates or updates the user. + + An update will be performed if `user.id` is set, otherwise a create. + To also set the user's password, + put the old password in the user's `password` field (for an existing user) + and pass the new password in the `new_password` parameter. + Returns the same User object but with the id and password fields populated. + """ + + if not user.roles: + raise DataMasqueUserError("User must have at least one role") + if UserRole.ruleset_library_manager in user.roles and UserRole.mask_builder not in user.roles: + raise DataMasqueUserError("`ruleset_library_manager` role requires `mask_builder` role") + + if user.id is None: + temp_password = User.generate_password() + + data = user.model_dump(exclude_none=True, by_alias=True, mode="json") | { + "password": temp_password, + "re_password": temp_password, + } + resp = self.make_request("POST", "/api/users/", data=data).json() + user.id = resp["id"] + user.password = temp_password + else: + self.make_request( + "PATCH", + f"/api/users/{user.id}/", + data=user.model_dump(exclude_none=True, by_alias=True, mode="json"), + ).json() + + if new_password: + self.make_request( + "PATCH", + f"/api/users/{user.id}/", + data={ + "current_password": user.password, + "new_password": new_password, + "re_new_password": new_password, + }, + ) + user.password = new_password + + return user + + def reset_password_for_user(self, user: User) -> str: + """ + Resets the user's password. + + The temporary password is stored on the User object and also returned. + """ + + if user.id is None: + raise DataMasqueUserError("User must be created first") + + resp = self.make_request("POST", f"/api/users/{user.id}/reset-password/").json() + user.password = resp["password"] + return user.password + + def delete_user_by_id_if_exists(self, user_id: UserId) -> None: + """Deletes the user with the given ID. No-op if the user does not exist.""" + + self._delete_if_exists(f"/api/users/{user_id}/") + + def delete_user_by_username_if_exists(self, username: str) -> None: + """Deletes the user with the given username. No-op if the user does not exist.""" + + all_users = self.list_users() + users_matching_username = [u for u in all_users if u.username == username] + for user in users_matching_username: + if user.id is None: + raise DataMasqueException(f'Server returned a user named "{user.username}" without an `id`.') + + self.delete_user_by_id_if_exists(user.id) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..3c153ec --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = python -msphinx +SPHINXPROJ = datamasque.client +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/client.models.rst b/docs/client.models.rst new file mode 100644 index 0000000..bfb22a5 --- /dev/null +++ b/docs/client.models.rst @@ -0,0 +1,101 @@ +datamasque.client.models package +================================ + +Submodules +---------- + +datamasque.client.models.connection module +------------------------------------------ + +.. automodule:: datamasque.client.models.connection + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.models.data_selection module +---------------------------------------------- + +.. automodule:: datamasque.client.models.data_selection + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.models.discovery module +----------------------------------------- + +.. automodule:: datamasque.client.models.discovery + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.models.dm_instance module +------------------------------------------- + +.. automodule:: datamasque.client.models.dm_instance + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.models.files module +------------------------------------- + +.. automodule:: datamasque.client.models.files + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.models.ifm module +----------------------------------- + +.. automodule:: datamasque.client.models.ifm + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.models.license module +--------------------------------------- + +.. automodule:: datamasque.client.models.license + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.models.ruleset module +--------------------------------------- + +.. automodule:: datamasque.client.models.ruleset + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.models.ruleset_library module +----------------------------------------------- + +.. automodule:: datamasque.client.models.ruleset_library + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.models.runs module +------------------------------------ + +.. automodule:: datamasque.client.models.runs + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.models.status module +-------------------------------------- + +.. automodule:: datamasque.client.models.status + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.models.user module +------------------------------------ + +.. automodule:: datamasque.client.models.user + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/client.rst b/docs/client.rst new file mode 100644 index 0000000..e8c7764 --- /dev/null +++ b/docs/client.rst @@ -0,0 +1,117 @@ +datamasque.client package +========================= + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + client.models + +Submodules +---------- + +datamasque.client.base module +----------------------------- + +.. automodule:: datamasque.client.base + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.connections module +------------------------------------ + +.. automodule:: datamasque.client.connections + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.discovery module +---------------------------------- + +.. automodule:: datamasque.client.discovery + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.dmclient module +--------------------------------- + +.. automodule:: datamasque.client.dmclient + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.exceptions module +----------------------------------- + +.. automodule:: datamasque.client.exceptions + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.files module +------------------------------ + +.. automodule:: datamasque.client.files + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.ifm module +---------------------------- + +.. automodule:: datamasque.client.ifm + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.license module +-------------------------------- + +.. automodule:: datamasque.client.license + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.ruleset_libraries module +------------------------------------------ + +.. automodule:: datamasque.client.ruleset_libraries + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.rulesets module +--------------------------------- + +.. automodule:: datamasque.client.rulesets + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.runs module +----------------------------- + +.. automodule:: datamasque.client.runs + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.settings module +--------------------------------- + +.. automodule:: datamasque.client.settings + :members: + :undoc-members: + :show-inheritance: + +datamasque.client.users module +------------------------------ + +.. automodule:: datamasque.client.users + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/conf.py b/docs/conf.py new file mode 100755 index 0000000..3ef2bd2 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python +# +# datamasque.client documentation build configuration file, created by +# sphinx-quickstart on Fri Jun 9 13:47:02 2017. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +# If extensions (or modules to document with autodoc) are in another +# directory, add these directories to sys.path here. If the directory is +# relative to the documentation root, use os.path.abspath to make it +# absolute, like shown here. +# +import os +import sys + +sys.path.insert(0, os.path.abspath('..')) + +import datamasque.client + +# -- General configuration --------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.napoleon', 'sphinx.ext.viewcode'] + +# Treat single-backtick `foo` as "try to resolve as any cross-reference +# (Python object, glossary term, etc.); render as inline code if nothing matches". +# Lets docstrings use the single-backtick style consistently, +# while still cross-linking Python identifiers automatically. +default_role = "any" + +# `default_role = "any"` treats every single-backtick as a potential reference, +# so plain code-like content (parameter names, string values, URL paths, HTTP headers) +# generates "reference target not found" warnings. +# Suppress those so the -W build stays clean; +# Python identifiers that DO resolve still get cross-linked. +suppress_warnings = ["ref.any"] + +# Exclude pydantic's `model_config` attribute from autodoc output — +# it's plumbing, not public API, and clutters every model's docs page. +autodoc_default_options = { + "exclude-members": "model_config", +} + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = 'DataMasque Python' +copyright = "2026, DataMasque Ltd" +author = "DataMasque Ltd" + +# The version info for the project you're documenting, acts as replacement +# for |version| and |release|, also used in various other places throughout +# the built documents. +# +# The short X.Y version. +version = datamasque.client.__version__ +# The full version, including alpha/beta/rc tags. +release = datamasque.client.__version__ + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = "en" + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'alabaster' + +# Theme options are theme-specific and customize the look and feel of a +# theme further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# No custom static files at present; add a `_static/` directory and re-enable +# `html_static_path = ['_static']` if that changes. +html_static_path = [] + + +# -- Options for HTMLHelp output --------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = 'datamasque_clientdoc' + + +# -- Options for LaTeX output ------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass +# [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'datamasque_client.tex', 'DataMasque Python Documentation', author, 'manual'), +] + + +# -- Options for manual page output ------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [(master_doc, 'datamasque_client', 'DataMasque Python Documentation', [author], 1)] + + +# -- Options for Texinfo output ---------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ( + master_doc, + 'datamasque_client', + 'DataMasque Python Documentation', + author, + 'datamasque_client', + 'Official Python client for the DataMasque data-masking API.', + 'Miscellaneous', + ), +] diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 0000000..e582053 --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1 @@ +.. include:: ../CONTRIBUTING.rst diff --git a/docs/history.rst b/docs/history.rst new file mode 100644 index 0000000..2506499 --- /dev/null +++ b/docs/history.rst @@ -0,0 +1 @@ +.. include:: ../HISTORY.rst diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..62aa87d --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,19 @@ +Welcome to DataMasque Python's documentation! +============================================= + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + readme + installation + usage + modules + contributing + history + +Indices and tables +================== +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..694bdc4 --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,38 @@ +.. highlight:: shell + +============ +Installation +============ + + +Stable release +-------------- + +To install DataMasque Python, run this command in your terminal: + +.. code-block:: console + + $ pip install datamasque-python + +This is the preferred method to install DataMasque Python, as it will always install the most recent stable release. + +If you don't have `pip`_ installed, this `Python installation guide`_ can guide +you through the process. + +.. _pip: https://pip.pypa.io +.. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ + + +From sources +------------ + +The sources for DataMasque Python can be downloaded from the `GitHub repo`_. + +You can either clone the public repository: + +.. code-block:: console + + $ git clone https://github.com/datamasque/datamasque-python.git + + +.. _GitHub repo: https://github.com/datamasque/datamasque-python diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..edcbbdb --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,36 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=python -msphinx +) +set SOURCEDIR=. +set BUILDDIR=_build +set SPHINXPROJ=datamasque.client + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The Sphinx module was not found. Make sure you have Sphinx installed, + echo.then set the SPHINXBUILD environment variable to point to the full + echo.path of the 'sphinx-build' executable. Alternatively you may add the + echo.Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/docs/modules.rst b/docs/modules.rst new file mode 100644 index 0000000..0c77d29 --- /dev/null +++ b/docs/modules.rst @@ -0,0 +1,7 @@ +datamasque +========== + +.. toctree:: + :maxdepth: 4 + + client diff --git a/docs/readme.rst b/docs/readme.rst new file mode 100644 index 0000000..72a3355 --- /dev/null +++ b/docs/readme.rst @@ -0,0 +1 @@ +.. include:: ../README.rst diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 0000000..0d92cfe --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,21 @@ +===== +Usage +===== + +To use DataMasque Python in a project: + +.. code-block:: python + + from datamasque.client import DataMasqueClient + from datamasque.client.models.dm_instance import DataMasqueInstanceConfig + + config = DataMasqueInstanceConfig( + base_url="https://datamasque.example.com", + username="api_user", + password="api_password", + ) + client = DataMasqueClient(config) + client.authenticate() + + for connection in client.list_connections(): + print(connection.name) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dae99df --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,122 @@ +[project] +name = "datamasque-python" +version = "1.0.0" +description = "Official Python client for the DataMasque data-masking API." +authors = [ + { name = "DataMasque Ltd" }, +] +readme = "README.rst" +license = "Apache-2.0" +license-files = ["LICENSE"] +requires-python = ">=3.9" +dependencies = [ + "requests>=2.31.0", + "pydantic>=2.5,<3", +] +keywords = [ + "datamasque", + "data-masking", + "data-privacy", + "api-client", + "test-data", + "synthetic-data", +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Database", + "Topic :: Security", + "Typing :: Typed", +] + +[project.urls] +Homepage = "https://datamasque.com/" +Repository = "https://github.com/datamasque/datamasque-python" +Issues = "https://github.com/datamasque/datamasque-python/issues" + +[dependency-groups] +dev = [ + "bump2version>=1.0.1", + "ruff>=0.9.0", + "pytest>=7.4.4", + "Sphinx>=7.2.6", + "faker>=22.2.0", + "pytest-cov>=4.1.0", + "requests-mock>=1.11.0", + "mypy>=1.8.0", + "types-requests>=2.31.0", +] + +[tool.ruff] +exclude = [ + ".git", + ".mypy_cache", + ".ruff_cache", + "__pycache__", + "node_modules", +] +line-length = 120 +indent-width = 4 +target-version = "py39" + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "B", "D", "I"] +ignore = [ + "D100", # Missing docstring in public module + "D102", # Missing docstring in public method — computed fields and simple properties are self-explanatory + "D103", # Missing docstring in public function + "D104", # Missing docstring in public package + "D105", # Missing docstring in magic method + "D107", # Missing docstring in __init__ + "D202", # No blank lines allowed after function docstring — allow a blank line for readability + "D203", # 1 blank line required before class docstring — conflicts with D211 + "D212", # Multi-line docstring summary should start at the first line — we use D213 (second line) + "D400", # First line should end with a period + "D401", # First line should be in imperative mood + "D407", # Missing dashed underline after section + "D413", # Missing blank line after last section +] +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" +per-file-ignores = { "datamasque/client/__init__.py" = ["F401"], "tests/**" = ["D101", "D103"] } + +[tool.ruff.format] +indent-style = "space" +quote-style = "preserve" + +[tool.pytest.ini_options] +addopts = "--ignore=setup.py" + +[tool.mypy] +python_version = "3.9" +explicit_package_bases = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +strict_optional = true +warn_redundant_casts = true +# Relax - API boundaries return Any from response.json() +warn_return_any = false +plugins = ["pydantic.mypy"] + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true + +[tool.hatch.build.targets.wheel] +packages = ["datamasque"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..da234e7 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,8 @@ +[bumpversion] +current_version = 1.0.0 +commit = True +tag = True + +[bumpversion:file:pyproject.toml] +search = version = "{current_version}" +replace = version = "{new_version}" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..f500df4 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Unit test package for datamasque_python.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..317778a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,74 @@ +import pytest + +from datamasque.client import DataMasqueClient +from datamasque.client.models.dm_instance import DataMasqueInstanceConfig +from datamasque.client.models.ruleset import Ruleset, RulesetType +from tests.helpers import database_connection_config, file_connection_config + + +@pytest.fixture +def config(): + return DataMasqueInstanceConfig( + base_url="http://test-server", + username="test_user", + password="test_password", + ) + + +@pytest.fixture +def client(config): + return DataMasqueClient(config) + + +@pytest.fixture +def connection_config(request): + try: + if request.param == "file": + return file_connection_config() + except AttributeError: + pass + + return database_connection_config() + + +@pytest.fixture +def existing_connection_json(): + return { + "id": "1", + "name": "an_existing_connection", + "mask_type": "database", + "db_type": "mysql", + "host": "my-host", + "port": 1433, + "database": "mydatabase", + "user": "mysql-user", + } + + +@pytest.fixture +def existing_rulesets_json(): + return [ + { + "id": "1", + "name": "db_masking_ruleset", + "mask_type": "database", + "config_yaml": "version: '1.0'", + "is_valid": "valid", + }, + { + "id": "2", + "name": "file_masking_ruleset", + "mask_type": "file", + "config_yaml": "version: '1.0'", + "is_valid": "invalid", + }, + ] + + +@pytest.fixture +def ruleset(): + return Ruleset( + name="test_ruleset", + yaml="version: '1.0'\ntasks: []", + ruleset_type=RulesetType.database, + ) diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..6a075b4 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,160 @@ +"""Shared test helpers used across the per-feature test modules.""" + +from faker import Faker +from requests import Response + +from datamasque.client.models.connection import ( + DatabaseConnectionConfig, + DatabaseType, + S3ConnectionConfig, + SnowflakeConnectionConfig, + SnowflakeStageLocation, +) + +fake = Faker() + + +def parse_multipart_form(request) -> dict: # noqa: C901 + """ + Parse a multipart form request body into a dictionary. + + Returns a dict where: + - Regular fields have string values + - File fields have dict values with 'filename', 'content_type', and 'content' keys. + """ + content_type = request.headers.get("Content-Type", "") + if "boundary=" not in content_type: + raise ValueError("No boundary found in Content-Type header") + + boundary = content_type.split("boundary=")[1].encode() + parts = request.body.split(b"--" + boundary) + + result = {} + for part in parts: + if not part or part == b"--\r\n" or part.strip() == b"--": + continue + + if b"\r\n\r\n" not in part: + continue + headers_section, content = part.split(b"\r\n\r\n", 1) + + if content.endswith(b"\r\n"): + content = content[:-2] + + headers_text = headers_section.decode("utf-8", errors="replace") + name = None + filename = None + field_content_type = None + + for line in headers_text.split("\r\n"): + if line.lower().startswith("content-disposition:"): + if 'name="' in line: + name = line.split('name="')[1].split('"')[0] + if 'filename="' in line: + filename = line.split('filename="')[1].split('"')[0] + elif line.lower().startswith("content-type:"): + field_content_type = line.split(":", 1)[1].strip() + + if name: + if filename is not None: + result[name] = { + "filename": filename, + "content_type": field_content_type, + "content": content, + } + else: + result[name] = content.decode("utf-8", errors="replace") + + return result + + +def database_connection_config(): + return DatabaseConnectionConfig( + name=fake.word(), + user=fake.user_name(), + password=fake.password(), + host="localhost", + port=fake.port_number(), + database=f"{fake.word()}_db", + schema="test_schema", + database_type=DatabaseType.postgres, + ) + + +def file_connection_config(): + return S3ConnectionConfig( + name=fake.word(), + base_directory=fake.uri_path(), + bucket=fake.uri_page(), + is_file_mask_source=True, + is_file_mask_destination=False, + ) + + +def sample_mounted_share_connection_json(*, id, name): + return { + "name": name, + "id": id, + "mask_type": "file", + "type": "mounted_share_connection", + "base_directory": "", + "is_file_mask_source": True, + "is_file_mask_destination": False, + } + + +def make_ok_response() -> Response: + """ + Build a minimal 2xx `Response` for tests that patch `requests.request`. + + Returned value has `status_code = 200` and an empty JSON body, + which is enough to pass through `make_request`'s status check + without the test having to construct a full HTTP round-trip. + """ + response = Response() + response.status_code = 200 + response._content = b"{}" + return response + + +def snowflake_connection_config_s3(): + return SnowflakeConnectionConfig( + name="snowflake_s3", + database="test_db", + user="snowflake_user", + snowflake_account_id="ACCOUNT-123", + snowflake_warehouse="test_warehouse", + snowflake_storage_integration_name="test_integration", + password="test_password", + snowflake_stage_location=SnowflakeStageLocation.aws_s3, + s3_bucket_name="test-bucket", + iam_role_arn="arn:aws:iam::123456789012:role/test-role", + ) + + +def snowflake_connection_config_azure(): + return SnowflakeConnectionConfig( + name="snowflake_azure", + database="test_db", + user="snowflake_user", + snowflake_account_id="ACCOUNT-456", + snowflake_warehouse="test_warehouse", + snowflake_storage_integration_name="test_integration", + password="test_password", + snowflake_stage_location=SnowflakeStageLocation.azure_blob_storage, + snowflake_azure_container_name="test-container", + snowflake_azure_connection_string="DefaultEndpointsProtocol=https;AccountName=test;AccountKey=test", + ) + + +def snowflake_connection_config_local(): + return SnowflakeConnectionConfig( + name="snowflake_local", + database="test_db", + user="snowflake_user", + snowflake_account_id="ACCOUNT-789", + snowflake_warehouse="test_warehouse", + snowflake_storage_integration_name="test_integration", + password="test_password", + snowflake_stage_location=SnowflakeStageLocation.local, + ) diff --git a/tests/test_base.py b/tests/test_base.py new file mode 100644 index 0000000..00bf32f --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,321 @@ +"""Tests for `BaseClient` (auth, healthcheck, make_request, re-auth retry).""" + +import logging +import warnings +from unittest.mock import patch + +import pytest +import requests +import requests_mock +from urllib3.exceptions import InsecureRequestWarning + +from datamasque.client import DataMasqueClient, RunId +from datamasque.client.exceptions import ( + DataMasqueApiError, + DataMasqueNotReadyError, + DataMasqueTransportError, + DataMasqueUserError, +) +from datamasque.client.models.dm_instance import DataMasqueInstanceConfig +from tests.helpers import make_ok_response + + +def test_authenticate(client): + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/auth/token/login/", + json={"key": "test_token"}, + status_code=200, + ) + client.authenticate() + assert client.token == "Token test_token" + + +def test_authenticate_failure(client): + with requests_mock.Mocker() as m: + m.post("http://test-server/api/auth/token/login/", status_code=400) + with pytest.raises(DataMasqueApiError): + client.authenticate() + + +def test_healthcheck_ok(client): + """`healthcheck` returns without error when the server responds 200.""" + with requests_mock.Mocker() as m: + m.get("http://test-server/api/healthcheck/", status_code=200) + client.healthcheck() + + assert m.call_count == 1 + assert m.last_request.method == "GET" + assert "Authorization" not in m.last_request.headers + + +def test_healthcheck_server_not_ready(client): + """ + `healthcheck` raises `DataMasqueNotReadyError` on a 502 response. + + A 502 from the ingress/gateway typically means the application container + is still starting up and not yet accepting connections. + """ + with requests_mock.Mocker() as m: + m.get("http://test-server/api/healthcheck/", status_code=502) + with pytest.raises(DataMasqueNotReadyError): + client.healthcheck() + + +def test_healthcheck_transport_failure(client): + """`healthcheck` raises `DataMasqueTransportError` when the server cannot be reached.""" + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/healthcheck/", + exc=requests.exceptions.ConnectionError("connection refused"), + ) + with pytest.raises(DataMasqueTransportError): + client.healthcheck() + + +@pytest.mark.parametrize("verify_ssl", [True, False]) +def test_make_request_verify_ssl_true_by_default(config, verify_ssl): + """Verifies SSL setting is passed through to the `requests` call.""" + config_with_ssl = DataMasqueInstanceConfig( + base_url=config.base_url, + username=config.username, + password=config.password, + verify_ssl=verify_ssl, + ) + client = DataMasqueClient(config_with_ssl) + + with patch( + "datamasque.client.base.requests.request", + return_value=make_ok_response(), + ) as mock_request: + client.make_request("GET", "/api/test/") + + _, kwargs = mock_request.call_args + assert kwargs["verify"] is verify_ssl + + +def test_make_request_verify_ssl_true_does_not_touch_global_warning_filter(client): + """With `verify_ssl=True`, the client should not modify `warnings.filters`.""" + filters_before = list(warnings.filters) + + with patch( + "datamasque.client.base.requests.request", + return_value=make_ok_response(), + ): + client.make_request("GET", "/api/test/") + + assert warnings.filters == filters_before + + +def test_make_request_verify_ssl_false_suppresses_warning_locally(config): + """With `verify_ssl=False`, `InsecureRequestWarning` is suppressed only for the duration of the request.""" + insecure_config = DataMasqueInstanceConfig( + base_url=config.base_url, + username=config.username, + password=config.password, + verify_ssl=False, + ) + client = DataMasqueClient(insecure_config) + + def raise_insecure_warning_then_respond(*_args, **_kwargs): + warnings.warn("unverified HTTPS request", InsecureRequestWarning, stacklevel=2) + return make_ok_response() + + filters_before = list(warnings.filters) + + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") # ensure we'd otherwise see the warning + with patch( + "datamasque.client.base.requests.request", + side_effect=raise_insecure_warning_then_respond, + ): + client.make_request("GET", "/api/test/") + + # The warning raised inside the request call was suppressed by the client. + assert not any(issubclass(w.category, InsecureRequestWarning) for w in captured) + + # The outer filter stack is restored — no leaked `ignore` entry. + assert warnings.filters == filters_before + + +def test_make_request_redacts_sensitive_fields_in_error_log(client, caplog): + """Secrets in `data` must not be written to the error log when a request fails.""" + request_data = { + "username": "joebloggs", + "password": "hunter2", + "re_password": "hunter2", + "api_token": "sk-live-xyz", + "access_key_id": "AKIAIOSFODNN7EXAMPLE", + "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "my_credential": "blob", + "PublicKey": "upper-case still matches 'key' case-insensitively", + "description": "not secret", + } + + with requests_mock.Mocker() as m: + m.post("http://test-server/api/anything/", text="boom", status_code=500) + with caplog.at_level(logging.ERROR, logger="datamasque.client.base"): + with pytest.raises(DataMasqueApiError): + client.make_request("POST", "/api/anything/", data=request_data) + + request_log_lines = [r.getMessage() for r in caplog.records if "Request data was" in r.getMessage()] + assert len(request_log_lines) == 1 + log_line = request_log_lines[0] + + for secret in [ + "hunter2", + "sk-live-xyz", + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "blob", + "upper-case still matches 'key' case-insensitively", + ]: + assert secret not in log_line, f'Leaked secret "{secret}" in log: {log_line}' + + for sensitive_key in [ + "password", + "re_password", + "api_token", + "access_key_id", + "secret_access_key", + "my_credential", + "PublicKey", + ]: + assert f"'{sensitive_key}': ''" in log_line, f"Missing redaction for {sensitive_key} in: {log_line}" + + # Non-sensitive fields pass through unchanged + assert "'username': 'joebloggs'" in log_line + assert "'description': 'not secret'" in log_line + + +def test_make_request_non_dict_request_data_not_logged(client, caplog): + """When request data = a non-dict, it should not be logged.""" + with requests_mock.Mocker() as m: + m.post("http://test-server/api/anything/", status_code=500) + with caplog.at_level(logging.ERROR, logger="datamasque.client.base"): + with pytest.raises(DataMasqueApiError): + # make_request's signature says `data: Optional[dict]`, + # but guard against a caller passing e.g. a list anyway. + client.make_request("POST", "/api/anything/", data=["not", "a", "dict"]) # type: ignore[arg-type] + + assert not any("Request data was" in r.getMessage() for r in caplog.records) + + +def test_make_request_empty_dict_logged(client, caplog): + """Request data = empty dict is still logged.""" + with requests_mock.Mocker() as m: + m.post("http://test-server/api/anything/", status_code=500) + with caplog.at_level(logging.ERROR, logger="datamasque.client.base"): + with pytest.raises(DataMasqueApiError): + client.make_request("POST", "/api/anything/", data={}) + + assert any("Request data was: {}" in r.getMessage() for r in caplog.records) + + +def test_re_authenticate(config): + with patch.object(DataMasqueClient, "authenticate") as mock_auth: + client = DataMasqueClient(config) + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/runs/1/", + [ + {"status_code": 401}, + { + "json": { + "id": 1, + "status": "finished", + "mask_type": "database", + "source_connection_name": "c", + "ruleset_name": "r", + }, + "status_code": 200, + }, + ], + ) + client.get_run_info(RunId(1)) + mock_auth.assert_called_once() + + +def test_authenticate_uses_token_source_when_provided(): + """`authenticate` invokes `token_source` instead of POSTing username/password.""" + token_source = lambda: "callable-token" # noqa: E731 + config = DataMasqueInstanceConfig( + base_url="http://test-server", + username="test_user", + token_source=token_source, + ) + client = DataMasqueClient(config) + + with requests_mock.Mocker() as m: + # If `authenticate` mistakenly went over HTTP, this matcher would assert and the request would 404. + client.authenticate() + assert m.call_count == 0 + + assert client.token == "Token callable-token" + + +def test_token_source_called_again_on_401_retry(): + """A 401 mid-request triggers re-auth, which must call `token_source` again (token may have rotated).""" + tokens = iter(["t1", "t2"]) + config = DataMasqueInstanceConfig( + base_url="http://test-server", + username="test_user", + token_source=lambda: next(tokens), + ) + client = DataMasqueClient(config) + client.authenticate() # consumes "t1" + assert client.token == "Token t1" + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/runs/1/", + [ + {"status_code": 401}, + { + "json": { + "id": 1, + "status": "finished", + "mask_type": "database", + "source_connection_name": "c", + "ruleset_name": "r", + }, + "status_code": 200, + }, + ], + ) + client.get_run_info(RunId(1)) + + # The retry consumed the second token from the iterator. + assert client.token == "Token t2" + + +def test_token_source_callable_exception_propagates(): + """Errors from `token_source` are surfaced to the caller, not swallowed.""" + + def boom() -> str: + raise RuntimeError("provider unavailable") + + config = DataMasqueInstanceConfig( + base_url="http://test-server", + username="test_user", + token_source=boom, + ) + client = DataMasqueClient(config) + + with pytest.raises(RuntimeError, match="provider unavailable"): + client.authenticate() + + +def test_instance_config_rejects_neither_password_nor_token_source(): + with pytest.raises(DataMasqueUserError, match="Exactly one of `password` or `token_source`"): + DataMasqueInstanceConfig(base_url="http://test-server", username="test_user") + + +def test_instance_config_rejects_both_password_and_token_source(): + with pytest.raises(DataMasqueUserError, match="Exactly one of `password` or `token_source`"): + DataMasqueInstanceConfig( + base_url="http://test-server", + username="test_user", + password="pw", + token_source=lambda: "tok", + ) diff --git a/tests/test_connections.py b/tests/test_connections.py new file mode 100644 index 0000000..ff8dd30 --- /dev/null +++ b/tests/test_connections.py @@ -0,0 +1,1158 @@ +"""Tests for `ConnectionClient` (CRUD + Snowflake-specific behaviour).""" + +import pytest +import requests_mock + +from datamasque.client.exceptions import DataMasqueApiError, DataMasqueException +from datamasque.client.models.connection import ( + AzureConnectionConfig, + ConnectionId, + DatabaseConnectionConfig, + DatabaseType, + DynamoConnectionConfig, + MongoConnectionConfig, + MountedShareConnectionConfig, + MssqlLinkedServerConnectionConfig, + S3ConnectionConfig, + SnowflakeConnectionConfig, + SnowflakeStageLocation, + SseConfig, + SseSelection, + validate_connection, +) +from tests.helpers import ( + sample_mounted_share_connection_json, + snowflake_connection_config_azure, + snowflake_connection_config_local, + snowflake_connection_config_s3, +) + + +@pytest.mark.parametrize("connection_config", ["database", "file"], indirect=True) +def test_create_or_update_connection(client, connection_config): + """Create a new connection, the test is parameterized to run with both a db and file connection.""" + with requests_mock.Mocker() as m: + m.get("http://test-server/api/connections/", json=[], status_code=200) # no existing connections + m.post("http://test-server/api/connections/", json={"id": "2"}, status_code=201) + + result = client.create_or_update_connection(connection_config) + assert result.id == ConnectionId("2") + + +@pytest.mark.parametrize("connection_config", ["database"], indirect=True) +@pytest.mark.parametrize("engine_options", [None, {}, {"pool_size": 5}]) +def test_create_or_update_connection_engine_options(client, connection_config, engine_options): + """Create a new connection with engine options. They should only be passed on the API if truthy.""" + connection_config.engine_options = engine_options + with requests_mock.Mocker() as m: + m.get("http://test-server/api/connections/", json=[], status_code=200) # no existing connections + m.post("http://test-server/api/connections/", json={"id": "2"}, status_code=201) + + result = client.create_or_update_connection(connection_config) + assert result.id == ConnectionId("2") + + request_body = m.last_request.json() + if engine_options: + assert request_body["engine_options"] == engine_options + else: + assert "engine_options" not in request_body + + +def test_create_or_update_connection_update(client, connection_config): + """Update a connection.""" + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/connections/", + json=[ + connection_config.model_dump(exclude_none=True, by_alias=True, mode="json") + | {"id": "1", "mask_type": "database"} + ], + status_code=200, + ) + m.put("http://test-server/api/connections/1/", json={"id": "1"}, status_code=200) + result = client.create_or_update_connection(connection_config) + assert result.id == ConnectionId("1") + + +def test_create_or_update_connection_create_fail(client, connection_config, existing_connection_json): + """Fail to create a connection.""" + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/connections/", + json=[existing_connection_json], + status_code=200, + ) + m.post("http://test-server/api/connections/", status_code=400) + + with pytest.raises(DataMasqueApiError): + client.create_or_update_connection(connection_config) + + +def test_list_connnections(client): + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/connections/", + json=[ + # Realistic API data, hence why it's so verbose + { + "name": "s3", + "bucket": "my-s3-bucket", + "base_directory": "", + "type": "s3_connection", + "mask_type": "file", + "version": "1.0", + "id": "88dabb63-aca5-4cc4-8f76-f78736a42f39", + "oracle_wallet": None, + "connection_fileset": None, + "is_file_mask_source": True, + "is_file_mask_destination": False, + }, + { + "name": "azure", + "type": "azure_blob_connection", + "base_directory": "", + "mask_type": "file", + "container": "mycontainer", + "version": "1.0", + "connection_string_encrypted": "some_base64_here", + "id": "490502e5-5bf6-4abb-b67b-c6091d40ecf0", + "oracle_wallet": None, + "connection_fileset": None, + "is_file_mask_source": True, + "is_file_mask_destination": True, + }, + { + "name": "mounted_share_dest", + "type": "mounted_share_connection", + "base_directory": "dest", + "mask_type": "file", + "version": "1.0", + "id": "7ba07e3d-f917-4bee-bfc0-c42b9b01a06e", + "oracle_wallet": None, + "connection_fileset": None, + "is_file_mask_source": False, + "is_file_mask_destination": True, + }, + { + "version": "1.0", + "host": "my-mysql-host", + "port": 3306, + "user": "me", + "db_type": "mysql", + "database": "mydatabase", + "name": "mysql", + "schema": "", + "is_read_only": False, + "password_encrypted": "some_base64_here", + "id": "f0557fb3-1c9a-4cb1-bcf4-9699cf496bf7", + "oracle_wallet": None, + "connection_fileset": None, + "mask_type": "database", + "is_file_mask_source": False, + "is_file_mask_destination": False, + }, + { + "version": "1.0", + "mask_type": "database", + "name": "db_dynamo", + "s3_bucket_name": "my-dynamo-staging-bucket", + "dynamo_append_datetime": False, + "dynamo_append_suffix": "-masked", + "dynamo_replace_tables": True, + "dynamo_default_region": None, + "dynamo_default_sse": { + "selection": "account_managed", + "kms_key_id": "arn:aws:kms:us-west-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", + }, + "db_type": "dynamo_db", + "host": "", + "port": None, + "user": "", + "password": "", + "database": "", + "schema": "", + "id": "d7257552-0485-4806-b0fb-d72b4d268073", + "oracle_wallet": None, + "connection_fileset": None, + "is_file_mask_source": False, + "is_file_mask_destination": False, + }, + { + "version": "1.0", + "host": "mssql-linked-host", + "port": 3306, + "user": "mine", + "db_type": "mssql_linked", + "database": "database_name", + "name": "mssql-linked", + "schema": "", + "is_read_only": False, + "password_encrypted": "some_base64_here", + "id": "48a7af45-f63f-4e05-bf9f-7b1cc3a0e89d", + "oracle_wallet": None, + "connection_fileset": None, + "mask_type": "database", + "linked_server": "name.database.schema", + "is_file_mask_source": False, + "is_file_mask_destination": False, + }, + { + "version": "1.0", + "mask_type": "database", + "name": "db_dynamo_2", + "s3_bucket_name": "my-dynamo-staging-bucket-2", + "dynamo_append_datetime": False, + "dynamo_append_suffix": "-masked", + "dynamo_replace_tables": True, + "dynamo_default_region": None, + "db_type": "dynamo_db", + "host": "", + "port": None, + "user": "", + "password": "", + "database": "", + "schema": "", + "id": "d7257552-0485-4806-b0fb-d72b4d123456", + "oracle_wallet": None, + "connection_fileset": None, + "is_file_mask_source": False, + "is_file_mask_destination": False, + }, + { + "version": "1.0", + "user": "snowman", + "db_type": "snowflake", + "database": "icicle", + "name": "snowflake", + "schema": "snowball", + "snowflake_role": "snowballs do indeed roll", + "snowflake_account_id": "ABCDEF-123456", + "snowflake_warehouse": "warehouse1", + "snowflake_storage_integration_name": "mysi", + "host": "snowflake.com", + "port": 443, + "s3_bucket_name": "ice-bucket", + "iam_role_arn": "swiss roll", + "is_read_only": False, + "password_encrypted": "some_base64_here", + "id": "f0557fb3-1c9a-4cb1-bcf4-9699cf496bf7", + "oracle_wallet": None, + "connection_fileset": None, + "mask_type": "database", + "is_file_mask_source": False, + "is_file_mask_destination": False, + }, + { + "version": "1.0", + "user": "frosty", + "db_type": "snowflake", + "database": "igloo", + "name": "snowflake_minimal_with_key", + "snowflake_account_id": "ACCOUNT-1234", + "snowflake_warehouse": "clothing_store", + "snowflake_storage_integration_name": "kennards", + "s3_bucket_name": "champagne-bucket", + "snowflake_private_key": "2831289a-4398-abcd-4112-fe09a1239f89", + "snowflake_private_key_passphrase_encrypted": "some base64 here", + "id": "f0557fb3-1c9a-4cb1-bcf4-9699cf496bf7", + "mask_type": "database", + }, + ], + status_code=200, + ) + connections = client.list_connections() + assert len(connections) == 9 + + s3_connection = connections[0] + assert isinstance(s3_connection, S3ConnectionConfig) + assert s3_connection.id == "88dabb63-aca5-4cc4-8f76-f78736a42f39" + assert s3_connection.bucket == "my-s3-bucket" + assert s3_connection.base_directory == "" + + azure_connection = connections[1] + assert isinstance(azure_connection, AzureConnectionConfig) + assert azure_connection.container == "mycontainer" + assert azure_connection.connection_string is None + assert azure_connection.is_file_mask_source is True + assert azure_connection.is_file_mask_destination is True + + mounted_share_connection = connections[2] + assert isinstance(mounted_share_connection, MountedShareConnectionConfig) + assert mounted_share_connection.is_file_mask_source is False + assert mounted_share_connection.is_file_mask_destination is True + assert mounted_share_connection.base_directory == "dest" + + database_connection = connections[3] + assert isinstance(database_connection, DatabaseConnectionConfig) + assert database_connection.database_type is DatabaseType.mysql + assert database_connection.database == "mydatabase" + assert database_connection.user == "me" + assert database_connection.password is None + assert database_connection.db_schema is None + + dynamo_connection = connections[4] + assert isinstance(dynamo_connection, DynamoConnectionConfig) + assert dynamo_connection.s3_bucket_name == "my-dynamo-staging-bucket" + assert dynamo_connection.dynamo_append_datetime is False + assert dynamo_connection.dynamo_replace_tables is True + assert dynamo_connection.dynamo_append_suffix == "-masked" + assert dynamo_connection.dynamo_default_sse == SseConfig( + selection=SseSelection.account_managed, + kms_key_id="arn:aws:kms:us-west-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", + ) + + mssql_linked_connection = connections[5] + assert isinstance(mssql_linked_connection, MssqlLinkedServerConnectionConfig) + assert mssql_linked_connection.database_type is DatabaseType.mssql_linked + assert mssql_linked_connection.database == "database_name" + assert mssql_linked_connection.user == "mine" + assert mssql_linked_connection.password is None + assert mssql_linked_connection.db_schema == "" + assert mssql_linked_connection.linked_server == "name.database.schema" + + dynamo_connection = connections[6] + assert isinstance(dynamo_connection, DynamoConnectionConfig) + assert dynamo_connection.s3_bucket_name == "my-dynamo-staging-bucket-2" + assert dynamo_connection.dynamo_append_datetime is False + assert dynamo_connection.dynamo_replace_tables is True + assert dynamo_connection.dynamo_append_suffix == "-masked" + assert dynamo_connection.dynamo_default_sse == SseConfig( + selection=SseSelection.dynamodb_owned, + kms_key_id=None, + ) + + snowflake_connection = connections[7] + assert isinstance(snowflake_connection, SnowflakeConnectionConfig) + assert snowflake_connection.database_type is DatabaseType.snowflake + assert snowflake_connection.database == "icicle" + assert snowflake_connection.db_schema == "snowball" + assert snowflake_connection.host == "snowflake.com" + assert snowflake_connection.port == 443 + assert snowflake_connection.user == "snowman" + assert snowflake_connection.snowflake_role == "snowballs do indeed roll" + assert snowflake_connection.snowflake_account_id == "ABCDEF-123456" + assert snowflake_connection.snowflake_warehouse == "warehouse1" + assert snowflake_connection.snowflake_storage_integration_name == "mysi" + assert snowflake_connection.s3_bucket_name == "ice-bucket" + assert snowflake_connection.iam_role_arn == "swiss roll" + assert snowflake_connection.is_read_only is False + + minimal_snowflake_connection = connections[8] + assert isinstance(minimal_snowflake_connection, SnowflakeConnectionConfig) + assert minimal_snowflake_connection.database_type is DatabaseType.snowflake + assert minimal_snowflake_connection.database == "igloo" + assert minimal_snowflake_connection.db_schema is None + assert minimal_snowflake_connection.host == "" + assert minimal_snowflake_connection.port is None + assert minimal_snowflake_connection.user == "frosty" + assert minimal_snowflake_connection.snowflake_role == "" + assert minimal_snowflake_connection.snowflake_account_id == "ACCOUNT-1234" + assert minimal_snowflake_connection.snowflake_warehouse == "clothing_store" + assert minimal_snowflake_connection.snowflake_storage_integration_name == "kennards" + assert minimal_snowflake_connection.s3_bucket_name == "champagne-bucket" + assert minimal_snowflake_connection.iam_role_arn is None + assert minimal_snowflake_connection.is_read_only is False + assert minimal_snowflake_connection.snowflake_private_key == "2831289a-4398-abcd-4112-fe09a1239f89" + + +def test_delete_connection_by_id(client): + connection_id = ConnectionId("f0557fb3-1c9a-4cb1-bcf4-9699cf496bf7") + with requests_mock.Mocker() as m: + m.delete(f"http://test-server/api/connections/{connection_id}/", status_code=200) + client.delete_connection_by_id_if_exists(connection_id) + + +def test_delete_connection_by_name(client): + connection_name = "my-connection" + connection_id = "f0557fb3-1c9a-4cb1-bcf4-9699cf496bf7" + connection_with_same_name_id = "abcd1234-1234-5678-90ab-cdefcdefcdef" + other_connection_id = "bcbcbcbc-5454-6565-7676-123412341234" + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/connections/", + json=[ + sample_mounted_share_connection_json(name=connection_name, id=connection_id), + sample_mounted_share_connection_json(name="some_other_connection", id=other_connection_id), + # There shouldn't ever be two connections with the same name, but we check both are deleted + sample_mounted_share_connection_json(name=connection_name, id=connection_with_same_name_id), + ], + status_code=200, + ) + m.delete(f"http://test-server/api/connections/{connection_id}/", status_code=200) + m.delete( + f"http://test-server/api/connections/{connection_with_same_name_id}/", + status_code=200, + ) + + client.delete_connection_by_name_if_exists(connection_name) + + assert m.call_count == 3 + assert m.request_history[0].method == "GET" + assert m.request_history[1].method == "DELETE" + assert m.request_history[2].method == "DELETE" + + +def test_delete_connection_that_does_not_exist(client): + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/connections/", + json=[ + sample_mounted_share_connection_json( + name="not_this_connection", + id="abcd1234-1234-5678-90ab-cdefcdefcdef", + ), + sample_mounted_share_connection_json( + name="not_this_connection_either", + id="bcbcbcbc-5454-6565-7676-123412341234", + ), + ], + status_code=200, + ) + client.delete_connection_by_name_if_exists("my_connection") + + assert m.call_count == 1 + assert m.request_history[0].method == "GET" + + +@pytest.mark.parametrize( + "config_func,expected_stage_location,expected_fields,unexpected_fields", + [ + ( + snowflake_connection_config_s3, + "aws_s3", + ["s3_bucket_name", "iam_role_arn"], + ["snowflake_azure_container_name", "snowflake_azure_connection_string"], + ), + ( + snowflake_connection_config_azure, + "azure_blob_storage", + ["snowflake_azure_container_name", "snowflake_azure_connection_string"], + ["s3_bucket_name", "iam_role_arn"], + ), + ( + snowflake_connection_config_local, + "local", + [], + [ + "s3_bucket_name", + "iam_role_arn", + "snowflake_azure_container_name", + "snowflake_azure_connection_string", + ], + ), + ], +) +def test_create_snowflake_connection_with_staging_platform( + client, config_func, expected_stage_location, expected_fields, unexpected_fields +): + """Test creating Snowflake connections with different staging platforms.""" + config = config_func() + + with requests_mock.Mocker() as m: + m.get("http://test-server/api/connections/", json=[], status_code=200) + m.post("http://test-server/api/connections/", json={"id": "2"}, status_code=201) + + result = client.create_or_update_connection(config) + assert result.id == ConnectionId("2") + + # Verify the correct data was sent + request_data = m.last_request.json() + assert request_data["snowflake_stage_location"] == expected_stage_location + + # Check expected fields are present + for field in expected_fields: + assert field in request_data + + # Check unexpected fields are not present + for field in unexpected_fields: + assert field not in request_data + + +@pytest.mark.parametrize( + "config_func,expected_stage_location,expected_fields,unexpected_fields", + [ + ( + snowflake_connection_config_s3, + SnowflakeStageLocation.aws_s3, + { + "s3_bucket_name": "test-bucket", + "iam_role_arn": "arn:aws:iam::123456789012:role/test-role", + }, + ["snowflake_azure_container_name", "snowflake_azure_connection_string"], + ), + ( + snowflake_connection_config_azure, + SnowflakeStageLocation.azure_blob_storage, + { + "snowflake_azure_container_name": "test-container", + "snowflake_azure_connection_string": "DefaultEndpointsProtocol=https;AccountName=test;AccountKey=test", + }, + ["s3_bucket_name", "iam_role_arn"], + ), + ( + snowflake_connection_config_local, + SnowflakeStageLocation.local, + {}, + [ + "s3_bucket_name", + "iam_role_arn", + "snowflake_azure_container_name", + "snowflake_azure_connection_string", + ], + ), + ], +) +def test_snowflake_connection_model_dump(config_func, expected_stage_location, expected_fields, unexpected_fields): + """Test that Snowflake connections serialize correctly for each staging platform.""" + config = config_func() + api_dict = config.model_dump(exclude_none=True, by_alias=True, mode="json") + + assert api_dict["snowflake_stage_location"] == expected_stage_location + + # Check expected fields and their values + for field, value in expected_fields.items(): + assert api_dict[field] == value + + # Check unexpected fields are not present + for field in unexpected_fields: + assert field not in api_dict + + +def test_list_snowflake_connections_with_different_platforms(client): + """Test listing Snowflake connections returns correct staging platform information.""" + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/connections/", + json=[ + { + "version": "1.0", + "user": "s3_user", + "db_type": "snowflake", + "database": "s3_db", + "name": "snowflake_s3", + "snowflake_account_id": "S3-ACCOUNT", + "snowflake_warehouse": "s3_warehouse", + "snowflake_storage_integration_name": "s3_integration", + "s3_bucket_name": "s3-bucket", + "iam_role_arn": "arn:aws:iam::123456789012:role/s3-role", + "snowflake_stage_location": "aws_s3", + "password_encrypted": "encrypted", + "id": "s3-connection-id", + "mask_type": "database", + }, + { + "version": "1.0", + "user": "azure_user", + "db_type": "snowflake", + "database": "azure_db", + "name": "snowflake_azure", + "snowflake_account_id": "AZURE-ACCOUNT", + "snowflake_warehouse": "azure_warehouse", + "snowflake_storage_integration_name": "azure_integration", + "snowflake_azure_container_name": "azure-container", + "snowflake_azure_connection_string_encrypted": "encrypted_azure_string", + "snowflake_stage_location": "azure_blob_storage", + "password_encrypted": "encrypted", + "id": "azure-connection-id", + "mask_type": "database", + }, + { + "version": "1.0", + "user": "local_user", + "db_type": "snowflake", + "database": "local_db", + "name": "snowflake_local", + "snowflake_account_id": "LOCAL-ACCOUNT", + "snowflake_warehouse": "local_warehouse", + "snowflake_storage_integration_name": "local_integration", + "snowflake_stage_location": "local", + "password_encrypted": "encrypted", + "id": "local-connection-id", + "mask_type": "database", + }, + ], + status_code=200, + ) + + connections = client.list_connections() + snowflake_connections = [c for c in connections if isinstance(c, SnowflakeConnectionConfig)] + assert len(snowflake_connections) == 3 + + # Check S3 connection + s3_conn = next(c for c in snowflake_connections if c.name == "snowflake_s3") + assert s3_conn.snowflake_stage_location is SnowflakeStageLocation.aws_s3 + assert s3_conn.s3_bucket_name == "s3-bucket" + assert s3_conn.iam_role_arn == "arn:aws:iam::123456789012:role/s3-role" + assert s3_conn.snowflake_azure_container_name is None + assert s3_conn.snowflake_azure_connection_string is None + + # Check Azure connection + azure_conn = next(c for c in snowflake_connections if c.name == "snowflake_azure") + assert azure_conn.snowflake_stage_location is SnowflakeStageLocation.azure_blob_storage + assert azure_conn.snowflake_azure_container_name == "azure-container" + assert azure_conn.snowflake_azure_connection_string is None # Encrypted, so empty + assert azure_conn.s3_bucket_name is None + assert azure_conn.iam_role_arn is None + + # Check local connection + local_conn = next(c for c in snowflake_connections if c.name == "snowflake_local") + assert local_conn.snowflake_stage_location is SnowflakeStageLocation.local + assert local_conn.s3_bucket_name is None + assert local_conn.iam_role_arn is None + assert local_conn.snowflake_azure_container_name is None + assert local_conn.snowflake_azure_connection_string is None + + +@pytest.mark.parametrize( + "stage_location,missing_fields,error_message", + [ + ( + SnowflakeStageLocation.azure_blob_storage, + { + "snowflake_azure_container_name": None, + "snowflake_azure_connection_string": None, + }, + "Missing Azure fields", + ), + ( + SnowflakeStageLocation.aws_s3, + { + "s3_bucket_name": None, + "iam_role_arn": None, # IAM role is optional, so only s3_bucket_name is truly required + }, + "Missing S3 bucket", + ), + ], +) +def test_create_snowflake_connection_missing_required_fields(client, stage_location, missing_fields, error_message): + """Test that creating a Snowflake connection with missing required fields fails appropriately.""" + config_dict = { + "name": f"invalid_{stage_location.value}", + "database": "test_db", + "user": "snowflake_user", + "snowflake_account_id": "ACCOUNT-123", + "snowflake_warehouse": "test_warehouse", + "snowflake_storage_integration_name": "test_integration", + "password": "test_password", + "snowflake_stage_location": stage_location, + } + + # Add the missing fields + config_dict.update(missing_fields) + + config = SnowflakeConnectionConfig(**config_dict) + + with requests_mock.Mocker() as m: + m.get("http://test-server/api/connections/", json=[], status_code=200) + m.post( + "http://test-server/api/connections/", + json={"error": error_message}, + status_code=400, + ) + + with pytest.raises(DataMasqueApiError): + client.create_or_update_connection(config) + + +def test_s3_connection_model_validate(): + payload = { + "id": "88dabb63-aca5-4cc4-8f76-f78736a42f39", + "name": "s3", + "mask_type": "file", + "type": "s3_connection", + "base_directory": "data/", + "is_file_mask_source": True, + "is_file_mask_destination": False, + "bucket": "my-bucket", + "iam_role_arn": "arn:aws:iam::111122223333:role/s3-role", + } + + conn = S3ConnectionConfig.model_validate(payload) + + assert isinstance(conn, S3ConnectionConfig) + assert conn.id == "88dabb63-aca5-4cc4-8f76-f78736a42f39" + assert conn.name == "s3" + assert conn.bucket == "my-bucket" + assert conn.base_directory == "data/" + assert conn.is_file_mask_source is True + assert conn.is_file_mask_destination is False + assert conn.iam_role_arn == "arn:aws:iam::111122223333:role/s3-role" + + +def test_s3_connection_model_validate_no_iam_role(): + payload = { + "id": "id-1", + "name": "s3", + "mask_type": "file", + "type": "s3_connection", + "base_directory": "", + "is_file_mask_source": True, + "is_file_mask_destination": False, + "bucket": "my-bucket", + } + + conn = S3ConnectionConfig.model_validate(payload) + assert conn.iam_role_arn is None + + +def test_azure_connection_model_validate_blanks_encrypted_connection_string(): + payload = { + "id": "490502e5-5bf6-4abb-b67b-c6091d40ecf0", + "name": "azure", + "mask_type": "file", + "type": "azure_blob_connection", + "base_directory": "", + "container": "mycontainer", + "is_file_mask_source": True, + "is_file_mask_destination": True, + # The API only returns the encrypted form; the plaintext is never sent back. + "connection_string_encrypted": "some_base64_here", + } + + conn = AzureConnectionConfig.model_validate(payload) + + assert isinstance(conn, AzureConnectionConfig) + assert conn.container == "mycontainer" + assert conn.connection_string is None + assert conn.id == "490502e5-5bf6-4abb-b67b-c6091d40ecf0" + + +def test_mounted_share_connection_model_validate(): + payload = sample_mounted_share_connection_json(id="7ba07e3d-f917-4bee-bfc0-c42b9b01a06e", name="mount") + + conn = MountedShareConnectionConfig.model_validate(payload) + + assert isinstance(conn, MountedShareConnectionConfig) + assert conn.name == "mount" + assert conn.base_directory == "" + assert conn.id == "7ba07e3d-f917-4bee-bfc0-c42b9b01a06e" + + +def test_database_connection_model_validate_drops_schema_for_mysql(): + payload = { + "id": "f0557fb3-1c9a-4cb1-bcf4-9699cf496bf7", + "name": "mysql", + "mask_type": "database", + "db_type": "mysql", + "host": "my-mysql-host", + "port": 3306, + "database": "mydatabase", + "user": "me", + "schema": "should_be_dropped", # MySQL has no schemas — must be discarded. + "is_read_only": False, + } + + conn = DatabaseConnectionConfig.model_validate(payload) + + assert isinstance(conn, DatabaseConnectionConfig) + assert conn.database_type is DatabaseType.mysql + assert conn.db_schema is None + assert conn.password is None + + +def test_database_connection_model_validate_keeps_schema_for_postgres(): + payload = { + "id": "abc", + "name": "pg", + "mask_type": "database", + "db_type": "postgres", + "host": "pg-host", + "port": 5432, + "database": "pgdb", + "user": "pg", + "schema": "public", + "is_read_only": False, + } + + conn = DatabaseConnectionConfig.model_validate(payload) + assert conn.db_schema == "public" + + +def test_mssql_linked_connection_model_validate_includes_linked_server(): + payload = { + "id": "48a7af45-f63f-4e05-bf9f-7b1cc3a0e89d", + "name": "mssql-linked", + "mask_type": "database", + "db_type": "mssql_linked", + "host": "mssql-linked-host", + "port": 3306, + "database": "database_name", + "user": "mine", + "schema": "", + "is_read_only": False, + "linked_server": "name.database.schema", + } + + conn = MssqlLinkedServerConnectionConfig.model_validate(payload) + + assert isinstance(conn, MssqlLinkedServerConnectionConfig) + assert conn.database_type is DatabaseType.mssql_linked + assert conn.linked_server == "name.database.schema" + + +def test_dynamo_connection_model_validate_with_sse(): + payload = { + "id": "d7257552-0485-4806-b0fb-d72b4d268073", + "name": "db_dynamo", + "mask_type": "database", + "db_type": "dynamo_db", + "s3_bucket_name": "my-dynamo-staging-bucket", + "dynamo_append_datetime": False, + "dynamo_append_suffix": "-masked", + "dynamo_replace_tables": True, + "dynamo_default_region": None, + "dynamo_default_sse": { + "selection": "account_managed", + "kms_key_id": "arn:aws:kms:us-west-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", + }, + } + + conn = DynamoConnectionConfig.model_validate(payload) + + assert isinstance(conn, DynamoConnectionConfig) + assert conn.s3_bucket_name == "my-dynamo-staging-bucket" + assert conn.dynamo_default_sse == SseConfig( + selection=SseSelection.account_managed, + kms_key_id="arn:aws:kms:us-west-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", + ) + assert conn.id == "d7257552-0485-4806-b0fb-d72b4d268073" + + +def test_dynamo_connection_model_validate_without_sse_uses_default(): + payload = { + "id": "id-2", + "name": "db_dynamo", + "mask_type": "database", + "db_type": "dynamo_db", + "s3_bucket_name": "bucket", + "dynamo_append_datetime": False, + "dynamo_append_suffix": "-masked", + "dynamo_replace_tables": True, + "dynamo_default_region": "us-east-1", + } + + conn = DynamoConnectionConfig.model_validate(payload) + + # Falls back to the dataclass default when the API omits the field. + assert conn.dynamo_default_sse == SseConfig(selection=SseSelection.dynamodb_owned, kms_key_id=None) + + +def test_snowflake_connection_model_validate_with_stage_location(): + payload = { + "id": "f0557fb3-1c9a-4cb1-bcf4-9699cf496bf7", + "name": "snowflake", + "mask_type": "database", + "db_type": "snowflake", + "user": "snowman", + "database": "icicle", + "schema": "snowball", + "snowflake_role": "snowballs do indeed roll", + "snowflake_account_id": "ABCDEF-123456", + "snowflake_warehouse": "warehouse1", + "snowflake_storage_integration_name": "mysi", + "host": "snowflake.com", + "port": 443, + "s3_bucket_name": "ice-bucket", + "iam_role_arn": "swiss roll", + "snowflake_stage_location": "aws_s3", + "is_read_only": False, + } + + conn = SnowflakeConnectionConfig.model_validate(payload) + + assert isinstance(conn, SnowflakeConnectionConfig) + assert conn.snowflake_stage_location is SnowflakeStageLocation.aws_s3 + assert conn.iam_role_arn == "swiss roll" + assert conn.password is None + + +def test_snowflake_connection_model_validate_without_stage_location(): + payload = { + "id": "id-3", + "name": "snowflake", + "mask_type": "database", + "db_type": "snowflake", + "user": "frosty", + "database": "igloo", + "snowflake_account_id": "ACCOUNT-1234", + "snowflake_warehouse": "clothing_store", + "snowflake_storage_integration_name": "kennards", + } + + conn = SnowflakeConnectionConfig.model_validate(payload) + + assert conn.snowflake_stage_location is None + assert conn.host == "" + assert conn.port is None + assert conn.db_schema is None + + +def test_connection_config_dispatch_picks_subclass(): + """`ConnectionConfig.model_validate` dispatches by `mask_type` and `type`/`db_type`.""" + s3_payload = { + "id": "id-s3", + "name": "s3", + "mask_type": "file", + "type": "s3_connection", + "base_directory": "", + "is_file_mask_source": True, + "is_file_mask_destination": False, + "bucket": "b", + } + db_payload = { + "id": "id-pg", + "name": "pg", + "mask_type": "database", + "db_type": "postgres", + "host": "h", + "port": 5432, + "database": "d", + "user": "u", + "schema": "public", + "is_read_only": False, + } + + assert isinstance(validate_connection(s3_payload), S3ConnectionConfig) + assert isinstance(validate_connection(db_payload), DatabaseConnectionConfig) + + +def test_connection_config_dispatch_unknown_mask_type_raises(): + with pytest.raises(DataMasqueException, match="Unexpected connection mask_type"): + validate_connection({"mask_type": "unknown", "id": "x", "name": "x"}) + + +def test_connection_config_dispatch_unknown_file_type_raises(): + with pytest.raises(DataMasqueException, match="Unexpected file connection type"): + validate_connection({"mask_type": "file", "type": "totally_made_up", "id": "x", "name": "x"}) + + +def test_dynamo_connection_round_trip_with_iam_role_and_prefix(): + """`iam_role_arn` and `export_s3_prefix` survive a full from-API → to-API round trip.""" + payload = { + "id": "id-dynamo-1", + "name": "db_dynamo", + "mask_type": "database", + "db_type": "dynamo_db", + "s3_bucket_name": "staging-bucket", + "dynamo_append_datetime": False, + "dynamo_append_suffix": "-masked", + "dynamo_replace_tables": True, + "dynamo_default_region": "us-east-1", + "iam_role_arn": "arn:aws:iam::111122223333:role/dynamo-role", + "export_s3_prefix": "team/dynamo/", + } + + conn = DynamoConnectionConfig.model_validate(payload) + assert conn.iam_role_arn == "arn:aws:iam::111122223333:role/dynamo-role" + assert conn.export_s3_prefix == "team/dynamo/" + + api_dict = conn.model_dump(exclude_none=True, by_alias=True, mode="json") + assert api_dict["iam_role_arn"] == "arn:aws:iam::111122223333:role/dynamo-role" + assert api_dict["export_s3_prefix"] == "team/dynamo/" + + +def test_dynamo_connection_model_dump_omits_unset_iam_role_and_prefix(): + """When the new optional fields are unset, `model_dump` must omit them entirely (not send `null`).""" + conn = DynamoConnectionConfig( + name="db_dynamo", + s3_bucket_name="bucket", + dynamo_append_datetime=False, + dynamo_append_suffix="-masked", + dynamo_replace_tables=True, + dynamo_default_region="us-east-1", + ) + + api_dict = conn.model_dump(exclude_none=True, by_alias=True, mode="json") + assert "iam_role_arn" not in api_dict + assert "export_s3_prefix" not in api_dict + + +def test_dynamo_model_validate_defaults_to_none_when_fields_absent(): + """An older server that doesn't return the new fields still deserializes cleanly.""" + payload = { + "id": "id-dynamo-2", + "name": "db_dynamo", + "mask_type": "database", + "db_type": "dynamo_db", + "s3_bucket_name": "bucket", + "dynamo_append_datetime": False, + "dynamo_append_suffix": "-masked", + "dynamo_replace_tables": True, + "dynamo_default_region": "us-east-1", + } + + conn = DynamoConnectionConfig.model_validate(payload) + assert conn.iam_role_arn is None + assert conn.export_s3_prefix is None + + +def test_mongo_connection_model_dump_minimal(): + """An unauthenticated, plain TCP connection sends only the required keys plus the defaulted booleans.""" + conn = MongoConnectionConfig( + name="mongo", + host="mongo.example", + database="people", + ) + + d = conn.model_dump(exclude_none=True, by_alias=True, mode="json") + assert d["name"] == "mongo" + assert d["db_type"] == "mongodb" + assert d["mask_type"] == "database" + assert d["host"] == "mongo.example" + assert d["port"] == 27017 + assert d["database"] == "people" + assert d["auth_source"] == "admin" + assert d["is_read_only"] is False + + +def test_mongo_connection_model_dump_full(): + conn = MongoConnectionConfig( + name="mongo", + host="mongo.example", + port=27018, + database="people", + user="alice", + password="hunter2", + auth_source="other-db", + tls=True, + direct_connection=True, + replica_set="rs0", + is_read_only=True, + ) + + d = conn.model_dump(exclude_none=True, by_alias=True, mode="json") + assert d["name"] == "mongo" + assert d["db_type"] == "mongodb" + assert d["mask_type"] == "database" + assert d["host"] == "mongo.example" + assert d["port"] == 27018 + assert d["database"] == "people" + assert d["auth_source"] == "other-db" + assert d["is_read_only"] is True + assert d["dbpassword"] == "hunter2" + assert d["tls"] is True + assert d["direct_connection"] is True + assert d["replica_set"] == "rs0" + + +def test_mongo_connection_model_dump_omits_falsy_optional_flags(): + """`tls`, `direct_connection`, `user`, `password`, and `replica_set` are only sent when truthy.""" + conn = MongoConnectionConfig( + name="mongo", + host="mongo.example", + database="people", + user="", + password="", + tls=False, + direct_connection=False, + replica_set="", + ) + + api_dict = conn.model_dump(exclude_none=True, by_alias=True, mode="json") + for absent in ("user", "dbpassword", "tls", "direct_connection", "replica_set"): + assert absent not in api_dict + + +def test_mongo_connection_model_validate_blanks_encrypted_password(): + payload = { + "id": "mongo-id-1", + "name": "mongo", + "mask_type": "database", + "db_type": "mongodb", + "host": "mongo.example", + "port": 27017, + "database": "people", + "user": "alice", + # The API only ever returns the encrypted form; the plaintext is never echoed back. + "password_encrypted": "some_base64_here", + "auth_source": "admin", + "tls": True, + "direct_connection": False, + "replica_set": "rs0", + "is_read_only": False, + } + + conn = MongoConnectionConfig.model_validate(payload) + + assert isinstance(conn, MongoConnectionConfig) + assert conn.id == "mongo-id-1" + assert conn.host == "mongo.example" + assert conn.user == "alice" + assert conn.password is None + assert conn.tls is True + assert conn.replica_set == "rs0" + assert conn.database_type is DatabaseType.mongodb + + +def test_mongo_connection_model_validate_defaults_when_optional_fields_missing(): + payload = { + "id": "mongo-id-2", + "name": "mongo-min", + "mask_type": "database", + "db_type": "mongodb", + "host": "mongo.example", + "database": "people", + } + + conn = MongoConnectionConfig.model_validate(payload) + assert conn.port == 27017 + assert conn.user == "" + assert conn.auth_source == "admin" + assert conn.tls is False + assert conn.direct_connection is False + assert conn.replica_set == "" + assert conn.is_read_only is False + + +def test_connection_config_dispatch_picks_mongo_subclass(): + payload = { + "id": "mongo-id-3", + "name": "mongo", + "mask_type": "database", + "db_type": "mongodb", + "host": "mongo.example", + "database": "people", + } + assert isinstance(validate_connection(payload), MongoConnectionConfig) + + +def test_database_connection_config_rejects_mongodb_database_type(): + """`DatabaseConnectionConfig` is for SQL engines; MongoDB users must use `MongoConnectionConfig`.""" + with pytest.raises(ValueError, match="For MongoDB"): + DatabaseConnectionConfig( + name="mongo", + host="mongo.example", + port=27017, + database="people", + user="alice", + password="hunter2", + database_type=DatabaseType.mongodb, + ) + + +def test_create_or_update_mongo_connection(client): + """End-to-end: a Mongo connection round-trips through `create_or_update_connection`.""" + conn = MongoConnectionConfig( + name="mongo", + host="mongo.example", + database="people", + user="alice", + password="hunter2", + replica_set="rs0", + ) + + with requests_mock.Mocker() as m: + m.get("http://test-server/api/connections/", json=[], status_code=200) + m.post( + "http://test-server/api/connections/", + json={"id": "mongo-id-9"}, + status_code=201, + ) + result = client.create_or_update_connection(conn) + + assert result.id == ConnectionId("mongo-id-9") + sent = m.last_request.json() + assert sent["mask_type"] == "database" + assert sent["db_type"] == "mongodb" + assert sent["dbpassword"] == "hunter2" + assert sent["replica_set"] == "rs0" diff --git a/tests/test_discovery.py b/tests/test_discovery.py new file mode 100644 index 0000000..2830287 --- /dev/null +++ b/tests/test_discovery.py @@ -0,0 +1,729 @@ +"""Tests for `DiscoveryClient` (schema discovery, ruleset generation, db-discovery report).""" + +import zipfile +from io import BytesIO, StringIO +from unittest.mock import patch + +import pytest +import requests_mock + +from datamasque.client import ( + DataMasqueClient, + FileRulesetGenerationRequest, + RulesetGenerationRequest, + RunId, + SchemaDiscoveryPage, + SchemaDiscoveryRequest, + SchemaDiscoveryResult, +) +from datamasque.client.exceptions import ( + AsyncRulesetGenerationInProgressError, + DataMasqueApiError, + DataMasqueException, + FailedToStartError, +) +from datamasque.client.models.connection import ConnectionId, DatabaseConnectionConfig, DatabaseType +from datamasque.client.models.data_selection import SelectedColumns, SelectedFileData, UserSelection +from datamasque.client.models.status import AsyncRulesetGenerationTaskStatus +from tests.helpers import parse_multipart_form + + +def test_generate_ruleset(client): + req = RulesetGenerationRequest(connection="conn-1", selected_columns={"public": {"users": ["email"]}}) + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/generate-ruleset/v2/", + content=b'version: "1.0"', + status_code=201, + ) + assert client.generate_ruleset(req) == 'version: "1.0"' + + +def test_generate_file_ruleset(client): + req = FileRulesetGenerationRequest( + connection="conn-1", + selected_data=[UserSelection(locators=[["a"]], files=["f1.csv"])], + ) + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/generate-file-ruleset/", + content=b'version: "1.0"', + status_code=201, + ) + assert client.generate_file_ruleset(req) == 'version: "1.0"' + + +def test_user_selection_accepts_mixed_locator_shapes(): + """Tabular columns use bare strings; JSON paths use list[str | int]. Both should round-trip through `model_dump`.""" + selection = UserSelection( + files=["tabular.csv", "nested.json"], + locators=[ + "email", + "phone", + ["employees", "*", "firstName"], + ["items", 0, "sku"], + ], + ) + assert selection.model_dump(mode="json") == { + "files": ["tabular.csv", "nested.json"], + "locators": [ + "email", + "phone", + ["employees", "*", "firstName"], + ["items", 0, "sku"], + ], + } + + +def test_get_db_discovery_result_report(client): + run_id = RunId(1) + include_selection_column = True + with requests_mock.Mocker() as m: + url = f"http://test-server/api/runs/{run_id}/db-discovery-results/report/" + m.get(url, text="db discovery report", status_code=200) + result = client.get_db_discovery_result_report(run_id, include_selection_column) + assert result == "db discovery report" + + # Test without selection column + include_selection_column = False + with requests_mock.Mocker() as m: + url = f"http://test-server/api/runs/{run_id}/db-discovery-results/report/?include_selection_column=false" + m.get(url, text="db discovery report without selection column", status_code=200) + result = client.get_db_discovery_result_report(run_id, include_selection_column) + assert result == "db discovery report without selection column" + + +def test_poll_async_ruleset_generation(client): + connection_id = ConnectionId("1") + with requests_mock.Mocker() as m: + # Test running status + m.get( + f"http://test-server/api/async-generate-ruleset/{connection_id}/", + json={"status": "running"}, + status_code=200, + ) + status = client.get_async_ruleset_generation_task_status(connection_id) + assert status is AsyncRulesetGenerationTaskStatus.running + + # Test finished status + m.get( + f"http://test-server/api/async-generate-ruleset/{connection_id}/", + json={"status": "finished"}, + status_code=200, + ) + status = client.get_async_ruleset_generation_task_status(connection_id) + assert status is AsyncRulesetGenerationTaskStatus.finished + + # Test failed status + m.get( + f"http://test-server/api/async-generate-ruleset/{connection_id}/", + json={"status": "failed"}, + status_code=200, + ) + status = client.get_async_ruleset_generation_task_status(connection_id) + assert status is AsyncRulesetGenerationTaskStatus.failed + + +def test_get_generated_rulesets_success(client): + connection_id = ConnectionId("1") + yaml_content_1 = b""" + version: "1.0" + tasks: + - type: mask_table + table: table1 + key: id + rules: + - column: col1 + masks: + - type: do_nothing + """ + yaml_content_2 = b""" + version: "1.0" + tasks: + - type: mask_table + table: table2 + key: id + rules: + - column: col2 + masks: + - type: do_nothing + """ + + with requests_mock.Mocker() as m: + m.get( + f"http://test-server/api/async-generate-ruleset/{connection_id}/", + json={"status": "finished"}, + status_code=200, + ) + + zip_buffer = BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zip_file: + zip_file.writestr("ruleset1.yml", yaml_content_1.decode("utf-8")) + zip_file.writestr("ruleset2.yaml", yaml_content_2.decode("utf-8")) + zip_buffer.seek(0) + + m.get( + f"http://test-server/api/async-generate-ruleset/{connection_id}/download-rulesets/", + content=zip_buffer.getvalue(), + headers={"Content-Disposition": 'attachment; filename="rulesets.zip"'}, + status_code=200, + ) + + rulesets = client.get_generated_rulesets(connection_id) + + assert len(rulesets) == 2 + assert rulesets[0].name == "ruleset1" + assert rulesets[0].yaml == yaml_content_1.decode("utf-8") + assert rulesets[1].name == "ruleset2" + assert rulesets[1].yaml == yaml_content_2.decode("utf-8") + + +def test_get_generated_rulesets_from_selection_success(client): + """Non-CSV async RG: server 303s to the task-status endpoint, whose JSON body carries `generated_ruleset`.""" + connection_id = ConnectionId("1") + generated_yaml = 'version: "1.0"\ntasks:\n- type: mask_table\n table: users\n key: id\n rules: []\n' + + with requests_mock.Mocker() as m: + # Status check and the redirect target are the same URL; both resolve to the same JSON. + m.get( + f"http://test-server/api/async-generate-ruleset/{connection_id}/", + json={"status": "finished", "generated_ruleset": generated_yaml}, + status_code=200, + ) + m.get( + f"http://test-server/api/async-generate-ruleset/{connection_id}/download-rulesets/", + status_code=303, + headers={"Location": f"http://test-server/api/async-generate-ruleset/{connection_id}/"}, + ) + + rulesets = client.get_generated_rulesets(connection_id) + + assert len(rulesets) == 1 + assert rulesets[0].yaml == generated_yaml + # The server doesn't return a name — callers set one before create_or_update_ruleset. + assert rulesets[0].name == "generated_ruleset" + + +def test_get_generated_rulesets_from_selection_empty_ruleset_raises(client): + """A finished task with no `generated_ruleset` in the JSON body raises a clear error.""" + connection_id = ConnectionId("1") + with requests_mock.Mocker() as m: + m.get( + f"http://test-server/api/async-generate-ruleset/{connection_id}/", + json={"status": "finished", "generated_ruleset": None}, + status_code=200, + ) + m.get( + f"http://test-server/api/async-generate-ruleset/{connection_id}/download-rulesets/", + status_code=303, + headers={"Location": f"http://test-server/api/async-generate-ruleset/{connection_id}/"}, + ) + + with pytest.raises(DataMasqueException, match="no ruleset was returned"): + client.get_generated_rulesets(connection_id) + + +def test_get_generated_rulesets_failed(client): + connection_id = ConnectionId("1") + + with requests_mock.Mocker() as m: + m.get( + f"http://test-server/api/async-generate-ruleset/{connection_id}/", + json={"status": "failed"}, + status_code=200, + ) + + with pytest.raises(DataMasqueException, match="Ruleset generation failed for connection"): + client.get_generated_rulesets(connection_id) + + +def test_get_generated_rulesets_in_progress(client): + connection_id = ConnectionId("1") + + with requests_mock.Mocker() as m: + m.get( + f"http://test-server/api/async-generate-ruleset/{connection_id}/", + json={"status": "running"}, + status_code=200, + ) + + with pytest.raises( + AsyncRulesetGenerationInProgressError, + match="Ruleset generation in progress or not ready", + ): + client.get_generated_rulesets(connection_id) + + +def test_get_generated_rulesets_download_fail(client): + connection_id = ConnectionId("1") + + with requests_mock.Mocker() as m: + m.get( + f"http://test-server/api/async-generate-ruleset/{connection_id}/", + json={"status": "finished"}, + status_code=200, + ) + + m.get( + f"http://test-server/api/async-generate-ruleset/{connection_id}/download-rulesets/", + status_code=500, + ) + + with pytest.raises(DataMasqueApiError): + client.get_generated_rulesets(connection_id) + + +def test_start_async_ruleset_generation_success_columns(client): + """Test when `selected_data` is of type `SelectedColumns`.""" + connection_id = ConnectionId("1") + selected_columns = SelectedColumns(columns={"public": {"users": ["col1", "col2"]}}) + + with requests_mock.Mocker() as m: + m.post(f"http://test-server/api/async-generate-ruleset/{connection_id}/", status_code=201) + client.start_async_ruleset_generation(connection_id, selected_columns) + + assert m.called + request_data = m.last_request.json() + assert "connection" not in request_data # connection id belongs in the URL, not the body + assert request_data["selected_columns"] == {"public": {"users": ["col1", "col2"]}} + assert "hash_columns" not in request_data + + +def test_start_async_ruleset_generation_success_columns_with_hash(client): + """Test when `selected_data` includes hash_columns with new table-level structure.""" + connection_id = ConnectionId("1") + selected_columns = SelectedColumns( + columns={"schema1": {"table1": ["col1", "col2"]}}, + hash_columns={ + "schema1": { + "table1": { + "table": ["default_hash"], + "columns": {"col1": ["hashCol1"], "col2": None}, + } + } + }, + ) + + with requests_mock.Mocker() as m: + m.post(f"http://test-server/api/async-generate-ruleset/{connection_id}/", status_code=201) + client.start_async_ruleset_generation(connection_id, selected_columns) + + assert m.called + request_data = m.last_request.json() + assert "connection" not in request_data + assert request_data["selected_columns"] == {"schema1": {"table1": ["col1", "col2"]}} + assert request_data["hash_columns"] == { + "schema1": { + "table1": { + "table": ["default_hash"], + "columns": {"col1": ["hashCol1"], "col2": None}, + } + } + } + + +def test_start_async_ruleset_generation_success_file(client): + """Test when `selected_data` is of type `SelectedFileData`.""" + connection_id = ConnectionId("1") + selected_file_data = SelectedFileData( + user_selections=[ + {"locators": [["locator1"]], "files": ["file1"]}, + {"locators": [["locator2"]], "files": ["file2"]}, + ] + ) + + with requests_mock.Mocker() as m: + m.post(f"http://test-server/api/async-generate-ruleset/{connection_id}/", status_code=201) + client.start_async_ruleset_generation(connection_id, selected_file_data) + + assert m.called + request_data = m.last_request.json() + assert "connection" not in request_data + assert request_data["selected_data"] == [ + {"locators": [["locator1"]], "files": ["file1"]}, + {"locators": [["locator2"]], "files": ["file2"]}, + ] + + +def test_start_async_ruleset_generation_no_selected_data(client): + """Test that the function raises an error if `selected_data` is not provided.""" + connection_id = ConnectionId("1") + + with pytest.raises(ValueError, match="`selected_data` is a required argument"): + client.start_async_ruleset_generation(connection_id, None) + + +def test_start_async_ruleset_generation_invalid_selected_data_type(client): + """Test that the function raises an error if selected_data is of an invalid type.""" + connection_id = ConnectionId("1") + invalid_selected_data = {"invalid": "data"} + + with pytest.raises(TypeError, match="expected `SelectedColumns` or `SelectedFileData`"): + client.start_async_ruleset_generation(connection_id, invalid_selected_data) + + +def test_start_async_ruleset_generation_invalid_file_data(client): + """Test that the function raises an error if `SelectedFileData` has empty locators or files.""" + connection_id = ConnectionId("1") + # Pydantic accepts the construction (empty lists are valid `list[...]` values), + # but the client validates that locators and files are non-empty before sending. + invalid_file_data = SelectedFileData( + user_selections=[ + UserSelection(locators=[["locator1"]], files=[]), # Empty files + ] + ) + + with pytest.raises( + ValueError, + match="Each `UserSelection` in `SelectedFileData.user_selections` must have", + ): + client.start_async_ruleset_generation(connection_id, invalid_file_data) + + +def test_start_async_ruleset_generation_request_failure(client): + """Test that the function raises an error if the API request fails.""" + connection_id = ConnectionId("1") + selected_columns = SelectedColumns(columns={"public": {"users": ["col1", "col2"]}}) + + with requests_mock.Mocker() as m: + m.post(f"http://test-server/api/async-generate-ruleset/{connection_id}/", status_code=500) + + with pytest.raises(DataMasqueApiError, match="failed with status 500"): + client.start_async_ruleset_generation(connection_id, selected_columns) + + +@pytest.mark.parametrize( + "csv_content", + [ + "schema,table,column,selected\npublic,users,email,true", + b"schema,table,column,selected\npublic,users,email,true", + StringIO("schema,table,column,selected\npublic,users,email,true"), + BytesIO(b"schema,table,column,selected\npublic,users,email,true"), + ], + ids=["str", "bytes", "StringIO", "BytesIO"], +) +def test_start_async_ruleset_generation_from_csv_success(client, csv_content): + """Test successful async ruleset generation from CSV with various input types.""" + connection_id = ConnectionId("1") + + with requests_mock.Mocker() as m: + m.post( + f"http://test-server/api/async-generate-ruleset/{connection_id}/from-csv/", + status_code=201, + ) + client.start_async_ruleset_generation_from_csv(connection_id, csv_content) + + assert m.called + form_data = parse_multipart_form(m.last_request) + assert "csv_or_zip_file" in form_data + assert form_data["csv_or_zip_file"]["filename"] == "ruleset.csv" + assert form_data["csv_or_zip_file"]["content_type"] == "text/csv" + assert form_data["csv_or_zip_file"]["content"] == b"schema,table,column,selected\npublic,users,email,true" + + +def test_start_async_ruleset_generation_from_csv_with_target_size(client): + """Test async ruleset generation from CSV with target_size_bytes parameter.""" + connection_id = ConnectionId("1") + csv_content = "schema,table,column,selected\npublic,users,email,true" + target_size = 1024000 + + with requests_mock.Mocker() as m: + m.post( + f"http://test-server/api/async-generate-ruleset/{connection_id}/from-csv/", + status_code=201, + ) + client.start_async_ruleset_generation_from_csv(connection_id, csv_content, target_size_bytes=target_size) + + assert m.called + form_data = parse_multipart_form(m.last_request) + assert form_data["target_size_bytes"] == str(target_size) + + +def test_start_async_ruleset_generation_from_csv_failure(client): + """Test that the function raises an error if the API request fails.""" + connection_id = ConnectionId("1") + csv_content = "schema,table,column,selected\npublic,users,email,true" + + with requests_mock.Mocker() as m: + m.post( + f"http://test-server/api/async-generate-ruleset/{connection_id}/from-csv/", + status_code=500, + ) + + with pytest.raises(DataMasqueApiError, match="failed with status 500"): + client.start_async_ruleset_generation_from_csv(connection_id, csv_content) + + +def test_start_async_ruleset_generation_from_csv_retries_on_401(config): + """Test that file content is correctly sent on retry after 401.""" + connection_id = ConnectionId("1") + + with patch.object(DataMasqueClient, "authenticate"): + client = DataMasqueClient(config) + csv_content = "schema,table,column,selected\npublic,users,email,true" + + with requests_mock.Mocker() as m: + m.post( + f"http://test-server/api/async-generate-ruleset/{connection_id}/from-csv/", + [ + {"status_code": 401}, + {"status_code": 201}, + ], + ) + client.start_async_ruleset_generation_from_csv(connection_id, csv_content) + + assert m.call_count == 2 + first_form = parse_multipart_form(m.request_history[0]) + second_form = parse_multipart_form(m.request_history[1]) + expected_content = b"schema,table,column,selected\npublic,users,email,true" + assert first_form["csv_or_zip_file"]["content"] == expected_content + assert second_form["csv_or_zip_file"]["content"] == expected_content + + +def test_schema_discovery_request_model_dump_minimal(): + """A request with only `connection` set dumps with empty lists and all `disable_*` flags off.""" + req = SchemaDiscoveryRequest(connection="conn-1") + assert req.model_dump(exclude_none=True, mode="json") == { + "connection": "conn-1", + "custom_keywords": [], + "ignored_keywords": [], + "schemas": [], + "disable_built_in_keywords": False, + "disable_global_custom_keywords": False, + "disable_global_ignored_keywords": False, + } + + +def test_schema_discovery_request_model_dump_includes_set_fields(): + req = SchemaDiscoveryRequest( + connection="conn-1", + custom_keywords=["foo"], + ignored_keywords=["bar"], + schemas=["public"], + in_data_discovery={"enabled": True, "row_sample_size": 100}, + ) + assert req.model_dump(exclude_none=True, mode="json") == { + "connection": "conn-1", + "custom_keywords": ["foo"], + "ignored_keywords": ["bar"], + "schemas": ["public"], + "in_data_discovery": {"enabled": True, "row_sample_size": 100}, + "disable_built_in_keywords": False, + "disable_global_custom_keywords": False, + "disable_global_ignored_keywords": False, + } + + +def test_discovery_requests_accept_connection_config_objects(): + """All three discovery request models accept a full `ConnectionConfig` and extract its `id`.""" + connection = DatabaseConnectionConfig( + id=ConnectionId("conn-uuid"), + name="prod_db", + db_type=DatabaseType.postgres, + host="db.example.com", + port=5432, + database="app", + user="u", + ) + + schema_req = SchemaDiscoveryRequest(connection=connection) + ruleset_req = RulesetGenerationRequest(connection=connection, selected_columns={"public": {"users": ["email"]}}) + file_req = FileRulesetGenerationRequest(connection=connection, selected_data=[]) + + for req in (schema_req, ruleset_req, file_req): + assert req.model_dump(exclude_none=True, mode="json")["connection"] == "conn-uuid" + + +def test_start_schema_discovery_run_accepts_typed_request(client): + req = SchemaDiscoveryRequest(connection="conn-1", schemas=["public", "private"]) + + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/schema-discovery/", + json={"id": 7}, + status_code=201, + ) + run_id = client.start_schema_discovery_run(req) + + assert run_id == 7 + assert m.last_request.json() == { + "connection": "conn-1", + "custom_keywords": [], + "ignored_keywords": [], + "schemas": ["public", "private"], + "disable_built_in_keywords": False, + "disable_global_custom_keywords": False, + "disable_global_ignored_keywords": False, + } + + +def test_ruleset_generation_request_round_trip(client): + req = RulesetGenerationRequest( + connection="conn-1", + selected_columns={"public": {"users": ["email"]}}, + hash_columns={"public": {"users": {"table": ["id"]}}}, + ) + + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/generate-ruleset/v2/", + content=b"version: '1.0'", + status_code=201, + ) + yaml = client.generate_ruleset(req) + + assert yaml == "version: '1.0'" + assert m.last_request.json() == { + "connection": "conn-1", + "selected_columns": {"public": {"users": ["email"]}}, + "hash_columns": {"public": {"users": {"table": ["id"]}}}, + } + + +def test_ruleset_generation_request_omits_optional_hash_columns(client): + req = RulesetGenerationRequest( + connection="conn-1", + selected_columns={"public": {"users": ["email"]}}, + ) + + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/generate-ruleset/v2/", + content=b"yaml", + status_code=201, + ) + client.generate_ruleset(req) + + assert "hash_columns" not in m.last_request.json() + + +def test_file_ruleset_generation_request_round_trip(client): + req = FileRulesetGenerationRequest( + connection="conn-1", + selected_data=[{"locators": [["a"]], "files": ["f1.csv"]}], + ) + + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/generate-file-ruleset/", + content=b"yaml", + status_code=201, + ) + yaml = client.generate_file_ruleset(req) + + assert yaml == "yaml" + assert m.last_request.json() == { + "connection": "conn-1", + "selected_data": [{"locators": [["a"]], "files": ["f1.csv"]}], + } + + +def _schema_discovery_row(row_id: int, column_name: str, table_name: str = "users") -> dict: + return { + "id": row_id, + "column": column_name, + "table": table_name, + "schema_name": "public", + "data": { + "data_type": "text", + "foreign_keys": [], + "discovery_matches": [], + "constraint_columns": [], + "unique_index_names": [], + "referencing_foreign_keys": [], + "constraint": "", + }, + } + + +def test_list_schema_discovery_results_follows_pagination(client): + run_id = RunId(42) + page1 = { + "count": 3, + "next": "http://test-server/api/schema-discovery/v2/42/?limit=2&offset=2", + "previous": None, + "results": [_schema_discovery_row(1, "email"), _schema_discovery_row(2, "name")], + } + page2 = { + "count": 3, + "next": None, + "previous": "http://test-server/api/schema-discovery/v2/42/?limit=2", + "results": [_schema_discovery_row(3, "phone")], + } + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/schema-discovery/v2/42/", + [{"json": page1, "status_code": 200}, {"json": page2, "status_code": 200}], + ) + results = client.list_schema_discovery_results(run_id) + + assert len(results) == 3 + assert all(isinstance(r, SchemaDiscoveryResult) for r in results) + assert [r.column for r in results] == ["email", "name", "phone"] + + +def test_iter_schema_discovery_results_is_lazy(client): + """`iter_*` returns an iterator that only makes HTTP calls as pages are consumed.""" + run_id = RunId(99) + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/schema-discovery/v2/99/", + json={ + "count": 1, + "next": None, + "previous": None, + "results": [_schema_discovery_row(1, "email")], + }, + status_code=200, + ) + iterator = client.iter_schema_discovery_results(run_id) + # No HTTP call yet — iterator is lazy. + assert m.call_count == 0 + + first = next(iterator) + assert first.column == "email" + assert m.call_count == 1 + + +def test_get_schema_discovery_page_returns_page_with_table_metadata(client): + run_id = RunId(7) + response_json = { + "count": 1, + "next": None, + "previous": None, + "results": [_schema_discovery_row(1, "email")], + "table_metadata": { + "public": { + "users": { + "primary_keys": [{"columns": ["id"]}], + "unique_keys": [{"columns": ["email"]}], + "foreign_keys": [], + }, + }, + }, + } + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/schema-discovery/v2/7/", + json=response_json, + status_code=200, + ) + page = client.get_schema_discovery_page(run_id, limit=10, offset=20) + + assert isinstance(page, SchemaDiscoveryPage) + assert [r.column for r in page.results] == ["email"] + assert page.table_metadata["public"]["users"].primary_keys[0].columns == ["id"] + assert m.last_request.qs == {"limit": ["10"], "offset": ["20"]} + + +def test_start_schema_discovery_run_raises_on_non_201(client): + """A non-201 response (e.g. validation failure) raises `FailedToStartError`.""" + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/schema-discovery/", + json={"detail": "connection not found"}, + status_code=400, + ) + with pytest.raises(FailedToStartError, match="Schema discovery run failed to start"): + client.start_schema_discovery_run(SchemaDiscoveryRequest(connection="nope")) diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 0000000..5f5574c --- /dev/null +++ b/tests/test_files.py @@ -0,0 +1,273 @@ +"""Tests for `FileClient` (upload, list, get-by-name, delete).""" + +import uuid +from datetime import datetime, timezone +from io import BytesIO, StringIO +from pathlib import Path +from unittest.mock import mock_open, patch + +import pytest +import requests_mock + +from datamasque.client import DataMasqueClient +from datamasque.client.models.files import OracleWalletFile, SeedFile, SnowflakeKeyFile, SslZipFile +from tests.helpers import fake, parse_multipart_form + + +@pytest.mark.parametrize( + "source_factory", + [ + pytest.param(lambda: BytesIO(b"this is my file content"), id="BytesIO"), + pytest.param(lambda: b"this is my file content", id="bytes"), + pytest.param(lambda: StringIO("this is my file content"), id="StringIO"), + pytest.param(lambda: "file.txt", id="str-path"), + pytest.param(lambda: Path("file.txt"), id="Path"), + ], +) +@pytest.mark.parametrize( + "file_type", + [SeedFile, OracleWalletFile, SslZipFile, SnowflakeKeyFile], +) +def test_upload_file(client, file_type, source_factory): + source = source_factory() + name_of_file = fake.word() + with patch( + "datamasque.client.base.open", + mock_open(read_data=b"this is my file content"), + ) as m_open: + mock_return_id = str(uuid.uuid4()) + mock_return_created_date = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_return_modified_date = datetime(2024, 2, 1, 12, 0, 0, tzinfo=timezone.utc) + with requests_mock.Mocker() as m_request: + m_request.post( + f"http://test-server/{file_type.get_url()}", + status_code=201, + json={ + "id": mock_return_id, + "name": name_of_file, + "created_date": mock_return_created_date.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + "modified_date": mock_return_modified_date.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + }, + ) + file = client.upload_file(file_type, name_of_file, source) + + if isinstance(source, (str, Path)): + m_open.assert_called_once_with(source, "rb") + else: + m_open.assert_not_called() + + assert "this is my file content" in m_request.request_history[0].text + assert isinstance(file, file_type) + assert file.name == name_of_file + assert file.id == mock_return_id + assert file.created_date == mock_return_created_date + assert file.modified_date == mock_return_modified_date + + +@pytest.mark.parametrize( + "file_type", + [SeedFile, OracleWalletFile, SslZipFile, SnowflakeKeyFile], +) +def test_get_files_by_type(client, file_type): + id_1 = str(uuid.uuid4()) + id_2 = str(uuid.uuid4()) + mock_return_created_date_1 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_return_modified_date_1 = datetime(2024, 2, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_return_created_date_2 = datetime(2024, 3, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_return_modified_date_2 = datetime(2024, 4, 1, 12, 0, 0, tzinfo=timezone.utc) + + with requests_mock.Mocker() as m_request: + m_request.get( + f"http://test-server/{file_type.get_url()}", + status_code=201, + json=[ + { + "id": id_1, + "name": "file1", + "created_date": mock_return_created_date_1.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + "modified_date": mock_return_modified_date_1.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + }, + { + "id": id_2, + "name": "file2", + "created_date": mock_return_created_date_2.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + "modified_date": mock_return_modified_date_2.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + }, + ], + ) + results = client.list_files_of_type(file_type) + + assert len(results) == 2 + assert isinstance(results[0], file_type) + assert results[0].id == id_1 + assert results[0].name == "file1" + assert results[0].created_date == mock_return_created_date_1 + assert results[0].modified_date == mock_return_modified_date_1 + assert isinstance(results[1], file_type) + assert results[1].id == id_2 + assert results[1].name == "file2" + assert results[1].created_date == mock_return_created_date_2 + assert results[1].modified_date == mock_return_modified_date_2 + + +@pytest.mark.parametrize( + "file_type", + [SeedFile, OracleWalletFile, SslZipFile, SnowflakeKeyFile], +) +def test_get_files_by_type_and_name(client, file_type): + id_1 = str(uuid.uuid4()) + id_2 = str(uuid.uuid4()) + mock_return_created_date_1 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_return_modified_date_1 = datetime(2024, 2, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_return_created_date_2 = datetime(2024, 3, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_return_modified_date_2 = datetime(2024, 4, 1, 12, 0, 0, tzinfo=timezone.utc) + + with requests_mock.Mocker() as m_request: + m_request.get( + f"http://test-server/{file_type.get_url()}", + status_code=201, + json=[ + { + "id": id_1, + "name": "file1", + "created_date": mock_return_created_date_1.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + "modified_date": mock_return_modified_date_1.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + }, + { + "id": id_2, + "name": "file2", + "created_date": mock_return_created_date_2.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + "modified_date": mock_return_modified_date_2.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + }, + ], + ) + result = client.get_file_of_type_by_name(file_type, "file2") + + assert isinstance(result, file_type) + assert result.id == id_2 + + +@pytest.mark.parametrize( + "file_exists", + [True, False], +) +@pytest.mark.parametrize( + "file_type", + [SeedFile, OracleWalletFile, SslZipFile, SnowflakeKeyFile], +) +def test_delete_file_if_exists(client, file_type, file_exists): + file_id = str(uuid.uuid4()) + file_name = fake.word() + file_to_delete = file_type( + id=file_id, + name=file_name, + created_date=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + modified_date=datetime(2024, 2, 1, 12, 0, 0, tzinfo=timezone.utc), + ) + with requests_mock.Mocker() as m_request: + m_request.delete( + f"http://test-server/{file_type.get_url()}{file_id}/", + status_code=204 if file_exists else 404, + ) + client.delete_file_if_exists(file_to_delete) # shouldn't raise an error + + +def test_delete_file_if_exists_raises_when_id_not_set(client): + """`delete_file_if_exists` requires a file object that has been persisted on the server.""" + unpersisted_file = SeedFile( + id=None, # type: ignore[arg-type] + name="never_uploaded", + created_date=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + modified_date=None, + ) + + with pytest.raises(ValueError, match="File has not yet been created"): + client.delete_file_if_exists(unpersisted_file) + + +@pytest.mark.parametrize("file_type", [SeedFile, OracleWalletFile, SslZipFile, SnowflakeKeyFile]) +def test_upload_file_if_not_exists_skips_when_same_name_exists(client, file_type): + """Returns `None` and does not POST when a file of this type already has the same name.""" + with patch("datamasque.client.base.open", mock_open(read_data=b"content")): + with requests_mock.Mocker() as m_request: + m_request.get( + f"http://test-server/{file_type.get_url()}", + status_code=200, + json=[ + { + "id": str(uuid.uuid4()), + "name": "already_here.csv", + "created_date": "2024-01-01T00:00:00.000000Z", + "modified_date": "2024-01-01T00:00:00.000000Z", + }, + ], + ) + result = client.upload_file_if_not_exists(file_type, "already_here.csv") + + assert result is None + # Only the list-by-type GET should have fired — no POST. + assert m_request.call_count == 1 + assert m_request.request_history[0].method == "GET" + + +@pytest.mark.parametrize("file_type", [SeedFile, OracleWalletFile, SslZipFile, SnowflakeKeyFile]) +def test_upload_file_if_not_exists_uploads_when_missing(client, file_type): + """Uploads and returns the new file object when no existing file of the same name is present.""" + new_id = str(uuid.uuid4()) + with patch("datamasque.client.base.open", mock_open(read_data=b"new content")) as m_open: + with requests_mock.Mocker() as m_request: + m_request.get( + f"http://test-server/{file_type.get_url()}", + status_code=200, + json=[], + ) + m_request.post( + f"http://test-server/{file_type.get_url()}", + status_code=201, + json={ + "id": new_id, + "name": "new_file.csv", + "created_date": "2024-01-01T00:00:00.000000Z", + "modified_date": "2024-01-01T00:00:00.000000Z", + }, + ) + result = client.upload_file_if_not_exists(file_type, Path("new_file.csv")) + + assert isinstance(result, file_type) + assert result.id == new_id + m_open.assert_called_once_with(Path("new_file.csv"), "rb") + # List first, then upload. + assert [r.method for r in m_request.request_history] == ["GET", "POST"] + + +def test_upload_file_retries_on_401(config): + """File content must be resent on retry after a 401 re-auth, not just an empty body.""" + with patch.object(DataMasqueClient, "authenticate"): + client = DataMasqueClient(config) + + file_content = BytesIO(b"seed content") + file_content.seek(0) + + with requests_mock.Mocker() as m_request: + m_request.post( + f"http://test-server/{SeedFile.get_url()}", + [ + {"status_code": 401}, + { + "status_code": 201, + "json": { + "id": str(uuid.uuid4()), + "name": "seed.csv", + "created_date": "2024-01-01T00:00:00.000000Z", + "modified_date": "2024-01-01T00:00:00.000000Z", + }, + }, + ], + ) + client.upload_file(SeedFile, "seed.csv", file_content) + + assert m_request.call_count == 2 + first_form = parse_multipart_form(m_request.request_history[0]) + second_form = parse_multipart_form(m_request.request_history[1]) + assert first_form["seed_file"]["content"] == b"seed content" + assert second_form["seed_file"]["content"] == b"seed content" diff --git a/tests/test_ifm.py b/tests/test_ifm.py new file mode 100644 index 0000000..11f3186 --- /dev/null +++ b/tests/test_ifm.py @@ -0,0 +1,404 @@ +"""Tests for `DataMasqueIfmClient`.""" + +import pytest +import requests_mock + +from datamasque.client import ( + DataMasqueIfmClient, + DataMasqueIfmInstanceConfig, + IfmAuthError, + IfmMaskRequest, + RulesetPlanCreateRequest, + RulesetPlanPartialUpdateRequest, + RulesetPlanUpdateRequest, +) +from datamasque.client.exceptions import DataMasqueApiError, DataMasqueUserError + +ADMIN = "http://admin.test" +IFM = "http://ifm.test" + + +@pytest.fixture +def ifm_config(): + return DataMasqueIfmInstanceConfig( + admin_server_base_url=ADMIN, + ifm_base_url=IFM, + username="ifm_user", + password="ifm_password", + ) + + +@pytest.fixture +def authed_ifm_client(ifm_config): + client = DataMasqueIfmClient(ifm_config) + # Pre-seed tokens to skip the login round-trip in tests that don't care about it. + client.access_token = "access-1" + client.refresh_token = "refresh-1" + return client + + +def test_ifm_config_rejects_neither_password_nor_token_source(): + with pytest.raises(DataMasqueUserError, match="Exactly one of `password` or `token_source`"): + DataMasqueIfmInstanceConfig(admin_server_base_url=ADMIN, ifm_base_url=IFM, username="u") + + +def test_ifm_config_rejects_both_password_and_token_source(): + with pytest.raises(DataMasqueUserError, match="Exactly one of `password` or `token_source`"): + DataMasqueIfmInstanceConfig( + admin_server_base_url=ADMIN, + ifm_base_url=IFM, + username="u", + password="p", + token_source=lambda: "t", + ) + + +def test_authenticate_via_jwt_login(ifm_config): + client = DataMasqueIfmClient(ifm_config) + + with requests_mock.Mocker() as m: + m.post( + f"{ADMIN}/api/auth/jwt/login/", + json={"access_token": "ACC", "refresh_token": "REF"}, + status_code=200, + ) + client.authenticate() + + assert client.access_token == "ACC" + assert client.refresh_token == "REF" + + +def test_authenticate_failure_raises_ifm_auth_error(ifm_config): + client = DataMasqueIfmClient(ifm_config) + + with requests_mock.Mocker() as m: + m.post(f"{ADMIN}/api/auth/jwt/login/", status_code=401) + with pytest.raises(IfmAuthError): + client.authenticate() + + +def test_authenticate_uses_token_source_when_provided(): + config = DataMasqueIfmInstanceConfig( + admin_server_base_url=ADMIN, + ifm_base_url=IFM, + username="u", + token_source=lambda: "callable-jwt", + ) + client = DataMasqueIfmClient(config) + + with requests_mock.Mocker() as m: + client.authenticate() + assert m.call_count == 0 # No HTTP call when token_source provides the JWT. + + assert client.access_token == "callable-jwt" + + +def test_401_triggers_refresh_then_retries(authed_ifm_client): + with requests_mock.Mocker() as m: + m.get( + f"{IFM}/ruleset-plans/", + [{"status_code": 401}, {"json": {"items": [], "total": 0, "limit": 100, "offset": 0}, "status_code": 200}], + ) + m.post( + f"{ADMIN}/api/auth/jwt/refresh/", + json={"access_token": "ACC2"}, + status_code=200, + ) + + result = authed_ifm_client.list_ruleset_plans() + + assert result == [] + assert authed_ifm_client.access_token == "ACC2" + + +def test_401_then_failed_refresh_falls_back_to_full_login(authed_ifm_client): + with requests_mock.Mocker() as m: + m.get( + f"{IFM}/ruleset-plans/", + [{"status_code": 401}, {"json": {"items": [], "total": 0, "limit": 100, "offset": 0}, "status_code": 200}], + ) + m.post(f"{ADMIN}/api/auth/jwt/refresh/", status_code=401) + m.post( + f"{ADMIN}/api/auth/jwt/login/", + json={"access_token": "ACC3", "refresh_token": "REF3"}, + status_code=200, + ) + + authed_ifm_client.list_ruleset_plans() + + assert authed_ifm_client.access_token == "ACC3" + assert authed_ifm_client.refresh_token == "REF3" + + +def test_401_with_token_source_skips_refresh_and_re_authenticates(ifm_config): + """When `token_source` is configured, a 401 triggers a direct `authenticate` call, not a JWT refresh round-trip.""" + call_count = {"n": 0} + + def token_source() -> str: + call_count["n"] += 1 + return f"callable-jwt-{call_count['n']}" + + config = DataMasqueIfmInstanceConfig( + admin_server_base_url=ADMIN, + ifm_base_url=IFM, + username="u", + token_source=token_source, + ) + client = DataMasqueIfmClient(config) + + with requests_mock.Mocker() as m: + m.get( + f"{IFM}/ruleset-plans/", + [{"status_code": 401}, {"json": {"items": [], "total": 0, "limit": 100, "offset": 0}, "status_code": 200}], + ) + client.list_ruleset_plans() + + # The refresh endpoint must not have been hit — token_source is authoritative. + assert all("auth/jwt/refresh" not in req.url for req in m.request_history) + assert client.access_token == "callable-jwt-2" + + +def test_401_without_refresh_token_falls_through_to_full_login(ifm_config): + """When the client has no cached refresh token, a 401 triggers a full `authenticate` rather than a refresh call.""" + client = DataMasqueIfmClient(ifm_config) + client.access_token = "stale-access" + client.refresh_token = "" # never had one + + with requests_mock.Mocker() as m: + m.get( + f"{IFM}/ruleset-plans/", + [{"status_code": 401}, {"json": {"items": [], "total": 0, "limit": 100, "offset": 0}, "status_code": 200}], + ) + m.post( + f"{ADMIN}/api/auth/jwt/login/", + json={"access_token": "FRESH", "refresh_token": "FRESH_REF"}, + status_code=200, + ) + client.list_ruleset_plans() + + # Refresh endpoint skipped; login was called instead. + assert all("auth/jwt/refresh" not in req.url for req in m.request_history) + assert client.access_token == "FRESH" + + +def test_verify_token(authed_ifm_client): + with requests_mock.Mocker() as m: + m.get( + f"{IFM}/verify-token/", + json={"scopes": ["ifm/mask"]}, + status_code=200, + ) + info = authed_ifm_client.verify_token() + + assert "ifm/mask" in info.scopes + + +def test_list_ruleset_plans(authed_ifm_client): + with requests_mock.Mocker() as m: + m.get( + f"{IFM}/ruleset-plans/", + json={ + "items": [ + { + "name": "p1", + "created_time": "2025-01-01T00:00:00Z", + "modified_time": "2025-01-02T00:00:00Z", + "serial": 1, + "options": {}, + }, + { + "name": "p2", + "created_time": "2025-02-01T00:00:00Z", + "modified_time": "2025-02-02T00:00:00Z", + "serial": 2, + "options": {}, + }, + ], + "total": 2, + "limit": 100, + "offset": 0, + }, + status_code=200, + ) + plans = authed_ifm_client.list_ruleset_plans() + + assert [p.name for p in plans] == ["p1", "p2"] + + +def test_get_ruleset_plan(authed_ifm_client): + with requests_mock.Mocker() as m: + m.get( + f"{IFM}/ruleset-plans/p1/", + json={ + "name": "p1", + "created_time": "2025-01-01T00:00:00Z", + "modified_time": "2025-01-02T00:00:00Z", + "serial": 1, + "options": {}, + "ruleset_yaml": "version: '1.0'", + }, + status_code=200, + ) + plan = authed_ifm_client.get_ruleset_plan("p1") + + assert plan.name == "p1" + assert plan.ruleset_yaml == "version: '1.0'" + + +def test_create_ruleset_plan(authed_ifm_client): + req = RulesetPlanCreateRequest(name="p1", ruleset_yaml="version: '1.0'") + + with requests_mock.Mocker() as m: + m.post( + f"{IFM}/ruleset-plans/", + json={ + "name": "p1", + "created_time": "2025-01-01T00:00:00Z", + "modified_time": "2025-01-01T00:00:00Z", + "serial": 1, + "options": {}, + "ruleset_yaml": "version: '1.0'", + "logs": [], + "url": f"{IFM}/ruleset-plans/p1/", + }, + status_code=201, + ) + result = authed_ifm_client.create_ruleset_plan(req) + + assert result.name == "p1" + assert result.url.endswith("/ruleset-plans/p1/") + assert m.last_request.json() == {"name": "p1", "ruleset_yaml": "version: '1.0'"} + + +def test_update_ruleset_plan(authed_ifm_client): + req = RulesetPlanUpdateRequest(ruleset_yaml="version: '2.0'", options={"enabled": True}) + + with requests_mock.Mocker() as m: + m.put( + f"{IFM}/ruleset-plans/p1/", + json={ + "name": "p1", + "created_time": "2025-01-01T00:00:00Z", + "modified_time": "2025-06-01T00:00:00Z", + "serial": 2, + "options": {"enabled": True}, + "ruleset_yaml": "version: '2.0'", + "logs": [], + }, + status_code=200, + ) + result = authed_ifm_client.update_ruleset_plan("p1", req) + + assert result.serial == 2 + assert m.last_request.json() == {"ruleset_yaml": "version: '2.0'", "options": {"enabled": True}} + + +def test_patch_ruleset_plan_omits_unset_fields(authed_ifm_client): + req = RulesetPlanPartialUpdateRequest(options={"enabled": False}) + + with requests_mock.Mocker() as m: + m.patch( + f"{IFM}/ruleset-plans/p1/", + json={ + "name": "p1", + "created_time": "2025-01-01T00:00:00Z", + "modified_time": "2025-06-01T00:00:00Z", + "serial": 3, + "options": {"enabled": False}, + "ruleset_yaml": "still here", + "logs": [], + }, + status_code=200, + ) + authed_ifm_client.patch_ruleset_plan("p1", req) + + body = m.last_request.json() + assert body == {"options": {"enabled": False}} + assert "ruleset_yaml" not in body # not set on the partial-update request + + +def test_delete_ruleset_plan(authed_ifm_client): + with requests_mock.Mocker() as m: + m.delete(f"{IFM}/ruleset-plans/p1/", status_code=204) + authed_ifm_client.delete_ruleset_plan("p1") + + assert m.call_count == 1 + + +def test_mask_success(authed_ifm_client): + req = IfmMaskRequest(data=[{"id": 1, "email": "a@b.com"}]) + + with requests_mock.Mocker() as m: + m.post( + f"{IFM}/ruleset-plans/p1/mask/", + json={ + "request_id": "req-1", + "ruleset_plan": {"name": "p1", "serial": 1}, + "logs": [], + "data": [{"id": 1, "email": "***@***.***"}], + }, + status_code=200, + ) + result = authed_ifm_client.mask("p1", req) + + assert result.success is True + assert result.data == [{"id": 1, "email": "***@***.***"}] + assert result.ruleset_plan.serial == 1 + sent = m.last_request.json() + assert sent["data"] == [{"id": 1, "email": "a@b.com"}] + + +def test_mask_request_omits_unset_optionals(): + req = IfmMaskRequest(data=[]) + assert req.model_dump(exclude_none=True, mode="json") == {"data": []} + + +def test_mask_raises_api_error_on_server_error(authed_ifm_client): + req = IfmMaskRequest(data=[{"x": 1}]) + + with requests_mock.Mocker() as m: + m.post(f"{IFM}/ruleset-plans/p1/mask/", status_code=500) + with pytest.raises(DataMasqueApiError): + authed_ifm_client.mask("p1", req) + + +def test_mask_soft_failure_returns_result_with_no_data(authed_ifm_client): + """A 400 with the full `IfmMaskResult` shape is a soft failure — return the result, don't raise.""" + req = IfmMaskRequest(data=[[42]]) + + with requests_mock.Mocker() as m: + m.post( + f"{IFM}/ruleset-plans/p1/mask/", + json={ + "request_id": "req-soft", + "ruleset_plan": {"name": "p1", "serial": 1}, + "logs": [ + { + "log_level": "error", + "timestamp": "2026-04-20T12:00:00Z", + "message": "replace_regex received a non-string value", + }, + ], + }, + status_code=400, + ) + result = authed_ifm_client.mask("p1", req) + + assert result.success is False + assert result.data is None + assert result.ruleset_plan is not None and result.ruleset_plan.name == "p1" + assert result.logs is not None and result.logs[0].log_level == "error" + + +def test_mask_raises_api_error_on_400_without_result_shape(authed_ifm_client): + """A 400 that doesn't carry an `IfmMaskResult` body (e.g. malformed request) is still a hard error.""" + req = IfmMaskRequest(data=[]) + + with requests_mock.Mocker() as m: + m.post( + f"{IFM}/ruleset-plans/p1/mask/", + json={"detail": "Malformed request body"}, + status_code=400, + ) + with pytest.raises(DataMasqueApiError): + authed_ifm_client.mask("p1", req) diff --git a/tests/test_license.py b/tests/test_license.py new file mode 100644 index 0000000..53738fd --- /dev/null +++ b/tests/test_license.py @@ -0,0 +1,47 @@ +"""Tests for `LicenseClient`.""" + +import uuid +from io import StringIO +from pathlib import Path +from unittest.mock import mock_open, patch + +import requests_mock + +from datamasque.client.models.license import LicenseInfo + + +def test_upload_license_file(client): + with patch("datamasque.client.base.open", mock_open(read_data=b"license content")) as m_open: + with requests_mock.Mocker() as m_request: + m_request.post("http://test-server/api/license-upload/", status_code=200) + client.upload_license_file(Path("path/to/test_license_file")) + + m_open.assert_called_once_with(Path("path/to/test_license_file"), "rb") + assert "Content-Type: application/octet-stream" in m_request.request_history[0].text + assert "license content" in m_request.request_history[0].text + + +def test_upload_license_file_stringio(client): + with requests_mock.Mocker() as m_request: + m_request.post("http://test-server/api/license-upload/", status_code=200) + client.upload_license_file(StringIO("license content")) + + assert "Content-Type: application/octet-stream" in m_request.request_history[0].text + assert "license content" in m_request.request_history[0].text + + +def test_get_current_license_info(client): + license_data = { + "uuid": str(uuid.uuid4()), + "name": "Test License", + "type": "enterprise", + "is_expired": False, + "uploadable": True, + } + with requests_mock.Mocker() as m: + m.get("http://test-server/api/license/", json=license_data, status_code=200) + result = client.get_current_license_info() + + assert isinstance(result, LicenseInfo) + assert result.uuid == license_data["uuid"] + assert result.name == "Test License" diff --git a/tests/test_pagination.py b/tests/test_pagination.py new file mode 100644 index 0000000..1e84ef3 --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,153 @@ +"""Tests for pagination infrastructure (Page, IfmPage, _iter_paginated, _iter_ifm_paginated).""" + +import requests_mock +from pydantic import BaseModel + +from datamasque.client import DataMasqueIfmClient, DataMasqueIfmInstanceConfig +from datamasque.client.models.pagination import IfmPage, Page + + +class Item(BaseModel): + id: int + name: str + + +def test_page_model_validate_round_trip(): + raw = { + "count": 2, + "next": "http://test/api/items/?limit=1&offset=1", + "previous": None, + "results": [{"id": 1, "name": "a"}], + } + page = Page[Item].model_validate(raw) + assert page.count == 2 + assert page.next == "http://test/api/items/?limit=1&offset=1" + assert page.previous is None + assert len(page.results) == 1 + assert isinstance(page.results[0], Item) + assert page.results[0].id == 1 + + +def test_page_preserves_extra_fields(): + raw = { + "count": 0, + "results": [], + "some_extra": "value", + } + page = Page[Item].model_validate(raw) + assert page.model_extra["some_extra"] == "value" + + +def test_ifm_page_model_validate_round_trip(): + raw = { + "items": [{"id": 1, "name": "x"}, {"id": 2, "name": "y"}], + "total": 5, + "limit": 2, + "offset": 0, + } + page = IfmPage[Item].model_validate(raw) + assert page.total == 5 + assert len(page.items) == 2 + assert page.items[1].name == "y" + + +def test_iter_paginated_follows_next_urls(client): + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/items/?limit=2&offset=0", + json={ + "count": 3, + "next": "http://test-server/api/items/?limit=2&offset=2", + "previous": None, + "results": [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}], + }, + ) + m.get( + "http://test-server/api/items/?limit=2&offset=2", + json={ + "count": 3, + "next": None, + "previous": "http://test-server/api/items/?limit=2&offset=0", + "results": [{"id": 3, "name": "c"}], + }, + ) + + items = list(client._iter_paginated("/api/items/", model=Item, page_size=2)) + + assert len(items) == 3 + assert [i.id for i in items] == [1, 2, 3] + assert m.call_count == 2 + + +def test_iter_paginated_stops_when_next_is_none(client): + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/items/?limit=100&offset=0", + json={ + "count": 1, + "next": None, + "results": [{"id": 1, "name": "only"}], + }, + ) + + items = list(client._iter_paginated("/api/items/", model=Item)) + + assert len(items) == 1 + assert m.call_count == 1 + + +def test_iter_ifm_paginated_walks_pages(): + config = DataMasqueIfmInstanceConfig( + admin_server_base_url="http://admin.test", + ifm_base_url="http://ifm.test", + username="u", + password="p", + ) + ifm_client = DataMasqueIfmClient(config) + ifm_client.access_token = "tok" + + with requests_mock.Mocker() as m: + m.get( + "http://ifm.test/items/?limit=2&offset=0", + json={ + "items": [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}], + "total": 3, + "limit": 2, + "offset": 0, + }, + ) + m.get( + "http://ifm.test/items/?limit=2&offset=2", + json={ + "items": [{"id": 3, "name": "c"}], + "total": 3, + "limit": 2, + "offset": 2, + }, + ) + + items = list(ifm_client._iter_ifm_paginated("items/", model=Item, page_size=2)) + + assert len(items) == 3 + assert [i.id for i in items] == [1, 2, 3] + + +def test_iter_ifm_paginated_handles_empty_page(): + config = DataMasqueIfmInstanceConfig( + admin_server_base_url="http://admin.test", + ifm_base_url="http://ifm.test", + username="u", + password="p", + ) + ifm_client = DataMasqueIfmClient(config) + ifm_client.access_token = "tok" + + with requests_mock.Mocker() as m: + m.get( + "http://ifm.test/items/?limit=100&offset=0", + json={"items": [], "total": 0, "limit": 100, "offset": 0}, + ) + + items = list(ifm_client._iter_ifm_paginated("items/", model=Item)) + + assert items == [] diff --git a/tests/test_ruleset_library.py b/tests/test_ruleset_library.py new file mode 100644 index 0000000..3e2e476 --- /dev/null +++ b/tests/test_ruleset_library.py @@ -0,0 +1,673 @@ +"""Tests for ruleset library support in the DataMasque client.""" + +from datetime import datetime +from typing import Any + +import pytest +import requests_mock + +from datamasque.client import DataMasqueClient +from datamasque.client.exceptions import DataMasqueApiError +from datamasque.client.models.ruleset import RulesetType +from datamasque.client.models.ruleset_library import ( + RulesetLibrary, + RulesetLibraryId, + ValidationStatus, +) + +LIBRARY_ID_1 = "aaaaaaaa-1111-2222-3333-444444444444" +LIBRARY_ID_2 = "bbbbbbbb-1111-2222-3333-444444444444" + + +@pytest.fixture +def sample_library_list_response(): + """Paginated list response (without config_yaml).""" + return { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "id": LIBRARY_ID_1, + "name": "my_library", + "namespace": "org", + "is_valid": "valid", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + }, + { + "id": LIBRARY_ID_2, + "name": "another_library", + "namespace": "", + "is_valid": "invalid", + "created": "2025-02-01T12:00:00Z", + "modified": "2025-02-02T12:00:00Z", + }, + ], + } + + +@pytest.fixture +def sample_library_detail_response(): + """Detail response (with config_yaml).""" + return { + "id": LIBRARY_ID_1, + "name": "my_library", + "namespace": "org", + "config_yaml": "version: '1.0'\nfunctions:\n - name: my_func", + "is_valid": "valid", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + } + + +@pytest.fixture +def ruleset_library(): + return RulesetLibrary( + name="test_library", + namespace="test_ns", + yaml="version: '1.0'\nfunctions: []", + ) + + +def test_ruleset_library_model_dump() -> None: + lib = RulesetLibrary(name="lib1", namespace="ns", yaml="content: true") + assert lib.model_dump(exclude_none=True, by_alias=True, mode="json") == { + "name": "lib1", + "namespace": "ns", + "config_yaml": "content: true", + } + + +def test_ruleset_library_model_dump_no_yaml() -> None: + lib = RulesetLibrary(name="lib1", namespace="ns") + api_dict = lib.model_dump(exclude_none=True, by_alias=True, mode="json") + assert "config_yaml" not in api_dict + assert api_dict == {"name": "lib1", "namespace": "ns"} + + +def test_ruleset_library_model_validate() -> None: + response = { + "id": LIBRARY_ID_1, + "name": "my_library", + "namespace": "org", + "config_yaml": "version: '1.0'", + "is_valid": "valid", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + } + lib = RulesetLibrary.model_validate(response) + assert lib.id == RulesetLibraryId(LIBRARY_ID_1) + assert lib.name == "my_library" + assert lib.namespace == "org" + assert lib.yaml == "version: '1.0'" + assert lib.is_valid is ValidationStatus.valid + assert lib.created == datetime.fromisoformat("2025-01-01T12:00:00+00:00") + assert lib.modified == datetime.fromisoformat("2025-01-02T12:00:00+00:00") + + +def test_ruleset_library_model_validate_list( + sample_library_list_response: dict[str, Any], +) -> None: + """List responses omit config_yaml, so yaml should be None.""" + result = sample_library_list_response["results"][0] + lib = RulesetLibrary.model_validate(result) + assert lib.yaml is None + assert lib.is_valid is ValidationStatus.valid + + +def test_ruleset_library_equality() -> None: + """Pydantic structural equality compares all fields.""" + lib1 = RulesetLibrary(name="lib", namespace="ns", yaml="content") + lib2 = RulesetLibrary(name="lib", namespace="ns", yaml="content") + lib3 = RulesetLibrary(name="lib", namespace="other", yaml="content") + assert lib1 == lib2 + assert lib1 != lib3 + + +def test_list_ruleset_libraries(client: DataMasqueClient, sample_library_list_response: dict[str, Any]) -> None: + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/ruleset-libraries/", + json=sample_library_list_response, + status_code=200, + ) + libraries = client.list_ruleset_libraries() + + assert len(libraries) == 2 + assert libraries[0].id == RulesetLibraryId(LIBRARY_ID_1) + assert libraries[0].name == "my_library" + assert libraries[0].namespace == "org" + assert libraries[0].yaml is None + assert libraries[0].is_valid is ValidationStatus.valid + assert libraries[1].id == RulesetLibraryId(LIBRARY_ID_2) + assert libraries[1].name == "another_library" + assert libraries[1].is_valid is ValidationStatus.invalid + + +def test_list_ruleset_libraries_pagination(client: DataMasqueClient) -> None: + page1 = { + "count": 3, + "next": "http://test-server/api/ruleset-libraries/?limit=2&offset=2", + "previous": None, + "results": [ + { + "id": LIBRARY_ID_1, + "name": "lib1", + "namespace": "", + "is_valid": "valid", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-01T12:00:00Z", + }, + { + "id": LIBRARY_ID_2, + "name": "lib2", + "namespace": "", + "is_valid": "valid", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-01T12:00:00Z", + }, + ], + } + page2 = { + "count": 3, + "next": None, + "previous": "http://test-server/api/ruleset-libraries/?limit=2", + "results": [ + { + "id": "cccccccc-1111-2222-3333-444444444444", + "name": "lib3", + "namespace": "", + "is_valid": "unknown", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-01T12:00:00Z", + }, + ], + } + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/ruleset-libraries/", + [{"json": page1, "status_code": 200}, {"json": page2, "status_code": 200}], + ) + libraries = client.list_ruleset_libraries() + + assert len(libraries) == 3 + assert libraries[0].name == "lib1" + assert libraries[1].name == "lib2" + assert libraries[2].name == "lib3" + + +def test_list_ruleset_libraries_empty(client: DataMasqueClient) -> None: + empty_response = {"count": 0, "next": None, "previous": None, "results": []} + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/ruleset-libraries/", + json=empty_response, + status_code=200, + ) + libraries = client.list_ruleset_libraries() + + assert libraries == [] + + +def test_get_ruleset_library(client: DataMasqueClient, sample_library_detail_response: dict[str, Any]) -> None: + with requests_mock.Mocker() as m: + m.get( + f"http://test-server/api/ruleset-libraries/{LIBRARY_ID_1}/", + json=sample_library_detail_response, + status_code=200, + ) + library = client.get_ruleset_library(RulesetLibraryId(LIBRARY_ID_1)) + + assert library.id == RulesetLibraryId(LIBRARY_ID_1) + assert library.name == "my_library" + assert library.namespace == "org" + assert library.yaml == "version: '1.0'\nfunctions:\n - name: my_func" + assert library.is_valid is ValidationStatus.valid + + +def test_get_ruleset_library_by_name_found( + client: DataMasqueClient, sample_library_detail_response: dict[str, Any] +) -> None: + list_response = { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "id": LIBRARY_ID_1, + "name": "my_library", + "namespace": "org", + "is_valid": "valid", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + }, + ], + } + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/ruleset-libraries/", + json=list_response, + status_code=200, + ) + m.get( + f"http://test-server/api/ruleset-libraries/{LIBRARY_ID_1}/", + json=sample_library_detail_response, + status_code=200, + ) + library = client.get_ruleset_library_by_name("my_library", "org") + + assert library is not None + assert library.name == "my_library" + assert library.yaml == "version: '1.0'\nfunctions:\n - name: my_func" + assert "name_exact=my_library" in m.request_history[0].url + assert "namespace_exact=org" in m.request_history[0].url + + +def test_get_ruleset_library_by_name_raises_when_server_omits_id(client: DataMasqueClient) -> None: + """If the server returns a list entry without `id`, `get_ruleset_library_by_name` surfaces a typed API error.""" + list_response_without_id = { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "name": "my_library", + "namespace": "org", + "is_valid": "valid", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + }, + ], + } + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/ruleset-libraries/", + json=list_response_without_id, + status_code=200, + ) + with pytest.raises(DataMasqueApiError, match="without an `id`"): + client.get_ruleset_library_by_name("my_library", "org") + + +def test_get_ruleset_library_by_name_not_found(client: DataMasqueClient) -> None: + empty_response = {"count": 0, "next": None, "previous": None, "results": []} + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/ruleset-libraries/", + json=empty_response, + status_code=200, + ) + library = client.get_ruleset_library_by_name("nonexistent") + + assert library is None + + +def test_create_ruleset_library(client: DataMasqueClient, ruleset_library: RulesetLibrary) -> None: + create_response = { + "id": LIBRARY_ID_1, + "name": "test_library", + "namespace": "test_ns", + "config_yaml": "version: '1.0'\nfunctions: []", + "is_valid": "unknown", + "created": "2025-06-01T10:00:00Z", + "modified": "2025-06-01T10:00:00Z", + } + + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/ruleset-libraries/", + json=create_response, + status_code=201, + ) + result = client.create_ruleset_library(ruleset_library) + + assert result is ruleset_library + assert result.id == RulesetLibraryId(LIBRARY_ID_1) + assert result.is_valid is ValidationStatus.unknown + assert result.created == datetime.fromisoformat("2025-06-01T10:00:00+00:00") + assert result.modified == datetime.fromisoformat("2025-06-01T10:00:00+00:00") + + request_body = m.last_request.json() + assert request_body["name"] == "test_library" + assert request_body["namespace"] == "test_ns" + assert request_body["config_yaml"] == "version: '1.0'\nfunctions: []" + + +def test_update_ruleset_library(client: DataMasqueClient, ruleset_library: RulesetLibrary) -> None: + ruleset_library.id = RulesetLibraryId(LIBRARY_ID_1) + + update_response = { + "id": LIBRARY_ID_1, + "name": "test_library", + "namespace": "test_ns", + "config_yaml": "version: '1.0'\nfunctions: []", + "is_valid": "unknown", + "created": "2025-06-01T10:00:00Z", + "modified": "2025-06-02T10:00:00Z", + } + + with requests_mock.Mocker() as m: + m.put( + f"http://test-server/api/ruleset-libraries/{LIBRARY_ID_1}/", + json=update_response, + status_code=200, + ) + result = client.update_ruleset_library(ruleset_library) + + assert result is ruleset_library + assert result.is_valid is ValidationStatus.unknown + assert result.modified == datetime.fromisoformat("2025-06-02T10:00:00+00:00") + + request_body = m.last_request.json() + assert request_body["name"] == "test_library" + assert request_body["config_yaml"] == "version: '1.0'\nfunctions: []" + + +def test_update_ruleset_library_no_id_raises(client: DataMasqueClient, ruleset_library: RulesetLibrary) -> None: + with pytest.raises(ValueError, match="id is None"): + client.update_ruleset_library(ruleset_library) + + +def test_create_or_update_ruleset_library_create( + client: DataMasqueClient, + ruleset_library: RulesetLibrary, + sample_library_detail_response: dict[str, Any], +) -> None: + empty_list = {"count": 0, "next": None, "previous": None, "results": []} + create_response = { + "id": LIBRARY_ID_1, + "name": "test_library", + "namespace": "test_ns", + "config_yaml": "version: '1.0'\nfunctions: []", + "is_valid": "unknown", + "created": "2025-06-01T10:00:00Z", + "modified": "2025-06-01T10:00:00Z", + } + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/ruleset-libraries/", + json=empty_list, + status_code=200, + ) + m.post( + "http://test-server/api/ruleset-libraries/", + json=create_response, + status_code=201, + ) + result = client.create_or_update_ruleset_library(ruleset_library) + + assert result.id == RulesetLibraryId(LIBRARY_ID_1) + # Should have called GET (list for name lookup) then POST (create) + assert m.request_history[0].method == "GET" + assert m.request_history[1].method == "POST" + + +def test_create_or_update_ruleset_library_update( + client: DataMasqueClient, + ruleset_library: RulesetLibrary, + sample_library_detail_response: dict[str, Any], +) -> None: + list_response = { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "id": LIBRARY_ID_1, + "name": "test_library", + "namespace": "test_ns", + "is_valid": "valid", + "created": "2025-06-01T10:00:00Z", + "modified": "2025-06-01T10:00:00Z", + }, + ], + } + detail_response = { + "id": LIBRARY_ID_1, + "name": "test_library", + "namespace": "test_ns", + "config_yaml": "version: '1.0'\nfunctions: []", + "is_valid": "valid", + "created": "2025-06-01T10:00:00Z", + "modified": "2025-06-01T10:00:00Z", + } + update_response = { + "id": LIBRARY_ID_1, + "name": "test_library", + "namespace": "test_ns", + "config_yaml": "version: '1.0'\nfunctions: []", + "is_valid": "unknown", + "created": "2025-06-01T10:00:00Z", + "modified": "2025-06-02T10:00:00Z", + } + + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/ruleset-libraries/", + json=list_response, + status_code=200, + ) + m.get( + f"http://test-server/api/ruleset-libraries/{LIBRARY_ID_1}/", + json=detail_response, + status_code=200, + ) + m.put( + f"http://test-server/api/ruleset-libraries/{LIBRARY_ID_1}/", + json=update_response, + status_code=200, + ) + result = client.create_or_update_ruleset_library(ruleset_library) + + assert result.id == RulesetLibraryId(LIBRARY_ID_1) + # Should have called GET (list), GET (detail), then PUT (update) + assert m.request_history[0].method == "GET" + assert m.request_history[1].method == "GET" + assert m.request_history[2].method == "PUT" + + +def test_delete_ruleset_library_by_id(client: DataMasqueClient) -> None: + with requests_mock.Mocker() as m: + m.delete( + f"http://test-server/api/ruleset-libraries/{LIBRARY_ID_1}/", + status_code=204, + ) + client.delete_ruleset_library_by_id_if_exists(RulesetLibraryId(LIBRARY_ID_1)) + + assert m.call_count == 1 + + +def test_delete_ruleset_library_by_id_not_found(client: DataMasqueClient) -> None: + with requests_mock.Mocker() as m: + m.delete( + f"http://test-server/api/ruleset-libraries/{LIBRARY_ID_1}/", + status_code=404, + ) + # Should not raise + client.delete_ruleset_library_by_id_if_exists(RulesetLibraryId(LIBRARY_ID_1)) + + +def test_delete_ruleset_library_by_id_force(client: DataMasqueClient) -> None: + with requests_mock.Mocker() as m: + m.delete( + f"http://test-server/api/ruleset-libraries/{LIBRARY_ID_1}/", + status_code=204, + ) + client.delete_ruleset_library_by_id_if_exists(RulesetLibraryId(LIBRARY_ID_1), force=True) + + assert "force=true" in m.last_request.url + + +def test_delete_ruleset_library_by_name(client: DataMasqueClient, sample_library_list_response: dict[str, Any]) -> None: + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/ruleset-libraries/", + json=sample_library_list_response, + status_code=200, + ) + m.delete( + f"http://test-server/api/ruleset-libraries/{LIBRARY_ID_1}/", + status_code=204, + ) + client.delete_ruleset_library_by_name_if_exists("my_library", "org") + + assert m.request_history[0].method == "GET" + assert m.request_history[1].method == "DELETE" + + +def test_delete_ruleset_library_by_name_not_found( + client: DataMasqueClient, sample_library_list_response: dict[str, Any] +) -> None: + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/ruleset-libraries/", + json=sample_library_list_response, + status_code=200, + ) + client.delete_ruleset_library_by_name_if_exists("nonexistent") + + # Only the list call should have been made, no DELETE + assert m.call_count == 1 + assert m.request_history[0].method == "GET" + + +def test_validate_ruleset_library(client: DataMasqueClient) -> None: + validate_response = { + "id": LIBRARY_ID_1, + "name": "my_library", + "namespace": "org", + "config_yaml": "version: '1.0'", + "is_valid": "unknown", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-06-03T10:00:00Z", + } + + with requests_mock.Mocker() as m: + m.patch( + f"http://test-server/api/ruleset-libraries/{LIBRARY_ID_1}/", + json=validate_response, + status_code=200, + ) + result = client.validate_ruleset_library(RulesetLibraryId(LIBRARY_ID_1)) + + assert result.id == RulesetLibraryId(LIBRARY_ID_1) + assert result.is_valid is ValidationStatus.unknown + assert m.last_request.json() == {} + + +def test_list_rulesets_using_library(client: DataMasqueClient) -> None: + rulesets_response = { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "id": "eeeeeeee-1111-2222-3333-444444444444", + "name": "ruleset_a", + "mask_type": "database", + "is_valid": "valid", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-02T12:00:00Z", + }, + { + "id": "ffffffff-1111-2222-3333-444444444444", + "name": "ruleset_b", + "mask_type": "file", + "is_valid": "unknown", + "created": "2025-02-01T12:00:00Z", + "modified": "2025-02-02T12:00:00Z", + }, + ], + } + + with requests_mock.Mocker() as m: + m.get( + f"http://test-server/api/ruleset-libraries/{LIBRARY_ID_1}/rulesets/", + json=rulesets_response, + status_code=200, + ) + rulesets = client.list_rulesets_using_library(RulesetLibraryId(LIBRARY_ID_1)) + + assert len(rulesets) == 2 + assert rulesets[0].name == "ruleset_a" + assert rulesets[0].id == "eeeeeeee-1111-2222-3333-444444444444" + assert rulesets[0].ruleset_type is RulesetType.database + assert rulesets[0].yaml == "" + assert rulesets[0].is_valid is ValidationStatus.valid + assert rulesets[1].name == "ruleset_b" + assert rulesets[1].ruleset_type is RulesetType.file + assert rulesets[1].is_valid is ValidationStatus.unknown + + +def test_list_rulesets_using_library_empty(client: DataMasqueClient) -> None: + empty_response = {"count": 0, "next": None, "previous": None, "results": []} + + with requests_mock.Mocker() as m: + m.get( + f"http://test-server/api/ruleset-libraries/{LIBRARY_ID_1}/rulesets/", + json=empty_response, + status_code=200, + ) + rulesets = client.list_rulesets_using_library(RulesetLibraryId(LIBRARY_ID_1)) + + assert rulesets == [] + + +def test_list_rulesets_using_library_pagination(client: DataMasqueClient) -> None: + page1 = { + "count": 3, + "next": "http://test-server/api/ruleset-libraries/{}/rulesets/?limit=2&offset=2".format(LIBRARY_ID_1), + "previous": None, + "results": [ + { + "id": "aaa", + "name": "r1", + "mask_type": "database", + "is_valid": "valid", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-01T12:00:00Z", + }, + { + "id": "bbb", + "name": "r2", + "mask_type": "database", + "is_valid": "valid", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-01T12:00:00Z", + }, + ], + } + page2 = { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "id": "ccc", + "name": "r3", + "mask_type": "file", + "is_valid": "unknown", + "created": "2025-01-01T12:00:00Z", + "modified": "2025-01-01T12:00:00Z", + }, + ], + } + + with requests_mock.Mocker() as m: + m.get( + f"http://test-server/api/ruleset-libraries/{LIBRARY_ID_1}/rulesets/", + [{"json": page1, "status_code": 200}, {"json": page2, "status_code": 200}], + ) + rulesets = client.list_rulesets_using_library(RulesetLibraryId(LIBRARY_ID_1)) + + assert len(rulesets) == 3 + assert rulesets[0].name == "r1" + assert rulesets[1].name == "r2" + assert rulesets[2].name == "r3" diff --git a/tests/test_rulesets.py b/tests/test_rulesets.py new file mode 100644 index 0000000..ae97b3a --- /dev/null +++ b/tests/test_rulesets.py @@ -0,0 +1,119 @@ +"""Tests for `RulesetClient`.""" + +import pytest +import requests_mock + +from datamasque.client.exceptions import DataMasqueApiError +from datamasque.client.models.ruleset import RulesetType +from datamasque.client.models.status import ValidationStatus + + +def test_list_rulesets(client, existing_rulesets_json): + with requests_mock.Mocker() as m: + # `/api/v2/rulesets/` is not paginated — the server returns a bare list. + m.get( + "http://test-server/api/v2/rulesets/", + json=existing_rulesets_json, + status_code=200, + ) + rulesets = client.list_rulesets() + assert len(rulesets) == 2 + assert rulesets[0].id == "1" + assert rulesets[0].is_valid is ValidationStatus.valid + assert rulesets[0].name == "db_masking_ruleset" + assert rulesets[0].yaml == "version: '1.0'" + assert rulesets[0].ruleset_type is RulesetType.database + assert rulesets[1].id == "2" + assert rulesets[1].is_valid is ValidationStatus.invalid + + +def test_create_or_update_ruleset_create(client, ruleset): + with requests_mock.Mocker() as m: + # Test creating a new ruleset with upsert + m.post( + "http://test-server/api/rulesets/?upsert=true", + json={"id": "2", "is_valid": "in_progress"}, + status_code=201, + ) + + ruleset = client.create_or_update_ruleset(ruleset) + assert ruleset.id == "2" + assert ruleset.is_valid is ValidationStatus.in_progress + + # Verify the sent body uses aliases + sent = m.last_request.json() + assert sent["config_yaml"] == "version: '1.0'\ntasks: []" + assert sent["mask_type"] == "database" + + +def test_create_or_update_ruleset_create_fail(client, ruleset): + with requests_mock.Mocker() as m: + # Test upsert failure + m.post("http://test-server/api/rulesets/?upsert=true", status_code=400) + + with pytest.raises(DataMasqueApiError): + assert client.create_or_update_ruleset(ruleset) is None + assert ruleset.id is None + assert ruleset.is_valid is None + + +def test_create_or_update_ruleset_update(client, ruleset): + with requests_mock.Mocker() as m: + # Test updating an existing ruleset with upsert (returns 200 status for update) + m.post( + "http://test-server/api/rulesets/?upsert=true", + json={"id": "1", "is_valid": "valid"}, + status_code=200, + ) + + client.create_or_update_ruleset(ruleset) + assert ruleset.id == "1" + assert ruleset.is_valid is ValidationStatus.valid + + +def test_create_or_update_ruleset_update_fail(client, ruleset): + with requests_mock.Mocker() as m: + # Test upsert failure for update + m.post( + "http://test-server/api/rulesets/?upsert=true", + json={"id": "1"}, + status_code=400, + ) + + with pytest.raises(DataMasqueApiError): + assert client.create_or_update_ruleset(ruleset) is None + assert ruleset.id is None + + +def test_delete_ruleset_by_id(client): + with requests_mock.Mocker() as m: + m.delete("http://test-server/api/rulesets/1/", status_code=204) + client.delete_ruleset_by_id_if_exists("1") + + +def test_delete_ruleset_by_name(client, existing_rulesets_json): + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/v2/rulesets/", + json=existing_rulesets_json, + status_code=200, + ) + m.delete("http://test-server/api/rulesets/2/", status_code=204) + client.delete_ruleset_by_name_if_exists("file_masking_ruleset") + + assert m.call_count == 2 + assert m.request_history[0].method == "GET" + assert m.request_history[1].method == "DELETE" + + +def test_delete_ruleset_that_does_not_exist(client, existing_rulesets_json): + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/v2/rulesets/", + json=existing_rulesets_json, + status_code=200, + ) + client.delete_ruleset_by_name_if_exists("not_a_ruleset") + + assert m.call_count == 1 + assert m.request_history[0].method == "GET" diff --git a/tests/test_runs.py b/tests/test_runs.py new file mode 100644 index 0000000..3360093 --- /dev/null +++ b/tests/test_runs.py @@ -0,0 +1,468 @@ +"""Tests for `RunClient` (start, status, log, run-report endpoints).""" + +import pytest +import requests_mock + +from datamasque.client.exceptions import ( + DataMasqueApiError, + FailedToStartError, + InvalidLibraryError, + InvalidRulesetError, + RunNotCancellableError, +) +from datamasque.client.models.connection import ConnectionId, DatabaseConnectionConfig, DatabaseType +from datamasque.client.models.ruleset import Ruleset, RulesetId, RulesetType +from datamasque.client.models.runs import ( + MaskingRunOptions, + MaskingRunRequest, + RunConnectionRef, + RunId, + RunInfo, + UnfinishedRun, +) +from datamasque.client.models.status import MaskingRunStatus +from tests.helpers import fake + + +def test_get_run_log(client): + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/runs/1/log/", + json={"log": "test_log"}, + status_code=200, + ) + assert client.get_run_log(RunId(1)) == '{"log": "test_log"}' + + +def test_get_sdd_report(client): + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/runs/1/sdd-report/", + json={"report": "test_report"}, + status_code=200, + ) + assert client.get_sdd_report(RunId(1)) == '{"report": "test_report"}' + + +def test_get_file_data_discovery_report(client): + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/runs/1/file-discovery-results/", + json=[{"id": 1, "file_type": "csv", "files": [], "results": []}], + status_code=200, + ) + results = client.get_file_data_discovery_report(RunId(1)) + assert len(results) == 1 + assert results[0].id == 1 + + +def test_unfinished_run_str_with_destination(): + """`UnfinishedRun.__str__` includes both source and destination connection names when both are set.""" + run = UnfinishedRun( + id=42, + source_connection=RunConnectionRef(name="source_db"), + destination_connection=RunConnectionRef(name="destination_db"), + ruleset_name="my_ruleset", + status=MaskingRunStatus.running, + ) + + assert str(run) == '"source_db", "destination_db": Run ID 42 in status `running`, ruleset "my_ruleset"' + + +def test_unfinished_run_str_without_destination(): + """`UnfinishedRun.__str__` omits the destination when it is `None`.""" + run = UnfinishedRun( + id=42, + source_connection=RunConnectionRef(name="source_db"), + ruleset_name="my_ruleset", + status=MaskingRunStatus.queued, + ) + + assert str(run) == '"source_db": Run ID 42 in status `queued`, ruleset "my_ruleset"' + + +def test_get_unfinished_runs(client): + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/runs/?connection_ruleset_name=&ruleset_name=&run_status=queued&limit=1&offset=0", + json={ + "results": [ + { + "source_connection_name": "queued_src", + "destination_connection_name": "queued_dst", + "id": 1, + "ruleset_name": "ruleset_1", + "status": "queued", + } + ] + }, + status_code=200, + ) + m.get( + "http://test-server/api/runs/?connection_ruleset_name=&ruleset_name=&run_status=running&limit=1&offset=0", + json={ + "results": [ + { + "source_connection_name": "running_src", + "destination_connection_name": "running_dst", + "id": 2, + "ruleset_name": "ruleset_2", + "status": "running", + } + ] + }, + status_code=200, + ) + m.get( + "http://test-server/api/runs/?connection_ruleset_name=&ruleset_name=&run_status=validating&limit=1&offset=0", + json={ + "results": [ + { + "source_connection_name": "validating_src", + "destination_connection_name": "validating_dst", + "id": 3, + "ruleset_name": "ruleset_3", + "status": "validating", + } + ] + }, + status_code=200, + ) + m.get( + "http://test-server/api/runs/?connection_ruleset_name=&ruleset_name=&run_status=cancelling&limit=1&offset=0", + json={ + "results": [ + { + "source_connection_name": "cancelling_src", + "destination_connection_name": "", + "id": 4, + "ruleset_name": "ruleset_4", + "status": "cancelling", + } + ] + }, + status_code=200, + ) + + ur = client.get_unfinished_runs() + # 3 statuses have both source and destination keys, cancelling has only source (empty destination) + assert len(ur) == 7 + for run in ur.values(): + assert isinstance(run, UnfinishedRun) + + +def test_start_masking_run(client): + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/runs/", + json={"id": "1", "name": fake.word()}, + status_code=201, + ) + assert client.start_masking_run(MaskingRunRequest(connection="1", ruleset="rs-1", name=fake.word())) == "1" + + +def test_start_masking_run_invalid_ruleset(client): + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/runs/", + json={"ruleset": ["Cannot start run on invalid ruleset."]}, + status_code=400, + ) + with pytest.raises(InvalidRulesetError): + client.start_masking_run(MaskingRunRequest(connection="1", ruleset="rs-1", name=fake.word())) + + +def test_start_masking_run_invalid_library(client): + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/runs/", + json={"ruleset": ['Cannot start run. Library "foo" is invalid.']}, + status_code=400, + ) + with pytest.raises(InvalidLibraryError, match=r'Run failed to start due to invalid library named "foo"'): + client.start_masking_run(MaskingRunRequest(connection="1", ruleset="rs-1", name=fake.word())) + + +def test_start_masking_run_invalid_library_without_named_match(client): + """Server says the library is invalid but the error string doesn't quote a library name.""" + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/runs/", + json={"ruleset": ["Cannot start run because a library referenced from the ruleset is invalid."]}, + status_code=400, + ) + with pytest.raises(InvalidLibraryError, match=r"Run failed to start due to invalid library\."): + client.start_masking_run(MaskingRunRequest(connection="1", ruleset="rs-1", name=fake.word())) + + +def test_get_run_report_returns_response_text(client): + with requests_mock.Mocker() as m: + m.get("http://test-server/api/runs/7/run-report/", text="the,report,csv\n1,2,3", status_code=200) + report = client.get_run_report(RunId(7)) + + assert report == "the,report,csv\n1,2,3" + + +def test_start_masking_run_invalid_library_with_quotes_in_name(client): + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/runs/", + json={"ruleset": ['Cannot start run. Library "library with "quotes and spaces" in its name" is invalid.']}, + status_code=400, + ) + with pytest.raises( + InvalidLibraryError, + match=r'Run failed to start due to invalid library named "library with "quotes and spaces" in its name"', + ): + client.start_masking_run(MaskingRunRequest(connection="1", ruleset="rs-1", name=fake.word())) + + +def test_start_masking_run_unparseable_ruleset_error(client): + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/runs/", + json={"ruleset": []}, + status_code=400, + ) + with pytest.raises(FailedToStartError): + client.start_masking_run(MaskingRunRequest(connection="1", ruleset="rs-1", name=fake.word())) + + +def test_start_masking_run_fail(client): + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/runs/", + json={"error": fake.sentence()}, + status_code=400, + ) + with pytest.raises(FailedToStartError): + client.start_masking_run(MaskingRunRequest(connection="1", ruleset="rs-1", name=fake.word())) + + +def test_start_masking_run_failure_surfaces_server_body(client): + """On a non-201 response the raised `FailedToStartError` carries the `Response` and names the status + body.""" + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/runs/", + json={"options": ["This field is required."]}, + status_code=400, + ) + with pytest.raises(FailedToStartError) as excinfo: + client.start_masking_run(MaskingRunRequest(connection="1", ruleset="rs-1", name="my-run")) + + assert excinfo.value.response.status_code == 400 + assert excinfo.value.response.json() == {"options": ["This field is required."]} + # The message surfaces the status and body so users can diagnose without re-inspecting the response. + assert "status 400" in str(excinfo.value) + assert "This field is required." in str(excinfo.value) + + +def test_get_run_info(client): + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/runs/1/", + json={ + "id": 1, + "name": "r1", + "status": "finished", + "mask_type": "database", + "source_connection_name": "conn1", + "ruleset_name": "rs1", + }, + status_code=200, + ) + result = client.get_run_info(1) + assert isinstance(result, RunInfo) + assert result.id == 1 + assert result.name == "r1" + + +def test_masking_run_request_model_dump_minimal(): + """A minimal request dumps with an empty `options` object (the server rejects missing `options`).""" + req = MaskingRunRequest(connection="conn-1", ruleset="rs-1") + assert req.model_dump(exclude_none=True, mode="json") == { + "connection": "conn-1", + "ruleset": "rs-1", + "mask_type": "database", + "options": {}, + } + + +def test_masking_run_request_requires_ruleset(): + """Omitting `ruleset` raises a validation error — `start_masking_run` only supports runs with a stored ruleset.""" + with pytest.raises(ValueError, match="ruleset"): + MaskingRunRequest(connection="conn-1") # type: ignore[call-arg] + + +def test_masking_run_request_accepts_connection_config_and_ruleset_objects(): + """Callers may pass full `ConnectionConfig` / `Ruleset` objects; their IDs are extracted at construction.""" + connection = DatabaseConnectionConfig( + id=ConnectionId("conn-uuid"), + name="prod_db", + db_type=DatabaseType.postgres, + host="db.example.com", + port=5432, + database="app", + user="masker", + ) + dest = DatabaseConnectionConfig( + id=ConnectionId("dest-uuid"), + name="staging_db", + db_type=DatabaseType.postgres, + host="staging.example.com", + port=5432, + database="app", + user="masker", + ) + ruleset = Ruleset( + id=RulesetId("rs-uuid"), + name="my_ruleset", + yaml="version: '1.0'", + ruleset_type=RulesetType.database, + ) + + req = MaskingRunRequest(connection=connection, destination_connection=dest, ruleset=ruleset) + + dumped = req.model_dump(exclude_none=True, mode="json") + assert dumped["connection"] == "conn-uuid" + assert dumped["destination_connection"] == "dest-uuid" + assert dumped["ruleset"] == "rs-uuid" + + +def test_masking_run_request_rejects_unpersisted_connection(): + """A `ConnectionConfig` without an `id` means the caller hasn't created it yet — raise at construction.""" + connection = DatabaseConnectionConfig( + name="not_yet_created", + db_type=DatabaseType.postgres, + host="localhost", + port=5432, + database="db", + user="u", + ) + with pytest.raises(ValueError, match="has not been created yet"): + MaskingRunRequest(connection=connection, ruleset="rs-1") + + +def test_masking_run_request_rejects_unpersisted_ruleset(): + """Same check on the ruleset side.""" + ruleset = Ruleset(name="fresh_ruleset", yaml="version: '1.0'", ruleset_type=RulesetType.database) + with pytest.raises(ValueError, match="has not been created yet"): + MaskingRunRequest(connection="conn-1", ruleset=ruleset) + + +def test_run_info_collapses_flat_connection_fields(): + """`RunInfo.model_validate` folds the server's flat `source_connection*` pair into a nested `RunConnectionRef`.""" + run = RunInfo.model_validate( + { + "id": 1, + "status": "finished", + "mask_type": "database", + "source_connection": "src-uuid", + "source_connection_name": "prod", + "destination_connection": "dst-uuid", + "destination_connection_name": "staging", + "ruleset_name": "rs", + } + ) + assert isinstance(run.source_connection, RunConnectionRef) + assert run.source_connection.id == "src-uuid" + assert run.source_connection.name == "prod" + assert run.destination_connection is not None + assert run.destination_connection.id == "dst-uuid" + assert run.destination_connection.name == "staging" + + +def test_run_info_treats_empty_destination_name_as_absent(): + """The server returns an empty string for `destination_connection_name` when there is no destination.""" + run = RunInfo.model_validate( + { + "id": 1, + "status": "finished", + "mask_type": "database", + "source_connection_name": "prod", + "destination_connection_name": "", + "ruleset_name": "rs", + } + ) + assert run.destination_connection is None + + +def test_masking_run_request_model_dump_includes_set_fields(): + req = MaskingRunRequest( + connection="conn-1", + ruleset="rs-1", + destination_connection="conn-2", + mask_type="file", + options=MaskingRunOptions(batch_size=100, dry_run=True), + name="my-run", + ) + assert req.model_dump(exclude_none=True, mode="json") == { + "connection": "conn-1", + "ruleset": "rs-1", + "destination_connection": "conn-2", + "mask_type": "file", + "options": {"batch_size": 100, "dry_run": True}, + "name": "my-run", + } + + +def test_start_masking_run_accepts_typed_request(client): + """A `MaskingRunRequest` is converted to its dict body before being sent.""" + req = MaskingRunRequest(connection="conn-1", ruleset="rs-1") + + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/runs/", + json={"id": "42", "name": "the-run"}, + status_code=201, + ) + run_id = client.start_masking_run(req) + + assert run_id == "42" + sent_body = m.last_request.json() + assert sent_body == {"connection": "conn-1", "mask_type": "database", "ruleset": "rs-1", "options": {}} + + +def test_cancel_run_returns_updated_run_info(client): + """A successful cancel returns the run record with `cancelling` status.""" + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/runs/42/cancel/", + json={ + "id": 42, + "status": "cancelling", + "name": "the-run", + "mask_type": "database", + "source_connection_name": "conn1", + "ruleset_name": "rs1", + }, + status_code=200, + ) + result = client.cancel_run(RunId(42)) + + assert isinstance(result, RunInfo) + assert result.id == 42 + assert result.status is MaskingRunStatus.cancelling + assert m.last_request.method == "POST" + # No body is sent — `cancel_run` is a pure command. + assert m.last_request.body is None + + +def test_cancel_run_raises_run_not_cancellable_on_400(client): + """A 400 means the run is in a state that cannot transition to cancelling.""" + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/runs/42/cancel/", + json={"detail": "Run is already finished"}, + status_code=400, + ) + with pytest.raises(RunNotCancellableError, match="Run 42 cannot be cancelled"): + client.cancel_run(RunId(42)) + + +def test_cancel_run_raises_api_error_on_500(client): + """Non-400 errors propagate as the generic `DataMasqueApiError`.""" + with requests_mock.Mocker() as m: + m.post("http://test-server/api/runs/42/cancel/", status_code=500) + with pytest.raises(DataMasqueApiError): + client.cancel_run(RunId(42)) diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..6df4e38 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,107 @@ +"""Tests for `SettingsClient` (admin install bootstrap, application logs, locality).""" + +import pytest +import requests_mock + +from datamasque.client import DataMasqueClient +from datamasque.client.exceptions import DataMasqueApiError, DataMasqueUserError +from datamasque.client.models.dm_instance import DataMasqueInstanceConfig +from tests.helpers import fake + + +def test_admin_install_basic(client): + with requests_mock.Mocker() as m: + mock_email = fake.email() + m.post( + "http://test-server/api/users/admin-install/", + json={"id": 1, "email": mock_email, "username": "admin"}, + status_code=201, + ) + client.admin_install(email=mock_email) + + request_data = m.last_request.json() + assert request_data["email"] == mock_email + assert request_data["username"] == "admin" + assert request_data["password"] == "test_password" + assert request_data["re_password"] == "test_password" + assert request_data["allowed_hosts"] == [ + "localhost", + "127.0.0.1", + "test-server", + ] + + +def test_admin_install_overrides(client): + with requests_mock.Mocker() as m: + mock_email = fake.email() + mock_username = fake.user_name() + mock_password = fake.password() + mock_hostname = fake.hostname() + m.post( + "http://test-server/api/users/admin-install/", + json={"id": 1, "email": mock_email, "username": mock_username}, + status_code=201, + ) + client.admin_install( + email=mock_email, + username=mock_username, + password=mock_password, + allowed_hosts=[mock_hostname], + ) + + request_data = m.last_request.json() + assert request_data["email"] == mock_email + assert request_data["username"] == mock_username + assert request_data["password"] == mock_password + assert request_data["re_password"] == mock_password + assert request_data["allowed_hosts"] == [mock_hostname] + + +def test_admin_install_fail(client): + with requests_mock.Mocker() as m: + m.post("http://test-server/api/users/admin-install/", status_code=400) + with pytest.raises(DataMasqueApiError): + client.admin_install( + email=fake.email(), + username=fake.user_name(), + password=fake.password(), + allowed_hosts=[fake.hostname()], + ) + + +def test_admin_install_requires_password_when_client_uses_token_source(): + """`admin_install` cannot fall back to `self.password` when the client was constructed with `token_source`.""" + config = DataMasqueInstanceConfig( + base_url="http://test-server", + username="admin", + token_source=lambda: "token", + ) + client = DataMasqueClient(config) + + with pytest.raises(DataMasqueUserError, match="`admin_install` requires a `password` argument"): + client.admin_install(email=fake.email()) + + +def test_retrieve_application_logs_writes_streamed_response_to_file(client, tmp_path): + """Verifies the full streamed response body ends up in the output file, across multiple `iter_content` chunks.""" + # Content spans multiple 4096-byte chunks so the write-loop actually runs more than once. + content = b"a" * 4096 + b"b" * 4096 + b"c" * 1000 + output = tmp_path / "logs.tar.gz" + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/logs/download/", + content=content, + status_code=200, + ) + client.retrieve_application_logs(output) + + assert m.last_request.qs == {"log_service": ["application"]} + assert output.read_bytes() == content + + +def test_set_locality_sends_patch(client): + with requests_mock.Mocker() as m: + m.patch("http://test-server/api/settings/", status_code=200) + client.set_locality("en_GB") + + assert m.last_request.json() == {"locality": "en_GB"} diff --git a/tests/test_users.py b/tests/test_users.py new file mode 100644 index 0000000..d4c64f9 --- /dev/null +++ b/tests/test_users.py @@ -0,0 +1,399 @@ +"""Tests for `UserClient` (CRUD + password reset + password generation helper).""" + +import string +from unittest.mock import patch + +import pytest +import requests_mock + +from datamasque.client.exceptions import DataMasqueUserError +from datamasque.client.models.user import GENERATED_PASSWORD_LENGTH, User, UserId, UserRole +from tests.helpers import fake + + +def test_generate_password_properties(): + """ + Generated passwords satisfy every documented constraint. + + The password is the right length, uses only `string.ascii_letters + string.digits`, + never contains the same character three times in a row, + and never contains three consecutive characters that form an increasing or decreasing arithmetic run + (e.g. `abc`, `cba`, `123`, `321`). + """ + password = User.generate_password() + + assert len(password) == GENERATED_PASSWORD_LENGTH + + allowed_chars = set(string.ascii_letters + string.digits) + assert set(password) <= allowed_chars, f'unexpected chars in "{password}"' + + for i in range(2, len(password)): + a, b, c = ord(password[i - 2]), ord(password[i - 1]), ord(password[i]) + assert not (a == b == c), f'triple repeat at index {i} in "{password}"' + assert not (c == b + 1 == a + 2), f'ascending run at index {i} in "{password}"' + assert not (c == b - 1 == a - 2), f'descending run at index {i} in "{password}"' + + +def test_generate_password_rejects_triples_and_sequential_runs(): + """ + Drive `secrets.choice` with a fixed sequence that forces every rejection path. + + Verifies the generator: + - uses `secrets.choice` (not `random.choice`); + - allows the pair `aa` but rejects the triple `aaa`; + - rejects a character that would complete an ascending run of 3; + - rejects a character that would complete a descending run of 3; + - keeps consuming the stream until the result is `GENERATED_PASSWORD_LENGTH` chars. + """ + mock_sequence = iter( + [ + # Triple repeat: third `a` gets skipped. + "a", + "a", + "a", + "m", + # Ascending run: `d` gets skipped after `bc`. + "b", + "c", + "d", + "p", + # Descending run: `x` gets skipped after `zy`. + "z", + "y", + "x", + "q", + "r", + # Remaining 6 benign chars, + # chosen so no three consecutive form a run or a triple. + "A", + "b", + "C", + "d", + "E", + "f", + ] + ) + + with patch( + "datamasque.client.models.user.secrets.choice", + side_effect=lambda _chars: next(mock_sequence), + ) as mock_choice: + password = User.generate_password() + + # 19 mock inputs - 3 rejections (one per bad pattern) = 16 accepted chars. + assert password == "aambcpzyqrAbCdEf" + assert mock_choice.call_count == 19 + + +def test_user_create(client): + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/users/", + json={"id": 1, "email": fake.email(), "username": "builder", "user_roles": ["mask_builder"]}, + status_code=201, + ) + + user = client.create_or_update_user(User(email=fake.email(), username="builder", roles=[UserRole.mask_builder])) + assert user.id == 1 + + assert m.call_count == 1 + assert m.request_history[0].method == "POST" + actual_request_data = m.request_history[0].json() + expected_request_data = { + "username": "builder", + "password": user.password, + "re_password": user.password, + "user_roles": ["mask_builder"], + } + for key, value in expected_request_data.items(): + assert actual_request_data[key] == value + + +def test_user_create_with_password(client): + new_password = "better_p@ssw0rd!" + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/users/", + json={"id": 1, "email": fake.email(), "username": "builder", "user_roles": ["mask_builder"]}, + status_code=201, + ) + m.patch("http://test-server/api/users/1/", status_code=200) + + user = client.create_or_update_user( + User(email=fake.email(), username="builder", roles=[UserRole.mask_builder]), + new_password=new_password, + ) + assert user.id == 1 + assert user.password == new_password + + assert m.call_count == 2 + assert m.request_history[0].method == "POST" + assert m.request_history[1].method == "PATCH" + actual_request_data = m.request_history[1].json() + expected_request_data = { + "new_password": new_password, + "re_new_password": new_password, + } + assert len(actual_request_data["current_password"]) == 16 # initial random password + for key, value in expected_request_data.items(): + assert actual_request_data[key] == value + + +def test_user_update(client): + with requests_mock.Mocker() as m: + m.patch( + "http://test-server/api/users/1/", + json={ + "id": 1, + "email": fake.email(), + "username": "builder", + "password": "temp_password1", + }, + status_code=200, + ) + + user = User(email=fake.email(), username="builder", roles=[UserRole.mask_builder]) + user.id = 1 + user.password = "shouldn't be changed" + modified_user = client.create_or_update_user(user) + assert modified_user.id == 1 + assert modified_user.password == "shouldn't be changed" + + +def test_user_update_with_password(client): + old_password = "old_password" + new_password = "better_p@ssw0rd!" + with requests_mock.Mocker() as m: + m.patch( + "http://test-server/api/users/1/", + json={"id": 1, "email": fake.email(), "username": "builder"}, + status_code=201, + ) + + user = User(email=fake.email(), username="builder", roles=[UserRole.mask_builder]) + user.id = 1 + user.password = old_password + modified_user = client.create_or_update_user( + user, + new_password=new_password, + ) + assert modified_user.id == 1 + assert modified_user.password == new_password + + assert m.call_count == 2 + assert m.request_history[0].method == "PATCH" + assert m.request_history[1].method == "PATCH" + assert m.request_history[1].json() == { + "current_password": old_password, + "new_password": new_password, + "re_new_password": new_password, + } + + +def test_user_creation_must_specify_at_least_one_role(client): + user = User(email=fake.email(), username="builder", roles=[]) + with pytest.raises(DataMasqueUserError, match=r'User must have at least one role'): + client.create_or_update_user(user) + + +def test_user_create_superuser(client): + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/users/", + json={"id": 1, "email": fake.email(), "username": "admin2", "user_roles": ["admin"]}, + status_code=201, + ) + + user = client.create_or_update_user(User(email=fake.email(), username="admin2", roles=[UserRole.superuser])) + assert user.id == 1 + + assert m.call_count == 1 + assert m.request_history[0].method == "POST" + actual_request_data = m.request_history[0].json() + assert actual_request_data["user_roles"] == ["admin"] + + +def test_user_create_with_ruleset_library_manager(client): + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/users/", + json={ + "id": 1, + "email": fake.email(), + "username": "lib_builder", + "user_roles": ["mask_builder", "ruleset_library_managers"], + }, + status_code=201, + ) + + user = client.create_or_update_user( + User( + email=fake.email(), + username="lib_builder", + roles=[UserRole.mask_builder, UserRole.ruleset_library_manager], + ) + ) + assert user.id == 1 + + assert m.call_count == 1 + actual_request_data = m.request_history[0].json() + assert actual_request_data["user_roles"] == ["mask_builder", "ruleset_library_managers"] + + +def test_user_create_ruleset_library_manager_without_mask_builder_fails(client): + user = User( + email=fake.email(), + username="lib_only", + roles=[UserRole.ruleset_library_manager], + ) + with pytest.raises(DataMasqueUserError, match=r"ruleset_library_manager.*requires.*mask_builder"): + client.create_or_update_user(user) + + +def test_user_reset_password(client): + temp_password = "temp_password1" + with requests_mock.Mocker() as m: + m.post( + "http://test-server/api/users/1/reset-password/", + json={"password": temp_password}, + status_code=200, + ) + user = User(email=fake.email(), username="builder", roles=[UserRole.mask_builder]) + user.id = 1 + password = client.reset_password_for_user(user) + assert password == temp_password + assert user.password == temp_password + + +def test_uncreated_user_cannot_reset_password(client): + user = User(email=fake.email(), username="builder", roles=[UserRole.mask_builder]) + with pytest.raises(DataMasqueUserError): + # id is not set, so this fails + client.reset_password_for_user(user) + + +def test_list_users(client): + fake_emails = [fake.email() for _ in range(5)] + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/users/", + json=[ + { + "id": 1, + "username": "admin", + "is_active": True, + "email": fake_emails[0], + "user_roles": ["admin"], + }, + { + "id": 2, + "username": "builder", + "is_active": True, + "email": fake_emails[1], + "user_roles": ["mask_builder"], + }, + { + "id": 3, + "username": "runner", + "is_active": True, + "email": fake_emails[2], + "user_roles": ["mask_runner"], + }, + { + "id": 4, + "username": "disabled", + "is_active": False, + "email": fake_emails[3], + "user_roles": ["mask_builder"], + }, + { + "id": 5, + "username": "no_role", + "is_active": True, + "email": fake_emails[4], + "user_roles": [], + }, + ], + status_code=200, + ) + + users = client.list_users() + # 4 active users returned (inactive user id=4 excluded) + assert len(users) == 4 + assert users[0].username == "admin" + assert users[0].id == 1 + assert users[0].roles == [UserRole.superuser] + assert users[1].username == "builder" + assert users[1].id == 2 + assert users[2].username == "runner" + assert users[2].id == 3 + assert users[3].username == "no_role" + assert users[3].id == 5 + + +def test_delete_user_by_id(client): + with requests_mock.Mocker() as m: + m.delete("http://test-server/api/users/1/", status_code=204) + client.delete_user_by_id_if_exists(UserId(1)) + + assert m.call_count == 1 + assert m.request_history[0].method == "DELETE" + + +def test_delete_user_by_id_not_found(client): + with requests_mock.Mocker() as m: + m.delete("http://test-server/api/users/99/", status_code=404) + client.delete_user_by_id_if_exists(UserId(99)) + + assert m.call_count == 1 + + +def test_delete_user_by_username(client): + fake_email = fake.email() + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/users/", + json=[ + { + "id": 1, + "username": "admin", + "is_active": True, + "email": fake_email, + "user_roles": ["admin"], + }, + { + "id": 2, + "username": "target", + "is_active": True, + "email": fake_email, + "user_roles": ["mask_builder"], + }, + ], + ) + m.delete("http://test-server/api/users/2/", status_code=204) + client.delete_user_by_username_if_exists("target") + + assert m.call_count == 2 + assert m.request_history[0].method == "GET" + assert m.request_history[1].method == "DELETE" + + +def test_delete_user_by_username_not_found(client): + fake_email = fake.email() + with requests_mock.Mocker() as m: + m.get( + "http://test-server/api/users/", + json=[ + { + "id": 1, + "username": "admin", + "is_active": True, + "email": fake_email, + "user_roles": ["admin"], + }, + ], + ) + client.delete_user_by_username_if_exists("nonexistent") + + assert m.call_count == 1 + assert m.request_history[0].method == "GET" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..7ddfdac --- /dev/null +++ b/uv.lock @@ -0,0 +1,1486 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] + +[[package]] +name = "alabaster" +version = "0.7.16" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776, upload-time = "2024-01-10T00:56:10.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" }, +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +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-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 = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "bump2version" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/2a/688aca6eeebfe8941235be53f4da780c6edee05dbbea5d7abaa3aab6fad2/bump2version-1.0.1.tar.gz", hash = "sha256:762cb2bfad61f4ec8e2bdf452c7c267416f8c70dd9ecb1653fd0bbb01fa936e6", size = 36236, upload-time = "2020-10-07T18:38:40.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/e3/fa60c47d7c344533142eb3af0b73234ef8ea3fb2da742ab976b947e717df/bump2version-1.0.1-py2.py3-none-any.whl", hash = "sha256:37f927ea17cde7ae2d7baf832f8e80ce3777624554a653006c9144f8017fe410", size = 22030, upload-time = "2020-10-07T18:38:38.148Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/21/a2b1505639008ba2e6ef03733a81fc6cfd6a07ea6139a2b76421230b8dad/charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765", size = 283319, upload-time = "2026-03-06T06:00:26.433Z" }, + { url = "https://files.pythonhosted.org/packages/70/67/df234c29b68f4e1e095885c9db1cb4b69b8aba49cf94fac041db4aaf1267/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990", size = 189974, upload-time = "2026-03-06T06:00:28.222Z" }, + { url = "https://files.pythonhosted.org/packages/df/7f/fc66af802961c6be42e2c7b69c58f95cbd1f39b0e81b3365d8efe2a02a04/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2", size = 207866, upload-time = "2026-03-06T06:00:29.769Z" }, + { url = "https://files.pythonhosted.org/packages/c9/23/404eb36fac4e95b833c50e305bba9a241086d427bb2167a42eac7c4f7da4/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765", size = 203239, upload-time = "2026-03-06T06:00:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2f/8a1d989bfadd120c90114ab33e0d2a0cbde05278c1fc15e83e62d570f50a/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d", size = 196529, upload-time = "2026-03-06T06:00:32.608Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0c/c75f85ff7ca1f051958bb518cd43922d86f576c03947a050fbedfdfb4f15/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8", size = 184152, upload-time = "2026-03-06T06:00:33.93Z" }, + { url = "https://files.pythonhosted.org/packages/f9/20/4ed37f6199af5dde94d4aeaf577f3813a5ec6635834cda1d957013a09c76/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412", size = 195226, upload-time = "2026-03-06T06:00:35.469Z" }, + { url = "https://files.pythonhosted.org/packages/28/31/7ba1102178cba7c34dcc050f43d427172f389729e356038f0726253dd914/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2", size = 192933, upload-time = "2026-03-06T06:00:36.83Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/f86443ab3921e6a60b33b93f4a1161222231f6c69bc24fb18f3bee7b8518/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1", size = 185647, upload-time = "2026-03-06T06:00:38.367Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/08b8be891760f1f5a6d23ce11d6d50c92981603e6eb740b4f72eea9424e2/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4", size = 209533, upload-time = "2026-03-06T06:00:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/df114f23406199f8af711ddccfbf409ffbc5b7cdc18fa19644997ff0c9bb/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f", size = 195901, upload-time = "2026-03-06T06:00:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/07/83/71ef34a76fe8aa05ff8f840244bda2d61e043c2ef6f30d200450b9f6a1be/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550", size = 204950, upload-time = "2026-03-06T06:00:45.202Z" }, + { url = "https://files.pythonhosted.org/packages/58/40/0253be623995365137d7dc68e45245036207ab2227251e69a3d93ce43183/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2", size = 198546, upload-time = "2026-03-06T06:00:46.481Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5c/5f3cb5b259a130895ef5ae16b38eaf141430fa3f7af50cd06c5d67e4f7b2/charset_normalizer-3.4.5-cp310-cp310-win32.whl", hash = "sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475", size = 132516, upload-time = "2026-03-06T06:00:47.924Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c3/84fb174e7770f2df2e1a2115090771bfbc2227fb39a765c6d00568d1aab4/charset_normalizer-3.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05", size = 142906, upload-time = "2026-03-06T06:00:49.389Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b2/6f852f8b969f2cbd0d4092d2e60139ab1af95af9bb651337cae89ec0f684/charset_normalizer-3.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064", size = 133258, upload-time = "2026-03-06T06:00:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9e/bcec3b22c64ecec47d39bf5167c2613efd41898c019dccd4183f6aa5d6a7/charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694", size = 279531, upload-time = "2026-03-06T06:00:52.252Z" }, + { url = "https://files.pythonhosted.org/packages/58/12/81fd25f7e7078ab5d1eedbb0fac44be4904ae3370a3bf4533c8f2d159acd/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5", size = 188006, upload-time = "2026-03-06T06:00:53.8Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6e/f2d30e8c27c1b0736a6520311982cf5286cfc7f6cac77d7bc1325e3a23f2/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281", size = 205085, upload-time = "2026-03-06T06:00:55.311Z" }, + { url = "https://files.pythonhosted.org/packages/d0/90/d12cefcb53b5931e2cf792a33718d7126efb116a320eaa0742c7059a95e4/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923", size = 200545, upload-time = "2026-03-06T06:00:56.532Z" }, + { url = "https://files.pythonhosted.org/packages/03/f4/44d3b830a20e89ff82a3134912d9a1cf6084d64f3b95dcad40f74449a654/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81", size = 193863, upload-time = "2026-03-06T06:00:57.823Z" }, + { url = "https://files.pythonhosted.org/packages/25/4b/f212119c18a6320a9d4a730d1b4057875cdeabf21b3614f76549042ef8a8/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497", size = 181827, upload-time = "2026-03-06T06:00:59.323Z" }, + { url = "https://files.pythonhosted.org/packages/74/00/b26158e48b425a202a92965f8069e8a63d9af1481dfa206825d7f74d2a3c/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c", size = 191085, upload-time = "2026-03-06T06:01:00.546Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1c1737bf6fd40335fe53d28fe49afd99ee4143cc57a845e99635ce0b9b6d/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e", size = 190688, upload-time = "2026-03-06T06:01:02.479Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3d/abb5c22dc2ef493cd56522f811246a63c5427c08f3e3e50ab663de27fcf4/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f", size = 183077, upload-time = "2026-03-06T06:01:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/44/33/5298ad4d419a58e25b3508e87f2758d1442ff00c2471f8e0403dab8edad5/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e", size = 206706, upload-time = "2026-03-06T06:01:05.773Z" }, + { url = "https://files.pythonhosted.org/packages/7b/17/51e7895ac0f87c3b91d276a449ef09f5532a7529818f59646d7a55089432/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af", size = 191665, upload-time = "2026-03-06T06:01:07.473Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/cce9adf1883e98906dbae380d769b4852bb0fa0004bc7d7a2243418d3ea8/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85", size = 201950, upload-time = "2026-03-06T06:01:08.973Z" }, + { url = "https://files.pythonhosted.org/packages/08/ca/bce99cd5c397a52919e2769d126723f27a4c037130374c051c00470bcd38/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f", size = 195830, upload-time = "2026-03-06T06:01:10.155Z" }, + { url = "https://files.pythonhosted.org/packages/87/4f/2e3d023a06911f1281f97b8f036edc9872167036ca6f55cc874a0be6c12c/charset_normalizer-3.4.5-cp311-cp311-win32.whl", hash = "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4", size = 132029, upload-time = "2026-03-06T06:01:11.706Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/a853b73d386521fd44b7f67ded6b17b7b2367067d9106a5c4b44f9a34274/charset_normalizer-3.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a", size = 142404, upload-time = "2026-03-06T06:01:12.865Z" }, + { url = "https://files.pythonhosted.org/packages/b4/10/dba36f76b71c38e9d391abe0fd8a5b818790e053c431adecfc98c35cd2a9/charset_normalizer-3.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c", size = 132796, upload-time = "2026-03-06T06:01:14.106Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z" }, + { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z" }, + { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z" }, + { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z" }, + { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z" }, + { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z" }, + { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" }, + { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" }, + { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" }, + { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" }, + { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" }, + { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" }, + { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" }, + { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" }, + { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" }, + { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" }, + { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" }, + { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" }, + { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" }, + { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" }, + { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" }, + { url = "https://files.pythonhosted.org/packages/be/76/96dec962aa996081c48f544d5e9e97322006a1e67e8f76bad41f3fb0b151/charset_normalizer-3.4.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:259cd1ca995ad525f638e131dbcc2353a586564c038fc548a3fe450a91882139", size = 283220, upload-time = "2026-03-06T06:02:53.024Z" }, + { url = "https://files.pythonhosted.org/packages/cc/80/050c340587611be9743eff02d1ca34b5fc76a4356849dcb74dfd898d6d87/charset_normalizer-3.4.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a28afb04baa55abf26df544e3e5c6534245d3daa5178bc4a8eeb48202060d0e", size = 189988, upload-time = "2026-03-06T06:02:54.448Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a3/bb6caf9f5544ccaaca5c7e387fa868868d3420bcb03e8bc30f37be2e8a72/charset_normalizer-3.4.5-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ff95a9283de8a457e6b12989de3f9f5193430f375d64297d323a615ea52cbdb3", size = 207786, upload-time = "2026-03-06T06:02:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/50/e56713141f2fdb3a4d46092425d58dc97a48e1e10ce321ac6ba43862aacf/charset_normalizer-3.4.5-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:708c7acde173eedd4bfa4028484426ba689d2103b28588c513b9db2cd5ecde9c", size = 203556, upload-time = "2026-03-06T06:02:57.31Z" }, + { url = "https://files.pythonhosted.org/packages/22/34/ed0cfd388dd9106725afc2beb036adbaa167fc0b5a9ee8cd3940757fb060/charset_normalizer-3.4.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa92ec1102eaff840ccd1021478af176a831f1bccb08e526ce844b7ddda85c22", size = 196552, upload-time = "2026-03-06T06:02:59.05Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8b/da4a4c3d26c539fdd777cfbd2c0d83e77e1218879517ef91c4ece7238563/charset_normalizer-3.4.5-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:5fea359734b140d0d6741189fea5478c6091b54ffc69d7ce119e0a05637d8c99", size = 184289, upload-time = "2026-03-06T06:03:00.448Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/9f67c1f94ea9ae1e08c8fa2182b1f5411732e18643e7080fc8c10ba1e021/charset_normalizer-3.4.5-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e545b51da9f9af5c67815ca0eb40676c0f016d0b0381c86f20451e35696c5f95", size = 195282, upload-time = "2026-03-06T06:03:02.161Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/aaf84a2e37e75470640e965d6619c6d9a521eb7c8aa097f2586907859198/charset_normalizer-3.4.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:30987f4a8ed169983f93e1be8ffeea5214a779e27ed0b059835c7afe96550ad7", size = 192889, upload-time = "2026-03-06T06:03:03.629Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/9b714873baf9a841613e8b49a5a3cd77d985d2c6c80f5038a5057395ebac/charset_normalizer-3.4.5-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:149ec69866c3d6c2fb6f758dbc014ecb09f30b35a5ca90b6a8a2d4e54e18fdfe", size = 185738, upload-time = "2026-03-06T06:03:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/ab/e5/bf57e1a9210a6ba78c740d66d05165a55b2cbeca29a83b8c659c9eb2d6c6/charset_normalizer-3.4.5-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:530beedcec9b6e027e7a4b6ce26eed36678aa39e17da85e6e03d7bd9e8e9d7c9", size = 209458, upload-time = "2026-03-06T06:03:06.54Z" }, + { url = "https://files.pythonhosted.org/packages/65/91/3c8cb46d840840f2593028fd708ea50695f8f61e1c490530ef1cce824f56/charset_normalizer-3.4.5-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:14498a429321de554b140013142abe7608f9d8ccc04d7baf2ad60498374aefa2", size = 195792, upload-time = "2026-03-06T06:03:08Z" }, + { url = "https://files.pythonhosted.org/packages/b0/43/783be5c6932fa8846a98313a2242fbcfe0c06c1c0ac2d6856b99d93069eb/charset_normalizer-3.4.5-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2820a98460c83663dd8ec015d9ddfd1e4879f12e06bb7d0500f044fb477d2770", size = 204829, upload-time = "2026-03-06T06:03:09.488Z" }, + { url = "https://files.pythonhosted.org/packages/36/7d/138b5311c32fd24396321db796538cc748287c92da5e6fc1996babc06f99/charset_normalizer-3.4.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:aa2f963b4da26daf46231d9b9e0e2c9408a751f8f0d0f44d2de56d3caf51d294", size = 198558, upload-time = "2026-03-06T06:03:11.585Z" }, + { url = "https://files.pythonhosted.org/packages/9c/87/ddd8bbdd703707c019fe9d14b678011627e6c5131dfdefe42aff151d718c/charset_normalizer-3.4.5-cp39-cp39-win32.whl", hash = "sha256:82cc7c2ad42faec8b574351f8bc2a0c049043893853317bd9bb309f5aba6cb5a", size = 132370, upload-time = "2026-03-06T06:03:13.327Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/d7cd28ae6d4dd47170b95153986789d69af4d5844f640edbc5138e4a70a2/charset_normalizer-3.4.5-cp39-cp39-win_amd64.whl", hash = "sha256:92263f7eca2f4af326cd20de8d16728d2602f7cfea02e790dcde9d83c365d7cc", size = 142877, upload-time = "2026-03-06T06:03:15.041Z" }, + { url = "https://files.pythonhosted.org/packages/9c/26/8d68681566f288998eb36a0c60dd2c5c8aa93ee67b0d7e3dc72606650828/charset_normalizer-3.4.5-cp39-cp39-win_arm64.whl", hash = "sha256:014837af6fabf57121b6254fa8ade10dceabc3528b27b721a64bbc7b8b1d4eb4", size = 133186, upload-time = "2026-03-06T06:03:16.476Z" }, + { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, +] + +[[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 = "coverage" +version = "7.10.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, + { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, + { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, + { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, + { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, + { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, + { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, + { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, + { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, + { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, + { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, + { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, + { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, + { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, + { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, + { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, + { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, + { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" }, + { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" }, + { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" }, + { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version < '3.10'" }, +] + +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, + { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, + { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, + { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, + { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, +] + +[[package]] +name = "datamasque-python" +version = "1.0.0" +source = { editable = "." } +dependencies = [ + { name = "pydantic" }, + { name = "requests" }, +] + +[package.dev-dependencies] +dev = [ + { name = "bump2version" }, + { name = "faker", version = "37.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "faker", version = "40.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "mypy" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest-cov" }, + { name = "requests-mock" }, + { name = "ruff" }, + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "types-requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.5,<3" }, + { name = "requests", specifier = ">=2.31.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "bump2version", specifier = ">=1.0.1" }, + { name = "faker", specifier = ">=22.2.0" }, + { name = "mypy", specifier = ">=1.8.0" }, + { name = "pytest", specifier = ">=7.4.4" }, + { name = "pytest-cov", specifier = ">=4.1.0" }, + { name = "requests-mock", specifier = ">=1.11.0" }, + { name = "ruff", specifier = ">=0.9.0" }, + { name = "sphinx", specifier = ">=7.2.6" }, + { name = "types-requests", specifier = ">=2.31.0" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "faker" +version = "37.12.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "tzdata", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/84/e95acaa848b855e15c83331d0401ee5f84b2f60889255c2e055cb4fb6bdf/faker-37.12.0.tar.gz", hash = "sha256:7505e59a7e02fa9010f06c3e1e92f8250d4cfbb30632296140c2d6dbef09b0fa", size = 1935741, upload-time = "2025-10-24T15:19:58.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/98/2c050dec90e295a524c9b65c4cb9e7c302386a296b2938710448cbd267d5/faker-37.12.0-py3-none-any.whl", hash = "sha256:afe7ccc038da92f2fbae30d8e16d19d91e92e242f8401ce9caf44de892bab4c4", size = 1975461, upload-time = "2025-10-24T15:19:55.739Z" }, +] + +[[package]] +name = "faker" +version = "40.8.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "tzdata", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/03/14428edc541467c460d363f6e94bee9acc271f3e62470630fc9a647d0cf2/faker-40.8.0.tar.gz", hash = "sha256:936a3c9be6c004433f20aa4d99095df5dec82b8c7ad07459756041f8c1728875", size = 1956493, upload-time = "2026-03-04T16:18:48.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/3b/c6348f1e285e75b069085b18110a4e6325b763a5d35d5e204356fc7c20b3/faker-40.8.0-py3-none-any.whl", hash = "sha256:eb21bdba18f7a8375382eb94fb436fce07046893dc94cb20817d28deb0c3d579", size = 1989124, upload-time = "2026-03-04T16:18:46.45Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "imagesize" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/59/4b0dd64676aa6fb4986a755790cb6fc558559cf0084effad516820208ec3/imagesize-1.5.0.tar.gz", hash = "sha256:8bfc5363a7f2133a89f0098451e0bcb1cd71aba4dc02bbcecb39d99d40e1b94f", size = 1281127, upload-time = "2026-03-03T01:59:54.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/b1/a0662b03103c66cf77101a187f396ea91167cd9b7d5d3a2e465ad2c7ee9b/imagesize-1.5.0-py2.py3-none-any.whl", hash = "sha256:32677681b3f434c2cb496f00e89c5a291247b35b1f527589909e008057da5899", size = 5763, upload-time = "2026-03-03T01:59:52.343Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +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 = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" }, + { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" }, + { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" }, + { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, + { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, + { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, + { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, + { url = "https://files.pythonhosted.org/packages/01/1f/c7d8b66a3ca3ca3ed8ded4b32c96ee58a45920ebbbaa934355c74adcc33e/librt-0.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3dff3d3ca8db20e783b1bc7de49c0a2ab0b8387f31236d6a026597d07fcd68ac", size = 65990, upload-time = "2026-02-17T16:12:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/56/be/ee9ba1730052313d08457f19beaa1b878619978863fba09b40aed5b5c123/librt-0.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08eec3a1fc435f0d09c87b6bf1ec798986a3544f446b864e4099633a56fcd9ed", size = 68640, upload-time = "2026-02-17T16:12:50.24Z" }, + { url = "https://files.pythonhosted.org/packages/81/27/b7309298b96f7690cec3ceee38004c1a7f60fcd96d952d3ac344a1e3e8b3/librt-0.8.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e3f0a41487fd5fad7e760b9e8a90e251e27c2816fbc2cff36a22a0e6bcbbd9dd", size = 196099, upload-time = "2026-02-17T16:12:52.788Z" }, + { url = "https://files.pythonhosted.org/packages/10/48/160a5aacdcb21824b10a52378c39e88c46a29bb31efdaf3910dd1f9b670e/librt-0.8.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bacdb58d9939d95cc557b4dbaa86527c9db2ac1ed76a18bc8d26f6dc8647d851", size = 206663, upload-time = "2026-02-17T16:12:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/ee/65/33dd1d8caabb7c6805d87d095b143417dc96b0277c06ffa0508361422c82/librt-0.8.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d7ab1f01aa753188605b09a51faa44a3327400b00b8cce424c71910fc0a128", size = 219318, upload-time = "2026-02-17T16:12:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/d4/353805aa6181c7950a2462bd6e855366eeca21a501f375228d72a51547df/librt-0.8.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4998009e7cb9e896569f4be7004f09d0ed70d386fa99d42b6d363f6d200501ac", size = 212191, upload-time = "2026-02-17T16:12:57.326Z" }, + { url = "https://files.pythonhosted.org/packages/06/08/725b3f304d61eba56c713c251fb833a06d84bf93381caad5152366f5d2bb/librt-0.8.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2cc68eeeef5e906839c7bb0815748b5b0a974ec27125beefc0f942715785b551", size = 220672, upload-time = "2026-02-17T16:12:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/e8cdf04145872b3b97cb9b68287b22d1c08348227063f305aec11a3e6ce7/librt-0.8.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0bf69d79a23f4f40b8673a947a234baeeb133b5078b483b7297c5916539cf5d5", size = 216172, upload-time = "2026-02-17T16:12:59.751Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d8/23b1c6592d2422dd6829c672f45b1f1c257f219926b0d216fedb572d0184/librt-0.8.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:22b46eabd76c1986ee7d231b0765ad387d7673bbd996aa0d0d054b38ac65d8f6", size = 214116, upload-time = "2026-02-17T16:13:01.056Z" }, + { url = "https://files.pythonhosted.org/packages/c9/92/2b44fd3cc3313f44e43bdbb41343735b568fa675fa351642b408ee48d418/librt-0.8.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:237796479f4d0637d6b9cbcb926ff424a97735e68ade6facf402df4ec93375ed", size = 236664, upload-time = "2026-02-17T16:13:02.314Z" }, + { url = "https://files.pythonhosted.org/packages/00/23/92313ecdab80e142d8ea10e8dfa6297694359dbaacc9e81679bdc8cbceb6/librt-0.8.1-cp39-cp39-win32.whl", hash = "sha256:4beb04b8c66c6ae62f8c1e0b2f097c1ebad9295c929a8d5286c05eae7c2fc7dc", size = 54368, upload-time = "2026-02-17T16:13:03.549Z" }, + { url = "https://files.pythonhosted.org/packages/68/36/18f6e768afad6b55a690d38427c53251b69b7ba8795512730fd2508b31a9/librt-0.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:64548cde61b692dc0dc379f4b5f59a2f582c2ebe7890d09c1ae3b9e66fa015b7", size = 61507, upload-time = "2026-02-17T16:13:04.556Z" }, +] + +[[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/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/56/23/0d8c13a44bde9154821586520840643467aee574d8ce79a17da539ee7fed/markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26", size = 11623, upload-time = "2025-09-27T18:37:29.296Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/07a2cb9a8045d5f3f0890a8c3bc0859d7a47bfd9a560b563899bec7b72ed/markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc", size = 12049, upload-time = "2025-09-27T18:37:30.234Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e4/6be85eb81503f8e11b61c0b6369b6e077dcf0a74adbd9ebf6b349937b4e9/markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c", size = 21923, upload-time = "2025-09-27T18:37:31.177Z" }, + { url = "https://files.pythonhosted.org/packages/6f/bc/4dc914ead3fe6ddaef035341fee0fc956949bbd27335b611829292b89ee2/markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42", size = 20543, upload-time = "2025-09-27T18:37:32.168Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/5fe81fbcfba4aef4093d5f856e5c774ec2057946052d18d168219b7bd9f9/markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b", size = 20585, upload-time = "2025-09-27T18:37:33.166Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f6/e0e5a3d3ae9c4020f696cd055f940ef86b64fe88de26f3a0308b9d3d048c/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758", size = 21387, upload-time = "2025-09-27T18:37:34.185Z" }, + { url = "https://files.pythonhosted.org/packages/c8/25/651753ef4dea08ea790f4fbb65146a9a44a014986996ca40102e237aa49a/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2", size = 20133, upload-time = "2025-09-27T18:37:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/dc/0a/c3cf2b4fef5f0426e8a6d7fce3cb966a17817c568ce59d76b92a233fdbec/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d", size = 20588, upload-time = "2025-09-27T18:37:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/cd/1b/a7782984844bd519ad4ffdbebbba2671ec5d0ebbeac34736c15fb86399e8/markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7", size = 14566, upload-time = "2025-09-27T18:37:37.09Z" }, + { url = "https://files.pythonhosted.org/packages/18/1f/8d9c20e1c9440e215a44be5ab64359e207fcb4f675543f1cf9a2a7f648d0/markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e", size = 15053, upload-time = "2025-09-27T18:37:38.054Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d3/fe08482b5cd995033556d45041a4f4e76e7f0521112a9c9991d40d39825f/markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8", size = 13928, upload-time = "2025-09-27T18:37:39.037Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f7/88436084550ca9af5e610fa45286be04c3b63374df3e021c762fe8c4369f/mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3", size = 13102606, upload-time = "2025-12-15T05:02:46.833Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a5/43dfad311a734b48a752790571fd9e12d61893849a01bff346a54011957f/mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a", size = 12164496, upload-time = "2025-12-15T05:03:41.947Z" }, + { url = "https://files.pythonhosted.org/packages/88/f0/efbfa391395cce2f2771f937e0620cfd185ec88f2b9cd88711028a768e96/mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67", size = 12772068, upload-time = "2025-12-15T05:02:53.689Z" }, + { url = "https://files.pythonhosted.org/packages/25/05/58b3ba28f5aed10479e899a12d2120d582ba9fa6288851b20bf1c32cbb4f/mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e", size = 13520385, upload-time = "2025-12-15T05:02:38.328Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a0/c006ccaff50b31e542ae69b92fe7e2f55d99fba3a55e01067dd564325f85/mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376", size = 13796221, upload-time = "2025-12-15T05:03:22.147Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ff/8bdb051cd710f01b880472241bd36b3f817a8e1c5d5540d0b761675b6de2/mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24", size = 10055456, upload-time = "2025-12-15T05:03:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[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 = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.1" +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/f3/6b/1353beb3d1cd5cf61cdec5b6f87a9872399de3bc5cae0b7ce07ff4de2ab0/pydantic-2.13.1.tar.gz", hash = "sha256:a0f829b279ddd1e39291133fe2539d2aa46cc6b150c1706a270ff0879e3774d2", size = 843746, upload-time = "2026-04-15T14:57:19.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/5a/2225f4c176dbfed0d809e848b50ef08f70e61daa667b7fa14b0d311ae44d/pydantic-2.13.1-py3-none-any.whl", hash = "sha256:9557ecc2806faaf6037f85b1fbd963d01e30511c48085f0d573650fdeaad378a", size = 471917, upload-time = "2026-04-15T14:57:17.277Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/93/f97a86a7eb28faa1d038af2fd5d6166418b4433659108a4c311b57128b2d/pydantic_core-2.46.1.tar.gz", hash = "sha256:d408153772d9f298098fb5d620f045bdf0f017af0d5cb6e309ef8c205540caa4", size = 471230, upload-time = "2026-04-15T14:49:34.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/a0/07f275411355b567b994e565bc5ea9dbf522978060c18e3b7edf646c0fc2/pydantic_core-2.46.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:84eb5414871fd0293c38d2075802f95030ff11a92cf2189942bf76fd181af77b", size = 2123782, upload-time = "2026-04-15T14:52:57.172Z" }, + { url = "https://files.pythonhosted.org/packages/ab/71/d027c7de46df5b9287ed6f0ef02346c84d61348326253a4f13695d54d66f/pydantic_core-2.46.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5c75fb25db086bf504c55730442e471c12bc9bfae817dd359b1a36bc93049d34", size = 1948561, upload-time = "2026-04-15T14:53:12.07Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/cba894bea0d51a3b2dcada9eb3af9c4cfaa271bf21123372dc82ccef029f/pydantic_core-2.46.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dc09f0221425453fd9f73fd70bba15817d25b95858282702d7305a08d37306", size = 1974387, upload-time = "2026-04-15T14:50:14.048Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ad/cc122887d6f20ac5d997928b0bf3016ac9c7bae07dce089333aa0c2e868b/pydantic_core-2.46.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:139fd6722abc5e6513aa0a27b06ebeb997838c5b179cf5e83862ace45f281c56", size = 2054868, upload-time = "2026-04-15T14:49:51.912Z" }, + { url = "https://files.pythonhosted.org/packages/9f/09/22049b22d65a67253cbdced88dbce0e97162f35cc433917df37df794ede8/pydantic_core-2.46.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba723fd8ef6011af71f92ed54adb604e7699d172f4273e4b46f1cfb8ee8d72fd", size = 2228717, upload-time = "2026-04-15T14:49:27.384Z" }, + { url = "https://files.pythonhosted.org/packages/e6/98/b35a8a187cf977462668b5064c606e290c88c2561e053883d86193ab9c51/pydantic_core-2.46.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:828410e082555e55da9bbb5e6c17617386fe1415c4d42765a90d372ed9cce813", size = 2298261, upload-time = "2026-04-15T14:52:20.463Z" }, + { url = "https://files.pythonhosted.org/packages/98/ae/46f8d693caefc09d8e2d3f19a6b4f2252cf6542f0b555759f2b5ec2b4ca5/pydantic_core-2.46.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb5cd53264c9906c163a71b489e9ac71b0ae13a2dd0241e6129f4df38ba1c814", size = 2094496, upload-time = "2026-04-15T14:49:59.711Z" }, + { url = "https://files.pythonhosted.org/packages/ee/40/7e4013639d316d2cb67dae288c768d49cc4a7a4b16ef869e486880db1a1f/pydantic_core-2.46.1-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:4530a6594883d9d4a9c7ef68464ef6b4a88d839e3531c089a3942c78bffe0a66", size = 2144795, upload-time = "2026-04-15T14:52:44.731Z" }, + { url = "https://files.pythonhosted.org/packages/0d/87/c00f6450059804faf30f568009c8c98e72e6802c1ccd8b562da57953ad81/pydantic_core-2.46.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ed1c71f60abbf9c9a440dc8fc6b1180c45dcab3a5e311250de99744a0166bc95", size = 2173108, upload-time = "2026-04-15T14:51:37.806Z" }, + { url = "https://files.pythonhosted.org/packages/46/15/7a8fb06c109a07dbc1f5f272b2da1290c8a25f5900a579086e433049fc1a/pydantic_core-2.46.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:254253491f1b8e3ba18c15fe924bb9b175f1a48413b74e8f0c67b8f51b6f726b", size = 2185687, upload-time = "2026-04-15T14:51:33.125Z" }, + { url = "https://files.pythonhosted.org/packages/d9/38/c52ead78febf23d32db898c7022173c674226cf3c8ee1645220ab9516931/pydantic_core-2.46.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:dfcf6485ac38698a5b45f37467b8eb2f4f8e3edd5790e2579c5d52fdfffb2e3d", size = 2326273, upload-time = "2026-04-15T14:51:10.614Z" }, + { url = "https://files.pythonhosted.org/packages/1e/af/cb5ea2336e9938b3a0536ce4bfed4a342285caa8a6b8ff449a7bc2f179ec/pydantic_core-2.46.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:592b39150ab5b5a2cb2eb885097ee4c2e4d54e3b902f6ae32528f7e6e42c00fc", size = 2368428, upload-time = "2026-04-15T14:49:25.804Z" }, + { url = "https://files.pythonhosted.org/packages/a2/99/adcfbcbd96556120e7d795aab4fd77f5104a49051929c3805a9d736ec48f/pydantic_core-2.46.1-cp310-cp310-win32.whl", hash = "sha256:eb37b1369ad39ec046a36dc81ffd76870766bda2073f57448bbcb1fd3e4c5ad0", size = 1993405, upload-time = "2026-04-15T14:50:51.082Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ff/2767be513a250293f80748740ce73b0f0677711fc791b1afab3499734dd2/pydantic_core-2.46.1-cp310-cp310-win_amd64.whl", hash = "sha256:c330dab8254d422880177436a5892ac6d9337afff9fe383fb1f8c6caedb685e1", size = 2068177, upload-time = "2026-04-15T14:52:29.899Z" }, + { url = "https://files.pythonhosted.org/packages/37/96/d83d23fc3c822326d808b8c0457d4f7afb1552e741a7c2378a974c522c63/pydantic_core-2.46.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f0f84431981c6ae217ebb96c3eca8212f6f5edf116f62f62cc6c7d72971f826c", size = 2121938, upload-time = "2026-04-15T14:49:21.568Z" }, + { url = "https://files.pythonhosted.org/packages/11/44/94b1251825560f5d90e25ebcd457c4772e1f3e1a378f438c040fe2148f3e/pydantic_core-2.46.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a05f60b36549f59ab585924410187276ec17a94bae939273a213cea252c8471e", size = 1946541, upload-time = "2026-04-15T14:49:57.925Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8f/79aff4c8bd6fb49001ffe4747c775c0f066add9da13dec180eb0023ada34/pydantic_core-2.46.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2c93fd1693afdfae7b2897f7530ed3f180d9fc92ee105df3ebdff24d5061cc8", size = 1973067, upload-time = "2026-04-15T14:51:14.765Z" }, + { url = "https://files.pythonhosted.org/packages/56/01/826ab3afb1d43cbfdc2aa592bff0f1f6f4b90f5a801478ba07bde74e706f/pydantic_core-2.46.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c19983759394c702a776f42f33df8d7bb7883aefaa44a69ba86356a9fd67367", size = 2053146, upload-time = "2026-04-15T14:51:48.847Z" }, + { url = "https://files.pythonhosted.org/packages/6c/32/be20ec48ccbd85cac3f8d96ca0a0f87d5c14fbf1eb438da0ac733f2546f2/pydantic_core-2.46.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e8debf586d7d800a718194417497db5126d4f4302885a2dff721e9df3f4851c", size = 2227393, upload-time = "2026-04-15T14:51:53.218Z" }, + { url = "https://files.pythonhosted.org/packages/b5/8e/1fae21c887f363ed1a5cf9f267027700c796b7435313c21723cd3e8aeeb3/pydantic_core-2.46.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54160da754d63da7780b76e5743d44f026b9daffc6b8c9696a756368c0a298c9", size = 2296193, upload-time = "2026-04-15T14:50:31.065Z" }, + { url = "https://files.pythonhosted.org/packages/0a/29/e5637b539458ffb60ba9c204fc16c52ea36828427fa667e4f9c7d83cfea9/pydantic_core-2.46.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74cee962c8b4df9a9b0bb63582e51986127ee2316f0c49143b2996f4b201bd9c", size = 2092156, upload-time = "2026-04-15T14:52:37.227Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fa/3a453934af019c72652fb75489c504ae689de632fa2e037fec3195cd6948/pydantic_core-2.46.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:0ba3462872a678ebe21b15bd78eff40298b43ea50c26f230ec535c00cf93ec7e", size = 2142845, upload-time = "2026-04-15T14:51:04.847Z" }, + { url = "https://files.pythonhosted.org/packages/36/c2/71b56fa10a80b98036f4bf0fbb912833f8e9c61b15e66c236fadaf54c27c/pydantic_core-2.46.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b718873a966d91514c5252775f568985401b54a220919ab22b19a6c4edd8c053", size = 2170756, upload-time = "2026-04-15T14:50:17.16Z" }, + { url = "https://files.pythonhosted.org/packages/e1/da/a4c761dc8d982e2c53f991c0c36d37f6fe308e149bf0a101c25b0750a893/pydantic_core-2.46.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cb1310a9fd722da8cceec1fb59875e1c86bee37f0d8a9c667220f00ee722cc8f", size = 2183579, upload-time = "2026-04-15T14:51:20.888Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d4/b0a6c00622e4afd9a807b8bb05ba8f1a0b69ca068ac138d9d36700fe767b/pydantic_core-2.46.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:98e3ede76eb4b9db8e7b5efea07a3f3315135485794a5df91e3adf56c4d573b6", size = 2324516, upload-time = "2026-04-15T14:52:32.521Z" }, + { url = "https://files.pythonhosted.org/packages/45/f1/a4bace0c98b0774b02de99233882c48d94b399ba4394dd5e209665d05062/pydantic_core-2.46.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:780b8f24ff286e21fd010247011a68ea902c34b1eee7d775b598bc28f5f28ab6", size = 2367084, upload-time = "2026-04-15T14:50:37.832Z" }, + { url = "https://files.pythonhosted.org/packages/3a/54/ae827a3976b136d1c9a9a56c2299a8053605a69facaa0c7354ba167305eb/pydantic_core-2.46.1-cp311-cp311-win32.whl", hash = "sha256:1d452f4cad0f39a94414ca68cda7cc55ff4c3801b5ab0bc99818284a3d39f889", size = 1992061, upload-time = "2026-04-15T14:51:44.704Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/d85de69e0fdfafc0e87d88bd5d0c157a5443efaaef24eed152a8a8f8dfb6/pydantic_core-2.46.1-cp311-cp311-win_amd64.whl", hash = "sha256:f463fd6a67138d70200d2627676e9efbb0cee26d98a5d3042a35aa20f95ec129", size = 2065497, upload-time = "2026-04-15T14:51:17.077Z" }, + { url = "https://files.pythonhosted.org/packages/46/a7/9eb3b1038db630e1550924e81d1211b0dd70ac3740901fd95f30f5497990/pydantic_core-2.46.1-cp311-cp311-win_arm64.whl", hash = "sha256:155aec0a117140e86775eec113b574c1c299358bfd99467b2ea7b2ea26db2614", size = 2045914, upload-time = "2026-04-15T14:51:24.782Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fb/caaa8ee23861c170f07dbd58fc2be3a2c02a32637693cbb23eef02e84808/pydantic_core-2.46.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae8c8c5eb4c796944f3166f2f0dab6c761c2c2cc5bd20e5f692128be8600b9a4", size = 2119472, upload-time = "2026-04-15T14:49:45.946Z" }, + { url = "https://files.pythonhosted.org/packages/fa/61/bcffaa52894489ff89e5e1cdde67429914bf083c0db7296bef153020f786/pydantic_core-2.46.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:daba6f5f5b986aa0682623a1a4f8d1ecb0ec00ce09cfa9ca71a3b742bc383e3a", size = 1951230, upload-time = "2026-04-15T14:52:27.646Z" }, + { url = "https://files.pythonhosted.org/packages/f8/95/80d2f43a2a1a1e3220fd329d614aa5a39e0a75d24353a3aaf226e605f1c2/pydantic_core-2.46.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0265f3a2460539ecc97817a80c7a23c458dd84191229b655522a2674f701f14e", size = 1976394, upload-time = "2026-04-15T14:50:32.742Z" }, + { url = "https://files.pythonhosted.org/packages/8d/31/2c5b1a207926b5fc1961a2d11da940129bc3841c36cc4df03014195b2966/pydantic_core-2.46.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb16c0156c4b4e94aa3719138cc43c53d30ff21126b6a3af63786dcc0757b56e", size = 2068455, upload-time = "2026-04-15T14:50:01.286Z" }, + { url = "https://files.pythonhosted.org/packages/7d/36/c6aa07274359a51ac62895895325ce90107e811c6cea39d2617a99ef10d7/pydantic_core-2.46.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b42d80fad8e4b283e1e4138f1142f0d038c46d137aad2f9824ad9086080dd41", size = 2239049, upload-time = "2026-04-15T14:53:02.216Z" }, + { url = "https://files.pythonhosted.org/packages/0a/3f/77cdd0db8bddc714842dfd93f737c863751cf02001c993341504f6b0cd53/pydantic_core-2.46.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cced85896d5b795293bc36b7e2fb0347a36c828551b50cbba510510d928548c", size = 2318681, upload-time = "2026-04-15T14:50:04.539Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a3/09d929a40e6727274b0b500ad06e1b3f35d4f4665ae1c8ba65acbb17e9b5/pydantic_core-2.46.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a641cb1e74b44c418adaf9f5f450670dbec53511f030d8cde8d8accb66edc363", size = 2096527, upload-time = "2026-04-15T14:53:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/89/ae/544c3a82456ebc254a9fcbe2715bab76c70acf9d291aaea24391147943e4/pydantic_core-2.46.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:191e7a122ab14eb12415fe3f92610fc06c7f1d2b4b9101d24d490d447ac92506", size = 2170407, upload-time = "2026-04-15T14:51:27.138Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ce/0dfd881c7af4c522f47b325707bd9a2cdcf4f40e4f2fd30df0e9a3e8d393/pydantic_core-2.46.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4fe4ff660f7938b5d92f21529ce331b011aa35e481ab64b7cd03f52384e544bb", size = 2188578, upload-time = "2026-04-15T14:50:39.655Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e9/980ea2a6d5114dd1a62ecc5f56feb3d34555f33bd11043f042e5f7f0724a/pydantic_core-2.46.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:18fcea085b3adc3868d8d19606da52d7a52d8bccd8e28652b0778dbe5e6a6660", size = 2188959, upload-time = "2026-04-15T14:52:42.243Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f1/595e0f50f4bfc56cde2fe558f2b0978f29f2865da894c6226231e17464a5/pydantic_core-2.46.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e8e589e7c9466e022d79e13c5764c2239b2e5a7993ba727822b021234f89b56b", size = 2339973, upload-time = "2026-04-15T14:52:10.642Z" }, + { url = "https://files.pythonhosted.org/packages/49/44/be9f979a6ab6b8c36865ccd92c3a38a760c66055e1f384665f35525134c4/pydantic_core-2.46.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f78eb3d4027963bdc9baccd177f02a98bf8714bc51fe17153d8b51218918b5bc", size = 2385228, upload-time = "2026-04-15T14:51:00.77Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d4/c826cd711787d240219f01d0d3ca116cb55516b8b95277820aa9c85e1882/pydantic_core-2.46.1-cp312-cp312-win32.whl", hash = "sha256:54fe30c20cab03844dc63bdc6ddca67f74a2eb8482df69c1e5f68396856241be", size = 1978828, upload-time = "2026-04-15T14:50:29.362Z" }, + { url = "https://files.pythonhosted.org/packages/22/05/8a1fcf8181be4c7a9cfc34e5fbf2d9c3866edc9dfd3c48d5401806e0a523/pydantic_core-2.46.1-cp312-cp312-win_amd64.whl", hash = "sha256:aea4e22ed4c53f2774221435e39969a54d2e783f4aee902cdd6c8011415de893", size = 2070015, upload-time = "2026-04-15T14:49:47.301Z" }, + { url = "https://files.pythonhosted.org/packages/61/d5/fea36ad2882b99c174ef4ffbc7ea6523f6abe26060fbc1f77d6441670232/pydantic_core-2.46.1-cp312-cp312-win_arm64.whl", hash = "sha256:f76fb49c34b4d66aa6e552ce9e852ea97a3a06301a9f01ae82f23e449e3a55f8", size = 2030176, upload-time = "2026-04-15T14:50:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d2/bda39bad2f426cb5078e6ad28076614d3926704196efe0d7a2a19a99025d/pydantic_core-2.46.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:cdc8a5762a9c4b9d86e204d555444e3227507c92daba06259ee66595834de47a", size = 2119092, upload-time = "2026-04-15T14:49:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/69631e64d69cb3481494b2bddefe0ddd07771209f74e9106d066f9138c2a/pydantic_core-2.46.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ba381dfe9c85692c566ecb60fa5a77a697a2a8eebe274ec5e4d6ec15fafad799", size = 1951400, upload-time = "2026-04-15T14:51:06.588Z" }, + { url = "https://files.pythonhosted.org/packages/53/1c/21cb3db6ae997df31be8e91f213081f72ffa641cb45c89b8a1986832b1f9/pydantic_core-2.46.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1593d8de98207466dc070118322fef68307a0cc6a5625e7b386f6fdae57f9ab6", size = 1976864, upload-time = "2026-04-15T14:50:54.804Z" }, + { url = "https://files.pythonhosted.org/packages/91/9c/05c819f734318ce5a6ca24da300d93696c105af4adb90494ee571303afd8/pydantic_core-2.46.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8262c74a1af5b0fdf795f5537f7145785a63f9fbf9e15405f547440c30017ed8", size = 2066669, upload-time = "2026-04-15T14:51:42.346Z" }, + { url = "https://files.pythonhosted.org/packages/cb/23/fadddf1c7f2f517f58731aea9b35c914e6005250f08dac9b8e53904cdbaa/pydantic_core-2.46.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b88949a24182e83fbbb3f7ca9b7858d0d37b735700ea91081434b7d37b3b444", size = 2238737, upload-time = "2026-04-15T14:50:45.558Z" }, + { url = "https://files.pythonhosted.org/packages/23/07/0cd4f95cb0359c8b1ec71e89c3777e7932c8dfeb9cd54740289f310aaead/pydantic_core-2.46.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8f3708cd55537aeaf3fd0ea55df0d68d0da51dcb07cbc8508745b34acc4c6e0", size = 2316258, upload-time = "2026-04-15T14:51:08.471Z" }, + { url = "https://files.pythonhosted.org/packages/0c/40/6fc24c3766a19c222a0d60d652b78f0283339d4cd4c173fab06b7ee76571/pydantic_core-2.46.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f79292435fff1d4f0c18d9cfaf214025cc88e4f5104bfaed53f173621da1c743", size = 2097474, upload-time = "2026-04-15T14:49:56.543Z" }, + { url = "https://files.pythonhosted.org/packages/4b/af/f39795d1ce549e35d0841382b9c616ae211caffb88863147369a8d74fba9/pydantic_core-2.46.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:a2e607aeb59cf4575bb364470288db3b9a1f0e7415d053a322e3e154c1a0802e", size = 2168383, upload-time = "2026-04-15T14:51:29.269Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/0d563f74582795779df6cc270c3fc220f49f4daf7860d74a5a6cda8491ff/pydantic_core-2.46.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec5ca190b75878a9f6ae1fc8f5eb678497934475aef3d93204c9fa01e97370b6", size = 2186182, upload-time = "2026-04-15T14:50:19.097Z" }, + { url = "https://files.pythonhosted.org/packages/5c/07/1c10d5ce312fc4cf86d1e50bdcdbb8ef248409597b099cab1b4bb3a093f7/pydantic_core-2.46.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:1f80535259dcdd517d7b8ca588d5ca24b4f337228e583bebedf7a3adcdf5f721", size = 2187859, upload-time = "2026-04-15T14:49:22.974Z" }, + { url = "https://files.pythonhosted.org/packages/92/01/e1f62d4cb39f0913dbf5c95b9b119ef30ddba9493dff8c2b012f0cdd67dc/pydantic_core-2.46.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:24820b3c82c43df61eca30147e42853e6c127d8b868afdc0c162df829e011eb4", size = 2338372, upload-time = "2026-04-15T14:49:53.316Z" }, + { url = "https://files.pythonhosted.org/packages/44/ed/218dfeea6127fb1781a6ceca241ec6edf00e8a8933ff331af2215975a534/pydantic_core-2.46.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f12794b1dd8ac9fb66619e0b3a0427189f5d5638e55a3de1385121a9b7bf9b39", size = 2384039, upload-time = "2026-04-15T14:53:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1e/011e763cd059238249fbd5780e0f8d0b04b47f86c8925e22784f3e5fc977/pydantic_core-2.46.1-cp313-cp313-win32.whl", hash = "sha256:9bc09aed935cdf50f09e908923f9efbcca54e9244bd14a5a0e2a6c8d2c21b4e9", size = 1977943, upload-time = "2026-04-15T14:52:17.969Z" }, + { url = "https://files.pythonhosted.org/packages/8c/06/b559a490d3ed106e9b1777b8d5c8112dd8d31716243cd662616f66c1f8ea/pydantic_core-2.46.1-cp313-cp313-win_amd64.whl", hash = "sha256:fac2d6c8615b8b42bee14677861ba09d56ee076ba4a65cfb9c3c3d0cc89042f2", size = 2068729, upload-time = "2026-04-15T14:53:07.288Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/32a198946e2e19508532aa9da02a61419eb15bd2d96bab57f810f2713e31/pydantic_core-2.46.1-cp313-cp313-win_arm64.whl", hash = "sha256:f978329f12ace9f3cb814a5e44d98bbeced2e36f633132bafa06d2d71332e33e", size = 2029550, upload-time = "2026-04-15T14:52:22.707Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2b/6793fe89ab66cb2d3d6e5768044eab80bba1d0fae8fd904d0a1574712e17/pydantic_core-2.46.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9917cb61effac7ec0f448ef491ec7584526d2193be84ff981e85cbf18b68c42a", size = 2118110, upload-time = "2026-04-15T14:50:52.947Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/e9a905ddfcc2fd7bd862b340c02be6ab1f827922822d425513635d0ac774/pydantic_core-2.46.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e749679ca9f8a9d0bff95fb7f6b57bb53f2207fa42ffcc1ec86de7e0029ab89", size = 1948645, upload-time = "2026-04-15T14:51:55.577Z" }, + { url = "https://files.pythonhosted.org/packages/15/23/26e67f86ed62ac9d6f7f3091ee5220bf14b5ac36fb811851d601365ef896/pydantic_core-2.46.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2ecacee70941e233a2dad23f7796a06f86cc10cc2fbd1c97c7dd5b5a79ffa4f", size = 1977576, upload-time = "2026-04-15T14:49:37.58Z" }, + { url = "https://files.pythonhosted.org/packages/b8/78/813c13c0de323d4de54ee2e6fdd69a0271c09ac8dd65a8a000931aa487a5/pydantic_core-2.46.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:647d0a2475b8ed471962eed92fa69145b864942f9c6daa10f95ac70676637ae7", size = 2060358, upload-time = "2026-04-15T14:51:40.087Z" }, + { url = "https://files.pythonhosted.org/packages/09/5e/4caf2a15149271fbd2b4d968899a450853c800b85152abcf54b11531417f/pydantic_core-2.46.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac9cde61965b0697fce6e6cc372df9e1ad93734828aac36e9c1c42a22ad02897", size = 2235980, upload-time = "2026-04-15T14:50:34.535Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c1/a2cdabb5da6f5cb63a3558bcafffc20f790fa14ccffbefbfb1370fadc93f/pydantic_core-2.46.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a2eb0864085f8b641fb3f54a2fb35c58aff24b175b80bc8a945050fcde03204", size = 2316800, upload-time = "2026-04-15T14:52:46.999Z" }, + { url = "https://files.pythonhosted.org/packages/76/fd/19d711e4e9331f9d77f222bffc202bf30ea0d74f6419046376bb82f244c8/pydantic_core-2.46.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b83ce9fede4bc4fb649281d9857f06d30198b8f70168f18b987518d713111572", size = 2101762, upload-time = "2026-04-15T14:49:24.278Z" }, + { url = "https://files.pythonhosted.org/packages/dc/64/ce95625448e1a4e219390a2923fd594f3fa368599c6b42ac71a5df7238c9/pydantic_core-2.46.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:cb33192753c60f269d2f4a1db8253c95b0df6e04f2989631a8cc1b0f4f6e2e92", size = 2167737, upload-time = "2026-04-15T14:50:41.637Z" }, + { url = "https://files.pythonhosted.org/packages/ad/31/413572d03ca3e73b408f00f54418b91a8be6401451bc791eaeff210328e5/pydantic_core-2.46.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:96611d51f953f87e1ae97637c01ee596a08b7f494ea00a5afb67ea6547b9f53b", size = 2185658, upload-time = "2026-04-15T14:51:46.799Z" }, + { url = "https://files.pythonhosted.org/packages/36/09/e4f581353bdf3f0c7de8a8b27afd14fc761da29d78146376315a6fedc487/pydantic_core-2.46.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9b176fa55f9107db5e6c86099aa5bfd934f1d3ba6a8b43f714ddeebaed3f42b7", size = 2184154, upload-time = "2026-04-15T14:52:49.629Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a4/d0d52849933f5a4bf1ad9d8da612792f96469b37e286a269e3ee9c60bbb1/pydantic_core-2.46.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:79a59f63a4ce4f3330e27e6f3ce281dd1099453b637350e97d7cf24c207cd120", size = 2332379, upload-time = "2026-04-15T14:49:55.009Z" }, + { url = "https://files.pythonhosted.org/packages/30/93/25bfb08fdbef419f73290e573899ce938a327628c34e8f3a4bafeea30126/pydantic_core-2.46.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:f200fce071808a385a314b7343f5e3688d7c45746be3d64dc71ee2d3e2a13268", size = 2377964, upload-time = "2026-04-15T14:51:59.649Z" }, + { url = "https://files.pythonhosted.org/packages/15/36/b777766ff83fef1cf97473d64764cd44f38e0d8c269ed06faace9ae17666/pydantic_core-2.46.1-cp314-cp314-win32.whl", hash = "sha256:3a07eccc0559fb9acc26d55b16bf8ebecd7f237c74a9e2c5741367db4e6d8aff", size = 1976450, upload-time = "2026-04-15T14:51:57.665Z" }, + { url = "https://files.pythonhosted.org/packages/7b/4b/4cd19d2437acfc18ca166db5a2067040334991eb862c4ecf2db098c91fbf/pydantic_core-2.46.1-cp314-cp314-win_amd64.whl", hash = "sha256:1706d270309ac7d071ffe393988c471363705feb3d009186e55d17786ada9622", size = 2067750, upload-time = "2026-04-15T14:49:38.941Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a0/490751c0ef8f5b27aae81731859aed1508e72c1a9b5774c6034269db773b/pydantic_core-2.46.1-cp314-cp314-win_arm64.whl", hash = "sha256:22d4e7457ade8af06528012f382bc994a97cc2ce6e119305a70b3deff1e409d6", size = 2021109, upload-time = "2026-04-15T14:50:27.728Z" }, + { url = "https://files.pythonhosted.org/packages/36/3a/2a018968245fffd25d5f1972714121ad309ff2de19d80019ad93494844f9/pydantic_core-2.46.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:607ff9db0b7e2012e7eef78465e69f9a0d7d1c3e7c6a84cf0c4011db0fcc3feb", size = 2111548, upload-time = "2026-04-15T14:52:08.273Z" }, + { url = "https://files.pythonhosted.org/packages/77/5b/4103b6192213217e874e764e5467d2ff10d8873c1147d01fa432ac281880/pydantic_core-2.46.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8cda3eacaea13bd02a1bea7e457cc9fc30b91c5a91245cef9b215140f80dd78c", size = 1926745, upload-time = "2026-04-15T14:50:03.045Z" }, + { url = "https://files.pythonhosted.org/packages/c3/70/602a667cf4be4bec6c3334512b12ae4ea79ce9bfe41dc51be1fd34434453/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9493279cdc7997fe19e5ed9b41f30cbc3806bd4722adb402fedb6f6d41bd72a", size = 1965922, upload-time = "2026-04-15T14:51:12.555Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/06a89ce5323e755b7d2812189f9706b87aaebe49b34d247b380502f7992c/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3644e5e10059999202355b6c6616e624909e23773717d8f76deb8a6e2a72328c", size = 2043221, upload-time = "2026-04-15T14:51:18.995Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6e/b1d9ad907d9d76964903903349fd2e33c87db4b993cc44713edcad0fc488/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4ad6c9de57683e26c92730991960c0c3571b8053263b042de2d3e105930b2767", size = 2243655, upload-time = "2026-04-15T14:50:10.718Z" }, + { url = "https://files.pythonhosted.org/packages/ef/73/787abfaad51174641abb04c8aa125322279b40ad7ce23c495f5a69f76554/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:557ebaa27c7617e7088002318c679a8ce685fa048523417cd1ca52b7f516d955", size = 2295976, upload-time = "2026-04-15T14:53:09.694Z" }, + { url = "https://files.pythonhosted.org/packages/56/0b/b7c5a631b6d5153d4a1ea4923b139aea256dc3bd99c8e6c7b312c7733146/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cd37e39b22b796ba0298fe81e9421dd7b65f97acfbb0fb19b33ffdda7b9a7b4", size = 2103439, upload-time = "2026-04-15T14:50:08.32Z" }, + { url = "https://files.pythonhosted.org/packages/2a/3f/952ee470df69e5674cdec1cbde22331adf643b5cc2ff79f4292d80146ee4/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:6689443b59714992e67d62505cdd2f952d6cf1c14cc9fd9aeec6719befc6f23b", size = 2132871, upload-time = "2026-04-15T14:50:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/e3/8b/1dea3b1e683c60c77a60f710215f90f486755962aa8939dbcb7c0f975ac3/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f32c41ca1e3456b5dd691827b7c1433c12d5f0058cc186afbb3615bc07d97b8", size = 2168658, upload-time = "2026-04-15T14:52:24.897Z" }, + { url = "https://files.pythonhosted.org/packages/67/97/32ae283810910d274d5ba9f48f856f5f2f612410b78b249f302d297816f5/pydantic_core-2.46.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:88cd1355578852db83954dc36e4f58f299646916da976147c20cf6892ba5dc43", size = 2171184, upload-time = "2026-04-15T14:52:34.854Z" }, + { url = "https://files.pythonhosted.org/packages/a2/57/c9a855527fe56c2072070640221f53095b0b19eaf651f3c77643c9cabbe3/pydantic_core-2.46.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:a170fefdb068279a473cc9d34848b85e61d68bfcc2668415b172c5dfc6f213bf", size = 2316573, upload-time = "2026-04-15T14:52:12.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/14c39ffc7399819c5448007c7bcb4e6da5669850cfb7dcbb727594290b48/pydantic_core-2.46.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:556a63ff1006934dba4eed7ea31b58274c227e29298ec398e4275eda4b905e95", size = 2378340, upload-time = "2026-04-15T14:51:02.619Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/a37461fbb29c053ea4e62cfc5c2d56425cb5efbef8316e63f6d84ae45718/pydantic_core-2.46.1-cp314-cp314t-win32.whl", hash = "sha256:3b146d8336a995f7d7da6d36e4a779b7e7dff2719ac00a1eb8bd3ded00bec87b", size = 1960843, upload-time = "2026-04-15T14:52:06.103Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/97e1221197d17a27f768363f87ec061519eeeed15bbd315d2e9d1429ff03/pydantic_core-2.46.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f1bc856c958e6fe9ec071e210afe6feb695f2e2e81fd8d2b102f558d364c4c17", size = 2048696, upload-time = "2026-04-15T14:52:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/19/d5/4eac95255c7d35094b46a32ec1e4d80eac94729c694726ee1d69948bd5f0/pydantic_core-2.46.1-cp314-cp314t-win_arm64.whl", hash = "sha256:21a5bfd8a1aa4de60494cdf66b0c912b1495f26a8899896040021fbd6038d989", size = 2022343, upload-time = "2026-04-15T14:49:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/5f/bf/b68a90dc87d4cfa9359a9771b9fd15f683d5af50b4087e1fde1d396f3077/pydantic_core-2.46.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8509c295057f7f43a40c90e5ad9e9b5fbacee389ebee1aeda646e4ba60a136b2", size = 2126003, upload-time = "2026-04-15T14:51:31.186Z" }, + { url = "https://files.pythonhosted.org/packages/3c/ee/4e6f4b9284e347d179eef8ad190e92f66214a2e1d0d7fba9ef6de266322d/pydantic_core-2.46.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:161f9aa1184e6998d642f840eed3142e62989deb65f80d3bb5393f1879ef409e", size = 1959499, upload-time = "2026-04-15T14:51:35.493Z" }, + { url = "https://files.pythonhosted.org/packages/e0/65/c2e7ec44640ad07c7264bfe9e65c58eb6d3089b1f6fb3092f306c6a9670c/pydantic_core-2.46.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22efeeccb9ff1f2f32d33cef6f6566f3be15cf7b55f182b58c3d23bd6f8095fa", size = 1979318, upload-time = "2026-04-15T14:49:35.986Z" }, + { url = "https://files.pythonhosted.org/packages/1d/43/ab6239b6e432301fdd5af6ddfd56405b12acb109eecda71962b282266b79/pydantic_core-2.46.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5194e7c0b28b18f066d0be82e6bb3a08355ceacd016cac142419e1efae252388", size = 2056114, upload-time = "2026-04-15T14:50:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/ac/09/eff15989bbbf41a1ca78ffe1220d95780eddfacf496e652e0d938cf96bf1/pydantic_core-2.46.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:104625adfb912aac8a512ec40c0a045953ad5070fb09607db280a7381d0d2258", size = 2230927, upload-time = "2026-04-15T14:50:26.166Z" }, + { url = "https://files.pythonhosted.org/packages/c2/18/d05852fcf41906c07910fd365271c20f567ef7c317fd40637fb15376bfb8/pydantic_core-2.46.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b39e1854a43cb8add63351a2a3e4091b5b0c26fd9cea90404ad8e80ae09fec19", size = 2301417, upload-time = "2026-04-15T14:49:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/58/ad/9ba6e1feafefdbd16aaa6aba35c9f9e08e9ae3e888cc2b78434c97d9d093/pydantic_core-2.46.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1dba87ac7b3b6c60a772097a12a7dbd63930100c961553bd698705ee82bef816", size = 2098743, upload-time = "2026-04-15T14:52:54.681Z" }, + { url = "https://files.pythonhosted.org/packages/69/f6/6d706f5251d3a446f1c3333062c367caf5ad4b900fb0aa8aa6bb5fb7e120/pydantic_core-2.46.1-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:89d6ff91d87ef21cab8b5989e60fde5f8b28da5b289b2f881aeab9c223207c2a", size = 2148067, upload-time = "2026-04-15T14:50:06.364Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6a/df4c961a1db2ea68b55241ee82db4f737a148af391959e84d8de9cc31b7c/pydantic_core-2.46.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8291ef2ef63ed90ef119c2f28930c33a55b892d45868cab8aecaddb97f0de66b", size = 2174713, upload-time = "2026-04-15T14:50:15.591Z" }, + { url = "https://files.pythonhosted.org/packages/89/37/dd68241e2be967e6a5149e530f1f82135e133455b423c38eda73203c0c9f/pydantic_core-2.46.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7551a20956235ca1332f195cf9399cecccf1a86e5000fa5f33f0b48ebb464327", size = 2186956, upload-time = "2026-04-15T14:50:59Z" }, + { url = "https://files.pythonhosted.org/packages/70/b7/655327f23c55172a5a6c157247fca852dfe8f3931255f810508c979fc133/pydantic_core-2.46.1-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:92fd83d0d9808ab26bc83c81c2382b6c2e7291b8e2907f32f40880b665649697", size = 2327501, upload-time = "2026-04-15T14:49:43.201Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b8/afb90c59f868e02f7e0bc35bd31816ae49a2ade8d58807a9e6f6f891f107/pydantic_core-2.46.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4f1955274ab896ca54d34b66d938845beb87f7a9a109641e3a6e316cdc1a2dbf", size = 2369803, upload-time = "2026-04-15T14:50:43.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/30/c27a40bc262737ecdc7313f5caeeadc5a7c4af7edcc584f354b14258716e/pydantic_core-2.46.1-cp39-cp39-win32.whl", hash = "sha256:ef8ced00e0a146f16f8664b1b7e7992de036148d470b6725046ac468e27ddcde", size = 1994800, upload-time = "2026-04-15T14:49:28.958Z" }, + { url = "https://files.pythonhosted.org/packages/15/96/b2968d56cd56d9009428a6f97c78d058836484d38323c82cc49659849aa8/pydantic_core-2.46.1-cp39-cp39-win_amd64.whl", hash = "sha256:192df1a2b5c48c4ac7d6d461a2e74dbee6de01e3012b4038b1e829634481012e", size = 2072909, upload-time = "2026-04-15T14:49:30.585Z" }, + { url = "https://files.pythonhosted.org/packages/44/4b/1952d38a091aa7572c13460db4439d5610a524a1a533fb131e17d8eff9c2/pydantic_core-2.46.1-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c56887c0ffa05318128a80303c95066a9d819e5e66d75ff24311d9e0a58d6930", size = 2123089, upload-time = "2026-04-15T14:50:20.658Z" }, + { url = "https://files.pythonhosted.org/packages/90/06/f3623aa98e2d7cb4ed0ae0b164c5d8a1b86e5aca01744eba980eefcd5da4/pydantic_core-2.46.1-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:614b24b875c1072631065fa85e195b40700586afecb0b27767602007920dacf8", size = 1945481, upload-time = "2026-04-15T14:50:56.945Z" }, + { url = "https://files.pythonhosted.org/packages/69/f9/a9224203b8426893e22db2cf0da27cd930ad7d76e0a611ebd707e5e6c916/pydantic_core-2.46.1-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6382f6967c48519b6194e9e1e579e5898598b682556260eeaf05910400d827e", size = 1986294, upload-time = "2026-04-15T14:49:31.839Z" }, + { url = "https://files.pythonhosted.org/packages/96/29/954d2174db68b9f14292cef3ae8a05a25255735909adfcf45ca768023713/pydantic_core-2.46.1-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93cb8aa6c93fb833bb53f3a2841fbea6b4dc077453cd5b30c0634af3dee69369", size = 2144185, upload-time = "2026-04-15T14:52:39.449Z" }, + { url = "https://files.pythonhosted.org/packages/f4/97/95de673a1356a88b2efdaa120eb6af357a81555c35f6809a7a1423ff7aef/pydantic_core-2.46.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:5f9107a24a4bc00293434dfa95cf8968751ad0dd703b26ea83a75a56f7326041", size = 2107564, upload-time = "2026-04-15T14:50:49.14Z" }, + { url = "https://files.pythonhosted.org/packages/00/fc/a7c16d85211ea9accddc693b7d049f20b0c06440d9264d1e1c074394ee6c/pydantic_core-2.46.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:2b1801ba99876984d0a03362782819238141c4d0f3f67f69093663691332fc35", size = 1939925, upload-time = "2026-04-15T14:50:36.188Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/87841169d77820ddabeb81d82002c95dcb82163846666d74f5bdeeaec750/pydantic_core-2.46.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7fd82a91a20ed6d54fa8c91e7a98255b1ff45bf09b051bfe7fe04eb411e232e", size = 1995313, upload-time = "2026-04-15T14:50:22.538Z" }, + { url = "https://files.pythonhosted.org/packages/ea/96/b46609359a354fa9cd336fc5d93334f1c358b756cc81e4b397347a88fa6f/pydantic_core-2.46.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f135bf07c92c93def97008bc4496d16934da9efefd7204e5f22a2c92523cb1f", size = 2151197, upload-time = "2026-04-15T14:51:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/3d1d2999ad8e78b124c752e4fc583ecd98f3bea7cc42045add2fb6e31b62/pydantic_core-2.46.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b44b44537efbff2df9567cd6ba51b554d6c009260a021ab25629c81e066f1683", size = 2121103, upload-time = "2026-04-15T14:52:59.537Z" }, + { url = "https://files.pythonhosted.org/packages/de/08/50a56632994007c7a58c86f782accccbe2f3bb7ca80f462533e26424cd18/pydantic_core-2.46.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f9ca3af687cc6a5c89aeaa00323222fcbceb4c3cdc78efdac86f46028160c04", size = 1952464, upload-time = "2026-04-15T14:52:04.001Z" }, + { url = "https://files.pythonhosted.org/packages/75/0b/3cf631e33a55b1788add3e42ac921744bd1f39279082a027b4ef6f48bd32/pydantic_core-2.46.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2678a4cbc205f00a44542dca19d15c11ccddd7440fd9df0e322e2cae55bb67a", size = 2138504, upload-time = "2026-04-15T14:52:01.812Z" }, + { url = "https://files.pythonhosted.org/packages/fa/69/f96f3dfc939450b9aeb80d3fe1943e7bc0614b14e9447d84f48d65153e0c/pydantic_core-2.46.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e5a98cbb03a8a7983b0fb954e0af5e7016587f612e6332c6a4453f413f1d1851", size = 2165467, upload-time = "2026-04-15T14:52:15.455Z" }, + { url = "https://files.pythonhosted.org/packages/a8/22/bb61cccddc2ce85b179cd81a580a1746e880870060fbf4bf6024dab7e8aa/pydantic_core-2.46.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:b2f098b08860bd149e090ad232f27fffb5ecf1bfd9377015445c8e17355ec2d1", size = 2183882, upload-time = "2026-04-15T14:51:50.868Z" }, + { url = "https://files.pythonhosted.org/packages/0e/01/b9039da255c5fd3a7fd85344fda8861c847ad6d8fdd115580fa4505b2022/pydantic_core-2.46.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d2623606145b55a96efdd181b015c0356804116b2f14d3c2af4832fe4f45ed5f", size = 2323011, upload-time = "2026-04-15T14:49:40.32Z" }, + { url = "https://files.pythonhosted.org/packages/24/b1/f426b20cb72d0235718ccc4de3bc6d6c0d0c2a91a3fd2f32ae11b624bcc9/pydantic_core-2.46.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:420f515c42aaec607ff720867b300235bd393abd709b26b190ceacb57a9bfc17", size = 2365696, upload-time = "2026-04-15T14:49:41.936Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d2/d2b0025246481aa2ce6db8ba196e29b92063343ac76e675b3a1fa478ed4d/pydantic_core-2.46.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:375cfdd2a1049910c82ba2ff24f948e93599a529e0fdb066d747975ca31fc663", size = 2190970, upload-time = "2026-04-15T14:49:33.111Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pluggy", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pluggy", marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, + { name = "coverage", version = "7.13.4", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "pluggy" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests-mock" +version = "1.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/32/587625f91f9a0a3d84688bf9cfc4b2480a7e8ec327cefd0ff2ac891fd2cf/requests-mock-1.12.1.tar.gz", hash = "sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401", size = 60901, upload-time = "2024-03-29T03:54:29.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/ec/889fbc557727da0c34a33850950310240f2040f3b1955175fdb2b36a8910/requests_mock-1.12.1-py2.py3-none-any.whl", hash = "sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563", size = 27695, upload-time = "2024-03-29T03:54:27.64Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" }, + { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" }, + { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" }, + { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" }, + { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" }, + { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" }, + { url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" }, + { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "sphinx" +version = "7.4.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "alabaster", version = "0.7.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "babel", marker = "python_full_version < '3.10'" }, + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "imagesize", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "jinja2", marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "requests", marker = "python_full_version < '3.10'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911, upload-time = "2024-07-20T14:46:56.059Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/ef/153f6803c5d5f8917dbb7f7fcf6d34a871ede3296fa89c2c703f5f8a6c8e/sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239", size = 3401624, upload-time = "2024-07-20T14:46:52.142Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "babel", marker = "python_full_version == '3.10.*'" }, + { name = "colorama", marker = "python_full_version == '3.10.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "jinja2", marker = "python_full_version == '3.10.*'" }, + { name = "packaging", marker = "python_full_version == '3.10.*'" }, + { name = "pygments", marker = "python_full_version == '3.10.*'" }, + { name = "requests", marker = "python_full_version == '3.10.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.10.*'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "9.0.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "babel", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "jinja2", marker = "python_full_version == '3.11.*'" }, + { name = "packaging", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "requests", marker = "python_full_version == '3.11.*'" }, + { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "types-requests" +version = "2.32.4.20260107" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/a0663907082280664d745929205a89d41dffb29e89a50f753af7d57d0a96/types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f", size = 23165, upload-time = "2026-01-07T03:20:54.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676, upload-time = "2026-01-07T03:20:52.929Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] From 58f3683ac047ef924018421fd94a07a63e3b010a Mon Sep 17 00:00:00 2001 From: Colin Haywood Date: Tue, 21 Apr 2026 16:19:17 +1200 Subject: [PATCH 2/2] ci: Add CI and RTD config --- .github/workflows/ci.yml | 124 ++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 62 +++++++++++++++++ .readthedocs.yaml | 18 +++++ pyproject.toml | 1 + 4 files changed, 205 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .readthedocs.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..66c70c5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,124 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + lint: + name: Lint (ruff) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.12 + + - name: Install dependencies + run: uv sync --frozen + + - name: ruff check + run: uv run ruff check datamasque tests + + - name: ruff format --check + run: uv run ruff format --check datamasque tests + + typecheck: + name: Typecheck (mypy) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.12 + + - name: Install dependencies + run: uv sync --frozen + + - name: mypy + run: uv run mypy datamasque + + test: + name: Tests (py${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + steps: + - uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --frozen --python ${{ matrix.python-version }} + + - name: pytest + run: >- + uv run --python ${{ matrix.python-version }} + pytest tests/ + --junitxml=report.xml + --cov=datamasque + --cov-report=term + --cov-report=xml:coverage.xml + --import-mode=importlib + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-py${{ matrix.python-version }} + path: | + report.xml + coverage.xml + retention-days: 7 + + docs: + name: Docs (sphinx) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.12 + + - name: Install dependencies + run: uv sync --frozen + + - name: sphinx-build + run: uv run sphinx-build -b html -W --keep-going docs docs/_build/html + + - name: Upload built docs + if: always() + uses: actions/upload-artifact@v4 + with: + name: docs-html + path: docs/_build/html + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4dc2ecb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,62 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +jobs: + build: + name: Build sdist and wheel + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.12 + + - name: Verify tag matches package version + run: | + TAG_VERSION="${GITHUB_REF_NAME#v}" + PKG_VERSION="$(uv run python -c 'import tomllib,sys; print(tomllib.loads(open("pyproject.toml","rb").read().decode())["project"]["version"])')" + echo "Tag version: ${TAG_VERSION}" + echo "Package version: ${PKG_VERSION}" + if [ "${TAG_VERSION}" != "${PKG_VERSION}" ]; then + echo "::error::Tag ${GITHUB_REF_NAME} does not match pyproject.toml version ${PKG_VERSION}" + exit 1 + fi + + - name: Build + run: uv build + + - name: Upload distributions + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + retention-days: 7 + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/datamasque-python + permissions: + id-token: write + contents: read + steps: + - name: Download distributions + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..eb29ab0 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,18 @@ +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.13" + jobs: + post_create_environment: + - pip install uv + post_install: + - UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv sync --frozen --all-groups + +sphinx: + configuration: docs/conf.py + fail_on_warning: true diff --git a/pyproject.toml b/pyproject.toml index dae99df..3ae4919 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ classifiers = [ [project.urls] Homepage = "https://datamasque.com/" +Documentation = "https://datamasque-python.readthedocs.io/" Repository = "https://github.com/datamasque/datamasque-python" Issues = "https://github.com/datamasque/datamasque-python/issues"