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/.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/.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/.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/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..3ae4919 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,123 @@ +[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/" +Documentation = "https://datamasque-python.readthedocs.io/" +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" }, +]