pax_global_header00006660000000000000000000000064152071001150014502gustar00rootroot0000000000000052 comment=ae12ffb2bbd9267f3c4f0a653bea65fbf19e67b3 tavern-3.6.0/000077500000000000000000000000001520710011500130075ustar00rootroot00000000000000tavern-3.6.0/.dockerignore000066400000000000000000000023221520710011500154620ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # IPython Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # dotenv .env # virtualenv venv/ ENV/ # Spyder project settings .spyderproject # Rope project settings .ropeproject tags .pytest_cache docker docs .eggs example .git .gitignore LICENSE # MANIFEST.in README.md # setup.cfg # setup.py # tavern tavern.egg-info tests tox.ini tox-integration.ini venv .idea tavern-3.6.0/.gitattributes000066400000000000000000000001621520710011500157010ustar00rootroot00000000000000**/*_pb2.py linguist-generated **/*_pb2_grpc.py linguist-generated **/*_pb2.pyi linguist-generated uv.lock -diff tavern-3.6.0/.github/000077500000000000000000000000001520710011500143475ustar00rootroot00000000000000tavern-3.6.0/.github/FUNDING.yml000066400000000000000000000000271520710011500161630ustar00rootroot00000000000000github: michaelboulton tavern-3.6.0/.github/workflows/000077500000000000000000000000001520710011500164045ustar00rootroot00000000000000tavern-3.6.0/.github/workflows/main.yml000066400000000000000000000074501520710011500200610ustar00rootroot00000000000000name: basic test on: push: tags: - ".*" branches: - master pull_request: branches: - master permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: simple-checks: runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.11" - uses: pre-commit/action@v3.0.0 unit-tests: runs-on: ubuntu-24.04 timeout-minutes: 10 needs: simple-checks env: TOXENV: py3 TOXCFG: tox.ini strategy: matrix: python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v6 - uses: actions/cache@v5 env: cache-name: cache-${TOXENV} with: path: .tox key: ${{ runner.os }}-tox-${{ env.cache-name }}-${{ hashFiles('pyproject.toml') }}-${{ matrix.python-version }} - uses: actions/cache@v5 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml') }}-${{ matrix.python-version }} restore-keys: | ${{ runner.os }}-pip- - name: Install the latest version of uv uses: astral-sh/setup-uv@v7 - name: run tests run: | uv sync --all-extras uvx --python ${{ matrix.python-version }} tox -c ${TOXCFG} -e ${TOXENV} integration-tests: runs-on: ubuntu-24.04 timeout-minutes: 10 needs: unit-tests strategy: fail-fast: false matrix: include: # integration tests - TOXENV: py3-generic TOXCFG: tox-integration.ini - TOXENV: py3-mqtt TOXCFG: tox-integration.ini - TOXENV: py3-http TOXCFG: tox-integration.ini - TOXENV: py3-grpc TOXCFG: tox-integration.ini - TOXENV: py3-graphql TOXCFG: tox-integration.ini services: docker: image: docker env: TOXENV: ${{ matrix.TOXENV }} TOXCFG: ${{ matrix.TOXCFG }} steps: - uses: docker/setup-buildx-action@v3 continue-on-error: true - uses: actions/checkout@v6 - uses: actions/cache@v5 env: cache-name: cache-${{ matrix.TOXENV }} with: path: .tox key: ${{ runner.os }}-tox-${{ env.cache-name }}-${{ hashFiles('pyproject.toml') }} - uses: actions/cache@v5 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml') }} restore-keys: | ${{ runner.os }}-pip- - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.11" - name: Install the latest version of uv uses: astral-sh/setup-uv@v7 - name: Install Protoc if: ${{ contains(matrix.TOXENV, 'grpc') }} uses: arduino/setup-protoc@v3 with: version: "23.x" - name: Run tests run: | uv sync --all-extras --all-packages uvx tox -c ${TOXCFG} -e ${TOXENV} custom-backend-tests: name: Custom Backend Tests needs: unit-tests runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v6 - name: Setup Bats and bats libs id: setup-bats uses: bats-core/bats-action@3.0.1 - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.11" - name: Install the latest version of uv uses: astral-sh/setup-uv@v6 - name: Run tests env: BATS_LIB_PATH: ${{ steps.setup-bats.outputs.lib-path }} working-directory: ./example/custom_backend run: ./run_tests.sh tavern-3.6.0/.gitignore000066400000000000000000000024651520710011500150060ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # IPython Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # dotenv .env # virtualenv venv/ ENV/ # Spyder project settings .spyderproject # Rope project settings .ropeproject tags .pytest_cache # Sublimetext *.sublime-* # Vim *.swp *.swo # Pycharm .idea # vs .vs tavern.lock pytype_output .mypy_cache .vscode out/ **/*.iml allure/ .ijwb/ .pants.d/ .pids/ bazel-bin bazel-out bazel-tavern bazel-testlogs example/grpc/proto # MyST build outputs _build tavern-3.6.0/.pre-commit-config.yaml000066400000000000000000000015671520710011500173010ustar00rootroot00000000000000repos: - repo: https://github.com/rhysd/actionlint rev: v1.7.12 hooks: - id: actionlint args: ["-shellcheck="] - repo: https://github.com/charliermarsh/ruff-pre-commit rev: v0.15.12 hooks: - id: ruff-format - id: ruff-check args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/rbubley/mirrors-prettier rev: v3.8.3 hooks: - id: prettier types_or: [yaml] exclude: (example/) - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.20.2 hooks: - id: mypy additional_dependencies: - types-requests - "types-protobuf>=5,<6" - types-PyYAML - mypy-extensions exclude: (tests|example/) - repo: https://github.com/astral-sh/uv-pre-commit # uv version. rev: 0.11.8 hooks: - id: uv-lock exclude: (docs/) tavern-3.6.0/.prettierignore000066400000000000000000000000531520710011500160500ustar00rootroot00000000000000tests/integration/test_cookies.tavern.yaml tavern-3.6.0/.readthedocs.yaml000066400000000000000000000013211520710011500162330ustar00rootroot00000000000000# Read the Docs configuration file for myst # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # This file was created automatically with `myst init --readthedocs` # Required version: 2 # Set the OS, Python version, and Node.js version # Note: Python is included for executing code in notebooks or using Jupyter Book build: os: ubuntu-22.04 tools: python: "3.12" nodejs: "22" commands: # Install myst - npm install -g mystmd # Build the site - myst build --html # Copy the output to Read the Docs expected location - mkdir -p $READTHEDOCS_OUTPUT/html/ - cp -r _build/html/. "$READTHEDOCS_OUTPUT/html" # Clean up build artifacts - rm -rf _build tavern-3.6.0/CHANGELOG.md000066400000000000000000000445021520710011500146250ustar00rootroot00000000000000# Changelog # 3.5.0 Allow using TAVERN_INCLUDE in files (2026-05-17) # 3.4.0 Add custom authentication via ext functions (2026-05-02) ## 3.3.3 fix default entry points (2026-04-08) ## 3.3.2 allow 'text' response checking with raw include tag for files (2026-04-05) ## 3.3.1 pydantic helper function (2026-04-05) # 3.3.0 generic backends (2026-04-04) # v3.3.0 Convert backends to be fully generic (2026-03-28) # 3.2.0 Added support for default document merge-down, allowing users to define an is_defaults: true section at the top of a Tavern YAML file to automatically merge configuration into subsequent tests within the same file. (2026-02-18) # 3.1.0 graphql support (2026-01-10) ## 3.0.2 fix pytest 9 warning (2025-11-21) ## 3.0.1 support pytest 9 (2025-11-15) # 3.0.0 release 3.0.0 (2025-10-11) # 2.17.0 Allow using kwargs in marks as well (2025-08-24) # 2.16.0 Allow using pytest marks which are function calls (2025-07-14) # 2.15.0 Add !anynumber token (2025-04-12) # 2.14.0 Fix pytest_tavern_beta_before_every_request not allowing a user to override request vars (2025-03-08) # 2.13.0 Update minimum python version to 3.10 (2025-03-08) # 2.12.0 Add dynamic skipping of stages based on simpleeval (2025-03-07) # 2.11.0 Remove requirement for 'name' in variable files (2024-05-11) ## 2.10.3 Allow using referenced 'finally' stages (2024-04-13) ## 2.10.2 Fix missing schema check for redirect query params (2024-04-13) ## 2.10.1 minor changes to fix tavern_flask plugin (2024-03-27) # 2.10.0 Lock protobuf version to <5 (2024-03-27) ## 2.9.3 Fix saving in gRPC without checking the response (2024-02-17) ## 2.9.2 Fix saving in gRPC (2024-02-10) ## 2.9.1 internal cleanup (2024-01-27) # 2.9.0 Fix mqtt implementation checking for message publication correctly (2024-01-23) # 2.8.0 Initial gRPC support (2024-01-20) ## 2.7.1 Fix jsonschema warnings (2023-12-26) # 2.7.0 update minimum version of jsonschema (2023-12-26) # 2.6.0 fix verify_response_with with multiple MQTT responses (2023-11-18) # 2.5.0 Tinctures: a utility for running things before/after stages, able to be specified at test or stage level. (2023-10-22) # 2.4.0 Allow using an ext function to create a URL (2023-09-18) ## 2.3.1 Fix error formatting when including files with curly braces (2023-09-18) # 2.3.0 Add 'finally' block (2023-08-05) ## 2.2.1 Update some dependencies (2023-07-30) # 2.2.0 Allow wildcards in MQTT topics (2023-06-25) # 2.1.0 Allow multi part file uploads with the same form field name (2023-06-04) ## 2.0.7 Lock pytest to <7.3 to fix issue with marks (2023-04-15) ## 2.0.6 Fix a few small MQTT issues (2023-03-13) ## 2.0.5 Attempt to fix deadlock in subscribe locks (2023-02-16) ## 2.0.4 Fix using ext functions in MQTT publish (2023-02-16) ## 2.0.3 Add type annotations (internal change) (2023-02-10) ## 2.0.2 Fix saving in MQTT (2023-02-08) ## 2.0.1 Bump some dependency versions (2023-01-16) # 2.0.0 2.0.0 release (2023-01-12) ## 1.25.2 Only patch pyyaml when a test is actually being loaded to avoid side effect from Tavern just being in the python path (2022-12-15) ## 1.25.1 Remove tbump from dependencies so it can actually be uploaded to pypi (2022-12-13) # 1.25.0 More changes to packaging (2022-12-13) This is technically not a operational change but I'm adding a new tag so it can br reverted in future ## 1.24.1 Format variables in test error log before dumping as a YAML string (2022-11-22) # 1.24.0 Fix using 'py' library (2022-11-08) This locks pytest to <=7.2 to avoid having to fix imports every time a new version comes out. ## 1.23.5 Fix missing dependency in newer pytest versions (2022-11-07) ## 1.23.4 Update stevedore version (2022-10-23) ## 1.23.3 Allow specifying 'unexpected' messages in MQTT to fail a test (2022-06-26) ## 1.23.2 Fix newer versions of requests complaining about headers not being strings (2022-06-12) ## 1.23.1 Fix docstring of fake pytest object to be a string (2022-06-05) # 1.23.0 Update pyjwt for CVE-2022-29217 (2022-06-05) ## 1.22.1 Fix allure formatting stage name (2022-05-02) # 1.22.0 Allow usage of pyyaml 6 (2022-04-23) # 1.21.0 Allow usage of pytest 7 (2022-04-17) # 1.20.0 Add pytest_tavern_beta_after_every_test_run (2022-02-25) # 1.19.0 Allow parametrizing more types of values (2022-01-09) # 1.18.0 Infer content-type and content-encoding from file_body key (2021-12-12) ## 1.17.2 Fix hardcoded list of strictness choices on command line (2021-12-12) ## 1.17.1 Allow bools in parameterized values (2021-12-12) # 1.17.0 Allow parametrising HTTP method (2021-10-31) ## 1.16.5 Fix 'x is not None' vs 'not x' causing strict matching error (2021-10-31) ## 1.16.4 Change a couple of instances of logging where 'info' might log sensitive data and add note to docs (2021-10-31) ## 1.16.3 Fix --collect-only flag (2021-10-17) ## 1.16.2 Fix some settings being lost after retrying a stage (2021-10-03) ## 1.16.1 Fix regression in nested strict key checking (2021-09-05) # 1.16.0 Allow specifying a new strict option which will allow list items in any order (2021-06-20) # 1.15.0 Update pytest and pykwalify (2021-06-06) ## 1.14.2 Stop pytest warning about a private import (2021-04-05) ## 1.14.1 Fix mqtt tls options validation (2021-03-27) # 1.14.0 Add extra argument to regex helper to allow matching from a jmespath (2021-02-20) ## 1.13.2 Fix checking for cert_reqs file (2021-02-20) ## 1.13.1 Fix using ext functions in query params (2021-01-30) # 1.13.0 Add support for generating Allure test reports (2021-01-30) ## 1.12.2 lock pykwalify version to 1.7 because of breaking API change in 1.8 (2020-12-31) ## 1.12.1 Fix pytest deprecation warning (2020-12-11) # 1.12.0 Allow ext functions in mqtt blocks (2020-12-11) ## 1.11.1 Fix bumped version (2020-11-07) ## 1.7.1 Bump max version of paho-mqtt (2020-11-07) # 1.9.0 219 response function calls (#614) (2020-11-06) Also log the result from 'response' ext functions # 1.11.0 523 add request hook (#615) (2020-11-07) # 1.10.0 Format filenames (#612) (2020-11-07) # 1.8.0 Move parametrize functions out of main class as they are specific behaviour (2020-10-09) Add filterwarning to schema # 1.7.0 Add TAVERN_INCLUDE_PATH to allow including files from other file locations (2020-10-09) # 1.6.0 Allow specifying just the stage 'id' in case of a stage ref without also needing a name (2020-08-26) ## 1.5.1 Fix strictness for a stage 'leaking' into the subsequent stages (2020-08-26) # 1.5.0 Allow using environment variables when formatting test marks (2020-08-26) ## 1.4.1 Fix reading utf8 encoded test files (2020-08-22) # 1.4.0 Support pytest 6 (2020-08-15) # 1.3.0 Allow autouse fixtures in Tavern tests (2020-08-08) ## 1.2.4 Be more relaxed in locking dependency versions (2020-08-08) ## 1.2.3 lock pytest to below 6 temporarily (2020-08-01) ## 1.2.2 travis (2020-05-25) ## 1.2.1 travis (2020-05-25) # 1.2.0 allow passing max_retries as a format variable (2020-05-25) ## 1.1.5 travis (2020-05-23) ## 1.1.4 Bump version: 1.1.3 → 1.1.4 (2020-05-23) ## 1.1.3 travis (2020-05-23) ## 1.1.2 fforce new verison to make travis actually commit (2020-05-23) ## 1.1.1 Travis fix (2020-05-23) # 1.1.0 Add new global option to enable merging of keys from external functions (2020-05-01) ## 1.0.2 Fix incorrect logic checking request codes (2020-05-01) ## 1.0.1 Enable formatting of file body key in requests (2020-05-01) # 1.0.0 1.0 Release (2020-04-05) # 0.34.0 Add new magic tag that includes something as json rather than a string (2019-12-08) # 0.33.0 Add extra type tokens for matching lsits and dicts (2019-11-25) # 0.32.0 Add option to control which files to search for rather than having it hardcoded (2019-11-22) # 0.31.0 - Add isort (2019-11-22) - Fix pytest warnings from None check - Add warning when trying to coerce a non-stirnginto a string in string formatting - Fix jmespath not working when the expected response was falsy - Fix compatability with pytest-rerunfailures - Add options to specify custom content type and encoding for files ## 0.30.3 Fix marker serialisation for pytest-xdist (2019-09-07) ## 0.30.2 Fix parsing auth header (2019-09-07) ## 0.30.1 Fix MQTT subscription race condition (2019-09-07) # 0.30.0 Allow formatting of cookie names and allow overriding cookie values in a request (2019-08-30) # 0.29.0 Allow saving in MQTT tests and move calling external verification functions into their own block (2019-08-28) # 0.28.0 Add a couple of initial hooks (2019-08-26) The initial 2 hooks should allow a user to do something before every test and after every stage # 0.27.0 0.27.0 release (2019-08-10) - Fix various typos in documentation - Allow sending form data and files in a single request - Fix double formatting of some string causing issues - Add a global and stage specific flag to tell Tavern to not always follow redirects - Fix not being able to use type tokens to format MQTT port - Allow sending single values as JSON body as according to RFC 7159 - Change 'save' selector to use JMESpath ## 0.26.5 Lock pytest version to stop internal error (2019-06-01) ## 0.26.4 Allow loading of json files using include directive (2019-06-01) ## 0.26.3 Fix raw token formatting (2019-04-11) ## 0.26.2 Fix loading global config via run function (2019-03-19) ## 0.26.1 Fix matching 'anything' type token in MQTT (2019-03-17) # 0.26.0 Add more advanced cookie behaviour (2019-03-17) ## 0.25.1 Fix fancy traceback when comments in yaml files contain special characters (2019-03-16) # 0.25.0 Allow specifying custom SSL certificates in HTTP requests (2019-02-21) # 0.24.0 Fix typetoken validation and correctly unsubscribe from MQTT topics after a stage (2019-02-16) # 0.23.0 Fix 'only' keyword (2019-02-02) ## 0.22.1 Allow referenced stages to be included from global configuration files (2018-12-28) # 0.22.0 Fix selection of tests when using run() function interface (2018-12-28) This used pytests's -k flag when we actually wanted to change collection of tests, not collecting all tests then selecting by name ## 0.21.1 Improve reporting of actual vs expected types in errors (2018-12-09) # 0.21.0 Add parametrisation of multiple keys without creating combinations (2018-12-09) # 0.20.0 Allow compatibility with pytest 4 (2018-11-15) ## 0.19.1 Fix typo in JMES utils (2018-10-14) # 0.19.0 Add retries to stages (2018-10-07) ## 0.18.3 Fix 'anything' token in included test stages (2018-09-28) ## 0.18.2 Fix formatting environment variables in command line global config files (2018-09-21) ## 0.18.1 Add content type/encoding to uploaded files (2018-09-05) # 0.18.0 Add 'timeout' parameter for http requests (2018-08-24) ## 0.17.2 Stop wrapping responses/schemas in files for verification (2018-08-07) ## 0.17.1 Dummy tag to attempt to make travis dpeloy, again (2018-08-07) # 0.17.0 Add support for putting stages in included files which can be referred to by an id - see 57f2a10e58a88325c185258d2c83b07a532aa93a for details (2018-08-04) ## 0.16.5 Fixes to requirements for development and working from local pypi indexes (2018-08-02) ## 0.16.4 dummy bump tag for travis deploy (2018-07-26) ## 0.16.3 dummy bump tag for travis deploy (2018-07-26) ## 0.16.2 dummy bump tag for travis deploy (2018-07-26) ## 0.16.1 fix delay_before/after bug (2018-07-26) # 0.16.0 Add 'raw' token to alow using curly braces in strings (2018-07-24) ## 0.15.2 Travis deployment fix (2018-07-16) ## 0.15.1 Fix boolean conversion with anybool tag (2018-07-11) # 0.15.0 Add basic pytest fixture support (2018-07-10) ## 0.14.5 Add support for the 'stream' requests flag (2018-07-06) ## 0.14.4 Pylint fix (2018-07-04) ## 0.14.3 Fix header value comparisons (2018-07-04) ## 0.14.2 CI fix (2018-06-27) ## 0.14.1 CI fix (2018-06-27) # 0.14.0 Allow sending of raw data in the 'data' key for a HTTP request (2018-06-27) ## 0.13.5 Fix for Python 2 regex function (2018-06-25) ## 0.13.4 Fix to formatting empty bodies in response with new traceback (2018-06-22) ## 0.13.3 Fix new traceback errors when anystr/anybool/etc was used (2018-06-22) ## 0.13.2 Bug fixes to logging and parametrization (2018-06-22) ## 0.13.1 Fix python 2 error (2018-06-21) # 0.13.0 Add new flag to enable 'fancy' formatting on errors (2018-06-21) ## 0.12.4 Fix case matching with headers (2018-06-20) ## 0.12.3 Fix extra expected keys beign ignroed in responses sometimes (2018-06-20) ## 0.12.2 Fix Pylint (2018-06-20) ## 0.12.1 Flesh out the 'run' function a bit more so it can mostly be used to pass in all config values without having to have a Pytest config file (2018-06-20) # 0.12.0 Add parametrize mark and make run() use pytest.main in the background (2018-06-20) See https://github.com/taverntesting/tavern/issues/127#issuecomment-398409023 calling run() directly will now cause a pytest isntance to be run in the background. This is to avoid having to maintain code and documentation for two separate entry points # 0.11.0 Marking, strict key checking, and multiple status codes (2018-06-18) - Add ability to use custom QoS for subscribing in MQTT - Add pytest marks to tests - Add strict key checking controllable by cli/per test - Add verification for multiple status codes - Improve 'doc' of test for pytest-pspec - Add internal xfail for testing Tavern ## 0.10.2 Fix python 2 type token issue (2018-06-13) ## 0.10.1 Slightly improve docstrings for use with pytest-pspec (2018-06-11) # 0.10.0 Add basic plugin system (2018-05-29) ## 0.9.10 Add new tag to match floating point numbers approximately in responses (2018-05-29) ## 0.9.9 Allow nesting of variables in included files that can be access using dot notation (2018-05-29) ## 0.9.8 Fix tavern overriding content type header when sending a file with extra headers (2018-05-25) ## 0.9.7 Fix error in formatting MQTT variables (2018-05-24) ## 0.9.6 Add bool conversion type token as well (2018-05-16) ## 0.9.5 Fix type conversion tokens and add more robust integration tests for them (2018-05-16) ## 0.9.4 Fixes to type conversion tags, and add a new 'anybool' type sentinel to match either True or False (2018-05-15) ## 0.9.3 Improve error reporting from dictionary mismatches and allow regex checks in headers (2018-05-04) ## 0.9.2 Minor improvement to error messages (2018-04-13) ## 0.9.1 Fix logging library warning (2018-04-11) # 0.9.0 Add file upload capability (2018-04-10) ## 0.8.2 Cleanup of type conversion code and better list item validation (2018-04-05) ## 0.8.1 Fix formatting env vars into included variables (2018-04-03) # 0.8.0 Fix matching magic variables and add new type sentinels for matching (2018-04-03) ## 0.7.7 Improve validation on the type of block returned (2018-03-23) ## 0.7.6 Move dict utilities around (2018-03-21) ## 0.7.5 Fix pytest-pspec error (2018-03-21) ## 0.7.4 Fix python 2 (2018-03-21) ## 0.7.3 Improve error handling in parser errors (2018-03-21) ## 0.7.2 Fix warning on incorrect status codes (2018-03-20) ## 0.7.1 fix delay_after/before to accept float arguments (2018-03-12) # 0.7.0 Add new 'anything' constructor for matching any value returned which should now also work with nested values. Also add special constructors for int/float types (2018-03-09) ## 0.6.1 Fix implementation of 'auth' keyword (2018-03-09) # 0.6.0 Allow multiple global config options on the command line and in pytest config file (2018-03-07) ## 0.5.4 Add 'meta' key to request block (2018-03-05) currently the only key in 'meta' is clear_session_cookies which wipes the session cookies before the request is made ## 0.5.3 Update README (2018-03-05) ## 0.5.2 Add MQTT TLS support and fixes to formatting nested arrays/dicts (2018-03-05) ## 0.5.1 Add regex validation function and verify tests at run time, not discovery time (2018-02-26) # 0.5.0 Add special 'tavern' key for formatting magic variables, and don't strictly enforce some HTTP verbs not having a body (2018-02-23) # 0.4.0 MQTT support (2018-02-22) # 0.3.0 Use a persistent requests Session to allow cookies to be propagated forward through tests (2018-02-15) ## 0.2.5 Fix empty yaml files hard-failing (2018-01-25) ## 0.2.4 Fix log format interpolation for py2 (2018-01-25) ## 0.2.3 quote nested json in query parameters (2018-01-23) ## 0.2.2 Support for 'verify' arg to requests (2018-01-23) ## 0.2.1 Add option to install 'pytest' extra (2017-12-12) # 0.2.0 Add python 2 support (2017-12-12) ## 0.1.5 Fix temporary file wrapping on windows (2017-12-06) ## 0.1.4 Fix global configuration if it wasn't actually passed (2017-12-06) ## 0.1.3 Fix global configuration loading via pytest command line (2017-12-05) ## 0.1.2 Allow sending/validation of JSON lists (2017-11-21) tavern-3.6.0/CONTRIBUTING.md000066400000000000000000000036631520710011500152500ustar00rootroot00000000000000# Contributing All configuration for the project should be put into `pyproject.toml`. ## Working locally 1. Create a virtualenv using whatever method you like ( eg, [virtualenvwrapper](https://virtualenvwrapper.readthedocs.io/)) ```shell uv venv ``` 1. Sync venv ```shell uv sync --all-extras --all-packages --all-groups ``` ## Running tests locally To run a subset of the required tests, run the [smoke test script](/scripts/smoke.bash) ./scripts/smoke.bash If on Windows, you should be able to just run the 'tox' commands in that file. ## Updating/adding a dependency 1. Add or update the dependency in [pyproject.toml](/pyproject.toml) 1. Update lock file ```shell uv lock --upgrade ``` 1. Sync venv ```shell uv sync --all-extras --all-packages --all-groups ``` 1. Run tests as above ## Pre-commit Basic checks (formatting, import order) are done with pre-commit and are controlled by [the yaml file](/.pre-commit-config.yaml). After installing dependencies, Run ```bash # check it works pre-commit run --all-files pre-commit install ``` Run every so often to update the pre-commit hooks ```bash pre-commit autoupdate ``` ### Fixing Python formatting issues ```bash ruff format tavern/ tests/ ruff --fix tavern/ tests/ ``` ### Fix yaml formatting issues ```bash pre-commit run --all-files ``` ## Creating a new release 1. Setup `~/.pypirc` according to the [official instructions](https://packaging.python.org/en/latest/specifications/pypirc/) 1. Tag and push to git with `tbump --tag-message ""` 1. Upload to pypi with `flit publish` ## Building the documentation Run this standalone for now: https://github.com/jupyter-book/mystmd/issues/2082 ```bash uv tool run --from mystmd myst build --html ``` ### Watching for changes To automatically rebuild when files in the `docs/` folder change: ```bash uv tool run watchfiles "uv tool run --from mystmd myst build --html" --filter all docs myst.yml ``` tavern-3.6.0/LICENSE000066400000000000000000000020371520710011500140160ustar00rootroot00000000000000Copyright 2021 Michael Boulton Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. tavern-3.6.0/MANIFEST.in000066400000000000000000000003051520710011500145430ustar00rootroot00000000000000include tavern/_core/schema/tests.jsonschema.yaml include tavern/_plugins/mqtt/jsonschema.yaml include tavern/_plugins/rest/jsonschema.yaml include tavern/_plugins/grpc/schema.yaml include LICENSE tavern-3.6.0/README.md000066400000000000000000000212321520710011500142660ustar00rootroot00000000000000# Easier API testing with Tavern [![pypi](https://img.shields.io/pypi/v/tavern.svg)](https://pypi.org/project/tavern/) [![docs](https://readthedocs.org/projects/pip/badge/?version=latest&style=flat)](https://tavern.readthedocs.io/en/latest/) ![workflow](https://github.com/taverntesting/tavern/actions/workflows/main.yml/badge.svg?branch=master) Tavern is a pytest plugin, command-line tool, and Python library for automated testing of APIs, with a simple, concise, and flexible YAML-based syntax. It's very simple to get started, and highly customisable for complex tests. Tavern supports testing RESTful APIs, MQTT based APIs, and gRPC services. The best way to use Tavern is with [pytest](https://docs.pytest.org/en/latest/). Tavern comes with a pytest plugin so that literally all you have to do is install pytest and Tavern, write your tests in `.tavern.yaml` files and run pytest. This means you get access to all of the pytest ecosystem and allows you to do all sorts of things like regularly run your tests against a test server and report failures or generate HTML reports. You can also integrate Tavern into your own test framework or continuous integration setup using the Python library, or use the command line tool, `tavern-ci` with bash scripts and cron jobs. To learn more, check out the [examples](https://taverntesting.github.io/examples) or the complete [documentation](https://taverntesting.github.io/documentation). If you're interested in contributing to the project take a look at the [GitHub repo](https://github.com/taverntesting/tavern). ## Why Tavern Choosing an API testing framework can be tough. Tavern was started in 2017 to address some of our concerns with other testing frameworks. In short, we think the best things about Tavern are: ### It's Lightweight. Tavern is a small codebase which uses pytest under the hood. ### Easy to Write, Easy to Read and Understand. The yaml syntax allows you to abstract what you need with anchors, whilst using `pytest.mark` to organise your tests. Your tests should become more maintainable as a result. ### Test Anything From the simplest API test through to the most complex of requests, tavern remains readable and easy to extend. We're aiming for developers to not need the docs open all the time! ### Extensible Almost all common test usecases are covered, but for everything else it's straightforward to drop in to python/pytest to extend. Use fixtures, hooks, and things you already know. ### Growing Ecosystem Tavern is still in active development and is used by 100s of companies. ## Quickstart First up run `pip install tavern`. Then, let's create a basic test, `test_minimal.tavern.yaml`: ```yaml --- # Every test file has one or more tests... test_name: Get some fake data from the JSON placeholder API # ...and each test has one or more stages (e.g. an HTTP request) stages: - name: Make sure we have the right ID # Define the request to be made... request: url: https://jsonplaceholder.typicode.com/posts/1 method: GET # ...and the expected response code and body response: status_code: 200 json: id: 1 userId: 1 title: "sunt aut facere repellat provident occaecati excepturi optio reprehenderit" body: "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" ``` This file can have any name, but if you intend to use Pytest with Tavern, it will only pick up files called `test_*.tavern.yaml`. This can then be run like so: ```bash $ pip install tavern[pytest] $ py.test test_minimal.tavern.yaml -v =================================== test session starts =================================== platform linux -- Python 3.5.2, pytest-3.4.2, py-1.5.2, pluggy-0.6.0 -- /home/taverntester/.virtualenvs/tavernexample/bin/python3 cachedir: .pytest_cache rootdir: /home/taverntester/myproject, inifile: plugins: tavern-0.7.2 collected 1 item test_minimal.tavern.yaml::Get some fake data from the JSON placeholder API PASSED [100%] ================================ 1 passed in 0.14 seconds ================================= ``` It is strongly advised that you use Tavern with Pytest - not only does it have a lot of utility to control discovery and execution of tests, there are a huge amount of plugins to improve your development experience. If you absolutely can't use Pytest for some reason, use the `tavern-ci` command line interface: ```bash $ pip install tavern $ tavern-ci --stdout test_minimal.tavern.yaml 2017-11-08 16:17:00,152 [INFO]: (tavern.core:55) Running test : Get some fake data from the JSON placeholder API 2017-11-08 16:17:00,153 [INFO]: (tavern.core:69) Running stage : Make sure we have the right ID 2017-11-08 16:17:00,239 [INFO]: (tavern.core:73) Response: '' ({ "userId": 1, "id": 1, "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", "json": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" }) 2017-11-08 16:17:00,239 [INFO]: (tavern.printer:9) PASSED: Make sure we have the right ID [200] ``` ## Why not Postman, Insomnia or pyresttest etc? Tavern is a focused tool which does one thing well: automated testing of APIs. **Postman** and **Insomnia** are excellent tools which cover a wide range of use-cases for RESTful APIs, and indeed we use Tavern alongside Postman. However, specifically with regards to automated testing, Tavern has several advantages over Postman: - A full-featured Python environment for writing easily reusable custom validation functions - Testing of MQTT based systems and gRPC services in tandem with RESTful APIs. - Seamless integration with pytest to keep all your tests in one place - A simpler, less verbose and clearer testing language Tavern does not do many of the things Postman and Insomnia do. For example, Tavern does not have a GUI nor does it do API monitoring or mock servers. On the other hand, Tavern is free and open-source and is a more powerful tool for developers to automate tests. **pyresttest** is a similar tool to Tavern for testing RESTful APIs, but is no longer actively developed. On top of MQTT testing, Tavern has several other advantages over PyRestTest which overall add up to a better developer experience: - Cleaner test syntax which is more intuitive, especially for non-developers - Validation function are more flexible and easier to use - Better explanations of why a test failed ## Hacking on Tavern If you want to add a feature to Tavern or just play around with it locally, it's a good plan to first create a local development environment ([this page](http://docs.python-guide.org/en/latest/dev/virtualenvs/) has a good primer for working with development environments with Python). After you've created your development environment, just `pip install tox` and run `tox` to run the unit tests. If you want to run the integration tests, make sure you have [docker](https://www.docker.com/) installed and run `tox -c tox-integration.ini` (bear in mind this might take a while.) It's that simple! If you want to develop things in tavern, enter your virtualenv and run `uv sync --all-extras --all-packages` to install the library, any requirements, and other useful development options. Tavern uses [ruff](https://pypi.org/project/ruff/) to keep all of the code formatted consistently. There is a pre-commit hook to run `ruff format` which can be enabled by running `pre-commit install`. If you want to add a feature to get merged back into mainline Tavern: - Add the feature you want - Add some tests for your feature: - If you are adding some utility functionality such as improving verification of responses, adding some unit tests might be best. These are in the `tests/unit/` folder and are written using Pytest. - If you are adding more advanced functionality like extra validation functions, or some functionality that directly depends on the format of the input YAML, it might also be useful to add some integration tests. At the time of writing, this is done by adding an example flask endpoint in `tests/integration/server.py` and a corresponding Tavern YAML test file in the same directory. - Open a [pull request](https://github.com/taverntesting/tavern/pulls). See [CONTRIBUTING.md](/CONTRIBUTING.md) for more details. ## Maintenance Tavern is currently maintained by - [@michaelboulton](https://www.github.com/michaelboulton) [//]: # (Note: Myst is hardcoded to look for a subheader with this name 🤷) ## Acknowledgements [pytest](https://docs.pytest.org/en/latest/): the testing framework Tavern integrates with tavern-3.6.0/docs/000077500000000000000000000000001520710011500137375ustar00rootroot00000000000000tavern-3.6.0/docs/.gitignore000066400000000000000000000000111520710011500157170ustar00rootroot00000000000000build/** tavern-3.6.0/docs/source/000077500000000000000000000000001520710011500152375ustar00rootroot00000000000000tavern-3.6.0/docs/source/.gitignore000066400000000000000000000000061520710011500172230ustar00rootroot00000000000000*.rst tavern-3.6.0/docs/source/_static/000077500000000000000000000000001520710011500166655ustar00rootroot00000000000000tavern-3.6.0/docs/source/_static/favicon.png000066400000000000000000000015231520710011500210210ustar00rootroot00000000000000PNG  IHDR22?sBIT|d pHYs+A+A>uNtEXtSoftwarewww.inkscape.org<IDAThjSQ^JR /Uš3>ϠN:1 !ڜv"|O{O+`.P"}xL*΁7,vc[NYN}>.π'9DZ K+/CȧL?PND<.~ iTU=j`oc=U`NXfNI/aPph71X֐Jj+͛4bD =_-d>2aI'"0E=>R,%Ђ?B2ħ[tG)׈ 'u#o2# 4[x thi}表K57/=K)mgz^3ff/,υh `ڃ9?4,%j5cM~[}Wp8"ڵ&R}1V& z1 2MD״ABO@h[:e D $Fc &cuخDWDGޝ]3R"[6ڕ"يu׈H~*=^ ;#_DOcP?֮"?0 Şk"Ԑ)Q{o\V<ڌFG.]lrw96IENDB`tavern-3.6.0/docs/source/_static/icon.png000066400000000000000000000026501520710011500203260ustar00rootroot00000000000000PNG  IHDRddpToIDATxTUǣbDnPdЕ@*++*_J @]"02E.V0JːCz:$p`[;33?`3{^i4jjjjjj8`+y \Uo=Lw~7q,*tw;dfQJon`_W4{!+N Yq;Ex^\S4%3N Rldv;)؝ɒ@Kf;n(GI_ZMn(gO0Ev/I2y`>v9۩ fq}T94 v*A\r;U!NeBC7Fq]v`$9Tͅ|E*pp۩ `v{ITL\*(Iv*p&SKg T9˒|S˕f*xtܗoS$GNe\T$۟EZLASS%/Nh`$ R+s)I6ǵKq)glWc-p]dINds;&Vd+IS_"=")*4OqVCn$lFRwRStFk0_p;5-OAFϷh"qbzZ@c_x ,N^8/h`G8' N D8Z Km? `t*qبt򥸜r," for doc in yaml.load_all(yfile.read()): File "/home/cooldeveloper/.virtualenvs/tavern/lib/python3.5/site-packages/yaml/__init__.py", line 84, in load_all yield loader.get_data() File "/home/cooldeveloper/.virtualenvs/tavern/lib/python3.5/site-packages/yaml/constructor.py", line 31, in get_data return self.construct_document(self.get_node()) File "/home/cooldeveloper/.virtualenvs/tavern/lib/python3.5/site-packages/yaml/composer.py", line 27, in get_node return self.compose_document() File "/home/cooldeveloper/.virtualenvs/tavern/lib/python3.5/site-packages/yaml/composer.py", line 55, in compose_document node = self.compose_node(None, None) File "/home/cooldeveloper/.virtualenvs/tavern/lib/python3.5/site-packages/yaml/composer.py", line 84, in compose_node node = self.compose_mapping_node(anchor) File "/home/cooldeveloper/.virtualenvs/tavern/lib/python3.5/site-packages/yaml/composer.py", line 133, in compose_mapping_node item_value = self.compose_node(node, item_key) File "/home/cooldeveloper/.virtualenvs/tavern/lib/python3.5/site-packages/yaml/composer.py", line 84, in compose_node node = self.compose_mapping_node(anchor) File "/home/cooldeveloper/.virtualenvs/tavern/lib/python3.5/site-packages/yaml/composer.py", line 133, in compose_mapping_node item_value = self.compose_node(node, item_key) File "/home/cooldeveloper/.virtualenvs/tavern/lib/python3.5/site-packages/yaml/composer.py", line 69, in compose_node % anchor, event.start_mark) yaml.composer.ComposerError: found undefined alias 'top_anchor' in "", line 12, column 7: <<: *top_anchor ``` This poses a bit of a problem for running our integration tests. If we want to log in at the beginning of each test, or if we want to query some user information which is then operated on for each test, we don't want to copy paste the same code within the same file. For this reason, Tavern will override the default YAML behaviour and preserve anchors across documents **within the same file**. Then we can do something more like this: ```yaml --- test_name: Make sure user location is correct stages: - &test_user_login_anchor # Log in as user and save the login token for future requests name: Login as test user request: url: http://test.server.com/user/login method: GET json: username: test_user password: abc123 response: status_code: 200 save: json: test_user_login_token: token verify_response_with: function: tavern.helpers:validate_jwt extra_kwargs: jwt_key: "token" options: verify_signature: false - name: Get user location request: url: http://test.server.com/locations method: GET headers: Authorization: "Bearer {test_user_login_token}" response: status_code: 200 json: location: road: 123 Fake Street country: England --- test_name: Make sure giving premium works stages: # Use the same block to log in across documents - *test_user_login_anchor - name: Assert user does not have premium request: &has_premium_request_anchor url: http://test.server.com/user_info method: GET headers: Authorization: "Bearer {test_user_login_token}" response: status_code: 200 json: has_premium: false - name: Give user premium request: url: http://test.server.com/premium method: POST headers: Authorization: "Bearer {test_user_login_token}" response: status_code: 200 - name: Assert user now has premium request: # Use the same block within one document <<: *has_premium_request_anchor response: status_code: 200 json: has_premium: true ``` ## Including external files Even with being able to use anchors within the same file, there is often some data which either you want to keep in a separate (possibly autogenerated) file, or is used on every test (e.g. login information). You might also want to run the same tests with different sets of input data. Because of this, external files can also be included which contain simple key: value data to be used in other tests. Including a file in every test can be done by using a `!include` directive: ```yaml # includes.yaml --- # Each file should have a name and description name: Common test information description: Login information for test server # Variables should just be a mapping of key: value pairs variables: protocol: https host: www.server.com port: 1234 ``` ```yaml # tests.tavern.yaml --- test_name: Check server is up includes: - !include includes.yaml stages: - name: Check healthz endpoint request: method: GET url: "{protocol:s}://{host:s}:{port:d}" response: status_code: 200 ``` As long as includes.yaml is in the same folder as the tests or found in the TAVERN_INCLUDE search path, the variables will automatically be loaded and available for formatting as before. Multiple include files can be specified. The environment variable TAVERN_INCLUDE can contain a : separated list of paths to search for include files. Each path in TAVERN_INCLUDE has environment variables expanded before it is searched. ### Including global configuration files If you do want to run the same tests with a different input data, this can be achieved by passing in a global configuration. Using a global configuration file works the same as implicitly including a file in every test. For example, say we have a server that takes a user's name and address and returns some hash based on this information. We have two servers that need to do this correctly, so we need two tests that use the same input data but need to post to 2 different urls: ```yaml # two_tests.tavern.yaml --- test_name: Check server A responds properly includes: - !include includesA.yaml stages: - name: Check thing is processed correctly request: method: GET url: "{host:s}/" json: &input_data name: "{name:s}" house_number: "{house_number:d}" street: "{street:s}" town: "{town:s}" postcode: "{postcode:s}" country: "{country:s}" planet: "{planet:s}" galaxy: "{galaxy:s}" universe: "{universe:s}" response: status_code: 200 json: hashed: "{expected_hash:s}" --- test_name: Check server B responds properly includes: - !include includesB.yaml stages: - name: Check thing is processed correctly request: method: GET url: "{host:s}/" json: <<: *input_data response: status_code: 200 json: hashed: "{expected_hash:s}" ``` Including the full set of input data in includesA.yaml and includesB.yaml would mean that a lot of the same input data would be repeated. To get around this, we can define a file called, for example, `common.yaml` which has all the input data except for `host` in it, and make sure that includesA/B only have the `host` variable in: ```yaml # common.yaml --- name: Common test information description: | user location information for Joe Bloggs test user variables: name: Joe bloggs house_number: 123 street: Fake street town: Chipping Sodbury postcode: BS1 2BC country: England planet: Earth galaxy: Milky Way universe: A expected_hash: aJdaAK4fX5Waztr8WtkLC5 ``` ```yaml # includesA.yaml --- name: server A information description: server A specific information variables: host: www.server-a.com ``` ```yaml # includesB.yaml --- name: server B information description: server B specific information variables: host: www.server-B.io ``` If the behaviour of server A and server B ever diverge in future, information can be moved out of the common file and into the server specific include files. Using the `tavern-ci` tool or pytest, this global configuration can be passed in at the command line using the `--tavern-global-cfg` flag. The variables in `common.yaml` will then be available for formatting in *all* tests during that test run. **NOTE**: `tavern-ci` is just an alias for `py.test` and will take the same options. ``` # These will all work $ tavern-ci --tavern-global-cfg=integration_tests/local_urls.yaml $ tavern-ci --tavern-global-cfg integration_tests/local_urls.yaml $ py.test --tavern-global-cfg=integration_tests/local_urls.yaml $ py.test --tavern-global-cfg integration_tests/local_urls.yaml ``` It might be tempting to put this in the 'addopts' section of the pytest.ini file to always pass a global configuration when using pytest, but be careful when doing this - due to what appears to be a bug in the pytest option parsing, this might not work as expected: ```ini # pytest.ini [pytest] addopts = # This will work --tavern-global-cfg=integration_tests/local_urls.yaml # This will not! # --tavern-global-cfg integration_tests/local_urls.yaml ``` Instead, use the `tavern-global-cfg` option in your pytest.ini file: ```ini [pytest] tavern-global-cfg = integration_tests/local_urls.yaml ``` ### Multiple global configuration files Sometimes you will want to have 2 (or more) different global configuration files, one containing common information such as paths to different resources and another containing information specific to the environment that is being tested. Multiple global configuration files can be specified either on the command line or in pytest.ini to avoid having to put an `!include` directive in every test: ``` # Note the '--' after all global configuration files are passed, indicating that # arguments after this are not global config files $ tavern-ci --tavern-global-cfg common.yaml test_urls.yaml -- test_server.tavern.yaml $ py.test --tavern-global-cfg common.yaml local_docker_urls.yaml -- test_server.tavern.yaml ``` ```ini # pytest.ini [pytest] tavern-global-cfg = common.yaml test_urls.yaml ``` ### Sharing stages in configuration files If you have a stage that is shared across a huge number of tests and it is infeasible to put all the tests which share that stage into one file, you can also define stages in configuration files and use them in your tests. Say we have a login stage that needs to be run before every test in our test suite. Stages are defined in a configuration file like this: ```yaml # auth_stage.yaml --- name: Authentication stage description: Reusable test stage for authentication variables: user: user: test-user pass: correct-password stages: - id: login_get_token name: Login and acquire token request: url: "{service:s}/login" json: user: "{user.user:s}" password: "{user.pass:s}" method: POST headers: content-type: application/json response: status_code: 200 headers: content-type: application/json save: json: test_login_token: token ``` Each stage should have a uniquely identifiable `id`, but other than that the stage can be define just as other tests (including using format variables). This can be included in a test by specifying the `id` of the test like this: ```yaml --- test_name: Test authenticated /hello includes: - !include auth_stage.yaml stages: - type: ref id: login_get_token - name: Authenticated /hello request: url: "{service:s}/hello/Jim" method: GET headers: Content-Type: application/json Authorization: "Bearer {test_login_token}" response: status_code: 200 headers: content-type: application/json json: data: "Hello, Jim" ``` ### Directly including test data If your test just has a huge amount of data that you would like to keep in a separate file, you can also (ab)use the `!include` tag to directly include data into a test. Say we have a huge amount of JSON that we want to send to a server and we don't want hundreds of lines in the test: ```json // test_data.json [ { "_id": "5c965b1373f3fe071a9cb2b7", "index": 0, "guid": "ef3f8c42-522a-4d6b-84ec-79a07009460d", "isActive": false, "balance": "$3,103.47", "picture": "http://placehold.it/32x32", "age": 26, "eyeColor": "green", "name": "Cannon Wood", "gender": "male", "company": "CANDECOR", "email": "cannonwood@candecor.com", "phone": "+1 (944) 549-2826", "address": "528 Woodpoint Road, Snowville, Kansas, 140", "about": "Dolore in consequat exercitation esse esse velit eu velit aliquip ex. Reprehenderit est consectetur excepteur sint sint dolore. Anim minim dolore est ut fugiat. Occaecat tempor tempor mollit dolore anim commodo laboris commodo aute quis ex irure voluptate. Sunt magna tempor veniam cillum exercitation quis minim est eiusmod aliqua.\r\n", "registered": "2015-12-27T11:30:18 -00:00", "latitude": -2.515302, "longitude": -98.678105, "tags": [ "proident", "aliqua", "velit", "labore", "consequat", "esse", "ea" ], "friends": [ { "id": 0, "etc": [] } ] } ] ``` (Handily generated by [JSON Generator](https://www.json-generator.com/)) Putting this whole thing into the test would be a bit overkill, but it can be inject directly into your test like this: ```yaml --- test_name: Post a lot of data stages: - name: Create new user request: url: "{service:s}/new_user" method: POST json: !include test_data.json response: status_code: 201 json: status: user created ``` This works with YAML as well, the only caveat being that the filename _must_ end with `.yaml`, `.yml`, or `.json`. ## Including raw JSON data Sometimes there are situations where you need to directly include a block of JSON, such as a list, rather than just one value. To do this, there is a `!force_original_structure` tag which will include whatever variable is being referenced in the format block rather than coercing it to a string. For example, if we have an API that will return a list of users on a GET and will bulk delete a list of users on a DELETE, a test that all users are deleted could be done by 1. GET all users 2. DELETE the list you just got 3. GET again and expect an empty list ```yaml - name: Get all users request: url: "{host}/users" method: GET response: status_code: 200 # Expect a list of users json: !anylist save: json: # Save the list as 'all_users' all_users: "@" - name: delete all users request: url: "{host}/users" method: DELETE # 'all_users' list will be sent in the request as a list, not a string json: !force_original_structure "{all_users}" response: status_code: 204 - name: Get no users request: url: "{host}/users" method: GET response: status_code: 200 # Expect no users json: [ ] ``` Any blocks of JSON that are included this way will not be recursively formatted. When using this token, do not use a conversion specifier (eg "{all_users:s}") as it will be ignored. tavern-3.6.0/docs/source/core_concepts/external_code.md000066400000000000000000000550371520710011500232350ustar00rootroot00000000000000# Using Python functions in Tavern Tavern provides several ways, documented below, to integrate Python code with your tests. Each approach has different strengths and is suited to different scenarios. | **Approach** | **Benefits** | **Downsides** | **Best Used When** | |------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | **External Functions** (`$ext`) | • Flexible placement (request, response, save blocks)
• Can inject dynamic data or save custom data for validation in Python
• Can express complex logic specific to one test
• No special decorators needed | • Functions must be in Python path | • Need dynamic data injection (e.g., calculated auth tokens)
• Custom response validation beyond key checking
• Extracting/transforming specific data from responses
• One-off test-specific logic | | **Pytest Fixtures** | • Integrates with pytest ecosystem
• Automatic discovery via conftest.py
• Can use `autouse` for implicit availability
• Session-scoped fixtures compute once and reuse
• Return values available for formatting | • Limited to function/session scope fixtures (not per-stage) | • Sharing setup data across entire test
• Loading configuration/credentials once
• Timing/logging entire test execution
• Leveraging existing pytest fixtures
• Need session-wide computed values | | **Tinctures** | • **Can run per-stage or per-test**
• Can introspect both request and response
• Access to stage dictionary | • Less powerful than fixtures | • Wrapping stage execution with setup/teardown
• Per-stage validation or logging
• Need access to both expected and actual response
• Reusable stage-level logic | | **Hooks** (`pytest_tavern_beta_*`) | • Suite-wide automatic execution
• Multiple hook points (before test, after response, etc.)
• No explicit test modification needed
• Good for cross-cutting concerns | • **'Beta'/unstable API** (names may change in future)
• Runs for ALL tests (less granular)
• Can't be turned off per-test | • Suite-wide logging/monitoring
• Global cleanup operations
• Recording all responses for debugging
• Adding test-wide configuration | **Quick Selection Guide:** - **Need it for just one test?** → External Functions or Tinctures - **Need pytest integration or session-wide data?** → Fixtures - **Need per-stage execution with timing/wrapping?** → Tinctures or Hooks - **Need it automatically for every test?** → Hooks or Fixtures ## Calling external functions Not every response can be validated simply by checking the values of keys, so with Tavern you can call external functions to validate responses and save decoded data. You can write your own functions or use those built in to Tavern. Each function should take the response as its first argument, and you can pass extra arguments using the **extra_kwargs** key. To make sure that Tavern can find external functions you need to make sure that it is in the Python path. For example, if `utils.py` is in the 'tests' folder, you will need to run your tests something like (on Linux): ```shell $ PYTHONPATH=$PYTHONPATH:tests py.test tests/ ``` ### Checking the response using external functions The function(s) should be put into the `verify_response_with` block of a response (HTTP or MQTT): ```yaml - name: Check friendly mess request: url: "{host}/token" method: GET response: status_code: 200 verify_response_with: function: testing_utils:message_says_hello ``` ```python # testing_utils.py def message_says_hello(response): """Make sure that the response was friendly """ assert response.json().get("message") == "hello world" ``` A list of functions can also be passed to `verify_response_with` if you need to check multiple things: ```yaml response: status_code: 200 verify_response_with: - function: testing_utils:message_says_hello - function: testing_utils:message_says_something_else extra_kwargs: should_say: hello ``` If an external function you are using raises any exception, the test will be considered failed. The return value from these functions is ignored. ### Built-in validators There are some external functions built in to Tavern to help assert common expectations #### Validating JWT `validate_jwt` takes the key of the returned JWT in the body as `jwt_key`, and additional arguments that are passed directly to the `decode` method in the [PyJWT](https://github.com/jpadilla/pyjwt/blob/master/jwt/api_jwt.py#L59) library. **NOTE: Make sure the keyword arguments you are passing are correct or PyJWT will silently ignore them. In the future, this function will likely be changed to use a different library to avoid this issue.** ```yaml # Make sure the response contains a key called 'token', the value of which is a # valid jwt which is signed by the given key. response: verify_response_with: function: tavern.helpers:validate_jwt extra_kwargs: jwt_key: "token" key: CGQgaG7GYvTcpaQZqosLy4 options: verify_signature: true verify_aud: false ``` #### Validating with pykwalify `validate_pykwalify` takes a [pykwalify](http://pykwalify.readthedocs.io/en/master/) schema and verifies the body of the response against it. ```yaml # Make sure the response matches the given schema - a sequence of dictionaries, # which has to contain a user name and may contain a user number. response: verify_response_with: function: tavern.helpers:validate_pykwalify extra_kwargs: schema: type: seq required: True sequence: - type: map mapping: user_number: type: int required: False user_name: type: str required: True ``` #### Validating with a regex `validate_regex` checks that the response body (or a specific header) matches the given regular expression. Optionally, a `jmespath` expression can be used to extract a string from the JSON body before matching. Named capture groups are returned and can be used in later requests. ```yaml response: verify_response_with: function: tavern.helpers:validate_regex extra_kwargs: expression: "^Bearer (?P.+)$" header: "Authorization" ``` ```yaml response: verify_response_with: function: tavern.helpers:validate_regex extra_kwargs: expression: "(?P[0-9a-f\\-]{36})" in_jmespath: "data.id" ``` #### Validating content with JMESPath `validate_content` checks values extracted from the response body using [JMESPath](https://jmespath.org/) expressions. Pass a list of comparisons, each specifying a `jmespath`, an `operator`, and an `expected` value. ```yaml response: verify_response_with: function: tavern.helpers:validate_content extra_kwargs: comparisons: - jmespath: "users[0].name" operator: "eq" expected: "Bob" - jmespath: "users | length(@)" operator: "gt" expected: 0 ``` #### Checking a JMESPath match `check_jmespath_match` asserts that a JMESPath `query` resolves to a truthy value in the response. Optionally, an `expected` value can be provided to assert the result matches it exactly. Without `expected`, it asserts the path resolves to a truthy (non-falsy) value—falsy values like `[]`, `""`, `0`, and `False` will be treated as failures. ```yaml response: verify_response_with: function: tavern.helpers:check_jmespath_match extra_kwargs: query: "items[?status == 'active']" expected: - name: "widget" status: "active" ``` #### Validating with a pydantic model `validate_pydantic` validates the JSON response body against a [Pydantic](https://docs.pydantic.dev/) model. Pass the entry-point-style location of the model class as `model_location`. Any extra can keyword arguments via `extra_kwargs` are passed to `model_validate` to control how the model is validated. ```yaml response: verify_response_with: function: tavern.helpers:validate_pydantic extra_kwargs: model_location: "myapp.models:UserResponse" extra: allow ``` To use this helper, Pydantic must be installed separately as it is an optional dependency. ### Using external functions for other things External functions can be used to inject arbitrary data into tests or to save data from the response. An external function must return a dict where each key either points to a single value or to an object which is accessible using dot notation. The easiest way to do this is to return a [Box](https://pypi.python.org/pypi/python-box/) object. **Note**: Functions used with `verify_response_with` or `save` in the `response` block should always take the response as the first argument. #### Injecting external data into a request A use case for this is trying to insert some data into a response that is either calculated dynamically or fetched from an external source. If we want to generate some authentication headers to access our API for example, we can use an external function using the `$ext` key to calculate it dynamically (note as above that this function should _not_ take any arguments): ```python # utils.py from box import Box def generate_bearer_token(): token = sign_a_jwt() auth_header = { "Authorization": "Bearer {}".format(token) } return Box(auth_header) ``` This can be used as so: ```yaml - name: login request: url: http://server.com/login headers: x-my-header: abc123 $ext: function: utils:generate_bearer_token json: username: test_user password: abc123 response: status_code: 200 ``` When an `$ext` function returns a mapping, its values are merged into the existing request block by default. The `--tavern-merge-ext-function-values` flag has been removed because this is now the default behaviour: ```python # ext_functions.py def return_hello(): return {"hello": "there"} ``` ```yaml request: url: "{host}/echo" method: POST json: goodbye: "now" $ext: function: ext_functions:return_hello ``` This will send both "hello" and "goodbye" in the request. #### Saving data from a response When using the `$ext` key in the `save` block there is special behaviour - each key in the returned object will be saved as if it had been specified separately in the `save` object. The function is called in the same way as a validator function, in the `$ext` key of the `save` object. Say that we have a server which returns a response like this: ```json { "user": { "name": "John Smith", "id": "abcdef12345" } } ``` If our test function extracts the key `name` from the response body (note as above that this function should take the response object as the first argument): ```python # utils.py from box import Box def test_function(response): return Box({"test_user_name": response.json()["user"]["name"]}) ``` We would use it in the `save` object like this: ```yaml save: $ext: function: utils:test_function json: test_user_id: user.id ``` In this case, both `{test_user_name}` and `{test_user_id}` are available for use in later requests. #### A more complicated example For a more practical example, the built in `validate_jwt` function also returns the decoded token as a dictionary wrapped in a [Box](https://pypi.python.org/pypi/python-box/) object, which allows dot-notation access to members. This means that the contents of the token can be used for future requests. Because Tavern will already be in the Python path (because you installed it as a library) you do not need to modify the `PYTHONPATH`. For example, if our server saves the user ID in the 'sub' field of the JWT: ```yaml - name: login request: url: http://server.com/login json: username: test_user password: abc123 response: status_code: 200 verify_response_with: # Make sure a token exists function: tavern.helpers:validate_jwt extra_kwargs: jwt_key: "token" options: verify_signature: false save: # Saves a jwt token returned as 'token' in the body as 'jwt' # in the test configuration for use in future tests # Note the use of $ext again $ext: function: tavern.helpers:validate_jwt extra_kwargs: jwt_key: "token" options: verify_signature: false - name: Get user information request: url: "http://server.com/info/{jwt.sub}" ... response: ... ``` Ideas for other helper functions which might be useful: - Making sure that the response matches a database schema - Making sure that an error returns the correct error text in the body - Decoding base64 data to extract some information for use in a future query - Validate templated HTML returned from an endpoint using an XML parser - etc. One thing to bear in mind is that data can only be saved for use within the same test - each YAML document is considered to be a separate test (not counting anchors as described below). If you need to use the data in multiple tests, you will either need to put it into another file which you then include, or perform the same request in each test to re-fetch the data. ## Hooks As well as fixtures as mentioned in the previous section, since version 0.28.0 there is a couple of hooks which can be used to extract more information from tests. These hooks are used by defining a function with the name of the hook in your `conftest.py` that take the same arguments _with the same names_ - these hooks will then be picked up at runtime and called appropriately. **NOTE**: These hooks should be considered a 'beta' feature, they are ready to use but the names and arguments they take should be considered unstable and may change in a future release (and more may also be added). More documentation for these can be found in the docstrings for the hooks in the `tavern/testutils/pytesthook/newhooks.py` file. ### Before every test run This hook is called after fixtures, global configuration, and plugins have been loaded, but _before_ formatting is done on the test and the schema of the test is checked. This can be used to 'inject' extra things into the test before it is run, such as configurations blocks for a plugin, or just for some kind of logging. Example usage: ```python import logging def pytest_tavern_beta_before_every_test_run(test_dict, variables): logging.info("Starting test %s", test_dict["test_name"]) variables["extra_var"] = "abc123" ``` ### After every test run This hook is called _after_ execution of each test, regardless of the test result. The hook can, for example, be used to perform cleanup after the test is run. Example usage: ```python import logging def pytest_tavern_beta_after_every_test_run(test_dict, variables): logging.info("Ending test %s", test_dict["test_name"]) ``` ### After every response This hook is called after every _response_ for each _stage_ - this includes HTTP responses, but also MQTT responses if you are using MQTT. This means if you are using MQTT it might be called multiple times for each stage! Example usage: ```python def pytest_tavern_beta_after_every_response(expected, response): with open("logfile.txt", "a") as logfile: logfile.write("Got response: {}".format(response.json())) ``` ### Before every request This hook is called just before each request with the arguments passed to the request "function". By default, this is Session.request (from requests) for HTTP and Client.publish (from paho-mqtt) for MQTT. Example usage: ```python import logging def pytest_tavern_beta_before_every_request(request_args): logging.info("Making request: %s", request_args) ``` ## Tinctures Another way of running functions at certain times is to use the 'tinctures' functionality: ```python # package/helpers.py import logging import time logger = logging.getLogger(__name__) def time_request(stage): t0 = time.time() yield t1 = time.time() logger.info("Request for stage %s took %s", stage, t1 - t0) def print_response(_, extra_print="affa"): logger.info("STARTING:") (expected, response) = yield logger.info("Response is %s (%s)", response, extra_print) ``` ```yaml --- test_name: Test tincture tinctures: - function: package.helpers:time_request stages: - name: Make a request tinctures: - function: package.helpers:print_response extra_kwargs: extra_print: "blooble" request: url: "{host}/echo" method: POST json: value: "one" - name: Make another request request: url: "{host}/echo" method: POST json: value: "two" ``` Tinctures can be specified on a per-stage level or a per-test level. When specified on the test level, the tincture is run for every stage in the test. In the above example, the `time_request` function will be run for both stages, but the 'print_response' function will only be run for the first stage. Tinctures are _similar_ to fixtures but are more similar to [external functions](#calling-external-functions). Tincture functions do not need to be annotated with a function like Pytest fixtures, and are referred to in the same way (`path.to.package:function`), and have arguments passed to them in the same way (`extra_kwargs`, `extra_args`) as external functions. The first argument to a tincture is always a dictionary of the stage to be run. If a tincture has a `yield` in the middle of it, during the `yield` the stage itself will be run. If a return value is expected from the `yield` (eg `(expected, response) = yield` in the example above) then the _expected_ return values and the response object from the stage will be returned. This allows a tincture to introspect the response, and compare it against the expected, the same as the `pytest_tavern_beta_after_every_response` [hook](#after-every-response). This response object will be different for MQTT and HTTP tests! If you need to run something before _every_ stage or after _every_ response in your test suite, look at using the [hooks](#hooks) instead. ## Pytest fixtures There is some support for Pytest [fixtures](https://docs.pytest.org/en/latest/fixture.html) in Tavern tests. This is done by using the `usefixtures` mark (see the documentation about [using Pytest marks](./marks.md#using-marks-with-fixtures) for more information about marks). The return (or `yield`ed) values of any fixtures will be available to use in formatting, using the name of the fixture. An example of how this can be used in a test: ```python # conftest.py import pytest import logging import time @pytest.fixture def server_password(): with open("/path/to/password/file", "r") as pfile: password = pfile.read().strip() return password @pytest.fixture(name="time_request") def fix_time_request(): t0 = time.time() yield t1 = time.time() logging.info("Test took %s seconds", t1 - t0) ``` ```yaml --- test_name: Make sure server can handle a big query marks: - usefixtures: - time_request - server_password stages: - name: Do big query request: url: "{host}/users" method: GET params: n_items: 1000 headers: authorization: "Basic {server_password}" response: status_code: 200 json: ... ``` The above example will load basic auth credentials from a file, which will be used to authenticate against the server. It will also time how long the test took and log it. `usefixtures` expects a list of fixture names which are then loaded by Pytest - look at their documentation to see how discovery etc. works. There are some limitations on fixtures: - Fixtures are per _test_, not per stage. The above example of timing a test will include the (small) overhead of doing validation on the responses, setting up the requests session, etc. If the test consists of more than one stage, it will time how long both stages took. - Fixtures should be 'function' or 'session' scoped. 'module' scoped fixtures will raise an error and 'class' scoped fixtures may not behave as you expect. - Parametrizing fixtures does not work - this is a limitation in Pytest. Fixtures which are specified as `autouse` can also be used without explicitly using `usefixtures` in a test. This is a good way to essentially precompute a format variable without also having to use an external function or specify a `usefixtures` block in every test where you need it. To do this, just pass the `autouse=True` parameter to your fixtures along with the relevant scope. Using 'session' will evalute the fixture once at the beginning of your test run and reuse the return value everywhere else it is used: ```python @pytest.fixture(scope="session", autouse=True) def a_thing(): return "abc" ``` ```yaml --- test_name: Test autouse fixture stages: - name: do something with fixture value request: url: "{host}/echo" method: POST json: value: "{a_thing}" ``` tavern-3.6.0/docs/source/core_concepts/flow.md000066400000000000000000000062071520710011500213630ustar00rootroot00000000000000# Controlling test flow ## Adding a delay between tests Sometimes you might need to wait for some kind of uncontrollable external event before moving on to the next stage of the test. To wait for a certain amount of time before or after a test, the `delay_before` and `delay_after` keys can be used. Say you have an asynchronous task running after sending a POST message with a user id - an example of using this behaviour: ```yaml --- test_name: Make sure asynchronous task updates database stages: - name: Trigger task request: url: https://example.com/run_intensive_task_in_background method: POST json: user_id: 123 # Server responds instantly... response: status_code: 200 # ...but the task takes ~3 seconds to complete delay_after: 5 - name: Check task has triggered request: url: https://example.com/check_task_triggered method: POST json: user_id: 123 response: status_code: 200 json: task: completed ``` Having `delay_before` in the second stage of the test is semantically identical to having `delay_after` in the first stage of the test - feel free to use whichever seems most appropriate. A saved/config variable can be used by using a type token conversion, such as: ```yaml stages: - name: Trigger task ... delay_after: !float "{sleep_time}" ``` ## Retrying tests If you are not sure how long the server might take to process a request, you can also retry a stage a certain number of times using `max_retries`: ```yaml --- test_name: Poll until server is ready includes: - !include common.yaml stages: - name: polling max_retries: 1 request: url: "{host}/poll" method: GET response: status_code: 200 json: status: ready ``` This example will perform a `GET` request against `/poll`, and if it does not return the expected response, will try one more time, _immediately_. To wait before retrying a request, combine `max_retries` with `delay_after`. **NOTE**: You should think carefully about using retries when making a request that will change some state on the server or else you may get nondeterministic test results. MQTT tests can be retried as well, but you should think whether this is what you want - you could also try increasing the timeout on an expected MQTT response to achieve something similar. ## Finalising stages If you need a stage to run after a test runs, whether it passes or fails (for example, to log out of a service or invalidate a short-lived auth token) you can use the `finally` block: ```yaml --- test_name: Test finally block doing nothing stages: - name: stage 1 ... - name: stage 2 ... - name: stage 3 ... finally: - name: clean up request: url: "{global_host}/cleanup" method: POST ``` The `finally` block accepts a list of stages which will always be run after the rest of the test finishes, whether it passed or failed. Each stage in run in order - if one of the `finally` stages fails, the rest will not be run. In the above example, if "stage 2" fails then the execution order would be: - stage 1 - stage 2 (fails) - clean up tavern-3.6.0/docs/source/core_concepts/from_python.md000066400000000000000000000021151520710011500227520ustar00rootroot00000000000000# Calling from Python ## Using the run() function The `tavern.core.run()` function calls directly into the library, which then calls pytest. Any options that would be passed to pytest can be passed to `run()` in the `pytest_args` argument. This includes things like paths to global configuration files, and extra arguments to pytest or any other pytest plugins that you may be using. An example of using `pytest_args` to exit on the first failure: ```python from tavern.core import run success = run("test_server.tavern.yaml", pytest_args=["-x"]) ``` `run()` will use a Pytest instance to actually run the tests, so these values can also be controlled just by putting them in the appropriate Pytest configuration file (such as your `setup.cfg` or `pytest.ini`). Under the hood, the `run` function calls `pytest.main` to start the test run, and will pass the return code back to the caller. At the time of writing, this means it will return a `0` if all tests are successful, and a nonzero result if one or more tests failed (or there was some other error while running or collecting the tests).tavern-3.6.0/docs/source/core_concepts/marks.md000066400000000000000000000303051520710011500215250ustar00rootroot00000000000000# Pytest marks with tests Since 0.11.0, it is possible to 'mark' tests. This uses Pytest behind the scenes - see the [pytest mark documentation](https://docs.pytest.org/en/latest/example/markers.html) for details on their implementation and prerequisites for use. In short, marks can be used to: - Select a subset of marked tests to run from the command line - Skip certain tests based on a condition - Mark tests as temporarily expected to fail, so they can be fixed later An example of how these can be used: ```yaml --- test_name: Get server info from slow endpoint marks: - slow stages: - name: Get info request: url: "{host}/get-info-slow" method: GET response: status_code: 200 json: n_users: 2048 n_queries: 10000 --- test_name: Get server info from fast endpoint marks: - fast stages: - name: Get info request: url: "{host}/get-info" method: GET response: status_code: 200 json: n_items: 2048 n_queries: 5 ``` Both tests get some server information from our endpoint, but one requires a lot of backend processing so we don't want to run it on every test run. This can be selected like this: ```shell $ py.test -m "not slow" ``` Conversely, if we just want to run all tests marked as 'fast', we can do this: ```shell $ py.test -m "fast" ``` Marks can only be applied to a whole test, not to individual stages (with the exception of `skip`, see below). ### Formatting marks Marks can be formatted just like other variables: ```yaml --- test_name: Get server info from slow endpoint marks: - "{specialmarker}" ``` This is mainly for combining with one or more of the special marks as mentioned below. **NOTE**: Do _not_ use the `!raw` token or rely on double curly brace formatting when formatting markers. Due to pytest-xdist, some behaviour with the formatting of markers is subtly different than other places in Tavern. ### Special marks There are 4 different 'special' marks from Pytest which behave the same as if they were used on a Python test. **NOTE**: If you look in the Tavern integration tests, you may notice a `_xfail` key being used in some of the tests. This is for INTERNAL USE ONLY and may be removed in future without warning. #### skip To always skip a test, just use the `skip` marker: ```yaml ... marks: - skip ``` Separately from the markers, individual stages can be skipped by inserting the `skip` keyword into the stage: ```yaml stages: - name: Get info skip: True request: url: "{host}/get-info-slow" method: GET response: status_code: 200 json: n_users: 2048 n_queries: 10000 ``` ##### Skipping stages with simpleeval expressions Stages can be skipped by using a `skip` key that contains a [simpleeval](https://pypi.org/project/simpleeval/) expression. This allows for more complex conditional logic to determine if a stage should be skipped. Example: ```yaml stages: - name: Skip based on variable value skip: "{v_int} > 50" request: url: "{host}/fake_list" method: GET response: status_code: 200 ``` In this example, the stage will be skipped if `v_int` is greater than 50. Any valid simpleeval expression can be used. #### skipif Sometimes you just want to skip some tests, perhaps based on which server you're using. Taking the above example of the 'slow' server, perhaps it is only slow when running against the live server at `www.slow-example.com`, but we still want to run it in our local tests. This can be achieved using `skipif`: ```yaml --- test_name: Get server info from slow endpoint marks: - slow - skipif: "'slow-example.com' in '{host}'" stages: - name: Get info request: url: "{host}/get-info-slow" method: GET response: status_code: 200 json: n_users: 2048 n_queries: 10000 ``` `skipif` should be a mapping containing 1 key, a string that will be directly passed through to `eval()` and should return `True` or `False`. This string will be formatted first, so tests can be skipped or not based on values in the configuration. Because this needs to be a valid piece of Python code, formatted strings must be escaped as in the example above - using `"'slow-example.com' in {host}"` will raise an error. #### xfail If you are expecting a test to fail for some reason, such as if it's temporarily broken, a test can be marked as `xfail`. Note that this is probably not what you want to 'negatively' check something like an API deprecation. For example, this is not recommended: ```yaml --- test_name: Get user middle name from endpoint on v1 api stages: - name: Get from endpoint request: url: "{host}/api/v1/users/{user_id}/get-middle-name" method: GET response: status_code: 200 json: middle_name: Jimmy --- test_name: Get user middle name from endpoint on v2 api fails marks: - xfail stages: - name: Try to get from v2 api request: url: "{host}/api/v2/users/{user_id}/get-middle-name" method: GET response: status_code: 200 json: middle_name: Jimmy ``` It would be much better to write a test that made sure that the endpoint just returned a `404` in the v2 api. #### parametrize A lot of the time you want to make sure that your API will behave properly for a number of given inputs. This is where the parametrize mark comes in: ```yaml --- test_name: Make sure backend can handle arbitrary data marks: - parametrize: key: metadata vals: - 13:00 - Reading: 27 degrees - 手机号格式不正确 - "" stages: - name: Update metadata request: url: "{host}/devices/{device_id}/metadata" method: POST json: metadata: "{metadata}" response: status_code: 200 ``` This test will be run 4 times, as 4 separate tests, with `metadata` being formatted differently for each time. This behaves like the built in Pytest `parametrize` mark, where the tests will show up in the log with some extra data appended to show what was being run, eg `Test Name[John]`, `Test Name[John-Smythe John]`, etc. The `parametrize` mark should be a mapping with `key` being the value that will be formatted and `vals` being a list of values to be formatted. Note that formatting of these values happens after checking for a `skipif`, so a `skipif` mark cannot rely on a parametrized value. Multiple marks can be used to parametrize multiple values: ```yaml --- test_name: Test post a new fruit marks: - parametrize: key: fruit vals: - apple - orange - pear - parametrize: key: edible vals: - rotten - fresh - unripe stages: - name: Create a new fruit entry request: url: "{host}/fruit" method: POST json: fruit_type: "{edible} {fruit}" response: status_code: 201 ``` This will result in 9 tests being run: - rotten apple - rotten orange - rotten pear - fresh apple - fresh orange - etc. If you need to parametrize multiple keys but don't want there to be a new test created for every possible combination, pass a list to `key` instead. Each item in `val` must then also be a list that is _the same length as the `key` variable_. Using the above example, perhaps we just want to test the server works correctly with the items "rotten apple", "fresh orange", and "unripe pear" rather than the 9 combinations listed above. This can be done like this: ```yaml --- test_name: Test post a new fruit marks: - parametrize: key: - fruit - edible vals: - [ rotten, apple ] - [ fresh, orange ] - [ unripe, pear ] # NOTE: we can specify a nested list like this as well: # - # - unripe # - pear stages: - name: Create a new fruit entry request: url: "{host}/fruit" method: POST json: fruit_type: "{edible} {fruit}" response: status_code: 201 ``` This will result in only those 3 tests being generated. This can be combined with the 'simpler' style of parametrisation as well - for example, to run the above test but also to specify whether the fruit was expensive or cheap: ```yaml --- test_name: Test post a new fruit and price marks: - parametrize: key: - fruit - edible vals: - [ rotten, apple ] - [ fresh, orange ] - [ unripe, pear ] - parametrize: key: price vals: - expensive - cheap stages: - name: Create a new fruit entry request: url: "{host}/fruit" method: POST json: fruit_type: "{price} {edible} {fruit}" response: status_code: 201 ``` This will result in 6 tests: - expensive rotten apple - expensive fresh orange - expensive unripe pear - cheap rotten apple - cheap fresh orange - cheap unripe pear Since 1.19.0 you can now also parametrize generic blocks of data instead of only strings. This can also be mixed and matched with items which _are_ strings. If you do this, remember to use the [force_format_include](./config.md#including-raw-json-data) tag so it doesn't come out as a string: ```yaml test_name: Test sending a list of list of keys where one is not a string marks: - parametrize: key: - fruit - colours vals: - [ apple, [ red, green, pink ] ] - [ pear, [ yellow, green ] ] stages: - name: Send fruit and colours request: url: "{host}/newfruit" method: POST json: fruit: "{fruit}" colours: !force_format_include "{colours}" # This sends: # { # "fruit": "apple", # "colours": [ # "red", # "green", # "pink" # ] # } ``` The type of the 'val' does not need to be the same for each version of the test, and even external functions can be used to read values. For example this block will create 6 tests which sets the `value_to_send` key to a string, a list, or a dictionary: ```yaml --- test_name: Test parametrizing random different data types in the same test marks: - parametrize: key: value_to_send vals: - a - [ b, c ] - more: stuff - yet: [ more, stuff ] - $ext: function: ext_functions:return_string - and: this $ext: function: ext_functions:return_dict # If 'return_dict' returns {"keys: ["a","b","c"]} this results in: # { # "and": "this", # "keys": [ # "a", # "b", # "c" # ] # } ``` As see in the last example, if the `$ext` function returns a dictionary then it will also be merged with any existing data in the 'val'. In this case, the return value of the function _must_ be a dictionary or an error will be raised. ```yaml # This would raise an error #- and: this # $ext: # function: ext_functions:return_string ``` **NOTE**: Due to implementation reasons it is currently impossible to parametrize the MQTT QoS parameter. ## Using marks with fixtures See the documentation on [fixtures](./external_code.md#pytest-fixtures) for the details on using Pytest fixtures with Tavern. If you have a fixture that loads some information from a file or some other external data source, but the behaviour needs to change depending on which test is being run, this can be done by marking the test and accessing the test [Node](https://docs.pytest.org/en/latest/reference.html#node) in your fixture to change the behaviour: ```yaml test_name: endpoint 1 test marks: - endpoint_1 - usefixtures: - read_uuid stages: ... --- test_name: endpoint 2 test marks: - endpoint_2 - usefixtures: - read_uuid stages: ... ``` In the `read_uuid` fixture: ```python import pytest import json @pytest.fixture def read_uuid(request): # 'request' is a built in pytest fixture marks = request.node.own_markers mark_names = [m.name for m in marks] with open("stored_uuids.json", "r") as ufile: uuids = json.load(ufile) if "endpoint_1" in mark_names: return uuids["endpoint_1"] elif "endpoint_2" in mark_names: return uuids["endpoint_2"] else: pytest.fail("No marker found on test!") ``` tavern-3.6.0/docs/source/core_concepts/reports.md000066400000000000000000000020621520710011500221050ustar00rootroot00000000000000# Generating Test Reports Since 1.13 Tavern has support via the Pytest integration provided by [Allure](https://docs.qameta.io/allure/#_pytest). To generate a test report, add `allure-pytest` to your Pip dependencies and pass the `--alluredir=` flag when running Tavern. This will produce a test report with the stages that were run, the responses, any fixtures used, and any errors. After that, with allure installed, run `allure generate ` to generate the HTML report. If you don't have allure installed, you can run something like: ```bash pnpx allure generate ``` This generates a test report in the `allure-report` directory. The report will contain all the tests run, and for each, test, a list of the Pytest fixtures used, and any errors that occurred during the test run: ![Report overview](./testoverview.png) The YAML definition for the stage, the request that was made, and with the response are included in the report: ![Test details](./testreport.png) See the [Allure documentation](https://allurereport.org/docs/) for more information. tavern-3.6.0/docs/source/core_concepts/testoverview.png000066400000000000000000002714621520710011500233550ustar00rootroot00000000000000PNG  IHDR pHYs+tEXtlogicalX1083.2ܒtEXtlogicalY271.2; IDATx^wxS7I-B{oDA{TT?EܸEDQeZ(6Iu4M/)mK+xsmzԼskө!B!B!B!uEYB!B! EYB!B! EYB!B! EYB!B! EYB!B! EYB!B! EYB!B! EYB!B! EYB!B! EYB!B!B^FElM%@ B!Bn 8 R0Z=FRl.B!B! I%h,%FK˛ed0]^A!B! j꼽R^w\Y`3R%B!rsX\hl򊫣`DK!B!Dd0KeQdXB!B!u(KMFYayDy"B!Rט%(GYVN!B!Ze6Si !B!NXݢU!B!NXńB!B"Ybu_,!B!(K!B!e !B!3e !B!3e !B!3e !B!3e !B!3e !B!3e !B!3e !B!3e !B!3e !B!3e !B!3e !B!3e !B!3e !B!3e !B!3e !B!3e !B!3e !B!3e !B!3e !B!3 yA8N(*W0LޢI( fZ-fQB!0Gj0 ONWe**17܌EQlA~ Ҋ˅X(FAe:F%/㘂S)*l.)5M!B!p GzG):,\dw6%@ Tw6)2xt8Sږ*e\ŷeR7WFRL]!B!䚘^hWD(/NWG9{ëGh Þ  XuZ<'o_Uyc)xǗqc.+>"JB!`:^GƇke\TNl$05gM1#+8)MFY?&DYw}ɱtE B!BqhWu PD5jbzHc)q%CmGYN__ZB!Bn~1]݋C+1VrKx@i+(+RZnw~cτ,L^t P*@zxۻ8]̸3=|C0 !Bo&Eݲ)q>"|ُ0bT>dlaЩߐ`W9|k5q‹lQ8lZq_ 12&4,\fO8ڄsFѵKЈ+Y9]XrzCkSvOeo @t:ՒQ}c[D|t%ƒsl/X+]=|څ),9`vz{CM"",ˑ_m)>se-0&+(*F1ϜL]ӰXr=jh`YqC1};X~fq\ok@;R#!BobjN;!BL/$ d o lbM̫d?"|З5A/ Bɟj{kk* Gwv߁#ލjFgFz$DZ(]'pB}8{LOƬ\l<yE(31^ | ݐ8bh* -&ګ/_&_cޝoc?Lh~cT⣩M|Y0ۺ^ZUZ2۸OMP7?wo 1 eK_u:S̻qS/!:5B!R_p*#+B~dT"Ww\(@k XCڇfD;۳ܳk])(֞B"hEK7vlepdwCk I]:3(GiNN./_DpV IS*|p]g,ܤλe5v%G;b,emR8ϷR̻o:̲=>䲶7d09PзJ+ǫݴ7P ^5U@ ZXo$U^lٙ9Ǚ:ŷ]eɍ;uP$ڄB!Fc]9j;Rd?e3W֧HSHZjƶb?_&:l6|cکI7oKp@{ j=b% K:>;3Rt\6iD xtQr$9rذ͗cS&|훭s]eh_Tww(?8n\Gݜn5{Q.3s&Oj7jƫ7BrcϕbG5oޔ( XX[h6=#Fȱk.ޘ4#=ey3xsG<7ͩ0# C@Lp[ŋYV2d}-yd 6_=Öc9!B2O;&L^ZmU,+EIRP65/dZ/%?zi([K|y4qH% 7?}@P{cދO>|m=u$3$G94~"G!ln??`ݎjD@9=iBGA@(_:gZg~a9=<v0mХ[;O|6W͊yoΟ9N;hџ `ޙWQc]Ty-i`¦汘žƼ w"B!EA!^ERjJȷJ:%ɒ&50?k񜼪([KvR:ovxESF~14@PDK!BnU~AחcG,k%ȌŇ&wxccFgV [?tEekCs.0ʻy{MenZM"UŒ$U\m =J6{}c:(`uk[#"pE q(5xԖJ~(H -Qsrf57tX 7Puȋ!BȭDO/X^ZV"SHu{嗲/`@O[@p? TRKVE<~nj'UD^]Pin!Z,͐aC_Z;ݣh]~+6="+oTs 6Os`:@p^w$^]*O_Muˎk}Rrjc}Oqv<:mڏu{wTOҮZѽaպj|sm#9Դ؅с6kFVٚ]*u~cҝUqPw?!B?jpy5I{/i'R6mtW{^5F5#,Yc2G@4Οڢ\?m𢰻Puރe&77gPv|q|%H/B4\p O>=`.XBO"65d-e^*;>3o|B!zI/XaMʘ7dp&/wd)q܀ՍZ6]FER ˶şYefkK9G(_Ҿ_ F{ޭq麭8!W-KNO}{X/amGXϯ^7gX۽6Y |'wS?>=ԡDż H79V TȞ w>X9ݬi?(B}쨑M583IHIN[,Ip}xW+I,ۏ7/߱@O[G2/ U|񑘳e6w5h> 1 O\`f_B!:Ԫՠ3 ۡRܘ RA^~68 ښFuKl^'WXu2d0Ot?PR fc{Ă .̟##fqeB!R?0VwhqC1; hZADz~'ze}|w+f6In9֡=u)*q*S)VYf4ig>奄B!Ԝ[ V3J=IivhtZ_eZ*i$]kݮѨ0y#R/=C!BH(9e]QA̷ZKN^NrYQV>MrLTO*!9ˮ<˺rlњ%^ a6~Sm"L>vyuR5,o3iR%,<%^DFhL_זX@}}z<>-$6Z7ii~l|q)itnmc5C6of5>O8z< ;)ﰢ6b,ߚU?ޗLu,Cψ] #L{yK[#KTk8NjֹSA3hLL}V|?MSnwos_XFvfobiobչȲ&3Ge5en`K>xqCu^u)踰񏌹Rf}͋wEhk=l~fC!U'Z,Y7vvH//HLoy鿏l-3!$  ?kk%*4Dɛaxc>VSg-Ҳ\Wb|3~ED7N+|} VynY`sBY̐bzh9cQ(+7S C9֩ ˏp⩊sIsدDB&4LUxOH]VEH+f\ޱ# ?`ѝ{*{~_ش_ַ籥o`@/l9!&4iڇ{E )iK_Rv ǀ("TJ2?t)q (lP%o(ai|qNPsV|ۣ&=uF{+uǧPT"_}B~Ȅáw|ƊKD.YY^MzDcX!wiW,8g: ( {& )TW!4gnl{9!B?5p7ȋnmdCQ Pq*1*QamtZ&ƷQfn_ jm΃S^1ů/>i@0?S1dYUOƁWb >q杻:Ul'녗3%=748W`W4V{XODDfz#[LvYRһ^,|o}uZ%0>M{yB9Yj0ϑ4#\+Si\9(gvlҬ˹BW r֘e#{t96k}cθNİq~9cb5 ԧU9  0t!Mt+.ކ7ny(E|[h3dz+bU^O2ܝ\^γTN ;A :e;ؑ}2z&tk]nذ$󌣤Wv(oYmoꋟ`[dF;(}d<:om]JQT*%Sh,%JXTV0C@DyT0:}CO,_==$W%%EOo v8yH'Fh#}?hcŻ;x&^ CQѳcx sI<6TXޡ-^μ"ty5y¹LTVFgS^R^Wh1x3j~lܚ0ƶorא~;(?k݈ޘB!Ce=:r!ZeƢ([{<8ܬl #s8RhA+tjɂPSdoqd-=c&BtˊUJw"fBIJ7?މOΓwD=?&+r]f(Zm6IR_^,'L3>p'>ݨڼeXi><=FSm)!;(: ZUL~JIk>x 3|b\i/k~CGr<3W#pꟛ授iuӊnEhf*d D[26dfA\w\euˮI%+׳Yc>!B?e$QXvu(1kS+?[3.fI 11w_'* p̢[ǣ9WO?ؽim,:耘} ︽{^[78c߾]O-ֳw"vl7mQR RO='W?Sx鹑1J_]`.( O I˫{*=}%9ܫV8ÕcCFq'Ƙ$U;WٓF>[j֑#bad.q #ܛUzo.)wإKiI#[^]h4ݐQe^#4]rP$@ku`fC>yŜ-{ B!\h_<+˭M\+_b.DMQ֒ShKubʏlPjo|Qwvd{7i:M; m0SvѤSK74zld_@JJ\JKuz>yr+p{[eёM8ovs~oѹ4j+sLRu8mE{M\:BlE[osk _?R),ҴObRȵTyoMnZGV P}OV~z !,ܯi|ǩ**vsP#-[pR󝓳9%Kx&GR2Ɔ g3 o;fh_֠`s\/ V5|rY*wjcJjbyukuj S-9#˪ӲN[v`91@GN}߷bIa' ;4'n*,Jߗ2 )6n=v#F~V,,z}&_:Vɦ~Bֻeʃ"< qה'%J~O:ω<4fT(qQЗFMܡ~D8S_SU`Au}mb5#иmg5p`\M շ28˂/JT"NXMut};$]6kˆ}фB!5ŴUL T+DoL"^UZ k9u?[_?ywTeJ#qh/,ٞ]iPgƫZ. I [ Z0o $.|(K5;JO?Kr1ݏhpJH#E|7{{_:ow{Dq[Sa"Xe3].댲W"Wooo҄ luA V4gA+]4+m+8"a`M~c&yRQMvCъe%349d%cNj'bny;?;HEɒcⶬL .?h2xЖOn\tK7,CG&X~Ng"Gx4G8Yu( w\||Z) Z2C_΃h@P_X~R^﷼LW~g!n:g^rm$D?(RA_@p`W6c dո!8nZV Uʍz^htY I9ZMj5qrcH^~΢D:U^d cEUOAcؒw9!s\/- ~q$a`kk c:jB!0<$UP#¼0{K% aܗa)2|[IIaࣹmR%eJe8Hv؝ue2.~Խz+o|B`!,  󤢃nZec;lnK1/Rw…ys Bs "ٸPϼ \ou ܞ)bqTt ?iĂRnm5ωER)׺W  ^ (;C2U)! OQsܞ5.|#?\CsM&BtODUB|ɏ|O/]ńۗ̈wpB<'6 q:f m}II$ p~3[Ǡ l>h}nΥXlXs.~X~radЅzƎ%{¥'4}yw}臿3 E^p3+,yQX.\,J^Z= ?|[Z핵٬:lUw{&fC)W*$xjKrXKe$ lH3WBsb9 \?*ajt}|ӋQW ${α;)[[#ɋ{"D2s!Vq%宪, 嬘vV^"%!eiDn~Nվtk Ct_8\KaB!̫Cr5@qPTl>LQ8e]U{6=tp,G+~Ι=u@iʢXks?7,T 7MKu XgüO}iNϒr7:;,2s^0Up3`Sdl[x|;`:h50 @'O拦}8x82|cjZZ/Zܚt  <]%Uyv-߳fgm]' B!F`j,O\)u{ 99T`7~A%؀Qi2KT)p[zF yoq*;qj`.q`Sc&6.7@x8r(%>0K-%utez_SЀ/g[ޢ&lŒ?B!NYR~ぇ?RŠ/O^j917i S3vLQPn9%|۠ IDATP)(hSfz)$zo?!}~ˏ lIM/+j5Z,RQTq|mH쥦k켥ѿz/c,tb 5B!4%M{k糇Lvh HϽ%ѩ5Р3O_| Zn޸P޺͛iOJI,8g{nMW*~;*Lm/ t<@@syj`)PDy_ 1O]h䃇[rZ3ꔛ)MǗqU5RGHh2nug' !BMd*Bmܑ*%s1J{dJ.MM;`p\PZ"*~0ǖ<[v}ǿ}{X kA96)XɆ toH<>khdϻ|l̕s|o0бOnp_/L6g`l"QzA@"PɊD.r,!B$[c+O;,* Qŋx#|v6(PfݟEU. A鸻crni?bxOcr JxUIV~&ι;K:,68iͰH? ÖoNM_\E&#sBͿIDqLPh(~V!B!7?Wծ#EROo^׶(-7q#No>pP7aS_O(~;6z^Cu^f^shtbE\?QQ0.s#[gV/!Bȯ{s:a^K*Ԍ\& dY-AZE,RnfR+l)k7f}iրHQXx4Ey͗[JlѻS6+e'\oMphv$z/wC`f2,ã2ş3!b-r/ KK+=^Z*l} 4q r4-v뷎l|8nr&3*Xwj`ӁXHRWMc)KDDDDDT}Re:J{iajzm':9Ъ|c jQR n)LK2p(?o}dmcݷs`>V\je|r g͒2lb3VAW$"&fRZ:0_?]䒩>]jP]˾\v!Ue_^2fܘ aWUkϽ}CvPI+Z %}{@㐶wCf [FOe~ʲ8&j2[Dd,~Y6Iʢ̀QFS6y9[_^<m~)Fo"f OFMLoۅ@s~unb%mif)ٱV!Ȑ^^-e 1MŕxdI6^bb#xQF JI`8 9]Qán .(B{U${q;p^ )ÒQ2z8_,S#4<Ҭ's.+usWOݙ<u\bZwƄ(0oKSnDDDDDTHM#hy'BiO;• XfR#ThWF#i\P"SE*RͼƟ%R#`7mja U"ܧ0"TDDDDDTOYfWq'nzBi12Z[lx2z㰔%""""sl hMo*J#lnꡌW@J5I uDDDDDDHʶV}6c>#yn*{#ԑUc&y6eb)KDDDDDPL2Z1MSOMwrA] k=on\P1tZ4؁,wfebn74*BqcUNbֳy歵B &LƂRK l'_GPWOpWiIJ4)JnS#mUwj6~\pKE%"""""t٤䣌WLP 枪([ƪU/Ά lr%"""""*KʲړLvނp:mW_d{Qtqj\P)`7|/[ n(DDDDDD2,RE[RJP7TlRFm)Ta^*vWqRU<\3XOJ1KYVu+ϫ z5|1nޕ8P `)KDDDDDtuL8uk/bVV5mJsv*WR @/̉jL^V6KCٖRUfRzXd[d jM[ou[igDzm;U,QusmNZ[inu]:vED\M_c)KDDDDDt]ȹ6LL]nꡊtWGzTˌRg⍶8jRHZ%"""""dz"z"T*?}5V-_ЈΫHf;,,ld]ʱʙV{U2˙V`wn\%"""""A&=َ}iT_.{*VK7蚈n*VKY""""""Z,1,ea)KDDDDDDu KY""""""cXQRDDDDDDTǰ%""""":,1,ea)KDDDDDDu KY""""""cXQV*Sk4:ZeA‰ vd-fѐ/T?T=m3T1é-DDЮ@Yw"JN_w^>~޾|VB켜,傛 I9mD8tWr wzC8= 'ï͝-DDT.kc:inCZJʩ5ڀ^~ ˂_@pF\VS@CE׫s?Kz^{@IDDT7޿Lڐ3D@D$(3ᡡi+2TN2Vs>黷7VFo"j ߹Ԡ#[_N5ȵ٥˷*i23RҏSѝGrWAj4`hfzZjF Of\~6N|uƚKy4^M&@~xU^XѠZzt֑Q[HY1wUV<e *pFJru^:承!gDO?Q}~߼'G%uq%yfuo՟cKָJ D|4m{8nE$&Kj57{F>gp r.FȓVqF -1=?dM|d#$H~5sΏа[~ekƯ ߒ3AʅCP^ܬ_yo)Wg!?|8r˔ <秝Z] [M]~_ *abDw{yGR*4p6*`nG= B;15-d8<9<|O-Cۻ5kn-<ʯL%aq6.TD8am':K;FxEf TtVq.r*_PnwUVS;Fw!nLl 3jnvT~MoxAm\flDDT]ja)[(} mSm+q_Nl=};vnקfDG}{Ɩ]u(^=جĸ{$eHR-Lَ#fd ⤕7c͙I3ZJ˙Ryz:7陇r<:'fJz/=J!<} sk`><:1ki _Rʚϝx;bshj!,:Zj\@XaP ңokuDC=%ꓶ ~ ٵ2\չ߀ ʐ|DM^zìvc۝5]p#s`.P{tɩVaP3U(VMM5l8boP#YTYSozx8vֆ4 glpUI:O|tTUЭ}ŠvNj,g4x ^%qc9WN5R#u~9YȐþmv wtф758bޔ%;a"[j}9Q!o{:t|;hT`J XĄV<u-'{Feo5;uU OVU*|GWMCM.֨8iK.}$jqtcqa[g=vՆxv1^$ d$bbض^68gxY*;e xqF&yPTVe!3\O GRcȗgVmnz/CEqڣV?DDTjo)  dŠލB=`0(Dއ3% ^6AZ??E3ۓ%]W/lj܇2Þ v˲w,Q.nn/-"Ĭ]{G'3=zl3o5ox鐩{4{ZDܮIxz:G@/۾[}N}M@^msԒcƒe{TIZ%ul|ܿrfvĀS|Iهnd. j .\85G,Ajqdz?98x^/o<|EFgmKdG IDAT P0e{ANVf'?ы_^c9`Ԩ>*v_{*Kpuぱ~؊ ?&1-ݴ ٯ22oK^ܘ\<.`r#ͳ,n֑m^}odm0i\nfi+3w [w(ɱh7E |emOY^g0t@{DE}=:56ɂeyzyv8?"S4JX,  sLM 2M2 \W N5zLWven_*B+gLpvܿ]p`R=n (@ A]h͙t'~r:E5Po7ؿ؟/˫愨성3&[ ∃>b!EeeerSa ;3lyr o~w}zCΞsc|0?sf-Z#_}kCS/C׮Jq܆*M&E\|/`4_:wP1}[v#Nj&g>mY\C"z_^mŇH bH)O3_<9Í[z;)3]Ӭ+5co'lJݷ^6dkrS^Xd}E#<\Ԣk^4ڷ9ʳ3'MPYO9_ gP%d5# ͪmFRIҪuЏڷޟz~=ѩo퓮,aP~yf4` F̹>tu{.U!|?|ۧr~gn_7qʋy򟁙c4>!.g!ޡ{nwصGԥPaV廥4fx3@N_}G=y}gÖRIb<$"jT{K1}+tdh} 7.~n?r#ǭEې{'ա7M7Dɠc3Kjѿ7-ϊO r:oKq}!v]2DۭQ{*˪=p%X2/i\P1sKl`ߟ_ D3sl^5Y\]s(ǽf -- _kI/9]< t6()1T6]|'P8J.m㎘ >)oSvwol慇y˂ 8;~kF6Kr1ak 5]̓Y @Οrfᙢ*G nj;ˢ1)E'Mk/Xc6MWFl8JY' mӖkًˍ? ̹6{.Uovn9,{l\=ÚD'ܝtT w-8Z R~XQ4.0$)+[Ό?cQ~LCl–K]p>5Jziyc|[|X`UE{`}<$"jT{Kـc:@fov,jյv] yy!Y'8p63CD]w$imNSP,!gs!d[R2?1+6Zll,}^bWRIU.ojg,Jr(R E+xF]eIU6")_;oЎ ~?`̈P]WxLR`)ph7~WBѳEX[D_/Q@kZy]v4$9័=4"/?6ϯ[_/S$L?S~aB0g ,fr] E,Ԣ/FXOW_i ז#cB؃72\baʹÇGi7\zy@.!ޢ:A\ )m`oQRvWE% ߪ @H T=9Tߤ-rAKހp]Y j ;93\ 9lųa`K) 6BZ.)H(^  T ~|˴ǻ #[~[3I!  w8[;JPY _G`<$"jT wvӺ[Io+OGUvIbx%V?`vwN}LQG.-~ә_^=W}9ۤ/gWi$Dk~f?`'ZvWA=:to]2X6M)<-M|dknǷ>ٓs򘜓)Gw/pj~i'{ذw ЇhXK$}EEzZ. 9hiRugSeg4/XYY `# @`7Kc{߾sQN:ىg_p;.&ӭ5ɯp,&~P;5]9GDΎlڹнT"ZY---%.*Gz cd[SJ:*;4j3\4rp_gxs,`<$"jT{KY'OQcw%8up\K}OhE$`MJ_Sޘ }JK7V7 `T@6SqM:C﫤`u*w爂d%Zh-8/hE_j-<6<1 \}Fql7@ܱeM|s(xueX85Ӈ1CHHUWe"קG}zJb:wojڊ u TVʞt2t2^AsG/>0qAo=[:r6l2Be bla ?3G K*ܱiѳ:5k`|1 '-z!4 s> 7Ό~s:9B\=8=r{?2d.V{{WA>Zy`1`7d~y#_˺ S?^8r sC Ġܗ3!׿k\gϖMn2 /u{JhWF `;srxxY ;urҘoovys_:)D%m:t԰nG>Q9Wr鿲LԱݧSBcΥe WǬ f3  d2sO=s&,Z>"RAA-7;p2Myw+3,mZ7I.>6eI=Y%;iשŮl`7ظ̙TQn}~i3 tBH_ϙ:mVN9[t53`7>pڜ+|e[b|潚/Y֩˂?Kkc`ɇ Wڞ?veje?wWoҿ'Ϝh=΍RI%wRgX]Nxc C:nh/6u*1t9Z=.[*tq{7/ucflmQN[]W3ZewvԘb~}W켤\ ~vOw +̙glW3<5-JK1)a.UqʪODDTmj])$aΕY[8RN'ĉS+^#2iM2$i3&ھ6Yvd1p߽1}|UY׶Ɵx%φE/]Z^{`;mx`]NS su_$=?7OwfkQ tθ1P=v<-ArS F E@nS j%A2#.UX  )}7tE/o(>S!@r֣4h 먀U#-4j9,O+jdT6pCY n*xxyz҃]=v,3eG $KO|b#Rԣ"x>٘/%=4 % 4ņ] Ra {›޲..z󀮳 Jл6%9uTAKl$]> ~P,ّ  Jl),l' ;*TRF墵6Z軈rA}re)A9@u[1+vJYI^SR@ { ~rNҥ7#%,рJ(Gy9RHq%CذIW] \Tphz{9,hBºow"gf \6XyãASݐs@ҿi@.PP*Wf*IPv ӹbC[vR)ʝ5CI n!xz&t≅\g.:)-Su,0[+ 疟T يܣ+C""~%2ԾRfhC+$=3=f]q9xuqe~m޸w'MRӦ6 3BDT9&LF5z$lTkjdg`Sr+Gn;lO_b jJ릿ںK F?9դҦ Qip:f Q}w/S약|<}+B f常@M#&BZʖ8۱jʷOi&} ?!l=u5M-Tu"zk Qһ{juzFR lIVb6 F-ji[E+uUO Ep:f Q}RnV+JnBDDDDDus)[.C/%""""Devc)KDDDDDDu KY""""""cXQRDDDDDDTǰ%""""":,1,ea)KDDDDDDu KY""""""cXQRDDDDDDTǰ%""""":,1,ea)KDDDDDDu KY""""""ct;!)P)cDDDDDT7 zM'~<{6"'&]'SU"VGnMS51w ^4N:G3Mk_5ZʞeG4l#Gx !  ~aS ȖahPre$I?EuSdw|ʍ<@*$ّ)P H% UP~ p& ΎZY Ȓ% B HXƜ@߳GFΜwD H@hi-O"Haw"%\J=u@@ %=Y(yfQXB&7ӎ D֘Vxm‡ -Eoc:ڣUkg^ߘlAO"]:fN2N6yD8LA%Kg1kQ?}{(TI;H~>\0H]__~Yno3Ql<ɪKfޱCcԩf-6[K?r:`_gGi4*ک?Ho6;ފfH3rTsYqH9qi{vͻlKu5j9=|Nh Qg_d15\VhXs^{A{=)շ 2/ d%Z7ȯ9-|yքAi8xY]""""")Ǥn?Rx !ܽi'CxZISAAn?o&;oSf lo| f_}u^#w ~bڭO%@ikF^;ʎАwz>\͚ؽ ,Zm#mk]s5l괉SNopW`6Csfl_ּKG֧`\O^q:ϖӦ ig& 3u< {t[θ\ڲ{si-mG?<;c}PeLїycػ?B΁~{ǮNwtTLKZҋT)"qJ`+ R.5{d9 |?˵{UqE%E^Ӷ:uYuZqNgWhwT.<}fC:o_p+"AJhSil "Kse 6ksjӌ7lmeOWQIiX?/\#jh)\)Y#Q-&߲j5u:R,2i`DklKzt RD}EOm$"".VQ/=vOZs3E-lWvD/E1""|G%ӷh=Y||g<cÒ 5UN7VT1&"QD1T{jc2ΤDr{!;=UQM{yõ㮼n؜Yj#0BD)~QQx$egKs4?S'lUiSa>\kb|}wϜyROř&Z7UopI4ά=[#Re+7u.BBDrKNd);e~L(2ͮIUWWZ1+")??_\\,"UsRaԔ[<$Iokj;>{NOqڃS畊>gTgд j׬]ijf/z({#~tjhߣ84ͻʝw?dط{ HsR)רni "}5>^=Sڷw.x}OxyYOʳ*qGL+Z+QO-'+Ϯm\6mK x{j<11%ߡ j*>Aβi]ZFWΈFX$**QQVDng^Oj48X<3uYt߯pU>_Tvg8U}|{yʵQ?zbX֦qmLvx=,q?]YTaҕ Zo"PyhW}_(gwo /_>}L [g*O],pʭ}*75͋U znx5 ==dH\׮pGj跄\Ԑ•+Cm]zoTcC"(#Gn >:jхX 6ɫ23ioG_}G&:Ű=W^.aGGϜeW_Zq㫿_{Ÿ$B }Oo\RrM6D1"b{.Mwg֪] 'G{/|5tܸ/H&S~84gΎ~[:MXtoD | =)Tܢ;%)lwqS*f'?ŮkGһ[jЌ9 h{d/-۪JiӫߊfBk?>y#tʠ_y{X"YyٵŁqx1rv 1uQt>)bj'g1TBDujyڤ1N k)v^Q.*b'8EDMDp_Wylg'©"V1[ԜRS"*bŷF1w5O\e16WB*fV-YXkco=юtnGpǞi7ȋc[{X6yírǧakfbrVU~O7UpK2ZD !RIv1qe4TBVT):`r*JM^}E1/*O;R|m](kq=//&FJn{׽ }pL&3sG>6:܌cU*g{v]vl@南n<ƮBU|շ9r8䉟cs ~r#D.( "N sLj"N}(g^sv۲M+WգV'Ne7鋗 ׬f}v[X`Hؿ'鋵;j㺮 vP;6E+{bЗ.@MDX?"IFZ2šv7e~W/(RP,]>?ZTѩKㇺ7s4bFYHE$yӊ啕tv|q%jMӇ{,ُ]* 1a'eOp+e|,fHyxh@+IV Jd#v&Ndk?g?qfZ\GhL8QrCz@NZG>rshqU|:}Z})pQ;9a CxM_s7:++>}_<%$oyO@\P#Y= uc lIp;"ҽiܸa"@^QQ}(_("K+..<="&spd%V?%ͩjFRa"NސGFuON5#U,cTW1S͜EpZ%9J[z]G`08E\ "kK&#&VGXŚplbC4/w jqgo3양W:[t?HQQq};K UCF""k߮[0ݵ[+1HS㈵T}&{fJ`p)RE }{RZw:ͮdWE6ǡJ4ạ[ <6o:*W?ZiG";y4ẓduڌ_Vv\ 8O7ڟ-+= QZqM*wdUgekyl-G[pF'5:( p3DY! Qf7C,en( p3DY! Qf7C,en( p3DY! Qf7C,en( p3&ԸYJ[?t>j(q1en( p3DY! Qf7C,en( p3DY! Qf7C,en( p3DY! Qf7C,en( p3DY! Qf7C~E{kZ\բ e}}b57Y̹Yi)G ?00($aɇ;j1~CQZ kٮcNfzRl[YP'mey9Y-K K%ۧ5owcI6[uile)ǒ,X1ZQ6Q#Yi]_ Þ|$Qs}(fRɱ{ZJb γZaYX88ܬа}<(X8h/gz3/? M |j{Z# Qfju?f6[4ǎ9l :eF;n?`@ddji+/UU՟(2umڴv=<|TDc"}{{j"9@ƊXjrW4MYe:eCCC~~@`@vvλύKEۧ[{O۶m~֟|&mctɞu58wn"еD+""Y%aѶpL8=e5D} h7uel+Y˯۟lL)ӗ+("9e1[99u{_g<҆446mt{J~IQm"]n?? J 3F7JVijjVҫ9{?'3}9u]=L%]촯3aà +r=ۨ"/|ڼ}%x7^;̐I I~nB=|-wsG--UW^\͞|ty^eT͚ JAaP‚ƍ1->]끡W6';DDn\ #K7:ҍ,0􋚑jnc#>)/rPDZ_3d#KKrIS"zE;6+nQSG>ѪچG>ԆF>kO5y'_[~ !]r{5m˷"h8ǖKغZnݨEG!cޟNp:U j:;&ED]{[3&بpl?{Ⱦ)]'^?r$+eej?0چSD%v9|7trWةꥊbqw &Z$i 5R&΂=Rdd IDATpչ(+" [{ߖ"";f̝"`B}^j]~8[oYzGNǾ;cZ{|d*5NSyx.,"2mƞҪx8m5lU&}V19DQhUȞģ\hu1.]SޗwG˳KKKu 'qß?LW=Wj9}GNiE^ۍǿjP!"գ9JJͻ\mRJh*9 i'_5R 6qi#ZY^z9/7~~q~}}˷wׯ}2h"R/^` .~KFwi|b)w!0o+*"xE78q?vӮRMn3XHՇUo3q3rEǭ꾝{vcA b PpF".ݥpYYι?y:)9th67*Pw111͚Ž?HVVJ.`NOv+_q@fCquuyyFW~&zeJ ~m-nmS>Wyolz:v{?}uSz0HpèhòcjqE46J1}ђ^ӕp(+"/0V {Z3|tx絷vi<=U-q0&ҪoOTQH^Ҩ&bٲwM;pa);qfgT/ak/яѪӑRm}﫳_k4==G;1wX㊯ڈ^)P(*/!"JıO* YM8űĈokXk5e(J`PH~npjL 7 L4}<(#ǐfݙL1=7;S_;j5ʊH1Yp_&94<"2&6~}3o:*W6IxES%Ś;u(lDF6h$a\}Sy\QVDJ%ٙa70-JM?0^FMM&cz5U=EE{Ѽ~/?}M:Xc0Q%7;¾y;m7C,en( p3DY! Qf7C,en( p3DY! Qf7C,en( p3DY! QfL$;w 48t[o} ]ww}O ҺSQnvF "2QOө߿UF^p:^y6fo;kܸqF R+W?vLߍs׬yzAyyiO>u#64;;g„|qYӟoժ]~Ѣ)ǏgffEGGwܹiƇ֟=} \w8pZj۷o+Vfg~FNpԩVppns玝cƌ}5}~S_o=f%q"RYԔs;-}MOEĜ,]V8|76)^o̕q߶ŕ : uzXоrh0[E䣏?=06688uxЦMVo9"K~G|ۦO_ZmN\6znvyH{gY`޷z׹j h3f>ǺX3f|9 |ŧ"r$@Q5iDٳ03^Xt䵭߸izk罸45RGoԩf:Y3TnMN&P9=1}y>LZY9R5J3LfG>tx޼o:v8aѣ.1u4r~I,YդeD׻dʔ'fۻE}קxO20\=0A=MRoښ蘚AWȘQQSD_S[MlQ`N/';ʟ rU2g`Ùؼ?JvV,]oueav{?MJKm۶VO#<,t";z>mڳ&q?Q["';O޵RѣeSO=ѰQLppPNN 4e"]E,o |H&7>05ה(E`?WƿH7^1"˧+E$55M~ WR#?}n1 vAyܘ׌ٞ^Z0u!!vcZc8i=>30 3xAvCx("ʹk8$2sT+|'C xe=g,; s)kV;=:fm7O{1Oo 俑a|!MĊVS{,x鿻2cc7:p1iw*&R{חybH랞tՌfE#aʿ"͚-<0')PD:}M4yeܔT+'E 2sh!SV$"ʹ~%Y)Si|&fjF8̿?HII}ٓ&=`4۫gg=c4V\믿((kc]װQlrW <(+"bgzzFҾɭo;,Hܾ;U{Ǐ{΃w,q.7!%eΛ; ",QD<»O5h3/O|uS+u"!"?li ""|8TD];E_-oZ{0r3oO?;)S&_wHOOٳ_Z֭3Nu3f寷pqQ`P4Mrr:.ǎW/<88X_8qvWX<==ZunvE"}f+/ !Oݴ7|wu8eO?)o_&}|E#-OĚ~$}!~1{bе)U~Y#"ݳW_8 wi޼YDDpZw Aʇ|t ѬfhxlM1TU6Mo˾w-F]~q_]D  bڑVzdS3m;>~[GsFOGH|!vrL^1ˮ*xm ^q|7?bĢE6lOdxxK` ie Dnk'jމ+d0`𑈤.z&ikl a9jKJ\Q0wF5UvK3Y/WVǗLհ}w}*ZLKZ}RܕͪSqpK䮟YSm醤{5:t+tb{jёyބȾo>WO_8-yuU3a9Gz)-C ^JssHɻ|CJo}54|˽%L,0`P"ÒUyZHᎹZPu0Z#(o4ًgKh;~ؕőoG~a"b/=qo%phBթSMOD6^-DDD+9t6PDٽҬ֣ ޮvtiW_٫ϔǒcPU];~Q؆ cfΝN[J&aÆ"+]s|;C5ɲhqqG+0E|UT_-E͆*,cuw_p喝VMu=u@vN]؇hݻ[Ũ,"U\YtcGH磾0yv'oo6;%߼s/|{?Il0´Nk*/aAO;T嫮ivJ?>yذ cc[ŷ2~W^%eEdmNfM2xgNNnJ9DÿY7CZaNا&cƏXYY\=={oUKOh_/lP&ׯgQ\k/fqq"sn~uUw0!x(ki}6uΦ+W' #N*m~[LקXdo6ރ 1؞ZttsڷMwQYgJ%kV_&"|WُmЍ۸C\{K8K*ٜ2U0ڲCD lb_TU}[K= e :"׬ܹSQkg؝^lW?r{HζEZ'fDDD4Yqwo@IQjq\$!ဈ4iD_8 qqqR B;st7A9""V( ?o_0wGuza]7ne-<׭t?o -9OZ'?ZIDL1Fw6EO[ݟ;Dl?do _D*iju4clP}Gμ?| M)'/Y7&dw㓯m۶UR~}D//X^M7oX$Ơ O=i)JM\+[D3nQJ+Zv}--uyh) wvY5ŵ{MHӦM[Nm ň6kZ)=xȄ>:DDĺs`۴'MsHH3QSW[HDFU[x3p"QyO3CBT3y'〈dKUDDrJ' -P~Cj^QDsO+pّM}"E~X~WwXoz㍷ȱQ))s|p aIq۱e"2kU_>KW_DķGwYswh1}9׭zc>}s~"M1痾㜅UyƚX9 3,n[;Χ7ɓ+d6?>1Y;?JONِQ&"߼1}_ۿz N1uϳzAٹbqBݿi?ӏ+ǪTz$˩ȳMY=nm+/>KYOfbބMRڢU{""rH|2bZD~죯}s=]/L9}.%,k7yW^^=&&:tɛoiq"kV>oSpjMDHe~y7n#G};tU$GIeVNܳ9ulРҠA;RJE^*<׮`-'(V=:&nO9ztDdjH_;ĤU&6lG;n]ZL'9TogtjADfw}ШvoFCD,cْun柳,ƭhc6Q=V{3s =r 3S;eiq*?Ma&""%6][^yI 4eWo/W iw?qc|p>>XM+[#'BЋwO^ IDAThۡC`:ѡ=Q)toju)Iiݩ(7||LFUî4m?32_(={[5%K~zrDX8\e8vY1w1Gidiyy L/m25ooQ_Dı_fBC|Ti}sxw #?DAG_~|"d$Jp-Vo$frH׉O\qlQYDh71Ɖ1FDıUywu}zH(~cWA[dⰫ)K618zXDD Eb[Sb'ZطJhb6kyj[- iΆbPԃRAV+Tħ!2VN5倔nk<[,Ͼ8 ˵1$_}25Huߨ )g׷l޼_:qJaO|eD_W̘9SQ>Ío}?_}V|r(k2njsCt7|{Լ y_Pںߠ>Od9׏c 8b"b߿mmw{Pմfƍ *ddd~e۶oPDb@{9[;-woѹ(izE.2L&S.!...^x@``~^^ZZ;vlܰp{ZWVZmQ2DTRaQxR([ns#eWjmàn# Qf7C,en( p3DY! Qf7C,en( p3DY! Qf7C,en( p3&@2 Ob4";\*4M՜Nn.d֏D)A1,&۷Z\oDBEYg0^-Ņ]_%Iʗjj6{xzzxx-{/iv[YYYin/SV5c0| IйQϯ2*?`4YK ʬ{/i^>~AQXi,%G1|r\j;zV^Wl4C䪪SӴ꽗2VZT^?'+.=ǗuV/[5 <)!8^nVza~ H4QZˌqi6T\H-GY@T?BUuV:K rʿ=(k6[*< F-: r FXUH-GYc*YO/_k FU֒BO/_}TH-oׁga^ű>`4xzZHZ/gΙ} o~i-5S]c Cl{'>LЍ lV{s?RIEd\i%׹ 3  :Yz4]v霖QQq魒nc "L3*/EDD(U=`.`ߥiƼ{H p`/)iM5YyUk޽Wv =}%ڈ*oa^>DY*++}o-ە >嗞cm|ҋ}]l4 >O_+g}$5σ¤ncmdWQ&. z]+iVVhFթŘQ@xFpz]#IL,TоnxHx8K./5B@e碐/^[o4c|5](3 Ra'mW#o!STlpLnpם~N%Z]_OznFN%$4cuuh.vЁ];weyesFuuuodeg;;;GGErˍ/=f19"ۢEƹXfXo؊˴n |tMO9*]z9s73>太Kɡ)eY{rJ꫿uDDDׯA۹u.LNPDw3:z~.yss0ֽglK-ow*Oo!?:bWςWk~tΏ@ȲM'aڨlp*-GpHQ昮(e˶#F KIM]b.СC.c]\yy[mͭ[{ӍĴ9bc33[ڵԱwYYYbbҮ{gGeQ%+g}/X,¢"TvͽPrA{&&/,zddggaA:d`Uq]Zv-ZڵH1[vE߾c9%blݶv1c;8;벲rmQ\Rj۾h޺}vڗ[wP;N҄M&a j'<|VNh[IX4i6ۓ藥?w!QVVw/(8zxB+eԳGW CEFfk%T޽zl/~hEwR\5-mtT i|]H?q tnI%&"Z=%9!h=jx U{[-TCBT9rX+"$X %-5Ϩ,kQ ,.{BTn H'r*PcT@wr+RrVx1P>{k|)3b{wR;ick>TYG+ڍMrss}U[y̝?{|ݺux{:cnq |Y(.}֭j#?uk$s XK+ߑ ]+ʫPVJ(Ug@QQc.}=']pӘQ/<,㝙rgܳ{>O<6w>ضc'j,uu^m4844~Ţesw[KTAs 7S64'|lА/?Y7Yxɲ#ƍM cSYY̷j̞? @G?4~}i e/]{C/N/n7Hn[ @Fy ucM q9""(B g5-M '٩c -Ne:gO1o/cC$[g#PwǘGu!~}pn|9 t6= NUC̝xM*1,;k;GykoUs0^3fbE5[gζ;^PWߓZjsl-,aW?rg|G F>-Hk!PՀ C'X5*+GnfAFlr]SK{[}"ZrO/E 4mT+-n^/E*0N<8dJݶJw>]>6 ږq9`ßT5˞?t{' t{v,?y/bN9;FtiAj({^J>V/K~MIIѣaCM\PP?JK])*j۶pd<I8QZZ}ǮN:3.;ſ,S~N Oٴk*35HQVVkZ''mTTضcJ?!!AΔ&x{nZݺ ?5dЀ} g_~f[嗞ٽkYi~1l!_NζN'ټlV{mzg3O>iO+tTVDhsΊ_zLhxˮu4_(+BXh[%==3?`G⊋K0Ow5nMy;?tHONmTvڔ$ϙjؿM[lGwǭƏJҢ_~=t$]sN8~fOl˱?|hLL= 9Nnr+ 3 -e%""Pv}wN+ɟӬ@;WPCo?h]w)/[ q)_ʏ@PCpK8A|vؐQU\2S2?b%Tlmzfgo_ Mx^+waVH}TwMl5פUWjXpz`HPezZpǤ ')fUmQϯZw`WQG`~<_{ڟ0&n5|C1NȵwW ?Hfy+t)8.tC-mU2`X-0ekCKX@u vֻ+<#zf>>n7|ȊP6PhpxMvN?tjÎ{v@U3k2?[H~'^;am9A GQ/CR <X_O'M2YRݷwެs37m۩}۵ vqˎ計Nږ˫08_olZO?Į_|y]ˉ" 7[o:h@?7.^=xEQ_o-`'mش]{Zm|Q#}Yݜ}եe˰yѵLDDDg3y퓍SR֜Xm˫?U;0rE)?7FLx[#_kcڵ)x@8(Ԓ U-U.?8{($e\YUf_s;`NeޞzJ(8 ʸ-oS}u 1N-r 3׵}n#Kذȧ+ϙS^ZZz$խ,XV-%Wq.b|16sl??nm z$}"-t&U@a71zj1Ȱ i=`̞Tq@#)m?{nWFٮ]n\? P7l,;;"#"u:T&UM o slgnieq5BZVhoadNn_z sNuiO<]%0(p+/Ԫ~ܹsAvxAAAhz9s۶]{Pť}9ض^3br>G}__ʲ2d2t:ۛ0>ljo+UG⎵9|cړ -B<jv :2@x0u76ee&ޖQ{U{~G%F˞Ǒ!rѮ/?8u4bmÇw/ݱEm7ZnwjcE S#g 6)K Aa` k1!z@P7.9s1rY I scHQ2b}%u߯i++Dc l9 vQ7z_RLr - jQ¾Ri]-C4$huZ.C53OZr47zPm^ !r,n!} 2J-F;71L6_Bn,Y6 w5rʼ5'zx,QTZ _+p9j`2dW6e~|;/+^=̡y衇bK3lÛz=|/eR$*5yEY`׏?-ݾs 7;U;}Ͽf #k鵶A_P=d6$yǟ+i4jJ(PXyY'O{i|}|>Y%iivftac5"5*)@ƾG';hP%i@6]ۤ1>۽%%g?T-"X&)SXe 5U4NVWi)v|6k}ש]vfL? W @O:O6Y[YPbɾ}Y,-95KBН=n]}&ͭ{`7ws8jMIM bddDmZHMMݐR mѶMk_~m+68AfIZ3[ rEEHX~ Ng; R5۞=Q"[^%*2v#--`mݶc_KO=-,eddE𑣊+\j_eyN74>{u^۶F H_[oӏug~[k>dFtt#{efV;,3+G1&f <2"tFhhH]YhUH(NSiU*7z0V""":/rUs{2:zV6a>&Y)Rԕߣn' =z҃Meί^/ڔ{LRء/?J:S`w]vVMaA~$o^b_o^-)8<𝪤mONe/PaAA.kVYO9[0HZ y_BfŊͶ~\ں3H%sr ~.OO oruWBް֭^rTV7<{mSe:uv/G vvذvO>IҔq~h/e{Cv3搯򎬪(.Pk>]4o[ճ/$U$ڱgЀc&ޚ{d#*>fvOmf_e0;h|[Ip߽aSݱOxwzbr ΤdcF&oZ leZZpmFϼ՜SC}'‡ }lKI^`O%ds2KLrtA*Syx.GV^g0T<ȴH??_Y=_ǟDmj5Ȳ3-PQQs׭ҟ}NU2y(͖W^<Խklmf55f ecJ>%UYYy&9eͺ +|\֭h>Ȉp_oYFܱ_~mA?.\ܽkNlfguaqC*:kN ꭕ=%Fq yz c'Szz?~ч<|^x)[ d??[%؉YnGBcZEǴP^n&8tf#O>6}j]ڷkc+&$>yDu-;[]ەoP% *ϵexhɥ-[¾JT5)miJ^C)*AεWly)z}1}o4=;KRԫ^^P}&$74}j Mk?R@9!6O†R˷S0U0I޿(,u wOԱes6~K( sY1O{h:ǤI9VMnH\Bg}H[髶mU&受~غ>^@ %ڄ M8,2-@H>AևҶӁQ̐~j݋z]`L{>9ͨs{*D*id[B#C*m:Z*/7zݻ"Wfyk됄n4#Q ON ӣ᱾NkK6~:.;+vQ htEŵd*k`GLVV/O/OO¢&.77Wo՚o2]@WJbҩB;1qTYsr;cxz{zy mkj??gSEeUNnn&t>()5ZMP``(83 O9 ܵ.Z2sE Xu{+7衉>ߟQSalREc v \?c;zr0j$xDR%U/";5=PY,HƎlGpj@JtW8<`*vA: = @A5R zwW|m3 ( $@1p,B%)0\wQ@ gk+ 6&J;앧h|OB$^³NB6 """f18㱙~|*ۈ.}m'Gd1 rMt9^~RƟ*T3^B{sSAȱ^\ ""BD_7)w5[VA|.5\YQ`6m+b6e{7 ԹɆҦDl )GW{Lж"vd,><^ZR_b2}ƶj,1]n}#DL}Y_o2W j>WtZe6U4Dб*QurhJl*ʒU2M&ceUe$F+/$ 7u4GyZFb$R<]ޗِ<Jv)l2> Q{T.aݩ7Y$eK=g%@+*c;CDDDDס҅zU(ۮc*q倡?P\d_q\~fejs(;x؈d2=zҭ9ߚ篧F $9)1cVÁ*3J `.ђ91F=Bma*. W jj(+?x0ss~MJ="R sbu}aVo%L  tuc#>]V(}Pa:io*Oh&ho`O/¢m;%I卤d""""\>O!T;=h ylO'WmдBj@ _eMFQULMpcU_mX .*NlBo*NZrh73={W!Ϗ~mh*<ǚn( jႧD>& :HY}h;[l&zCç<|^ \K ~ґ3_aqj)oKݪ>3&ꠗ޶j@lO|p61Qw=cQGB w1']/1QpOBGDZ7Gs^ش$wkeΟ"4)ӝt:>j$I, ?_y">;лW φaϬsDЄ졂1ck1)f{i[{G{[Wf<ܻ?ԑ5}9Os33#s9BS>4*JٕT *?QYueNn|Ox'JD_F1*[YY`S~,00)332v!E O*{[n P`gO>g Kv_r}4{hGM}ti0fl>d`I ~A˜a};?GnqƄOL5;u?/"}{AKYxubľ->ƳV:a3%{N~`:S$'uqCzYy"""" geNl i&C|~_+0e |;顇eȿ/%1eֶ@sShw>1] 9o3hɧ[ QTn8cJjOwQS P}̲|lOrO^ptX3v_M9K@%HV:Sj^ӕ#4> 2n l?N z[HnDDDDDOeNVE-oE}~L&(-.aw=oˎ9q!|Cxve3r}a޹3hEݷ v}gns0L ͑+ZB^*˫mRaQQX7o^V{,N]s =qVu mmK5eg!""""j8rFő,o5';Kv*d'KI9Em^" *`4jkpw>嗂[G**=UܲpsaRC[Re,<5 ^ 6gg'0o$"""".V5GAREjF kP^^ 9˺R ݝzޛcgx7>K5п 7dॆd\l `]X ! hs6CLDDDD0HK莣hEA&0;ZX;_k\C|`j v~np @z?O WxWem9;!"ޚAԺL{(!J`;pTg;S+DdwA;0r2]Ja,dYחeˈz--?NKub]sQFeK?Y}+B{>›& V.]?w G&bw)k[_O ݿyHɷʾ*߼ڠvmJ3{2#n~WkV:3\:H_/1:k^6蕥_UU:/na;]5FYPRaV7ZFw;pR  UeVن ej~,_{xcŴ CqSwwfl;T(:?iBWyOmS=2bđ䢨]^O*D* zTV6FNTU#8z-h)7O!z-P%$"""6^Y(=M3`݆3\-eJ"u 'o8i5pk8рp[kMQ:ⳤɝt=s}ul,襍cRWIGxޘ(EDDDDD < r!MūQ]\UC+Ⱦ*([m-B-d2{ӥ[s5/@yŜ7y*VaБ'>:A=O@͏E0M3{@!pآ̬|ֹs'W3ֶЪNӏF>51Q=ϓv|By([U4Pƾ-po(kR3^ZŴ iqߤ) 1VU0xVͭbl@)7&-N?<Wy>v縤lZ9vCy*dG`= gN=W N~$Eī5g rnjX5#""" 4]ߪ*|Mup47MKKr}j^Ο3i#-B2}oUUHyl.O^nJ[wQET#wSw.);QayߕeGTp7][o |I񘈈.pb'%!yfBe'j>\+TTXdg).Iq%ge:6xM`ۇ*Ih )_=]&IwӹjP`0H}@Y㸴kgamP^^6EU@-7PV UBazKKadJ(uy}tOk2D)Eg_ X;DDDDDg+啭RsqXMQQ([df'WvrXOS!(>[v `ux*@%5`de 9= *3?{Eնٖ! 酄 DADAQ@;>6l:tQ>2eeW2 U7ɽ۝"tՒ!"""oB˵Ǻ:u(yk(T=U.CJjMhYDDDDP]sztt|$ũz䠲5+,V렙G̱d&?do{ǧU߾&@\opy'/ rjC1*o?~os>xLJi=YҴ)ȴNsdHђ [7RØ`IX-z5*oߦ-BDDDD 6smUs*0MYrF*CV?$c@{ߦ.7T2zy6HS? Qrř{.(_ɭY;| 3aAЦB`>A-9ߵ!)37/imL_kM6Ӷ$玭7\{X{ZE䖘t][Xr`Vȩl99kA6)sefpvq-Q[3D&0ԣ?GJ!ta*KDDDDDD,9DDDDDD`a*KDDDDDD,9DDDDDD`a*KDDDDDD,9DDDDDD`Q+T+/e_/'+U'1:7ѕ8 SY""""""r0Le0%""""""T SY""""""r0Le0%""""""T SY""""""r0Le0%""""""V%W[YD,B7ҕG (U,Qݢ ӱ|EU5Le}W(]g [,1]]Xb QSZbkG/-OqDz<.(P\Œ.i]n*o6N0i0#Qj||RYp51iq1bI=ZZ\UG2!"""jb=l<.#6S٘Ry)Qʂk3glʗ*55]퍔gf#T5D\ά!k:mF}aeAU}ʻCޭ$5^PgGTMj߽v\}uӝjWZ5|эH=5&VYvܝP-+{K``pK3M]:tj|JN]]}uzvɶ%,.-y$l3t| j[rus)/[BLBzB씹۷]݅}%et3\y C>3U\M))吾Dk#{}J5=mDO!Xb^l:rMW(v'/Lr;*A%rlR} 9oZ蜜7p@I~\MvVrs f{F93rΙץs͞ qՀ?aAv 詏y{k?wnZmePl|WZݷ6z`w|9 bptoc/}nl#r[9SBNLml"UW}eϟ* ;3T{ɧ;6+{O4-ؠkw<`=kne>:CAn-Kܽ Ѕ2t;(ONKhggt}_(A gf־d/%\:nW6-J:ڻSJz?o]#>J3iMz;:ŀ[Gw*_j"4GOYFŋ̟T'>ܱS,K|{IezHZ4$v.B/LZ\y|8M@l3>9͚ǻܻSfC,}yٯ# g Y1 e/4=W=.YJM(oNŲ猩o[ `~#Rin~s1x3{N~`>K4'4p}I\E?4 pn} Ꝍz&v\=t$u{w>1#"G瞾oMO۸E!_0{+5&r{kf?3j&@TqS0_ }F|O qHݥ=v_x:.@^l;MH޳N7lo,mP,~J_ ?k˗""""[i;BYBLeo=CѢ'< ן~Cm;GSY $SVNe[ $*>M'4E|XcRW`y[(L;[ )bFna:9{(!S-Ul[`~\g} IDAT m{וֹ{e'ncs,[}_]ieӸذe%`Ò"՝R饷mFKAXvZ֔39nnniʲX8Xwy) N.|"GwD0A8kOشbg9lZX/VZW@**w~o. [mVkVFOUkʴi}ZjKt8g=e[N~Բd9ۦ_мK_Xb>ݱj/TTO""""녩-PTh(*TFWŬj@~uxx!O${a]\̶ -+v*Yvc]5z I'*A DzW*:&~4.8H9-*TrIC sͮ۽Jmczv4R/^T S| f3bϹݚr6m]_75nA-g?غZCZcwG;#D8Y_((-pqᚯ]},G|]#D~V1+iti=80.>&4o`ɯk)UͶn|WMЗ+Iksl4v[V,+~nǁdtʕVjG˪)Qô\OP0e*^q=?k!D;ydž8W!z9RnHUCNo%b)# CeoB^n&6v 9CKoIJ= KY@h)#Amo@ y{g7H%9'KK-M9+[z R? bUERXUzю(i_X""""jR~_xV`cZ1#sd i{HYa*K ,lե[Wn zQ[v+K/'*@xO25hW@ 5lLe0%""""""T SY""""""r0Le0%""""""68^QÖ 687@DDDDDp8 SY""""""r0Le0%""""""T SY""""""r0Le0%""""""T SY""""""r0LeZ9k?B*4k =6!@>]-eIV'"""""U.x ! ܝ(_-$fon+ z^+S2 )õOAEG&R? UJ*L%WdTvgz $fr u z N..>QdڼgqQޔY:r! i?;Q,z Ÿl_F2]#P]{76/ϕG.qtWUFPٵj*W{5F\u)\DDD/WӪO$\,CQzdŅx;z sx83UK:gPlifV^TpWkѵc#cXKe@?)tZ#PN.hg Rz =;:;$z|TQ#bDSuKgg]~^SR8L{0J05҂vj3GC!瓊>fP Tm$ɨ c[_dI*Lת\dVd9 wo;l^#W`P{צDNdӤmT*s?-n\K wQ7p7X1,$)j`г-?װ`U/UziC2SĮ#M#;`I2oo3>EgUx&GWufbB @3E1/t*K2T: ҠU'IRQ9%[Jnx4Le=”r\SPHZ-2'F0wLxOu0; _X/~=g7o p^k,Ebkf= @K=d+]p9U~M>|sZDdݽOt|Gp7/"zsa~\=;|PyP13bz{͡aӧl:ͷD{K]{9N{w?e<"|,E^U{.}|3qsirk7aƇ}ߗomߗvqpƤFF i;^y?}˨ɣiڦ]Bo>7gkunv{Ywcgdi@Ir_zM>[_.6۾YscgzQ'۱z,s'6}\81'~z>;iǧtMiU7{}Sߟ3*͇dLMeWP-t5iG`IrWTCB5",ή-ܥ۳%WnvfGekbǖFmW# QBQݫ.9g\sXU"\J^b- SYC2RO|`BxeqRnK >‡~[Fc!MXJDw69aʄGSij+_v4N}&;;@B Ve{PpiZ~_v +v9V.Yވѯ5|CVyĕ3 zOswʇشx󞥇7zxׅ_] @6]A 1}zm-3}Cё{[/K=bg).SEOgߞ8cVWb^M]}Ej|:)t#ϭߵUpv&~2óW=|RK=όyy=3õ!"""*#'~~da~.q{V\0GP>SMԍd9ץ|pƚyLprn۽c: 4l*NyGaCNzbU'o.QrYT)'{oV4;.?Pm)EmʒGmyȹa< rrH<ɔyGmô o2 GV֫\[.ƣk1](/]|vT9kJ65^mÚ]?\3^3kzM0Lg`I-]aw7ܢ_~.}9p^NRytCp nVfG4?y͞{\.XuX$CِrpcUZ}\b#E7oe7`(Xӗ NHG<_݋OID)_z"Ǟ*]hզm$&K.&o?lI0l)>Tt4>R)Xۢ7#wTm\~'8}30<$3^ѓ7)~] տ8Vj nN,˒;ksu@j]GR2OQނTt֐_~Q1sKRvPFoؼDi&an=+(Թ۴v`/ ;V;~FP7}?r)ZP=kXPvi4EtƿEgZxZز\t"jk8RmJ(4z]Y6Yt!8toyW>@t`C#{O1ޟ<DžUJIutW#t Bm; ٥xKF=fWV,kT6|'!c_r)׹n&V5j"^ZK70h\vqIr*b_Ԋ奜zrg]ѭО9V1$ l9+O4Zqc)2~:d3W>Y-aٝn6Mi/?sX,d&bA"UM}/6UKY-o;ˮq޹cfʛSl{9O? ڽchuĴߦ@]E;FϞ}/wR_7 PZ$S52:Ru:8S}g}=5QUڮ+\ٵ:7|z>MU F<n j]SsK~^wQ+d[vʩ _֑zVE祿UhWN` N66rW7y<)&p[4ň}VNVoW L?ȿR5_-deǹf:olAAڶݺ:nʭ:3i.Tz$%Z7oZ~vYBrjW]('rIL>( j<:e3-Aդ=ê?7Y77ݥ$ n඲,zW-^ӟ;OHQhڸuϸ^^k޸u:ǐN*/&ٗݖ)}vJ9eWeoݡ{i~͆.x|^e k6pWsU>*5x6Q1.B#+g̮vI :Tڶ5;ϭ:ܝ1_=nczsX-G7l]}yI3~߽7t,Slx ^bCd1Ȧח'uDŽ?ܵrC^C>r[MY ?Zо?yy/\* t9SwkCDDDTV- 8q<1;ѭE,Wm`>&11ᰢU:CĜ, &MhjaT_ݶJY'QJ{bf+oH\D;wblyM֥?pce@yg/Y`_MO}cgKj˃ӐP/FLn,mJ^tS'2y sxۤoLȒ\>߯Zܷ}b!퉙?]s~R$7#K67#ڻ`+rlBf]_b ?H9l;ie7TY6-&0=7ŭ4>P;5wszc%ORwmwό6,Ϲ/~)oO.oڻUzt߼g%3=nJ. ((~4_{Oj͇5?uѱe5j-AcŒ IDAT(BltyGH&ÄaQ#}h'6 )R+p/e몴2 6UoةP,R♿vZߡFmQ(`ZqLjf+W6HTyM$BR&G(<"e\FAHo.30G_ɖBWZp}CdcPEAT1\RNR:a=| Q b@s͐!@ Ð*u)Ar+k˞ `)M1#AJ9aNt"lg*Fqol1IgPt/oo)lM7.5Z6C$hb ar)TM eWW$trayc(TPEﮂ+e쁔!Y"""sW9 lt6u%-tMFdeHٕPBΫf]jg5@YQE( Y ƳqţIU\K*PLLeUNe93 rfB`4k,$_.@B_X"""""zb*KWȌߎ˿n>ȇ`*KDDDDDD,9DDDDDD`a*KDDDDDD,9DDDDDD`a*KDDDDDD,9DDDDDD`a*KDDDDDD,9DDDDDD`a*KDDDDDD,9DDDDDD`a*KDDDDDD,9DDDDDD`a*KDDDDDD,92@7w2DDDDDD԰d*C$ 9N0&""""""T SY""""""r0Le0%""""""T SY""""""r0Le0%""""""T SY""""""r0Le"@@$e SY"j޾IˮMQClTc1-2jVKpcr4eY T7 RƮ (cbKjĤTC vg^@1XcV[myl,kނ\{,[y)Qʂk3glʗ*hkg =[+7Xmݷydn7+ ʨjx3Oy}ȻUurB\z|ewYR=7.׾?V=SJѭA/Hiƀ2|KiނܢZG7ӧdYV֨x:G=; dےcC:`TA$KL^pbpնjR_xTچCFxnT`o3(T<md2%Kٶ{lp8X|4F{$նeDxנ`WI=}eo)I͏״S].go3e }Gh{yg쥵Cypb2Jk۰jҳ9˛L|}}|r6mOdX[꼵BBmᑊAA.´:Y'jQfM={fe.ۘ{B \[ -]Ϝŧ<B}_m{F ʲ[EY۞f@Od5Pd0?'=[Ӯ;FwV.>ܵqêiCB։6B63q"0?+/WWaȧcK>ݰ)%e_!};qF< 쫥;Q0k#dӌ}ǦQAȶϿyStKh!e{do?^;ӳ&Dmܲl=V>2o޽Du0E3A c<ʽ7j3|;[w_Y%Y :uo폏6N+J:Z|}^_nmێ6`Wq7 w5l"""K:0e ނVKKwmDixb)))I|IYM(иK=W>,N3یONf.Եn1}^'C={$B7}?M?nKE۬ pzj{tz2gK!e!|nxgLc5Qm7_> j\ge´#߲&-_y^.:4juN]zbFH):7[=<$]Cz6.gTMq_0A̼cU!aAy/nbK{t\m; P"+VZkw*<`MI_ :9 rhof@7j 5L-eߟRe1[b)..l6F*@ObqVg쥝 s10# @f|(F ,h&O'e/~mԖRUl_MR?vf/8  hw-]R*t@ڹveI&~ml5pLHutR^Om=4 ;@e/^ve o &[k{W ZŻ o'h=V٫ޝrGvl2 ;[۾luzM Q4S6S=u(ۻ*-j! D (5ذյϵZVֵ! {G7!H'HoIPTJ&~/{sxܙ9Slz)gwIl,k˗SN6N*2#)}V ƳCGuc?zG[ݩz%Y?w>v>Iznߧ_5mu޺ i~[ =?mWZ?'}7vؿSW8$>{ք4Iǿ炼*:[^S]nء[ap>r׶j^pU6fW4wf,ڒ:p3VXté/T'_NyWTvPęz1sW=;(1I)%_pG,DZ(&^n=ڟձh֬.. 'H"i٦>YTn$Gv7CZ_~7-蹡 <}+=hЫ^:h7aOylc?v}8wVy[yoUYX4nS;ի_ÐW̉ 6]#LǽgIc{~Ix*s6/h[Tsb>r^-_q3ܜ>uP겵ǖZg818e-c 螵dR8oTI}4U BgK1xkW,,UXu؃Inꑢa :2וZcֿ5oX_"i=O/,ϻV̯5+h=̅ձh_;,PV8ԖSVd)Kz;IcM/{RSm(yOwd!+_~|Sw9eaWᶥM3yjʷ'O]u72QʊH^y-Zu") .n?'M!B ,Zf‚_c%/K8W ں$KKȄ3G]춣d70^NW&li*L!PQEF~I RJJ۸:%+ҿ%[2q7u.>=iGN0vGi7vL~0Ol٥~[s+3}?ҹQhyکuIJ Mo¤A.Zr', CӞ{A ybZƍ!긆~ָsL]3/_o%-߾i-j5͚+oړs)+駘>ٿ8#m?>W?x#,ݬUQ٣λJRUm}Z;wa4zvq+ۊ4CIF.y9qۋqOd[^1o͊&N,7}պqVv]ד<{LN̓%,D 'JVŷ욹BGKޕڡIћhےyξ7g~y.IUtRB۞:bI9۔7cFWk{읁)~gZuh#oF`kl-'3#+G;^+~ؚu¯wuÛkmBr:'%ulelcG=/<hݻvmךּ ,bWt(rュ'&&HRߞ}Ͽad̼5k^\[N lϯ)|!$l;%H8v\զcyOSJUPq@کԹntG5ffg>yAgA>ؑlm|mKX1i[Óf)p9퇝;.suT{UyێgjOV+ؿ!:j?l[QRՇY*s]Nd8!]ڿ/rÌ3b{qvNw5rt4vQgJW~%p:eQ3.|Q=2O+Vz8 CrIvuN8NTxƨx>DYeC8 Q0DYeC8;-%ٲvN&lsq0DYeC8 Q0DYeC8 Q0DYeC8;')-Y-@E-qKkWx;J7x&m>?iWp@ &>oBaRwYp[-yzfܿ!#cGr~- rm~WX/틜Tf/zv ;IaJn&>ߋK'EO^fLv:$8I;tu~Vۜ4{~ ~#M \G=EiJ~?==CŨnVeUo,C[χ<:>pg嘎s8EM.Bu[6Kn;N$T{ekȶҦ7ض#I ܣRC%-tueuuȖ,޼is ~(>m FtKN ۰!cEjfWwpE5 -*s䜒[u-)u[Il(/7on|$ЋI+]rhN[[NMB:45Mcӏ;3`mCy7iӎsbkϲn2!77X7.i;e3mK:zǻ sJ+LטßLRI=Ս?{n+6h̚vXugغy[ '+ʮkunBDtd/gfOˮg_73DRښY;tAcd-mNI~}H*?he7%=ihXb+Ğ8>7'w[L%C68mvNM0:Қi#!B?nX_A\Ӳ͛64x.T? !)oD~J>+qHBmn}LTRp׮"#6N\p2i[$3^,?Gv nuKgݺF>1e$4?7Cg_g_IJJbt،}n)IW22KG>ds}.?|b}_ I z#Mҡ|%ry;;cafmVVѧ*u]D^7њbL-sȱN)oysy F7ٷȝ7Domuv^KC ou1Kj.?|~,J<1_{\vC£ GRࠝZqķRnw'*ӯF}<[ rdwz})~G9]dž.$Eu[O5 GPƮ]߲9=1Is{h4˶˶JZ^0ՍC_LI"F=Jw+ OG${Eu{AA#!'ْ% K}:)m^w+Wq^Ca)CGJX=ʐB*θ.C8p>)O++ #G)Lm®2V)Eʢ4!_ٮ/gF-ʊw%ŶCq˶%Ck_^_bIo~7#.:.nxdF[8%~ߎ lG]sʊC>) Z,iSs;U߁wzpJϼ|_~O;&D6G)i kQdۏB$=3^nA֑&DҺI4Cf#srÿipӎ|6MfKrI4n8#ԠBo#/t~?ﺲa7>ʣx_W˨rhҖ]vn|MC:4Wz` MluOri^dۓe8UpܺO)uS(ʽ](㒑Osqx =+oWw T?!Bhc<ϐ%cƦIJLuf=nͣc[uIǦIJjS/Y{{m,.)M|ҧόf++jS?#YRQƚQvT{7 Hƅ TR3. T>J+g}%Jl,itq$半9t;{T_zeY)^$6mw׉(nAMR(\1g{o<[?ttX;l[~՝h*|[uF*`% $(5"UtRʷǩG}ˮiGsp-0ʿDH_0y{ϊз/#yسjM wdLR!! $󇪕J97U}1| &Y 4.Ts8r3|+T}/_8vbbu֣Y/Zn|mg"i٦>YTn$Gv7CZ_~7-蹡 ,^U뗔_vTUwKtr {~j/Oey|w%S>tk=ܱfucbn߁$7E*Th{oʝ:UR!> ~{E{"_bYuBiF!F$m޵^? ڒdH )a%ORc7iܸoجsg3.дq۾2#6zvڂ[(^%Jßnw~y1~r 7{O1UQO&aaK-Xzeaʱ%uhޫSm]ԥRd™ONvQv}_+ۇ]o4&LސT"#\bpυxS)Jtb NHvKO oISwܦ-Y#)⌤E3S:+uCM>(R$7|aҠzeěurE_gߝ}yaGvKӾozǩa:{ڳ}/H,?+iدrvrg˖ƥU6p~#V@:N$2e[79VҔۇSLRGi_϶kKMv׫_<ƿmn֪(Q]%?K;POP[=o8_mE!|r\n|äSzk'.x-Õ6͛-4q _4lW_c~UN~5Qn-f&dҭwvh70uR&~stnI^rDGҳxoO^OތkLw%DYWsSV& N>+D֭%*,ɔzw6o҇c?U~ < /m~E'5P(ƨ<k'[=RŻl}c9[ӷ׉<:#L=p `VĴ(o띏ȱp,joߝ$.IVşHqZ\qU' Y\~f03w-wOwbɽg<ڢ}+ʟ 8Q5?]ꢬ$iQ37nuv[1;5lZZ\sΜ3=NBpAos̛_pdS ɔ ɖ,rZ̔̓6Ipz>pp$~,a!( p,a!( p,a!( p,a!( p,a!( p,a!( p,a!( p,a!( p,a!( p,a!( p,a!( p,a!( p,a!( p,a!( p,a!( p,a!( p,a!( p,a!( p,a!( p,aFYkjC(kDY@MXDYJ"(.J"(J"(eM5iGaUpUaY@MafhXxPj Z4 0}0oHhPSr:ʚÕScyYbwphSTX`Vp'aa[Ѣ@YpR ?|#*ҲR-˖/4 r=ٱlkN#D @D8 Q0DYeC8 Q0LzFvIENDB`tavern-3.6.0/docs/source/core_concepts/testreport.png000066400000000000000000006242321520710011500230170ustar00rootroot00000000000000PNG  IHDRA pHYs+tEXtlogicalX1079.2fJctEXtlogicalY290.4z IDATx^w|E^ɥwB ! {UQQP|T+*<(J 5j {J.Gr9y {3{β@!B!4[i!B!ҬPdK!B!yȖB!BH&1MJTJhADB!B1yT*I2sd+BPVU `G!B!4( j8JaL "ʊrQB!B}$BUEyueFn0ͮ"eui*!B!rPQ^VllMX P^ZVL3!B!LY]%;= rݩ&(%B!UbT# k !B!ddR)#O6s;?8}~~"1´6~*;JMn}M3)[k_-5i Qt4i[k}ʳ.//Mwڶ[AC?ڻ8#[By]lhKp,CbGF #&O;Ժ,k>-uq_Ń..?XB5)Q&ƫgxI8xvD$߯׾d3;}e[1ߎ]E!N}}[98XYYd)Ғ̋Z>[2I4(SqAN ~UAr-sgIZaD@_\dRR \WZz[~<옭5+*yoÖQk3;e;(5誀3[,^,Wυ+(ϕiӊeq2ַe@ѵBUyܱܽӽwN ɞUUbFӔ.p*EbD&90qS e=C Yv *`hC5pɍ)8_Xstw C\Z!PVCn\e]$[ ljI,PZ 6-9Է;@wkpR L{2KK+ߞ{`r7p"`@m\F[b3Bylgdgӎ(qɟ23"FMpF^q\6%U4fgo TILJܲӼںu6a OKM5kٚyGMSbkgwAԺ(^qd;L{(>l_v}y,xyVYor%<|`e`HăA^rgRw=\aVN`?&w}}tdJ;Q&uQrǭ"r-&a–cvMZ v mm%u0齙j3w0s|\`YٰPNGv'xX eL |NN[][[%: *iYN}Չ݆ɟX06׻SpF[YKD E}]gOLyO!/ԙwutζ.-zsy2ͩm&d.-ʒm%ڬ&v3g>;&')[w9a]g qmS,ڸx 6~Ҁ,@kҠe>SYv鱁ӧXy:j]Æy᯷ΝZa+݉" eÜxwK,/w)*q4S.ۿ-{оpPjVIn\u޶oo<ҍ'Ӫuu²yDNu룔go掳)`Zի{]8nzK暅|#r o+GyMi\iß_ԆTشh]: [.Z#c&WFo`rG e鹥*Z>*'3wt3 i;)O$[[I_?T9W @, Wx#-<^X ]^XO\`ֽsH\ b60;>~Ͽ9oRʯ/pdպ͵&mnP+ ٯ,5iWk;D;L}<lֲWtӘ?rT޲L|wvׄ"ޭsy T1gƨ]O9-ܧO"WÂlzuu:xƧ?Wܴrb!@"d>nlKj.{lY~>@)Hmeg Y֏,8#]7?~ʐ~r21)l:wVr@% ھ;tź7cvLuSzYⅶz̽ hK h/Vd$Ko<<ӯϕŝl \pyqXM[Uiʶm[6[+p˷g>rJ/c~휝ZEqN}-lIo}\F0ɧ9Шo;K!v={ JeL&%1hZMP}!o`n~8􉋆ˉbI\f=5mIu0`3o~S;+Ӿ]+tWƶuۂ>zj\ _q/0ׁAuYёbΞhbQe; tl]LsNl4' ;NmV}pâT<2€䝚Z7il ? &g,FX)llb9M\#}?ǿDe.v\im<fPرWY4b(hc~w&=eسg1^A ?q4eC&Y=UtŨxޱO%b1{{ǰ^2YQa;.5{萏>ف[wqJ,ys)sy‡]29om0۷_V/]ԩ֮87<;ֆV}4[S2ը8Vdsw q<7^.R?UwXV:my;Ri%v+[f%gMY>bRue) JH61AB25=)ӿ@(pw~Ī$gֈ1up0Ear%Yb.8X1xp;1qik* /M̨H٥S[-.eWgdjZUI`#raҿSȐݛ(%njj;L vur9N$.-ԧN]4ǬJ1ξUh?iWlbo&_+ YwtV$ٺ!B ^.K=Mjł?S Fprb|JX,dX1ړ˥D"' s3-(CZVr< GGK%vR]OϽspIJ<6)|;W Q{t[*b+xJMv61RG맯BœE=|lMm-вMHmX :rN^gNEHa}IҢ‚gk4EAҹ2`WĤHZKrw-{~߁gmX+l:Hҥ;!ۯ;o%G[bpdd[^Qm1U.\|7CvR{w\u!pjc<w( gDuͰI'7?.Riyv(bҙ1Uh 8N|{_,YJwfT&j'sP|f;%܂*J}kծFuzz>mF͓?NpaUOtHmUe?$6݌4ҫ3B&xmYi魘:_r1fn L ǿ}׮}PgA=~V6gKO龢=OU^\G<%n>eT@Zps&5n4 ;L^ՙɬ/mByG'ڬ;aƉuv'α$N,0@C3oO:)|BgwGS5WKoF;f^ {O:3o#b?_lZ. TҷuYqZ+x k;|tiXZmXXpk, _wfc7DÞ2Mk2=uv]"Q8X(.`K Tg^-Ĥ;{1;1Y!^Vug? M?(>cN R}ꎴˍIrJ]M !XB* J s/F0 kb_N 8[ )Qqh˧ IDAT{OAizѕ 6.%@V 9#:tʕqlPTg"Nz/cДovYcg_8ZYO.wJӝy,|azdyAeּ7:Q K(mQcբ3UIyluWť¨a%Vn6΍T!?W]O>*:!El j Է;gtp>-3[pg9& FT_ʮߓ4n<:̑I/^6)'4BܝPk+g|lSOMtbSi8dL{3hǖ?W(2Nת;& s y(Gx⹓'=;?@mc7رcJ4#&J}3v:5Kfv*vVt~ظ.q.dXOc,uvցv).s𗇵x!}g:nwѳG/~u֑~IJQڰ ;ۀ6#:966 K1ɋ]%Ĝ9ytǞ{u+mY>?\K̽TR%bĀC;*0bZB`ڑv}ȹ/w$/i4:dgΊU\Kz譂Q#Ý#**4Wr95ԗ^67R76U);}G!Ę(yQ"4Bc & {ލ$jͩ9nI߮T&헦rppңNV<uYp.~o< :ro^۳gϺuװ<绷&|8lZDKId܋ O^sLڅ;l Ϊ$s e24RD*Q`t3?\JR5>rIvA-,sG=ǒ1i$>ƍ0[aú >R)Ly+l'_ꂤSl;jbȈGZrJ.c|yǐk~wĩ뼭N\Վs]򅃤By+W(kkÞl#|)Z%J.b.Yכ1g[$ 7er!ٝ-^}f++uy5h^"Wanam]@@U3%̶n=^ZQz;6d՗)MYf(H&jor'5 TJ*UuFN_9¨Bե~4~24.#GB0\kKD^קLJDՀZDv^mȮ.Ϫ""Hkc/Ò$bu6ڬ=<ևMį\Bv\߇_(0Օ;zUc&]ja3| Fn"~a>j$W:P. R"UrjPg:a߰2LCm;PmXy}_,XL|7Wm3uSG<Շ.vZ]9ߝē;Wv2o*3݌ sL3maKz8ؔ/l{u!#˿1eF%0م~r:B,RV}P@X7xa-KwUDTG! &Du ˡYKh ̕9\r!qfN}9Bq}'T&f?[Ϲ tҞk֭}M}iV-lʴ0u{FI)BHkB}b鳓:M^WߚάmLLo_͓al<5_lqghek>{C@ N6heC!{S}7 !gsUUkM3Ռ#ۺ/#|ĵ2)3p1PP]/۝M-W4BHQTe !,ϯy"ӧN;8v "L ޺O7F?٥GPnV[?܈*|cpsu./o:R;^r ۴#Ng?@!\N(|f !BAFOfz I4WX]G¥<{0Mq-% 5@1e8v~ݺ~` }KFIpxCMBP]oI$B!:wz㦩wI9ucd;–XiOK(7d l/p]$2 ;9Yk&@->.mwn/՗Dž niˏ3P6t%K#6n: !BAF"8Ѩ_ɍ;>LseaΜ7M[Kof ߞ7 N3?as)fܰnY~:mxnQSUJL90|X} T>F!B2~\7::I(y2И,wɎkh %1x}^_`;?cY}n!B!׃lSrg@:r\M9WNrLWtRҒODd(# p!DyR".U-D0 f~BTۮW]Y-# hcp>4FrrϝK#++%2osqU KgxG m9P +jTp[6{SPǶ$+=4ewbfK{zx`eg^<2٨S;Y}|Ol`G6}k@{?TsTҦhU~`7xL o'}J{oZ䀞 I#yy7RFUї0ףM6T\6l <ޚ- ,KV/[6gKmxOKaof|YqcGjN)K`Ti|y<׺[XMA~w62>Z6dnHV~>2Ƶ?O_#n\)wU#idUB!BjLtT/ z;OI&iG\ }!v}t&A<r^lTMw]]eޓt svܺp'݆j hGJ5:A|&ko~}O>"n5𹳵˶1sQTsuqb%}<ƾf-8](9m{uҮjV;6-5?"xwȗq-3@3OK_Mr1 @w{yguקI'9Ÿ~@/j۳+ԡ۴nwM 8K{B80{(~Knjʰn~@`|IJ~|uJ2`^GųHNs24~RFlz}MkK!BH3Da7ݳ_~)T$J)qVũ ϴO>yky>6܂?s$*ّ*h`WiBz2?Mi2|@d1j>Re  2bL6(էK8x;OX-imVAIuJk94%U@c e۱~Ծ}DJV"Jyte嗯崕wk21JXlk=''hJmK2z*!͌Z{4,;9`"$Jp*p$`."e4jQHHH"U\9J]5;Mt{Y`m3CTia?Ɔ-t=P 25a҅"7P@JjSP-5l4*F;V&>e2 m?`*`OQU>F̈XvҨԳ {v˲~fݶg8L77ӵ2XpveP.bѢǯ3ޱ]_P/{3!'sq_֌v ףGRڞw;@}v}!/O̘6@9P_)vnv^hw]lqcj7-Mls;UC?!.dN+,Lw2FoZaݩN:w= ^x%f@E.`r3ЕB!46Sݠ/* 3j"{ &uKø[kԎlv65eq3!B!fu 3ԳWYod OQrDі1A_#p^DF-L8&N-9/ўY[w/Z;ޫa-rrBS{_RkGmb@{?gf+ÀG<]3 9_'&і'k_ Z1 *'V])̵M!jXO$ ^ڒ2"['Xَ+"խQwSYba¥txK [,9kl:Z6AzZmڰ {|'(/J~%B!Bf:{6,xe`QM/uPt_o,'-VV2g9WP]3fwro3yvHyͲoIspiV6~RZѤܸ1Ck:T}4]][iR%Rܸg%8}LNIaA~(Q)yp.!B!f޸nX$9rw Zxگ~o[@t3+[uŚ!jHZp+NrXBjը`n`7b)82 * ON $C_k,[4. ?':281`]Gg'҄#̠FIx=ߡEOʵ icᣛOL?sgxq>|9^V'>fm{}$ܐnD?+}lz[OX{ͳ:H0Жn>at \Rd  `5>؅YI|Rg~j8I:U2@Tȅ#I2MV/ohUxAss:?}srg( 4`}xpnW'/"Y<_yB,^GKZQ s mwnj˽xwIY*bu.lP' [IaXÿB!)l/ua$Mf]>pmD"^^!Ff/+K*,y1}<x*ط`% !Bi8cLГ&VQ!v9B"f nA[-&>|s7"㠸 fp2Rwr _H U*G1AŻ-XQ.1 0@%m@fxr!#]H KUPW]wNhui,:twTn6y$/fPqvBr̻PGױe/fj2g5 rm[\!)bQ9LD.m9 T BFh'߂w3ZHcp.ʙ֝fR!%]QCΓ uȭt wXi@Y28)eƇuӸg It+D*{l|0;ҖV'fptel|h,CԖw7 Bf8~B!ec[y̧)%B!B0zBB!B!G-!B!2[B!B!MeB!B6l !B!4mB!Bi(%B!ҴQfK!B!i̖B!BHF-!B!2[B!B!MeB!B6l !B!4mB!Bi(%B!ҴQfK!B!iVr ]-87J-ҋXl6.'=@i\h}?68JjpB!B*OW`='pm0 G WXf 3G!B!Qxs1綱:;׏Vć/T]v5.{º[sM50{|%[Ƹu?76pIEogz*B!Ә@cެ|Jmզs"%_4q=9EE7N/ٚɽՖwoլhm=$S1P{)mկZ`,u#L&&A 2Rt|֕Le&~` kKgyڪEﮂU*̼W>R|#E[%E)CqF`& K퍣8w2=8Z}n>7ҫN]\x+f<~p ZyƮB=nN-޹`ci0cvw[+vN{yItё?=7v@6#VB^ֵGHӿ_6&wsk^,yݹ8>%pQ~Y2C齊>n'c/б[opNso~eǝ{6gg˞KKH>> S9iwk/6ڤ;u=ֹOXCf)8_cJ!Bө13ي;<C'㐁zxv֪&-D\jC @he2aMI: DbXU}zNJu(cAx.@ˀ7UބN%4l۬`& #ZdVfT1Ғ̌ h mk=G|@_y[1NZYK0E_&+p}|PyD!u[uy󶻲Ӯtz軠weϟV⮯}Zb";R{َ>`iKKńVlH^<Ѹу }"x !Bir3XY[o"K'!(`U,JVnhM4iJoxA}pxWO/椽>-<%J˵&!jFٻ62鬿D-";b WnԔd 6ĭ#N]#0h&4D'~؋_iCWUV ھKo]+*)w>u',s?~w(_-E-;ux@JUsJXq$}}jalSm=j==,EܢUq^Y AR(榦V6,//0g6VW.;e^-lֹ,y8&%"wB!d59Fx:a܈*suBQY/g>L+;"Ie>8D&V{?DTA0n#>>9xrҝf.0s@:&fRZPf sӇCK~tu@rҲRy6״@po.f6'IE処Q՚َr!7t'8Ad{ r]7AYᵂ,8v3W0?' <}B!& 1wӪU1/370qȀPi2 T>n͔&ŋCKYʪ2sPRK豒{x*2Jp2e+u1ӕ%ZȵLZrfO/jy&&곂Tiiɕ0AWN\?Ro/>/ͤ$L+WҮA0,H/{$(GT@6"|1Cq^XdmxpNe\c:"XP`&+;mxT.PIȠMTԆ6yh"ǘ1m{\4! ehĞne!ByjM@l6m2cxW*tYԜu/%eo#2bjx"L]p v/ܻUchAC?h2ʗbMֺԓұא#HNJ|˚Mw.k+G*Y%:+@Gs1\ v!4mܤ5'f; zzO{"(';Q;,[H+I&fvj&LKLUBq~~EjmR˂B^%kwg͙~q5B!B9C۔=VLJHjl MٵƸ?gĄIi5gAkmW$e{uZ(.+RW)]c%%%9$ι~`gh]UߌI(ұ~vZ10GkWn?&0qNy:yQ_.6"'0iXT!}uobѽ:NP4!U+) =~Tb@[(%B!M[cf 2 &kl6p8 ЦGkR"LH73lѵf¥aA-VYnYo CIXVc4]tS<(em:> "k1g\|#u^-CmOLvV6o0>ft£"̺{ok=I!ͭ-NMհ;_JQe8Sմ \IEQ q_LۻrmP ]֩su=P-lp6и!B!MMcfJlfxse~͇i\[2gD PkyI&{6=Z[q-gK/8isHSQywYP9SH\[|+qTmF:BN窾A[5WUk%{ (YP5 j~yqR↕٥ 2y S[)+Bme,eUC@P5up\>ASO-Oo^S_=E@j50QCY 16T?pyuޜe,6Ed];/ @Ġ*Tj5د$Q$va~v/\*y[eUB!B&Ϋ /3.x:_q>VQ1V\7x^]b2 ~v , hQ fP8+.=w Ahˁ`TX:ʛ)E,9QG[FĻ@ }!jI!m]JcKBw @9Zh+$p~TKx{ܡh%LU14Cfpd? &XM㨼 ׬3' PG_@Hz4W5y@ȅNYqp&+g geωD \3XB!֡q#gwOx ]RӔHuqASi|܇y yB!4:̶o|SY>LDŽˊ/JT2ˎ-,ZB!B1}FnU!6C$-{mNL 74CjV]B!B"ꂐ/鯹eb``z-J-h-!B!+OKf p!ѝӯ=mْQB!B*fu4}3 eJRJ֬j|_l?hi㲧Xh9Jc>r̜ 6hrtcD1X3N*9U 轡)w\-o)ݬ4Ԏ8ݽ~f+mh꒒K.hϗšómD}gOaIw9FJ-TJu͒!;^[9,$Mn_.{ȭl;Ɩ妮PPXt@mLXEA߽Ӿeu514IM) PGɣd"^ jEM1$,]8Rրŭ=-UJMxTʉеj!pԸ]at0_UieS=D9Ա'\Jl3Nw;IaB`63ɒ|7oS3yNNq }m*73Ddg{brXT\[w'KFw%x[YsC˭L3w 6&}E"ѝoRuo;:M,niRi,/1d(LQ@qv 2_ņ[p!\?*EdCލTnU"ؘxM} d& \hYd0ɝ6D&0ʃ̝EZ޶|;쪶/9;YU񮼯TZt(Ry#Z_&mܢ4$Ry U.9ҍqbṳRݸ?ohx0WŢ@qHn@zuzuM--B!OӞjޥe5ŭ߷ɬ!h Q;131rG??>ߦAkuC_Rv^>L5}Cz2 $ޱC7/nnDQ\l yggkw,4E~ 1gwWhܴ{Gח/߇l\ݤߧGBlgo4@ij&r&L0.K?8@ƑXn|j;:Ks^xw9C˿ƍv 8Ng,Gn;LX(YsSzߛoEʺe1 X6zOֽ97H^V6FW?z[v~~7L-~&Rmo&ALg^̀|y~z]g0v`įECjk'L`^LOjoxkk+#:.]4dea13bUIWv+[<97:H %y5'.;na7SC:#-/w@:L\to˯[H~Fz7Q:¦W]:L}|\&sp7x/}~~]^uVIlT.lϮ_TzR?J饟wfvu6!{[OxZ[s̱N{gG8oTQ8_SB^K4$hҗKF{dpWW)~жPDEc3NnUe?/z ɻ/\0#~H13k^ɲO/տgu+:.xBO3y3 Ϻrt* -h?͑Wu+]y> !=m/?3PEݏYC`ï V$ $jCu96'ukvݑeMqo<͎_ $$%se]}1 eoߒwV¸܊NԿ)g}uٮa72wqmބR~]T%ssݻuǺzH_ӿiðЩXNsx޿߷B8i-u`Ÿ\}dFK.>8ri_x)Ϭ޵}每%/581x'p*V<>_N۔W*=Ǚy3uh'_>{QnjfbyO[؟;O5o=ӏfg֭֝~q/W~Y)9N]\ Df&%\\1noըޛ;הpk)$J=A.KW?:;wgL}ƌgOZWOh=VGeШm,](~+w?<)=2qƷp[ gul۹5k~<]׫Xa6gagדO6b H3^z뭿t[o\Ƅ lN #E ƥ>.XFayGAߟl~ݭe[SI+^8ubᛳFiَ1b0*~+IvXΊAv!_YO+rʬIkM; ~+wV\hޥe'Og_*7ڥլ)X쑟.:h7 vzw՛_Xuꛠ}JB!Ik/+} UK[1s9$\>6I u{tƽ4o yW.-kлG}!+6qxwWſ\g܇nH.6>Sn{'? Vug1]_8˳X@h,xƯ]"hp56[i5t`9FAΪQ8ct5ª{LuXMSg,2oL5w<}^/jٶ~Qq]/1faݧ0Z=\^N]^9819M}2TJݶ􋹣|.v$aU_#xZyMY3_p웈OrDC񶗶3bUpTY;4bK#z"-0gA;>^}>ӆ;6IHkg'B.''}Zxxסj-Pc4t BHx3[_6N-Z,8 D; u_N4="ͧT.vLB_V6C @V}> s]8 lγ8á~ׄޭvlQ<\vՓu>[FV͌b[{>큽v-sk ο\UFaS~,xy=z\sFJ+ qc8K q!6/'&N:Hw"BVsvTi[I:Cr`p8q5Fz Dr@*KۼFx؍M<?q0Ə#GCʣ >$y}}!nv\a zRbdVH(*IP$G+?qlE٬]o_H;ZΣ.$]-s>+J@nYznN98gRG迃fŵ]ɄGI2].'.$c{a$GBEB񽒂ݳgo [of֭{$Z)8"Щ$'էzyK{ͷ.6RQ5`\D#3nOkBIm[KML%zvu@ &B=(TXYp5}TȥYYj&ۿY#MynР7 ])+뇻7siiExƫ3s?pn, l|l>(Z%6uh!gZ>EdS {#XػO3a$j4tzyU~6bZ7a/kqgVV'58%9U;{؅oM{ϥ3.}j϶-7V_IJ$R蓧>ڱ7RzeXeY^ 6{o spF Q0:'t%UY¾%{A@ޯa(3 3X*_vIs]G-P!g7&ma kkU|D-)M8v5yk@7wB)zW }]Zexu:5T{[0ȶ{hŻ# v#ڢk'ަcg @B61A/_%:]ՔG=~?{7?=S}+złBlZP=3ǎȠ9'Gp2[yY'$K _n,(RMMkI27"0ӈS xx lo!Y2"hn'07[}༰ XI/{R%0l1f|3@eÛ ʟb܇ W 5o]cvy~O'~y7ƶrɒ݋ZhV*!r5Ue=:or7qG^^]SDwh5UǚrU5`.vu@M[ BӞ:{eB/1Sƞ >Lcu\^=0us_-[EQPp?Oi#nU~Uk݌?h5;*Ε}?ѽxU~"_q {+pRt0ң+w=|ku{INlV]Z;\tɋ=2:sP_6:`Bhr0֝en)> %-P|-k=j-x;A@Σ7*=x&N嗿:;2* oTW(63k*Eܧ܁>o1112Nk%YLB6_OC*+y|f9@xŸv`BL p u[ٸ[nn^gn,|sΞKV1LfP]T79>|\OG}k.iukQRbsT =q+N&%7re+}P#Y ϶'9۹A^ RLTMhwg}3~" ׽zȅ?ITx%t%{x!NyBJ?eL֭{PZڤ ns/Okۛ`pe̢"n$)qc/ X5pܺy3_;f/tw֒C3bMs v"OjJ4^tIS)Kf{Ew 4dФRGnGFFjGк|e3SjrשU>n:zۈ!`1[R/oƋ'8 e‹*/bߚSd xI3w[^ƻoBWd߇VTM7ʌ^F,k22ۇ~.m-H|3eֳT`1"@[B`W/6v,U0/)č >^LvqkϠS~Զ٢kISF6?ZvB(JD`KA߹=_FKP._תY.̂M*;kTnѱ'nЁ IDAT>YX򟜯ӟ|쌝BX[K ɷ<@N=?X:Ϛ 1sZ8zMrDS0qs?㯯\{{Aʦϟ.Sf.^ORRLM6]E^߯u;[h +'?/|42{ͅw dUꖪ95n8w'խOosFfzޣ߿;ʡcB,Pe"x7v/oγ>ޟ>eq]G}To[ BSŷ~gZIٯ`a)5JR3ko7oϾzc* 7>shWj[ _8q,JpL կP/]wћ[lz+Us/l+T9tDž4h-u 8Fg^jj-N!)~'uEb#bvX15ʃw6-->72&$k?^|껥URNQeZԾn#WeV3 !YkA^V@ZB@x~ψ؄3*dUwgm0nt|k<\7v(I-UZ +,&pLSܸj:F!yu`]4cS=8 PE71}^L9/̎,;FCH;C|h˧{`L ]2tw6!y7މLwP 7ߢ+8NHzy؊;d* ͵*M*:*VΛ7M|y'& 8 eޑ91FLFi$;9HdEǀZCD C֍oޞ|!67>@ԕ312SXE0lk|!F݆3Pi_sC"gϖ}~:F!'ƶPl fKm&J,YXsfR)K lB!0lldB*Xr`#6kRZKjTyRLfb.HbpR5LhjB(n%!B=lI{OveC[62dTD@(.?'Eld{^/k/]y\7O<ɑ- jZmk]7rv, z賲1%[ry"*og׳,""""""z -ٶyvߚ, jhYDDDDDD \tƲ^z}DDDDDDHhd""""""mlȶ1%""""""̖l3[""""""mlȶ1%""""""̖l3[""""""mlȶ1%""""""̖l3[""""""mlȶ1%""""""̖lֲ҈V@+Bäa%0K0XV_Kf+.p{ADDDDDDšaGj`:Pd:"- rIȖ-M60=͡( DDDDDDd[Dx l<- D[2-"j W.;hXEDDDDDD~z2mQ~DDDDDDti,KVdfKDDDDDD󾿡wG""""""uh74so*&"""""GЃlʎ-6fDDDDDDdۘmZ%De!Pc3Iy'UN"fZb gT\e!AO]3ܼYeyoLDDDDDt_8:9HMIs۲Ȧ<6~T=q |;ThTaCUA MǙ G,{ݭ$h%2ؐp=IJDDDDDDF,*k>ıC6qlE`Gޟ2~M6CN;o+a&wt!QIZ`Ao>XpD( :+LI +e=`Yb.33s-KmjEaڄJfɿ.,S53WJ r4Sf9)@+?MZL=(ψ?j+Noߣ㷓snUY&}&9Mu5qrrLq%?. lHw3Koԧ&H$gbv+ARp\` $9{e=Xm:t<}ei_cYy\2[, |]fYMkde+̼ 69tfew6:ؼᗓǎTʋ'ЅCFʰ,1HvZABwidC֖IDDDDDtV|}oKs]tʼn=}|-+Je:kPQ#6{Xs?q.|ٟVk@~5N VkDm` x9_ɁLDFүdu]g A?RjRu\"""""BrR+ =>59e- *7U~KfT0/K\ƿ+{Ueys\\.gc?E}p0Xן,'$ r_<Mc=:NISZ '=j@g \׽;\/ l” ;LAVY&^կqA>rܲ=i=8ʭDdy?~VYh(؋rC{ e?~_sK"/^5ۇdWEjYh,e9KN2YeDDDDDDtDDDDDD{h5` م}0%"""""z줦/9U 91ɲF&"""""Ξ:^aL>DzΌ eUCj7/\5V# @L~κkUj)eY, !RɆ4ᦄ,[p хժJj8k2{c``-|]43{s0\Dh>l<ԭ#TC-*@w]VUϨKG"Η*='Au@Aޢ=D˧V4uBo19U932r%"PKsZq+F>oT 'U3Mˊ eFg%&D'GQ@C-B Ѥ@aDyNHLR.$+7119WcsF"44!W$/ָ^BF ʉN . G]Zr߀M:3q>N* `4qf`lOE,oj8z:32CJlN} nIhʾbtr0E@Cj b+WX= C^-HqQ_'no& SYa#kclrE^ _0 +Ep-ű\2GJL;zZ\6 9]i55QS+ P^w$1lSajjpՀ6gRd '@f$6˒ݬF=‹l]`?Q_n=OD%fQU]SxEVIJ04.#5/ ]puɊy>\8V ١O,| tjcѽg5*LX6[e 4-j|ǥzlڵnd@%/Uǽ8{iY@MܨO 8uZQ "l\;ːdmwnAN]fj<7ocՀ/|>Z8Hrzw 6=SYae+υ4ˀ䋇?v?9x n=苔1Ƕ.X$vK=}xc|kN]_}KDEftk=jmi!k7,1ĸd8݈:ZCSF]ݷj#bDElt/86t!x2UYy8<W_j¶iֿMl@~ }j:N58:yD[ZlR8kr0oCv17Yoj ݬ{^%1tެoBhܿՠA#x9ߚ-Uu{gSGIH~Uk?;wMlO=VCvȫq^dl?3-Z 0᫈< ݪԸI}`_Cpg|1 H7`w;+ňa˹}2[F=_et1'\ꯆGt3x1ig{)5&C'pw67yIݟJv*)] hS8ǎȉqrO2zG,4*}:VC? W>K3ޭlGصIXExfhJl$06ߊق"7wf(Ȏ-)/ةՕᩫDVm6^uѳoe]@_Y1IF9}9}yS]}alەU6Hnm|Y/GdGaUm:6ͅ]׹V:=3̗P 924_ !:so<Ozcy j\ͽJsWn6dEb'1+]aɀ|78i4kFq^ { 5BطX[_3}#ܾ߀V|_dHM U|1ޒ-@֠?]٦-7v րNljtIg*bq*W&QD1X |am:σ_xK ƚܟPOH'$W`K:*՚1+9Gaφ B:0w-/n1].~@I.ϪS_l  )1]3rp[юٗ2\UC^7D3BC3|}o 2oyg^ѡg ?l17)+KV c A/$A}=`>Q޻ÿ8v`}ko%3˷ yw:IWnް;"{Do˒JU[Nj/2MUXT0dpJ.ӫp׈6끒ekyk4>.t⏭?78SR) D?ALYC\7^.Wn>\}_2N=e7~2e+f$X""̶(UںjXMY[ycUv'nt(@Nvtido'xm.;']8s, ɊhV;36_nUUosƴD2_vts!Gn;Q+\nQWNm Lb`ӜɬѤ5D.iif4&hp>Hq?-૕|^nktF NC?/'-~@6 -Nin-9$8x0P:(+q}tGț(x`3=# 7ru~siC)-W.ѺN[>%.4.oP uż9~_f>4ұsIFՐ\.e_o0)C<Kvp뜥\.*׳iY k?w^tHtVFVǀ(ܝD;[moFR<$ưi\1@NֹVDi-"`ԉ@;o$URfk+ͯjzSW%uξSn6D$mJN5: t&HZr~uԯdyNF;wkk;ui7U1)R_ Đ|ww7߅U1)R·iB⨵Dgm|z6pl|_?LVpcR 647*%RXa|klɱ #{;.UX8i/gml}>Z!.!Ylg ͘'o{>FO9exJQ6T"8N竐s!O\XIO.(w?λ=e˘/{u0@ሑ{ Xư YQJ0¢4Oa钷SXv\( 7WI"cȀ)E W٣zx1f)B mf=t'f3_Jq֕~;֥k8^h.Ys۹O]Hr:Gˊ•wBkK%EqoAIPS[O#nQtg):5btw{ ""*mH˖,{4¥L@H-kO:p]]r8okn!W[pO0q@Y5 [yZV:rnu\6kN_#;eH*Ij!2L\mb0 gPi ]c!W g 93pM:#(s`_~=iӌ]kUr~5-&tDZ׶ ]6[)NmP=V9}Nn d+hװOhT=:SoɅHA~ùG JKi\Vx_"Eݼq5ЫVgv3Ίi:S5>x+JW$>% 4~Aݐn@"rĤf{ٙuuŋB#,c8EVm[K[1ug_2oE- >M ˴tJ͕ڗlQQVaᅶ.yr6Puqn6d~ԺdeKMxw( ԣewqDzY:ӗ|*VUH^|'"AsRT&ufut^S3<ˉ fU'.!78|Z|Qc_w&Mŷ|NfGv l=ӲBՆ_T\p𚚮.VC\>]gIS? i,Vjnc'XZw%UllnUŮ.RU\6ʳkBW8yz9=z Z9|3ݛ`yȾ͢7}Ι;'5|͟AO.b7W&vGn .N?=vj^‚I+nBC@\n[g71乗8vstnУTywZco]=Fn..&Ѕ|[uN l;["RV}j! O*Noz;^Bg'T J&ၚ'+O)C:#j:;aj k2['C~p M^ ֽ;~ݝ)ZذiGk)Tg|#~׶]YWhz '`\9fxƿ9nD&_㿋G6dU".e=t竈V_Td96ZdLP/0D]/|;G=Ro56ݽ_[!dIɭPe;U+8qG'`X7tpm3FPw~2oczD^py͕2k+??u*7 z{ }΅2F ti>|vGCFPu-п_nv+ BJf-ܩ缍P[]I-nMxmV~r|V ^{chz;gد+bۂ}sql/|O'@Pdp͝2gb3d@v_4w$ [ؓ/8t=Bd@R5+"7TJKz$?VLmjWG 0}BΛOP"..kuZ #)5l}J!g-]y̧{|S3*۰rܮ7ʊ%6K֪ue')ثGxfW\řm78MYY>d_g7mw0u:VFءYnk%6۷KQÉ^4`YH{=r|ϷO'":!~;r r8%. ;]K#P 6XQm-dKwBy }L=tm!\Abhj"{W#uеtr44C&@SKRt⢕s\ i|粒0تzb梫 GAiӣ\TVXpڋAJQHu ݔd];ari`\S` _Uo2CG1vbr}5; /4$ۂtMkP[Pbs Tpyҵ9ӟg#EE2HaH[`k'9@p jJSsb•㐯ZU NBPp3T@"sNvum`z%]CM6ꤛ.C+YNp2t7iZa]BqB` ٦Dג ļ6G I:ECS҉ a8 z;—_| ߀t5_!_djCS}[]P7@S kѝF]ރyD?hp o:ڛMpԬèDxko} TkYo هrfաA qE`J )l,Zxujv3 .* ϔn MZdn$C%K'J}9)^ mÉ?;e̖9mqG3=pڪp""}l \'XH~k'$"}7_lYDDDDfڌm4j4Mn\&x0ÖDD6|<Ybfk3BD~f#gQڜK[3/m,%""""G3[!1dųOU$""""""zh1%""""""̖l3[""""""mlȶ1%""""""̖l3[""""""mZX=~Um5FntW>}SF+749/b,ܘ,Ѽ]VQk?OӣНQX}Xs;vePղ fYtonbS\T J+* A>1e=#b#{a__;r޲)g9OD2̶CtmhyU--OCm=unX Q[' jh&"nֽ*Jq*A 4οƵx[ξ3wcrڪ !ʝkdKv5Pk{Öt44nu?%C pֆJ"F8O#϶|$+-n~owG14*+ Chֺ{0@@c¶]%y :n٥rJ墹ywWtkQQr˸RQi{; F&SLh~bMGA$0䟥,_sԻAjs_4nיִT7eקTw6ixPҢ4xym-K1[g9!ѲTu%ݰ{iu:ʦ=xy9W9`ܓ~zM3cRJzqk]= R :oYqovI*z>_/l=sñ-k$ܽ?JY ڜ_~wEyzߕ@{ mge{|2~,^-QG0G#[%|:ӟdʷǟ]x|֨g&YVKW~olW]nW9ٲ\YvBM˺]o43F/+y`AD"b3 .AW; Xe^{ytOFNBU`eP噥;ϲvvR:SصIKqie]i2 z=LXx_̉<;}<J&D)TM^}1VyI1WX+' k Ų(*]= R A# N,.I%ܥ޳ʉ|~H+QNeKMs1qɆ+慱wXS"{=ObNWag(Kߣs{䮅#L&w,ft o NE&@4ᖄH`~"jb@j#"@Xˉ!QrĔLewb\X9[D IDATU4@f$6˒#54u< \L'#N@`&TQbz&W;X PN41.bY< ?O1=C WnE-`/Yf^1J{ G -""[tǕh޴p'%3u`\Z@ނ%_ɿ@S'TSIJ\O*4-V|2mTSO5 upG٭p<,)%=PED/rɞڣ(˸+ nQ p*4(@)GbeNh/4^bއ=P=UE2>l;l o Iʅdf>z:z:#$ɧ`k\[h4q .ZjN@}debPZ~A¹Y@+ 4gzR8hܿՠݵ >p(|n;zf7o'WI 7뛐1E ׮W+ԑ#fn3ꍣ$?Ǫ5}=3v 'lxXt7gn_7fN$z򖦜>ڡ)~m-x2SZ|s-}v[w(vOE"yr#hBe+υ4ˀ䋇?v?26x?4ec1m^N~g0Kޚ雺Hsl%ɹuկA)nݰnԬVlSlZO{W@,E0uНC%߮n^.,v{mvɲ<V#|_VfwH#Ү^KWBB=9Ws_`Y`Rsn[Qa²*}#}Nc;s#'_v֞4m#OM G4U}wT_ŸU[)hkyk%S p\#~* nm/m9{l|kW]g|kWF{Ԫ*vYu'0G];HOяߜ2f t2 Z֙7QQ< h_ `;CQ3Elt/-Xʲ_%NbwwX.o723KTqWvX [KJPᣗI*ߢvw\g*ʆȬ]XyF٥zZkg(JrT?g|=wER/rTY~K^{qܥ^oύnn {7v ru:0Sry+CÒ~%#P^YC 磫HÈ+|`SmVՍu'gwzv}GkW ^ =智G';V0D{/bk:D߯9K/?ygmT{CRNho= }UAS_}շX3p֋ ~\|oW;AQ OԷRy՞wD ]]灰Vp]s+ }Ӯ_qq/t ͎2M'els_o:@gą$pF]і_bD\3"h+~<{xHy ݋f;ոG}26yҟ)mG=] $mQN jZ @yzG+] /|173쏐PonDyȧ҃gO|+'Wbu Dы?M3MsgV)ZDr{瞾~)sD);:btIo$@0(C3e릣|sߟ;xRo]ugl7mVq O hȿݘt>[aT 'WTpc8SSJޚnE[EU+6>JSpg7$ V2އvZy 8lp.IN%q:N)U͊1cj!QR#?)LNIUnu*aZI0ȪjdX?am[UOeA_OYV s'#| (F՚}~yۏ_g]aPq~EX)TYv;:>Vtp/BM͈ĺsgOb^S\x͔t]GϴSw\3h1 M[RvԐͷI ^mē•_NZQhfb/X'j vǏۿmՁp~-zSS'yPXxggĔĐǮ%0x] 8w'3l.rrd0$T:eOh xcWnO*IL[K6f&1; ^UBU[׊Zͻ'g*wDo;\>s|jVU]}˰l׿]-9Vfp_^1NLoҚl3|4igIxd(9 ; j[% :ckck{K^~=]|^gS PX+[];]Gvf/_dyuPͯVV^̹v9cnҮRc>5m1%tU)$0eY>re|2ŚR77d_urCZWiՌ$Wx.r|*[_YԺ5lzkCnu QR8r5}w_ ՟W•rGub.1'; ]%{k}r)y$&6ZEʫJeW^NjZ$ZwdgF=v":Sj$~wc!-;{hquIK&q\'+UAW'G.pX7iuz18$RrѠ/gtJ !Ou{պ*-}**سŔk)A?@+a=++qvss'zv|.I,R)5cܿT@eY)$Ƭ6Z!30ODމ7ğ6e%JϞUi9+`t᭯[<%ּb)%ÝS;i^{ 9<թl42?p(Z 5?(Pv|u3*3sU@H |2Φ:兏2ɻ3IWo[TaL3&hcE&JY+o$76X!OX1-.i5ǘaT,?+%rY 5pʤG 8|^csX(7T8:ņB͘? ƄEMڞaǁ vlzO֩-bD{79@uHTY3Red]Շ+Zo+3qޯ`\q)`4Oa?s} c霒n}hk~QBff] bL7 j؅#blf}Wx6c@Z[|hnKh)6]$ E|Q,Kߥ@dn,3]YQh!S-ݥ_T6ͧt!`k#&eҌֿVŜ+9@BZ,甕>!Px员8bX}_QSS-Di%:~e*G_nmz(՗Uv䀮ƿUM%x–C;!%*Hm?b e( jFSXn-7<=7oZ*u{)1Sv/TQͲyMVQkxyY+I4)˜l׈_kWg3.)[&ъ򜰷v\zª x'IvڹmowaBDqߣs=:c.}e)R${wq>4)SvpwИB*pMb{-pdUyd(+X?a3p*,[WbR`TQ?:Ix%?^޹zjNISBd=HRK1TLd9}8Fo"{a*7aE5%XN Li!}1/<ta֫CvWF[$ vsǾsCHN{fNl&rlW&wC[/5q]Ԯ {5}j;7xCpU nrż/WI@ Ȉ:ѳSu+zN̟j#nE$5ޯоqB+Յ:6%Zԭ;Gl*@Aӟu>> IDAT#&WT+G<>T] ry2 Ӻl!K6Zmv Rv=ؿf D@+tȆ*V}.zYVn{oYs!kzic6^Uo>nM04cghWJuMb5+X5<_Ë,p$YaL"Nn=}[6ᗭI|'YRw޴cO:2nO뒊Wciz-QR FmY]*|9o eV)ï/[ۋeYtf>ڹeY};#:tJ<':Z5I4)C\%WaC# [Ylоd[֔[)p0J|@GGtx/I4@vN:MZ +_ݼ]}4hnsSWWNQhEyt{n.o sIlx voby̛XpmWi/uI' _ _U@'[<\x#оJ5=t04kjcz[OM\8~-Fw~olIu ̅۶~Wfa|ၐjݧLӭYˌ#o6o`RYV5ŞSS\:ƈ-HOlg~c殿Wj毞*9ϙ9h8c/> 1تwMf}+mWKgk.eMM,zIx' =qFtNrrCi|Z)  ޾R/'aNov nw+sI]w_\;/PhW-׻vx?-P\(Mu|"廪hhL44T:f嬶Sj{9H GCWDEcڊv?q~C<^N³4_ܬQS_Z6Z4cꉁ*.>Dx~_l/Vn<_Ͽwg8.m08a9rr`«}z?\g˱:mZ.r c|u: 7(NS+I4)˜([6dM,/I,}8aU(ʭ|1'2s)4]R,6$ ?5e*kxӀM'vcSݮ+KUKT)i&{vluۡa]P3[t:LwkXCO{1Yyi'|.{՛䂢$e/RI0&V*v}wdg).>3mݗSo%*(訔i򤡍z+ װ:bIq@ao^0ngƺOݠTu'ٖM~8;KR?q__<꼴K;f6nٴ%0ĜvU!'9#`܍zpS fTuAu;^kEtgʵ _+5`(nxTbJX)X9I& ^<p䫃#_d:@@aŵ?y̓ߌ~-}ro C;LUS҄=~B2(>IWGb/~Rox 3?h9j@;ZuL$%ܯX^2сU0hJvr`0OфXM,yTQ2;W}xݟ|=|y%!Շa/h!_ -],W``mCӅSGU%3]8EmjmW(w _3/&Ճ葿͉!V|ʽOѺ.qCpMCzYINR/p0Bv3Ѷ|fM//lǑ}-wPXE SO~]ɧ# }i>DO@rb l"VG> AǎϾ4}@A )*T#U 'Īb^b%9򦚸چP 0+zqUTթh4,^ [4oeC$ASns)N=!50ԬOXTF9 ҃FO%(fCRFVTOAaƂ#t-juE{G%)^<%,w<DH y[+_G {a0 (y/>,:K$?V/H|c{ƀHVkxLdQMmXNp|=ymM0>7$>$uIyW#'%; 8BaaCMJ#]LWn'K1*8Ԃ}B tv \q0XVP&Rv;WՔ=@$X/TbU[P$@j2 % t4wK]!5aXiaSTWtVD5r4MAnJW++ S,7Jv cѯ+SaJnbg˷@rTieŏa-Wu9MNp}KOWRȓ0^Fp-3ProkAM+PEh ޑWu7ϼ3} \D 8mg>0aFz%mʫ3:=!9E1^z26|?=a _f]L]T~u8wȖ"&w ?v֨yA4M"=,1ar0Kj@Ȗ[&օ}OePg/~^e#mO} ׊QTӯ[QIIzr},ZT&5 =l^HRWQ]@\. Ya{vX>HlnipvU9u ͕exm[GxLMBS5JwRnzDФ6дS6ᰐR=B^z_QA) ic~G QUQvʥnk'?r6#1ǝ*Ue;"Y%*eb%wR#CjhǴUTx *(8"OY%*%L A.oqYxV9ň*6FDDDDDDT1%"""""-UllbcdKDDDDDD#[""""""QȖ*6FDDDDDDT1%"""""-UllbcdKDDDDDD#[""""""QȖ*6FDDDDDDT1%"""""-UllbcdKDDDDDD#[""""""fd)DDDDDDDaF)Y)DDDDDDTɊeʃ0#IiKDDDDDocOEn3%"""""˖)Ìl7_,2ӱÌlӕDDDDDD! ~-O,"2(HϱL<֠`.版>STdᡄw˄܅U+ڇeQd7}$I -ӉїGBC k(ܳ5Qd;j#A#Bz$n""""""*@/#(󯌲D&-QZ<"ʌ-UllbcdKDDDDDD#[""""""QȖ*6FDDDDDDT1%"""""-UllbcdKDDDDDD#[""""""QȖ*6FDDDDDDT1%"""""-UllbcdKDDDDDD#[""""""QȖ*6FDDDDDDT1%"""""-Ul$Qwi6(YfEQdA&+e6݅"eU(ltvN]3SSUU\ @y? P,sHksvhm,3NggXQ.ɳjM4@vVe Z@*kH):v)Y,RdTtjw6:WoIW DIwd)2g&峳wC~Tn^Xe^)X5u_XҸe޽NxaqApvw8 :x`ADDDDDDldm!+#evZ#RrIގNnn Ň,Ssi6ήi DDDDDD#[nd $? *>skڈ K<I2*+մ)N"|%$eb#[Ι|[2g<έ}kUlcv>L^,Ǡ )њnkVy;zþĿ"d`طS>ުFFD>GX l, ?o ^+X$[{dT"""""G=5٢/*f9Q!6̝V[zƧ[bmM3^f)eeaO8-gBm9p73F;gIF#6]&x:;+)j漿*I,Eݲ~ScdKDDDDDtr>m>6o[Nv߮Ȃmo}ZQVsME_q=G>|WLhO{ҹÛ<=T>zם}DmDij_ܞ{XEb " Pλγ1ia',r'"""""xfExϓ0 ~MķH|j$^؎`pQwY7ٺBq" .E̮u!#Mw0=*7PbŽEJw{x2la[y|{8ǣي@TbjZgGe^12Sd>gKDDDDDT؜coqm' Z:?6OmUP9HϐΈ3ul^IDx>z;ok~; Q|v~\: 3ҫO\3?zX1@^YpƥCUڍj"tsR=Y ?oc}F>=%2B@i>Y.4-Y0Ip!yVWRÐC & ǡ&lzU'g%xVTGh){駡D3m T Y N-DoAURK&ZۛLUĸ(|naGRZ;a4SY(ޟdIbq/2Sb0%"""""!?g/jZrBfjNB/*F>'++;3""""""* FɖDDDDDDtp62UllbcdKDDDDDD#[""""""QȖ*6FDDDDDDT1%"""""-UllbcdKDDDDDD#[""""""QȖ*6FDDDDDDT1%"""""-UllbX&t\<%C'̌Ԥl<""^<آwuT$ITSqQiiIDDyeňʃKeo]]L%"ocWQ#*FU2Ex|,a@x#[wpL_"" l+,{Ȗ7_"" l+,{ȶ,DY@m>"}'J`HTB{UQJ&:D <źjP 4pJ]flѥK5 QN_Z#|ZxbN=nxޔr]'³ngՓm*L<"8>ZB IDATmzjJf_ /C({FߕX敤>5/t!&"uu]Nl4tټ/lsߒlͥ=wā}kZE镤tQnO!*GFcGD…,{rfӾA<qGAF%@ti/X*reS]D_Wyx #\\死^x){!$-ep{ZV{sDZp]{|;*:ް\حD)%YeN[vm9|i};pMOo/BC\yoxݟRP߈D@$䒉_z5\ {/ k8{F)mAJ5 Ȋ=tUA / JHQOB; |E"EFmTPGF++a2QRePqK-PGzeDߏ;Dշӥ$g^AԽi":RN]bnzK 0yP[J9ن3dm(tqpm3%itM<ȮQ@ NeܔBE]VQKW&/55D2N1+׭H@U$hEqM6w+%@ *Bn$xj!#TFF@lnA0 Tԕࡱ֓=yȑm펁cмtYO_ΚAw?.߭z2٧%z"t֥Kw^u9i6~c epݳWh} #}2ytᩩ]Z|af^ҵC]1ζ7^^=,ӧrV Bnk!_Vtsw4I_]Xwy1?;<5u;pq-ST׳;z/Gk#;son^҃ LMkթErt]Ϸt YQ ]?߾[={Y7(鯅Z-\u EμtZUӖ|<?2|RⒷ;Ǎ>e֪Z_TWhP"n:PLB=zhゞv='0֊E!-'Qd_'%}?vz>͛iԃlۋy%ƣ=4t aϥъڨ&?5 C'#L)!9@7-4V Ík~>)deeBjrRN^thn7W\7axCa5 HrD>?r%V$Qe3ѻ*4Z^pkT0ՁM}]ֳ^f`{ѣm"f ׳3ܽgtthcN/yUYtkJl{lzSNn1桦98@TmO_Ǜj+Nd]YťEN!nռ2,>^/?]D&:]:4]t(gvGT"cX]glWAw,,[|?F+=ߥUӧvBܞ^[u[nBn={vlʸ +Z}Kl^;Kl sRNlJQ'{%;M45ޥ_Ԣ,TP"G= a? oM-g lÖ])@ܶ}O6 ȍlJu<3oS 9j SeC7Pݒ߿wO~5&Y}K7N{mjV7.]x~U֞N ^zz|f>By*8stɋjuV?Q&-\!'l= ZKnXXޅ!2t6dϭj,z J=}*[`> nj$5.e#[vs89 a}ҳUyé3~A<ԴtG/|53>j;mه~>զ】mZX,Ȍ/r0X)-~ j7gxc~<\W9'W'"A8[m\?5;@݇nB8/Հ_QrVTU@_1{s]͹́!`ҧsslLٷPxtDu}>!asOo{W&W|IIjZ Y]O:;;Ȃ쑓fZ 9[p 4z( aC.'Dȼ-v.UCc/\Ʃz8:Zxj^:zGRSٺ:8i@v"9oj7CM6v~N6iOkov@k=ģm}GQ}̖MH B{ҫ AEׂ"J!$t˦lm6e r_?>$眙3f{̙r@y`o;XmvXe$M_N]ij={tň9ٕxo"Adٻ~ݟ ZXAVV\D;,gH9]4vzCYϾ;e8v(7F[-CFi"w$W ⭏V qG62nQ tvi_yaB=/O+kk ./^;9+2<$d¬7;'*+@wPTfzf%+qH+fV 7. ÂAdeg]ria#< rX*} 3!K*M_ȷqIu?WC,~z䐥5Y}r?fP]*0Z;W !Th*6} '*I*:[qY:|lqm2tzWXV( p2WsV.Ae@bp H兙տ:@*)UnQ$xJ*?ѭ)ϒæV7/Ș>o_ҕ?v,&ic g?.wVr]7󹈇{h;>zV|mwt]OC?}>*Ri(!!5 Ģ'61Jrue<0 n9fMa 7똮iu%u۹m&xj\kD53b>w=R{:pC´ d\V[ʋ@hVpL1w,~8"+4F7>SM=qJa}h`@O e2/tw}+g51 S;N/pL{|avלFr[eǟ_lEbyp$wQ~9\u~LipC _ٽܾ0oǠg/dY$ N[!:qnz e !1pbᏥ旲v[,a<'1k:PPǻOřVPDY>.ѐ3^ 1-%uVSAǵu4OsP??M4w:$d{u}e"S4DaLa[z pT1mg~~_l{o}n W&J VJAl+c@^iYl-Lzz>\#$;MZ t༭UX|}~gUcLkvDk`->>!hm[o]fv US׶mg@ת2/Imn]Ʀ v4ߌq_M_4smo(u*%.-@p譖u^:1zw S |dn;>v_9j䜹-/Z{1'}0ϖ}F1CJ7V:1@I^޵|};W̐M#]99]faͣ5[0zpR*]U##|R%`r@p܍P.Cc^ ?wkg3p-':#Ǵ*d^dZZ z7BS{_6תUʄ4` زwwH?2rnEEZR FFW $ LXvrCށ_?8ҧ3[O[ (ϻi5kT9Jir `VRp P=gȳ3icsw|lN2W2m[;m,xO!h!]~\m~*s6WkqS6-Zinsܼl&69M9Pp/bdk%ˑSyZQzUv*řc (}mh]mZVڽ\=w!Fm;aM3'z=u91#F,g$yz{:z?tg7YsՒ৞/fh'2t< 6dBZo|jrqf[`&Xv)$3Ѳ35G"2tCnv#$m%:'%u|[OϽv)DXFZVI'7Fv^:W15eȤk g=M0/}N蟳f?c\m#&=B) ٗ|"bX]3Ɨx[Yque:zcn}=嶪'DO^02LQy#Evq7*i&!䡖*cBrA <u쓏MX8{Ek*K3l bcUŁrliBm!@=CTB zBROlZ͆CK>G"[l4'j4&kˉo}u1%:\<ˢC1rc 5׿>Tg#hJ\\;2ᄄ{AdI-l`xRA#TnڙDgfcڛm7Ńʡ_Z?rlcWaݡ[IJ?3 ac_:rbE!C"}-M4\DBjqFpdV^1Q6z~C8-G/|)uLeɇ~}Σ&tHɱ<;.|%xm;S# 5;=kUb§`wy3}qֻNb%m*NS9fڳTusw*icb\gY7y52/ԡH IDATy|1oUڨ:ȴwjꕏ$Rd5صJ\f^~5hldQiT=k+Fsv|5sZu4W.\Rw٣j- uShb-JlJȿ޲Ȅu;ɕ"SB <aL~=\9NT .|`B %5xg)L2p;o~UOnm^ /Uw{A!sn)LaINŷ RYErk`i; i*֣hǻ2\dz[ N++)f%lV.=6r޹%+)dM;Tᜳvtug©N0@y[ɋS(Yi)+QЊs ϳU Shƕk&M?q.W<%7]s; !϶'ڥR߾2Z?mBJZⷯ~IرҴi|БQI(X:Ba)Mg&ITΊ !uXȻ *u$U;L\vxY X~ cނw71 3XpP~L[ r T`m!#|P-X4rԵurΐxb'DfS9LVxЬ|Ht*tC^`N-RSgyL}RRŠ-Y;1, U޼GoتXv*+ Bўw$ Vz2?b۞W$&݌ x|7vזpxޑI9IОLI )ՇkL'76PtN@{0gr):tg>X<|pqK2(;m95+QK0FW߅4_-n4BQzdBRbܖyf F#s<)[لiǤcRfC\fUX)4I `1 Y D(ҥ_~@R< Ԣ怔p@J>][b KE"&3pVyֲ"ec0\ka 0BwJJ9eYL}!B\x¢Pb:-BB[DfKgE 4@3%,e4ԉ'2A7QSR} |L4Ʈ{ݲѭ x !CѲA&p!6GxDe[N'K! |yE1BO*KV͆z4iŅ11=hbeICU\kYD![ |. RH7nagmYJF0!gbܡ5TcjbBBVu="(4Ņ:ֲB!Hsis,K !&!I- !B!F2[B!B!eB!B7l !B!4nB!Bi(%B!ҸQfK!B!q̖B!BHF-!B!ƍ2[B!B!eB!B7l !B!4nB!Bi(%B!Ҹ, ifJ+{'7[ ׁTEc0WZBHcF9F0:f۲@dvv%%Մ8Qk(&F#KNn[,%FBэ4=ْͲӮBi(sHC-illUEԅvBH@G3b<̖,&~3]kf(47at4#h Mo^ضe!l|9S|-K okoYA!4`8z>w]"ww*,{o@HCV;*M_jD׹XVBl78x@vO7#Y{9&Q/w{okx %KwuQçkpyovrڲ=& OJ2ܽͤAG<ܹ:`U?XVWa̓.pÁQZʬBNLT{daN[aGXsLL7-(VҤ4]eY 70x, >q)>P:ظ{̬zղ.:]xc78qﺳ`da@2:+[%ߓ/BWǻNi׿׹q˺{i3Bd}fh:BꌆuA V` (/KI:e#Sy ߝ!xqbέ,pS7M?a$>_rNU*8|+lj:+$6baC<^T ͗2K6%~9Pl^P-|WsUy{Ų?}lYWSvKgY~?PylqOyR-iBDf+1 c:+m겣{ d@ wtR:9 5G/hw3Y2 .=wհFW eXuh!8@+o69˽nP/_g?>dYQ w_3PmڀuZ9Iw ~$1fa/ߩ / WX_RX.Fe(WO:f˺Ȁ"k\9ژbb6+,l?eͅVxqv~bT lꁑ#|]R`W^ Yj9]ݑ{S9NΟ[ TdJ>l'(mhk B-/>`[<V'*4@ݙ,d}(zl:܊CnA \MfkSsFU+!IdEhgRL2,kz'>i(?eE/F{0U'~t[}x(T>]o o3ٟ}dh^zW:ˆ]cOVmPяVKmq?)ܔ,iqP$">)-x mD5 <(CD_}9ˠQh w]O/U dK0]GpYǹ!B7?; 9t 1Hx`p݈Bh% T5# PxnObqFh+r7}~jјfe,RY<6eE_oMnDxDg+S9Ϭܦ///_Xb=(wϩMg*HSWZ&_ɐXrX~UW>1-ttQwsGm-x|Z޽.l>n!(b"Z6ZutV_+-_ u 9,N8|17 ;#1ȷ_w6~b S:͆Ӧe@6tivgYeplj\$9Ը lDFULɎ?MTj͉ T<"J@"3? V>s=4ɦr2nS LkN!eP 8/~2H Fv8 / F\riDfk٫ w}JKT]pԷ\Sby[xu͇nj?AL=[N~~E,(bO -|b3ۊxu[L<'z$ә?!wD<a;pB?8ALOvEUCfM k[JN\|mELJ};^- #/G4RAg|m{x7wVNۛMU0iL_p@; 'OUᳩ=Zv@Rnm'H[s|*2>ћ̔WpHϐ97O ث7ܚl0?sя Zx2Mck?5sdWNi%y} |`e MIΉM=̝׹S[ϕ?y i@? l,e䞩'* qEt9;QxoS&d}p2^Omŧ1c{{w~Tc!rG^Yqx8dȎW!CazyCz@ kKyyOÜmC-YUc߬ӂ'EHQ| q, ^g\`?PRm?͹ 봫>^ybN79zw5oioPX+e6/ӷb& ȋJ7'`PW{[9w!@n!c*nM҅~W5vN ukǹAbRZpEzfw7էgc+egx̠f9VQ XI'YNRU/&ml'_jOv=zTOnga0vړk%eOv0&7޴~AVu ח]t9"XaG V\xv^+τ4 " =ғlsxv`?+39`$ֿ+ %WNӤ/|i= ztU纞}XWJD=+H0WV͢SBnkjW/|SrpN+йkk>_M]e<-:t&K}3pQ?krɾ]:/Wr-;ٜş]`Vy#m;8wӁ|ꇌae`jˈ_)=Ƌ%/(/Rc潱@'KN;u6ж$䥇;L厎;ݶoҵ[h ]R*`;}z{qYb{_و]k|rNMլ3xnw{"Yew6{=[Ɨ7dTz~?0-[# 2 `eڤMr^ͳ9Х(;{m>=_u[F_Y9 :Da CF(Zf¿wF4_U~RӪ!f mwP -Op_Zx{7ވɮTӆmS%\{u{6B*ּp^~2UF*/rfW/u]w@&7{xel=v=η{]^^q0 ]zγ=z :bZ:+2[,46SZkR~w2mzwV(qɐqҋEu;mxR}" #<0rÜ,/_:G;sPk>8\([p\VI1LqإKeg-ߋNĨYs2뀲r)c^cQv!?T9/r"tRj;?ja,|ztqSFx pqG_V}S}j1q^_rpn5!--;zD8/|~{t%g~W/dC:(%@F(~կR MQY8M^5oѳW=}*$GM%0R٫8&cTd6{>^_+i.sS;ehR\᪀OF~y 8YM϶oH]C7cOVͽ8NN!hܚ\P))щGdOemYRѹ6C[:g&j&0wE7j4ׁv>Hr/39<;zՌ;mg\kgܒe#CܑM@9̳[e}Z&t0&dzB=/O+kk ̦<"xry1Bm@\Fˇߏ//@NP-ߋZhdG\WlɳL \p%h`'S_H[NM(uT\?@~iH&JrZm<^xV WuxVzt'X>P"Fi~ L35,LT8xgժWۉV6)"J+[=želˡ8*rm[Pg&T- 8t4#2!m9a}Zӕ .!ؕyd.&'2`y ٫oL+Ӕi<]29Q>bGUo=î͠U'sV\iZ6#?Om4[|j`א&~fOB9_o7Ĝ!F2[d쾽8wD&2;.8ZY}e9斁#ZwׯǝQ0$(.zYu*2Kqy;Wλ.Ql-y ݿٜi&wpS( m+i]23ꏐAdoZ(dJ@^R~ҏO[OLzh#:?|R!^ ;gp\tEL+)WعVLGTer팧v:qLT3JpzG`g[oLQac:aҩ~`/^׭^R8Pʫ_`rSr-Ug^UUWgm\sUMi+7@0y8::??D#$Nʹ1E-njL_fb ָ\U&)ݟ=Q,<~~֋_/NE_bE\y˪i UI%>9֟/Tܫ|=f.uǞ;vs|z@r ^y J7 HJ7 "'ogXO*>cjT'L ^<'u:{Jajv9דM?É3[.h@tA TgxXNߴnsкDd@=V8 +#QU_*ɼ^5 2LïP 2god r ]AU\PfKZFF^ul]c8@XRz!"4GsEyk8*ƯVye` IDAT\Pζ2C o~&==wouWzޝ 5kR 3 m7מ2}v{Cŗ{)2X)9'W^EZæŪ6G#TUvO @Vbs(לtV_3FZLDƯ6}w+^pKrԽ73 }ikxή˪>,<ԱJTgIYj|^˪]Ϥgh_9mO %VX ]cm ;qX㵱:Ɣ#x^e;_-GW}:c N `bNVf8ݸ -xj^ uĜWJ$E[w(kxvAj7QeڂrH $Xc)717cг, h_YE$UkրqTˀޝ[yW^M+ݺU7W(8Q;f @\LAyPbT5yNPUU87T\Li?]eC5 3ęCې&LosY[-'OQ 6BrX/+Yio'҅ Ìή.,aс;vp+?46t]tׂ mpV\]"%e >?+g o=5'¹E0Y"s<ٴ=~WԮSv;QtK͆CK>G"[l4'j48%8ߘUNeE. ohX2cs>i,W|.|%7'e<LjRiY%">_Q.yMs=l^~ljѝϼ5ǀsm֕j}ZmM؝%Lׯ &~/a9+5j렉l||%w|M ]uͥ-]& ݍJ_~&%}_!uiק{ 5^̘>8xn=z5I]}_(ؾ//xl kY[=F3Y 9{'!g݀sq~hg%YQJ$DPu֗~l")g1s]\uڢ#=ohG¤#`, }zߴ'Ggn7}kS˦%T1̀\  Q$diCFq }éxw7\41ԭ3"KLې~t+P."ʀ땛TO/bvw5_9EsG~ۏy10&v6XLqÓ^јE}*ّC'qo]?(xd|UDDgG;o@ 6_n٧H9QmT0Qkz C:r衩!r,gثh<11/4B0_պ6șFvad^X=+>b-xk-+O.Ʒ_BbcppmEs>[؂]Ǻ-2/wmHLPsQcOՂ!e/I;nՓSrReH{ɏ 9<ʨ ?z% 1?ne\0oY%ڈ$^=Sfh $QdL|{C#^ 1Qui AD7k~YN`kţAY+^3CQ: ZXĝA\JWXaȐHinjrSl+sGw1E'0_6mMl}HiIB0.K?2@a1Y(_%dma<ЬЛw9B)4g8;Xu=|95+)/CV{􆭊ey ~]P0 \ZO!)SZ+8(ByW?-g`9G@* B0& bnez+H1>%l;񎮬\+$^!VlR}\|83.å08y@ױPi (.-NӪ?Ūxw !V>@$ g 5R90,:D`^3dw$ULb"BQI }4;$gy/f3؝wbF *8g9[R)4x!fG9s.Ra?)0^5&2qLiUlaՎwrcaVnd dp &COyВSZRV|/_םE1 R8;(9'7XiN-Ti@NU0 Pّ rug9B0x2L׎x-"ȪhY3wj!sn)LaI+\#؉ 3 *d;yBVz oM@э4~N.1=̖2rk !M>bi3ۆ>!B!R?lI%Y#.B:s?&2[|iJ- *f̖4_ŅUZRU!M͈9HC-it:mn松4[:ֲB! | E7<`Nn6*A&2Miqa.~BHSBF0:NfYB!M>BHD !B!4nB!Bi(%B!ҸQfK!B!q̖B!BHF-!B!ƍ2[B!B!eB!B7l !B!4nB!Bi(%B!ҸQfK!B!q̖B!BHF-!B!MfY@H3TZ;ت~DX)-.鴖uB]Aѓ)-;;'7/oFdvv%%ՄBkﮠIHsCITZ5TZYB!w'!ewr,j6Bi mif2[|ت,Ƹ̈́BfD'Yi(w|3Pc91n3!lH4+{Z;;;併=)6Gl}9 B!M˗[k6B|f; 3=Gǁ+:+ ye!oBu6>bYAHE)!U>EM@vXhd4<B XҤ-"/(ݓqyJ (`(eBL=+KJB.e-j*v%/#y9YT )BHvܡSɝ!M6Bg''_.W։ô^}Cm[@GyD]H!σl[؂s*M{ D1泣Ҥ|.v"-x ۷;Xi]cbFJxq߻B!P>ژ%;)Zz8W8L;A^!ԶB[~hl˃#OfV$gFGLoYGird@wk ji KJ.mLqT&u b4"c~]mlIſ^42knZ+"=L Zr_of ܧ* HcC0 [wAmZ[_M{ o?VyHP3p!ӈ"w|E^IF !BHBoRWA"+(" " DEQ;$! 4/\J?s>3,, 0 @;U|y SADJV$+@́I{"*՚rtיGZxh?j7c1^?¦ImɬK6XQ>-:N8qobKm̓St\S4,ר#)|we9A+5?l㱺d6[JiAWNjU}˿﯊}L.y1y@ڢ\2ҕ_&xK eC c"KDK!gEX K@ut-C+HQB,P i([Z$ @![T&؜;թhS Q}||H !~U):-9?+Ru?xи4;5Pa=bUO[tՉ[gY+=ڿ|KKZ{{1׶M~]q{}徶#?:.iCE%~DŽv8C瞃th/[[.]6;}@YV.iWu3?H詥k~rhu$ґ]~1o[/??>y]Ze@Nωsa#CpG_g|H)!2;f +X˹ȋR`vg>cٖMRښ\|ܺuVcMm 9z6m v9f 3S9"kh`$`]dls.6fʰãm9d9.]s3ui>KVuSʳ`j]"Rt=qce8a5怅fY؞jufo}4c҇_9-VU&fu?:L T(ߺ3z>}NyL)vrS/<]:cjG&FҭO83[Tg!9o[Ԫbb~T)Y.Yv\{L\@'?~stpWgs㟟)7~}z\1EBcaFb_H,iʺuYwp~&!Vv FQ*蹮b+? @ᑣ %ce+ ƈ[I?dl8^p|gubАo3Cj^3wd]t)Ӊ2JOc6U[{`[SG!JNz@5pU@Ǣbdv۰C`"Q`Njq ?8qOMֹՖJzt܆u>o|$H){ĪL c|SyoݨvxX"ag:ݰ>ӌչ列 ].js_{37}ubN)"[Bekq&\qxL X| bWcϧS{ 4VxM[I{~s+{O 0oG-D 7v~%<\gE} i>!񕙛k*U:B%a3کyS6ͷU ײƒ "]ɟzLA`hhĕ[Bey!zՅ;Х˕F F'dͲH@7@;V.Qp'tms_?Fy\YQw-o 15̐x>Sf]`f] y:AΌ?oXx}ʃǞ010Jwr/VUŕ#UG^;yѭ]"ۦNH\j쎍@q[u2_ QxºqxR_N-~ˮL렐rNjKE=&Ȏ\Ol:)MŽJ?>Qs^#15do0噟O/\*‹8[vN~ykÁu,ݜzf熺%<>hd[f(^ mqX"϶9*;I]0LJDmQkWOvf-:s{ JrQ}vMnEK|WOw;xu~;+疷ngAP ų.C}$<ۜm?T\)I Tǟ<]}[9!=ûNڬm[|3_;XZjF9Ȭ:pJ` @a9{&.=~8~_jjq͵b6 &Kab"s;t8ٚYuԉrgi0RZ QyXx>[KûgHه5s-MP{OK8b!ǁ~Rg[3E:7\VZ޾uKwEmo$g@Up3G| IDAT-ä Z{ bSR$,4;7!&BhcQBeŧßQ6}4P229_×/xҲA?kߑ[ +ZK} : P^֧kU0P&iX{],elYsy*@[9-: cZS,HIGZ >/ M8eV_ﺶϒ`'J"/`g ' ݓx'kSlhgыjS欩*+(J)1posں6g8C`_h|zPVnwT\]&5-X}kx ͪ+y +dX{;&Bɬ}{w_R@hoKxEꄐGi<(.ȨqhE-\aS':6 Wnx/?ݾ0gZu|ǼK'Ig^iQful4tﴶ_p96J!5qت;ˏCo!Nl;ےW.Sߜ1A3[[ˮ:\3GEYj칔=艓u3s%<윜|)HUZ7rߥo7Zb'^x>oyV KvYt)u܋Cee9)jGHQꋺz{/.HVm0(9t9{8|F6î_ =w]p#A}lyG^=G>WeV[\xq{=I>cK3*!:9\'=r4I/qżCE%d$M~93Ҷkz$)>^<(]2vWK蚛"NN:H'"2"rADǮ*$wgW֭m _sOn}Tzy*ޡȡOt0y&(q"?N9B;?=F~nKQTud |39]l)ej^ݒq`%=t0ghxl)?2ĐcP^ Btl^p:x]!QQfM]VTg}ۗxoB$j|obӇgTᝇ@YBv?x姓R^F zFd_cJl]ѓ=|pdM@S䷄c;)߀o~͗3bDF*[wr $@=u"jv}Qk7(yyW#Fk>0l|oS^ 2RN;0QOw6R 5HM^~寧.|w 256sw?aк IG\7੉}?" KǶs=uH>}rͼR.6eCbA9iۃ>V2~+zÿj;H9P@aFgӛ!>a0xMŜ<^Lt>){6mv;:̌><!Xp 0oL яԠ<ƎX(<.ZH+5`dth/m/bQ&[^5)'X'r 8oƍuZ8wN j= yD9Կ?m1SN]p+AXߺx ~ 2|GsМ0NDX&.nX`}a%UXA,XmlOш{(~] 5* ƏE)bAJ꼨2/'BsMȦ}BQF?bX )L2JdQH2@X;jfr @5* AVx=X@ m|!0@=n*;DŽ}!&6^̍IbXbXx0ц+mx1 9D, |)#@hxƧGЁOڙ !&+u/l6\,̽\n!V6YYezX%Ig'jީqdSŢLV&;]}+?kp'BZp^]U՚-x j= y_7B)UVֶ)%:/m,~I*Ĝ)W@%UU: Bcs>+( 8T:ӊ/=uK ok--efMJ_,&V6ҍDy|1fT\U[Ug6C`a]F({6pPXhԚ̲=b񺁗k3{+ djj= yTȖ<֬\݌S,8BX[Ii"PLkLGPI# EãTh4*k;' Kşzy]UeEyIo&ݿo>LGPIQE#AƩB#>B#5N B!BL EB!BLEB!BLEB!BLEB!BLEB!BLEB!BLEB!BLEB!BLEB!BLEB!BLEB!BLEB!BLEB!BLEB!BL8nj\nfmda8:/y]UeEyIZ]mB!KyXsru3N% 8Nbemkem[T&B72y|fsru͌S !Bl8o!BYْǗ8o!BYxd>GH2}Bg=q|>aJbjYm+gz6E!y{NZQXT3Wbznky!z{\{jI9Vy9[msN=nn+ymQ%Y!L!r3[@#y8u7}ycWOˁ3/ 2^QKksFkX=qb`64Uqzm-[M_j[ƙ_.B!\sc{%3ͽ7ZC' p_,ni!3L|qt_m4Z_i-Y@W 7xşUqz'~[G"B1]7r;ԓۗk-eUc5UX3т`naiVTTqf{ d̺k:J01Pi]US Ll<H|} W+g %cCI%ģҠ ZqP#GW=ܭve͠ :$U80`Ρ X K@u~X0h΂ ='v~Y*A9ە"Zsp !B=ֻ{.ys,\R9\)&=y W9˫ c]Ķ+َ%? >}M܏tS.oןO;HD~Rg+:M{JEyz˖MV8YJא8t㚙kT<*iJ-W's ˶yRג筏 f¡Z3y]BBjVf/d֪l*1~|@KO^wUF?/kc/r͒YφiQJӗ3 ;Nҥeb_V}Ƅ-=! ?~=5EcV ~0E@&9 Q34rfv.!IgDľlOW3\Ō?]VPzhۙZxe)oK/kwI<ż(YgwF:]d[}Ɵ𷛋v3ԧæ-xaɁR,֭hgBV{/~:pw0c<I>a|oQʑżamх,]R@VDsԦ&/tQU7Zez֯P7S-1ba~ӟʽ#m /9*?9O֡w?F\S{'zrqO-{by9#g\-@Y~';t3_4/$*ҏzosjy#/!B?!GXׁe+EڱgMsU"R}OWouGTudZ}Krxζӛ6e%X:ҳ/m+='+{s4t޳%\uvv+kbɊ=&>?oYtcf~ܭgWI2bl8QmoH&Du8v\߾®]>fzw].zijo︟N *,3qͲ8&`dv۰~c&kN^R20=_ڡsS]^؅s €X~Sno~agƇ8֍jǍNj%vvS Ky<㯀ސvz&_ZKzw>WV[luN։9%*\-v˧LtH2CuZu&WW,N(m5+VaA \ tZzieqq=^#|stnw?ǐO(p aٶVC^o؋T%1:tPT20EV 2܇8Rxv J(zcJv_:+=?Es{-1sAk.ӊHuhhU~FY#o\DC$>x]./_; (|ҀQlhu1!1+U*͸0&T~ ot =.oїУw?έÇpLbcpxT%lc[7bJO,fB!ӺMyI>|w>6Q} F$MX Øy;bN.eD,hP> ,pmx~츯_X oY樚́A}l'鞲QT𲘬O~nƤif=?_˻ 쏘5xuYӜ~ɻb~5سT)V1Фڴm}ߕꚸYdOս:}}3sM,6;5o ۓZl]~?,,dsDR~ 0dS 춲^gP؍ s9ͼelsאfVyy`5o++ ^QOp~UaN}SǃէOf6huڶ'rqhS;13~p62r|O[R-G@b-.z̦mUlZ5صY;N1gl !ojmB!+E-xO@W#[@ mk氵b͒W ǫ FitPK,d| |l*M` 8ZX?됲ӝ%[̼٬MGli (~зPٌ{nbz橭<8pK,֬+ohU/ m66V2y1ᤜJ}j>?n76(޼pt%)QH]^@(9Rgja7)?0ʭ1@xd4eS2K;@A)5RX !r8=JV4|PlU}!gCK)$1?륣y+чOL[W<1֥KMl@~ڴf$6w; 4q@og_ IDAT+djKƏ:{̈Z[=FW@Q  ]!06tüTWufOw]r 1.L>38еk/etmHOD,8*4XJTݮ e|mU n 2EӛB!$"۪LܛâguwU0{0ʚH@iNMq'o+>`G̳y6OɎnrS 7Q2\i`,9\/$7}W}J8hQ)Sv2=N}{kH<<*ؚ0sWB 5MEA"0 &*竊je030/r01ZdI{UIN`B*EA`8VbieݝTAO0DR3ᓻ'pLܖx$|spso3:m]+)`ȓZؾԲ٣Cv~axût莱uzC[r'XMi6+rtwq^ kPsʉGO8GXK= "ƒ`Fa-UYgfB!IDW2J +y5cu3 JÚg|E{'`ގ=sܮ[JlXTlX%n7ZySM >Zu/M;dDsMJPCbu&2)w.o 率~YFvR<{ɯL%צXIT4AwCۍ%:oGu^@f̮[ttr_pHi`BcrB!nH-)eۧ~a:X\-*2Tb$fa䒙$O淲%lo^N (X_|i;w=[zwgU$SɔE"}+#+0saQՋ+Cf t/0GX>{ 'T;c}[ȝZEHo P&iX{],elYsy*#:Hԏ97 ,H)N*7 j)0_0LJ @ڬwyz61(d"R FK/~Z%4w㶕Ê٪آ#+0fÜr Ȯe4[[i>M*rȡb Q`8yθgU)8| 04`LZod^ Ye .Op1m8ES0KpB!Iil y| f@XǢ3\ 0ۢWYe#12U'{muyԿDS:[opRqOiW$3:֖ N\=}K^>ggN>dGЧKFd b37[?EgRA8@7~a}rv]4 4`jACF<"gJ2Axn>žO|h .Z/yRj=7_Yᙟoؘ˻xھr+iJnޔ\e ;'/_ RMwf vcǬw]_<>|Mf땿--sA!ZMe֍\HAuuIHR+y81Uj}9!8KfyRK믍{q,'Z)JFG].صWRdEd޾CS v VL^8γFĩd%{Tşr7ОB0kynIn{1EW5$P܃ƪ+&/ծ"ɡ躷E?9mPGnT"NI `{B!IDiSTB~O<\A.\Չӧ7=\Xis'A65wZ3<{ve]m]rgV~y&-Jhٕ3ldر͇4Z1(C5e2 xgӷ*sTkk+l}u' sӠ.jɷWѣhGTT0s{G>p2SQ+: |ەX庻ÂŔNiQj=9O)qo[!ؓ^:f8߀Z/gBǍU=Rj?}x(0rQ k-^v㉒Gzm=jD FS䷄c;)߀pnO9'`njrڬO?jZ~o̪ :$09`JD9~SlR _Qƈ7V}}{ۇwFvJS/o1Hq:يqQ'_]=_b;(ݿ8W uBLݩBn_:tk }~`$ yMJScNN(MK vhbSZRdB!^i"㮏O_lp4jxl-cۉ13JrP\KhcKHCTCW;vkVLH; X֩ш%W?uV5pތ#byt@`bevouu@AP;7!j4bq@rXvbmEeloK^-Ry-DeXd[ !;7Z&0VҶ+#b 5pC{Xt 0V0k80'U 0>jcӑP%yP]]Y;־9XV,/+oBօhXBkgEQUpkǃw6XWƶ=cf&Cu IƹuAUbT]87Xt|*8_0f̶͵k ػ`ᝉ=$>J`oB!?&پ3ɬcO^(KNB4|lvv:CH<,dXWFn.!d;D>`!]"+Vᜫ9Y>۶]/YlGygY[Fn.*K]}i>Tvo#k_+,2>^$jQZ'9(EB #&ayB4MP (BcPpRD@cG@w VpY@ |KIѯҺ<[&[MKyDAT%B!FlUbn4Pti0x!41A<&wA)w}CvD$J\6]߸8cœZoĘ[*]%?C"Z(y^jB!B²oӋlyK,vmqqn>|XUU՜ވ|Ys?: KvS_{ICۍ?{EٖM/B {QzA b" {jH=gR)]|?n6 WyhbVe5 ) @\"J޶ޅᖈqɶF~|cSLpѺAA/4WϞzg[6ڽ{O>?zXVZF)OT˾~]4 U, ( 󰧪'dJ& ,/*zH:~}n-\<^~Oo-5_v8/T4+3-IY%"""""'Ծdw+7nƎ `կfȾu|=o>̟d?0=#8o9@hN–Rܧh۷:iwܚ&'xiR[[}'CN2 M7-氤 =gk|xĔ+>$=~℁>^)M;Ux*23r)\pz뽇67*:99ku:LDDDDD5]I.nA* ?/1!^Y͛3j<̜#3~vԴ<7/Е߄nKVV ߞcű>-u{&>g}diz/lW\`Ks8A1TeǧKpM0w-\TiRX @ tkpFN.eɉ:QY" el,KaUe xݱM4j\ǺHܑcTDDDDDDw]I&#p+IҙS [аq#kXWiB> nߙON]+S_F[p0Cq=hɭv@fI*w[" )+V T;~܋/\59}jR7ɉ5f2ֽdek0@RRye]J$%'hWp>jŌWVk8j}Gޯ~5ǒ|ۻ ^!:YŻ-*/)JV&&]?p/.K?6=2k&/ޑE\>m"""""J&ӱGwvS+XϷI6w;}l{uͱsX䬽W?g ݻI}Z kXA ΒZ9̭Jڒތ'@lU)I]!Y ^m{۳NPλSX?.&[haV mxRvZ4ovb^^3̙;zu}ظ8͚5}Sjm0_m-DHQ zW21 &ۑ O-x!٩gmҟ]d2TY5򉈈d jX-e6DFDa|adu#F={|$d|e y.ʪZѧV9:^7cQpǮVݎlK 4믿/uk\˪~Yiݚ6m *c<-;4c1\HJ%Uuv[5ՁAlc#3n}UO_(pu~^`9c 6c L?]ǯ(J]u.Z~˟bsxz'P3,5뚢&XsrV6aӋf (ٚ-/ن?֩cǏ>Z:utpݩג%jj۪1X_ܑӷwC.0˸Q :~1҈Lʔ'z3S wn4-)92ҥg|R`(X -Zv&Yjd%C\w02QMlkld'O7EuyS-[.^RPPL yëɕ2 r3.%""""&[))͜5i7iР~}s#&&CWPE]+NX-̴$NӻjuzZ ;dbM![FQT-IV^zeÿ$3W'lUfejw?RQ1&::OJK""""""ɖj7&[""""""ݘlvc%"""""ڍɖj7&[""""""ݘlvc%"""""ڍɖj7&[""""""ݘlvc%"""""ڍɖj7&[""""""ݘlvc%"""""ڍɖj7&[""""""UVдrW7uU $ٖiMy֨\DDDDDDtg1M[w![Q#HiK66%JqFV!ԀI٢$" )j(FUt*b#!r&`+].N^h㍮N jmvv DDDDDT30"QT~\^}w}mϏilkOk7˵gbj=Jsoַ)jCPQ_EΞ^jl&)Ťl2oggsow_? ~P%sc]JNe]Bo޴`L YIIԼ DDDDD5ÿڿ.[۹lXOt3W/d8v`6U4_YJpP'7ѫ^fH^pBMF_(8P+Ty|Š^ڴ @P-OUJ""""*ՠwFr* %iNX_-1 ?`%|ΰ=IpV9k b {UUZRVꝰx 6;GW8wAaIUqO\|QH{yx ^EY+9m[!\].y]06(d4]#e9'ۨ"$iBرJr5wM5͠ڪ1 wV5 5C-uk ֞l=CTS㱅0Icwr\X)s[qx3ݶEltN:nbtkhm, ߷+̜| :67.eD@?XU `r$j&jS5wQJ%ٸ.nT-'[r\w{ov}:kFg0@F Cij;?+~Mu%.7ED50뙓vi&4Ewٸ.+>m>w:Q:?.]E)\'^ti̒;88+h& RFe57*܈_f{5cĐC=xUnW,N"&iRƞX5ewï^9㋁cJnIaf_4/` 3SdwqŏX8-Qu׃"5 JаuK cumiٖY]s]slTLW`QI_K1zv itXvwIu1V*IYr KxkdOؤt0=5{[eh]tm:x챶kvO'5]8g(1yyin].^. nuܺ㭕pSݙߚxfҘop w݆ĥm\EpsE#_8!_ۻiΓM^;V}rkmi}-&Z{WƌP:y~6׸WXj=CĂ\o0gc?-_ŏ MX5k%9I;[DDDDDD6f]HuktRo$#1g*Ѵ~=+͆ 9ҶuV[͸(x.ˇ: K.BZF' O6ۯaLNe9oeUz(ȒlXSkgUGB24Y$.v;_>W0ݑTsҤ)#nm?ymk6 A?3d~6xs1m ӑsCUN0[xw_Xi^n_g z= l%a޸@Tlrvc 匍uS)a_] cEtM~aKk{[Ȳ ?7`ݐ)/Ra8 KDDDDtNA(jwqB!.БSqE=Nmɺ 2+qF$yb<7o8 @Jzv P״X nZmAZ/~Mx\Oo^yj_A.uC+yI7](@,5H^u6V|Kp1@' C=Mwujg},|%Up \T:PdƆmp)9Hi7JOH`3Da'R4k=s|_zvfƶn66k5n̶F%u*%E_ڏs]4C;e+c?~'s HؼsO>lH?^[76eG-}jPt݅o*^uNw~Kf۲3UXЉ[fj:{kX/)K5T7-}.l3W2b{7{Gzs-&%ۢV[ÉG|, XiKDƤk[G4W@T\ZI3z/,%t}Bԩtꈮ-qpz*kXS]Я_cM[|[bτ#?N=r}}b⽡]P oSh1qLk飊;OXZ "`Q-^޶d0{_& -"""" ']׾kMj߽cDR1pe l_cuhle ǕĨ\Fɰ5yb^>"'֝hR!ۀÎ#?N߹722 7FéX~eհ: kwwI`q-169 7uu%A0;rݜTҵf`lS}']woeC9 k⥔Zfi6GYSyoRy*M [^tlLÍ^ }͑#bbKՉW,0 ),Y^ܓ+=mf^>xS/o]i&A5u{G/*ꁃf4# e--On3.;l=lݺOȗ|᫱Rbaz/ Z˹7x.;؟r o DDDDTh/eKM/HMgOmg/e|qAdbR6TN~^Z]Xv_d|U٭ywT 'S eS*euq?*w+;%bJw#""""WӢ]CFCk.5WՇͨv⯬VL%8 P#} /ظ;uOt]QzRv%nzLU-N8_PMҧ^iQ;7pܘû U;vxi v޽'uiTq̶ڴ]]W ۓgӽΪ`-PWU +DMDŽ&{B#M[~Kb6*⥜?"l <T6lVG1FJ;C'۪h ML.H;zHҪ?TUM pJO ]06(d4]#e9|ZOQEeȧiU[٬R5ץ*|~Nmޝu:͕Ɣ~8;9rO/w}k.ۮdV@OoGm _Slh iǵ}DDDDDv;tsw-l#a;*S09J1Z30Vy?%ٸ.nT-'[.;< 7VKj=jׯ1kc,$=s\Z3&wy}tΣ'&ΔwlT o7߻ E?}2S`+'>2n2bnWĽ&[M8+2F׼9wĒ<>?U¥I۾{rES,:sÄ /\gpy?aGץi/u;q2Ԫ_YV~#;f9U;:DNjԻχ>ΕC'8']I`A*_]=EN)%sCFfO~׾-9#Qըmz%HakBQ~>1 N١6(ئiLcJ–c6[yC6 &jS:)KdI6ouIuuwdmo~B<}? ^߼' {6C_ 7Bu5c@PC 9~(m^s؃Q_vt׷O[ձU/xG'7;S\oޜ`yO@ l֬ W6|؇,oO]WP:xت1=z?_|v Vu|2A|> SyoƼ θE3W^>r)7[ײ=MŖsk=_n77Zb:M%[}qo%%qBDDDDTuzuL.% HHҧKG3V#ӳ+jmj' ,{e9KbeI=֮)3 Υ6=4OoY>*O_;6Tw1+g#o XG<޲i zOk[->$2'h;؟a+]n?ڸcor19匍rFgҐ{b-/#g+rm;]=Ϥnm>xvɩ¿xb6^nXrc?F~ )_x!6O=dʓUtw,36ɝSXgM&xRاOH)k`m:s?b΍|ϱ].o.}@zd!G96R75p+洈Rg!"""" X%%bX4NT>mWvH "6%Y]b-lZ*L zh ?&{BuQ_t+mu!=HϹ~BVem;6?bی`ˌ`qF8#74M ?pjv\~:F dCwn◈ IDATw3u7oj{7{¥U8P~zlI+`;p7#?Mm/p)')6$^:x!KPPkI 'o}ىW;-Vo Nl۸>cnjv[:| Kԡ~DDDDDUcXe@.s]ls|_zvfƶn66k5n̶F%u*%E_\]YU!Nzh#[P ,Ү%êvRL<[d==aO};b`Yi[ 9w5i:6 Z ?!o|d#m;9 t`ڢM0thXy.@TXع m-6z_TA:ܬkqoBTѝ' p߀FgF.j!"""".D9mڿWs[ENeX R l_cuhle J_g [')-q}bfC&=@סey:R+U$S\e1_}?/?:녦-UیuOљOOl>l.ʱ|%A;ڜ\,Ve{}dIIBZ76.{׬Z5|ؔh=LXrXWʽQ|k9ط^0?::WWw&k?y"4ZL/X!)?Li>?~:geYڴ l7sޖ?\ ,,pu>l6G~ΛK9 vRiߦ=9ztOis:6(<߷iVSٲ#T_pFc wS6Hprd %"t駬2S%r$ige&^Uk_'S6H)`<]@uSaF+8A9l+8Ck]-*[ l AJ8ai{CrܱnbPspMt {/ѿl2J ᰜq[320zu[fr*w ~oQ#Gv`i VJÀb'b`'A"G ;,nYHie<b#ѯ%JIᰞ+>zݞ?umKӋDDDDD;|J*1[%ø#g /o([Ѹ1іpS)dآh$* I7`.[ZFXpSъP[SX۟7$o^Gjoʲ#9[aV H0Q4lW¡gA`(ݸX# ReNJS<) ʚ1mtoI~e#U%<i.6rV8d)l3.[j(}JNW~Y?(wLNz|Բ H/9E=i(Bkc6DDDDDLM6IY8]tL6!o)v g) @ UvΒ=`A%޻;;0RM rTY^ݜj5)Gs',Ƚ<'m]J[DDDDDR2pkw׎DDDDDn/|i""""JTj&[""""""ݘlvc%"""""ڍɖj7&[""""""ݘlvc%"""""ڍɖj7&[""""""ݘlvS+ E*KDDDDDDD5,ۊl3%""""""ǯ1g#QdKDDDDDD-nLDDDDDDT1QdKDDDDDD-nLDDDDDDT1QdKDDDDDD-njevRT54M6UNn:^ъJ\$٬`0$jwmԨєɓZnմIN ///2**""~LT@w .ʆJkuz7O Y@hӥLOQjQM3j r5.8JT|睅WY㉀Ȁl]3#W*j1ggZ&eQY|d[g;w$jǎq$P!! fL6jH[mwBѨ#3_;qcj|ʛ懝9ګ/>,;;gT`ჭiYGY-H'ۮ(դV;_SN|b`Ee-˷Xm-t ^)LDDDDDR);th?E֭pV~;u})py4]{hVpqdm=>{˧;ZxX-X]=OB~srM}+?ym͑8NJ#F᝛l """""*_-Ky ޳Z''OD*6F>Ђw珛0ZEb>>n&M]Z톽QGkV%z!-J,+9̷zT-;OԶn{ KQ%3bPnQݒjY}z?4})o_Z2/wOF׿mۋIy.K0 - ׏FQ h۷:iwܚ&KOSw2Lׯ&:eؑ!@qب0`'Τ&3xd:Ts09D;?7U,'I 뗞Ǟdsjj+ eLq%vցdJֻ:9)K7pګr9'T89e)DDDDDD+- 0HY%&+Uֲy3?sq@<=!{.؋eRͪl͒@S|D:2Bk/yw_ -ݛM~ޢɒ?Mauzй[{u[MFn4rb; Q)M)F L[o/3%;7y9I AG7 -u~ ;>r翤.{ͅSu ?q skVq2ze&nVIΉ^/*Ɏtz=-U]IM4 $IJNJc| 7oŋQܵ?+M4Yˏ?O>l\ EŇ2iݟ9_JJ3۲QNXgCq|=! W]ЪeCnYƞ|+@s=50zXdV3f_Bwp#x0Tڌ]žζܔf\kZ(zEb{~"b#/Nv_>L9QP-N[cݫ΃љ=:4 `{O4kPǟVnU ڵFuհ}-DeX t?>d9_yDQ$jzq+ArG_# g&39d4;zPP*Ht&Tbb{WjPKgA(\ @eْ +p8TBѲK ƅhޣ]'n uup62A0yn`=S0Cq'6uq՚v-kـаuWMk[8k5, a΀('|  ָS}f,+o@cWfp?˯ܢE #*߸Rn/]J, =5O1~h@&32=}.ޔjS3W#Son[g/uٷM*S+`6e^+t1Rm#P)0`S4©:wDfb;phh-iv#F KS%.R MRToq]~lmbfmm~-}uɄB!bﭳ}R}2>}o> `Ur0ϘޭkWNok*w>;=#g-`a|9'}M:i' I xis"p5i {cRfĘAZF"fWߎU^IS\+?,z ib~AF_n^`q+0hC^<1v~o@`~nՈd[b@|pG|Ƒu9#oTZYۉ3\J(.҆v!sk*.4R?[B!Bȃh`-{uq!_/]}ǎ>_R"g [jEK*FZ:xO 6`}ǡzK9z3 '4vpf/Z[IN_ Y:,~;}=C wS8=ZqnW%rqc-{9u8yJڀ6k_ǃ&F mQjQ\'+lk_4bmVM Z}%0OJ!B!5bB#oG>3q,.^p>M֭ZҲXrҭ۶#i;063a d]0^gC4 .qa ΁kzc,T$J`;}Ջr}= 8v`u1q+.-cߝu !!R+Օ[u#cm~"*SR:2uB!BiE?7Ʀa(T35|<s <H)Sz܏I!BN*ozAA80~MbPӊ (6NN. E|8 faz{>PW?(W /@)xXEb8=F~eC>`[u* r֋~ˑp|GXz,ެ }xR?;o'O !),XZY30e<㨳 ZwRFg /rqf 9O}p{Y8zL-%n QwL`G[WޙaundڇN`W˹F~lNPXǒLe 񻘮r>Ҝ,o?hwWU-Ll^!i}| r~laKcS~^xpجCjW Otzڼ!# AEWB!O#[ ە;iS(ea+ddֹJU&4BB[mׁaŵ"Ё=i:I<8ikݦKK-&/|ElA۞)l* g륏o1 Sc;ޚsMRfX'~sYwkwg6Ŭ u&Um*Vظ SjȪ}j-PQYb[2.Ldv#cidyJq9}ކYRrBL\~ͻV,j-`. 6SWwVB)HCX!:}2H~3gR }=gM|epJ\D4qf!ĄԢBGGg~:Y!)&p{8/!y˙__OprpRݢC%c>%_#kkQXrO&%˜R/f9 rc}Ƕpg? נ`wK|Uӟvt u;\`W29gfjerYVVɮKwȟøVF%%ڣQ .O+J?8 4j.e|jb|@yW/G.3ϙtreg#:}g#{ sbK.Z+0(\nSXXr|km$ M9y$ށ&\.2oߝ.p5SZ ZɫX5homd6E%kT:˙uL6 k sj\уsiE:3Ʉ!~,5[I48iH tn&Ralb0v``?:Ԋ+ה2#}:r׿v,+OH?usKcn6]3V~`T#e5q7omf{c2OOFwjk2_PB!O>N@"y a^>s-pœg>'bYqeF9w$׻{+NH9yv}d>k>2Wg :[Vh.>qa`@%@)p`ݮyxh//U-rYF'`|E{ŭq"ήK9?~eؘK:+ eocڭ )y'3 ]zNr6[vEl.s.}sꀷĹ.$%ŧ3Lej 7?qqӫW,fہǷYa>To6{]~{?`Z:/+*$_ }]{k-}ϕ:-Xс,a4nmCW1$=$i{ZǾ/Ywbƴ l:aZï[/]kZKwv1LQ/#Г)?wo۶K;Z#~mLѠ]yXFۀ/_d>:ؙ6j,0hȠs)1%:7IG4aىlӿMxckjUoVg Z)M~FPUup=֚_ĭ1;鱲O!w%g^ٳs8*|N)]b1LPR>Je8՜6EN),"1IJsbŔk2Ւ3:[Ƃ|-W\{#9[)Wò^ѳIYp@V^2OT.ժ5YQgҌ:=Gc5///?/W8 3p]ɺc*]$Rw(]c5oN>,w{N~č Au@~qƱ׏^] %W±_*czn+FC+J<رdkw=֮SGuW'-͌DU&GsG*tYi|&8z+y`=-fҪĿV E֭"~bw?dm{_k#ÇKn=~@V(;|qҪ;W JӪ~TӪۺ˦{MLp©8xk`ý^Y>ø³AA=n򫺽/KK_Rgx50`JnNQ/,rh zYPn=dlTkY<5c² M]v͞5eg`ӗ_y99ؐm~=uv l^j/E16_p1#-Y_x{tҲ7?|[2- NO~-k1L[P ԯ;:!+/r\ [(,w,]Q⼪`Ή3lù4PׇhS;dSVd]#{Vf{٧xDǏ\Z+R5n s, m.O23vM|ҭWx>yxHdS;rf){Dh),@D|6bR }Ѹ:ӓZZZ7w`Y67'[(,8S K8w xoe^Po, k-'2?0E=͝=kK5VLܝu㶛[RyS `"qB6f8|)-C"3q+_w..=|};څ{˨ hﳋ60cIXLl᥽BEҶ|O/޿gݫz7򌻕Բ& D}3*USğ_oq20` ;wӗyȡGufk֞-=z9]5+B*J}u=l2~뉮-,9+f'e]?[pg #jpF@W &n`_s "ܾbL<%iL $-œٲ,+a&' k3aNfY%E1[%p]ɺ,'V7/~c:cFkM }mXEiV]<[`نv p-0ewt!{p;) }N<+Fokٲ!"N-[ ]1@o PZoY?'TjWSڡ:O5L>eC|]=S]t-ϭUӈܞ*ߧ*Q!D/k֚(Jռ +ƚZk2ЋݚEA.+Ù*?P>' ɰ-!swllc[qtzƻq gRv򴙗_ ۷ub7ڷmN!ۻ{ T$wYQcKӟ8-* rJjoI0bh/vsso_{vvv"4Dc90y^[KW5/e}:O+Vw L6\-SDze[e8yrc:4 $޺e=J'8ۭAFFQQ]]mr-hxUf9ůʰV(j蟦l3|ʫCfNnd%be:3s'7w== jXXeem ^dI;F,#|azu>^/6#i*Kֻ."T̓@` y*7tUy4YKFn\m^J%ԅ/JogUx&P2}jgYDd[BX!gajBklk'7@R` v66WbNJ5[qߋC=FaMEVs.F79-B0#y"!t5.i4V,N۫o08q4Zk.;fwv^=y9ݙ [$sȳ:nnMCX.]87~'*oj4HsmKFqԹü3'LuLGwȧZeR2TfT<A_R>#a/ȠC2R$]ϧ:q XX(Rg?c|h(VY=Wc1]ú-T[[k̨Xo0Ҝb匝7~v[򯢵UsawfSg,Aqb+=K+% s.̃U0RU^➫ޮ*FVf)l BMw \GUqd%|MGJxyNJfEA]7f Fft_oՁ=Ux%V(cꘓ *y5%#q4@JpJܧԗrK|7l60t5yD|[RWBٲܜnvR>P)FcRʡ5̱Ik=*.U=z4+TkԦYT#J>{ZLz )5/rݺɭJp_=O ;.ƻy5o٦g[+}8Rcj/ zY_|i}ua-bqRQr9T/mZ5}zݪCIkW(4K(iO<@#k)ܼ7hٴ]ꜿ0<²_Mץg*FBZh貪r: r+2 u`|nHp_-}Tt[,>;]gl"VE yXKZ"ۚ7;-P+!\x:WS%kŶ1pՁK|Iᨹ)pv]*Ϋ9݃\e1ۑV]HͩH"BA8|pӧ|9X[W mm܊,V [auɯ9*Wb- [Zg{79O\Fr@g `]HzWpo e-1j޻Qt@'Vn0osRR(*t OrNݙ>MWM>R/[@i@C@KT{hmŴoժ_4u'ܪ ?xLؔ/ 3;F-Aik*je'׮W3+4Kia6ͺt~k}wJ8)0u@[MkrKm:ptVU `8TU+Vkox3t/_, :SqӽQ ۼ|Fw6gE_ mͭt`|c#{ԎJcGŵ a[1 xLlL_GffIϏ9'.Z׮vicn+7X d{2#Z7ظ\}ܳu GZ3>a1sx}8uջskgrtӨy!ި6{2+IY2L~<4LѥݩB-VDfw L<p,zാϴN=Z Sm-!1#I- E~uSRwgd3S~o_N.7ؕƢ^25sN/S||ٗ-ᓡS6}}*jA^*X5Kݺqg_SIIya>!3^1 nS3__kYM_~}~Eu=@PHQΖ8|+qꝫm}1Ye7Xq*E}f/w7EmM{b)b[c8mzIQhyM/ƤgF#w (:'RwzK_kW6t:7.U~ĉt)]rb:4Y-a־v淟Ҕ IS.0t;ťs|0w\Jt7Xh/Jq6!BD\\Niieu;1ZIm? L\8ɉ˕ 21ؘ*t:\&p`] ɠR6&g .W_%am (!);/ɺߔWU(6{t-q\ql;{E'9U*ngefGd+Z>zcUM ˺ѩ~+ +y┺+O2๠px3þomiT5=ES#p4l]Yx^YeK1ttȪ7ň}: J\o^B<(r~^%pJ"[嗟rN;FyٵwxS7xYs_s9 AX=;OV=\a;%xZ>nǧBɯs޺9?fV.6Ԯ(?IbwN=L~yt +/n@-*[jH~> ^~Zdƫ-)/_߅7?Sai|g?kol=nA\Pװ^`_A~?ؘ=vjӅIc70L.KD;Ew.X8B& \1#Wwa]<u1~[ 0\./i ç_6n6j G#-\Tzo4JZI(X7Vެ[XY w>0v.!+`Ց$B`XWA]̧1\30Қv4֬WXO9pCw@yA{HaЛ} pMX6.᳒E'cl;nVçAӀa#B-M{frZj/ktTS \wUQ00=y#kBүiE?7Ʀa(T35|< :VBCd_^P'789qX,*鴢 Kd2ŁDHEb &o(N7ùYX?ޞ)  P tD5b)`džzYYߦQ`8~C\[/pbyBM`&qvvMsvq lDѬXXYT$6hut^=- ,֖Oi8 K4a|$ᅄX__˳5tqtwdv/by:),'z^~9Rocev}j8!N}diuT=sڍgB!V,,\p`q@ZJJa-;IJTjmc#3  8a`k 'ͣHEp{Y8zL-S}۸:41X9 09V@5fX`?ؕr۾V+SyC.Ϫ4?f4WLMxɺr +'KTtX^Q_;.>^@RN7TEjq#ͳBvbV8&XB!Xg<8 ܴ)V0{22\*hu!-ڶЁ=i:Ie<8ikݦKK^_o>c95UsI]X3lwǷ,Wݢ{$S_-jݘ ^mh>UL͹Uu ԐUςP? &Zr]&}/PԠ.ץ !$1Ec+$rYOovLJQA笉ϾNVH=n,? P\ZTPѨeu;}ICRL '=q^B3꟤nR^kնoj*q'ckdmy;]K)ӤגQSDfgHF5(?u]kxhנ`wK|Uӟvt u;\`W29gfjerYVVɮKwȟøVF%%ڣQ .O+J?8 4j.W־ؗ3mcɕu~g",0F]ښT>5Lr=p03Lᦑ/F4 jWUO'lXNQT9qY3G(ߟ~Yoto,;WpBFXlD3!RgNx/2Ig9;٨KY,ϑ WZMQ̸`nePcp^V̒J &q fYli̠~Q;3+Gy#l:%8_ T.%^On&)XuŐ;}֡tKsO96$hJC m;]rZA2ww’c4TBFHCX}|\ c: \d0百I:g=;rXV\jΝ> `nJ@RN]'dt~?ټϚútyh.1DU26xF#F{5 qZ0b`zғ,li`B h"o=Kb `+qRe/h6$JrM::lBe% eb)| ]졍Fpf2g`S |]5zLWyЌ_^N[0[f1NBE[OyD]6r~ʍ ʰ1t.V5\iVifN\ͧϰU\=Ll9~fq-;_}/>7 ^7,"nOP{d:aZï[/][>ihaHߤWoNx ~zҲ UWPK$_' \ F~4nmCW1$=$i{ZlC[3|>Jy+/3u,cf"mt ^.ySi|=ֿK/YK> VǠ7y  VoA\/1?>480# @7?ntIQk&~zL0M|u~_+t;r0 "K}bkx\TK>:uyC`^s ӑ#ê9]OAP϶C3ʯUSgGvt|ץoN֔8łUD߷BE!+{St7荪l{{sD %X :ø³AA=n򫺽/;A+Z0lu\,SSo7ׯM?=:1ڤɋ#v=P~] mdٮIPqf>A+~߾:57`=OX'Y'pfY7f1n"g;{#kumeӿz˽& &jsu{gW5hΛ3Μ¤n ^v3'tp ╅fa~0bWM?"X^O3>؟y颵o7V%R(.nG7G&+3v-9qރ%n^:1a2u;OnjX++/Wd >6T_~pV.Zo B-0C;[};Or=!tH(S˖5o;-Oyk%mcEetSXkL`aZQ2Ar8ٲhɥDE:euyHWD`/Zg kQ1e`tfLWkڒN Q8pwkXSX[tAk2/v+c{Hy(J6KvS SOYhw8R6-!ux;SXk-*1uVh~Ydk俘*p^J^:^N6o%Lzm]l !<=jAFJJo^,¨OwO9ku[5̂TapE>'i77lvսgoggkz.RMH9Vp^-3 DZ/H4W(ȸUH#38)76C3B⭛YIJA[T-W=I$fvkQTT*&z|p[\| uYN2ab4g)sFz${_Y-6ç:dIN[-.V=3wrsa܏Q^6 Uj̮soϲ9P< +N.׫Ql\}R!TZiaF7mʭ#1^`%Q^-o9@"uJ/SЗ7vPaʬ".6'T7kbᢄ%k aX.0x6tq+L5OO?vd֒W'5"WQonٸ((e1i6yTyVMͧYa} `(:w`愩NslZ[D*y<ݨS՝y/LI>{(02(dx̰+1Iq wŘ1sVlLװ.wQTmm!!{EAQ,諈b"MiҤJo $$!3Bwyyesvv.)4ZkrP qSu))L.,|ᾏ͸[Y.RKNB*zrhqo eo2\R,V> ieUzI]ݿ )mH(;xqYZ>RBI 1k(j;rwQ z3-P^ӓNDDtX̌~nּe-v24"<,7vOVXcvI_x5=ZVۖU+Rİv; Rf_t]MҊռO+8xwiXjY[+<T*V\1,QI8`GUi-˹ڧ DFZh׺6NF~ptFaaœc-,nG/ tg9Z7\+t0Q]q6.3_-2ҴMD^@Ͷ*y9Z}y}oM\py/]z~;?|)l e;inY,wۑ6 uq)@{z{8_Py ]8}*Ilb*Kn; h jenvw!eoҕ2);CMvV RBJ5f:#W?!T>KimZa6΍.n0c?W]̀D_؋@Gg{k4P ,hZ9&@oV`\SƍJ+ H6ʣW&50mp 6b# #ؠMTe&v{(ϏMKI=B0H NSׄl k+Y*ѽ tj7񊿢ϼ. Fk@w}lXl 4v"ErӁ|]ʏGͶ*d/طFfzf8eXr.\𝫶R管#gZ<6%g5U;Ueo铄KYnzuѴµ2jz%**GmCᦠvo_;b57mtYBPckK } W7Xpl)Ywl׽;[)/sj9k\N~_{s}}}io* ҵcwëIKDDt}dl'g +Nkv}rdJAߞRkkcb|%f3WWW[diu* ٨gF.D- p gSvwW*\ 0$gJ&p8+ $ė~C뷳 Ge?)7vlbcRSk,r t݋;^~eCXsU}W↾+!:X=lFh XXd1šoVA*Ly{oOK(\l[;guV)lO5p=gfYwf?p?{|K#js1M8eBQ睩5\&IkBQxܾ$-BxWK=_9syWuQpܐ탗ּ[Z[Մs%0/m $l5Bde󿝾`NI fgVooМsͰ3MFs ek/~={Cɕ~(%EOL3+s'=;~mPTu\Мv'}.gݞ^v^]mg0x7x$~gɉ[{ YZg vxYL/ȑߴX œ):"PIfx3ˢ{˷dJjs:fgX|bNg4GaM)/vԢJ>>z[7O{P6IVʏOb YU[ ""SxtsBd:Pj<YprMZf },!6oZ*T&IgeE+[ V'hX/ %D\L8SYo)@UfHef^̵(m= U.<)Un&M ;=) (+3#=5vop[\*rSfRCRpo"xz Jʐ Ŕ?Ap-6A/%킔C$%0D?ô]p l$다CΉٳ|オЮb@ A(H鑰D\~gMosgi[DCdM/e5֒.Ft VnMoXa=_K S,#@BM餔89o/`/6n 1VL?LE>b@F) Yi:vѯ)eY{!A xHJjCE.\/Cŀ|Q ON2a#J; IOxbC-ސ0=NFE9#]9 )a*! w rVM"-N(l7ȶٝ}!TwҊP%RFP8G7݁hy/%&S[zjks/ʲjŲȳ x ʍڻ{ǎm!Q]]d3a4mt5ʚb0lMnۉX!۸G߮Z/\#o]'/{~dEfKDDDA!IT QFK4dX&\\]N=|kpP3S2!|퉀|dЖhhZi j?]* *xk\ o4WƏe+֬oGqn"""* IYP[*W@C_HLH>w1 _Ѹ*#$:zxp6J h .d1WAc@F^!fjGk>6rLZ Owo3MپG"B|fpT$oOzoj7h-zK-_0ýWtckSȔ͂ȭ팲*"F74|cutieXKmZ-9!dS܁P7Q)YV|m`?aP-,^8zɩ5j&VmuCuIL&#Tp_WN}ګ!Iן\WKNU6򆨒 j;fWQ~Cl> {bD F%:@|du{HFǜ(; V[@#zjnuJYfyS0 E'mt~rAo[ptK Ԧ&u'XT<9`V;xQÉ59~B]Lrرv沟pٽlNM*:L|й!V-Dn^TUUR՗VtoF:6>[\}]e+T*NDDDUej5H+:m^ڝСܠzOO}/_ʪUPlh*$ BTR⥼\//^r-a *jS^T[gС'R3Kz Q9ʪkp iƷg?ycD e@@]N(5t&h8IQŻn]0[ks׬=DMyC)Pfwo56I'3$h]MTBuZ6-h ?!*A(ĭȸ~}t3UQW~0Ic7|dn b7,IJc6,i8{h?v%mޓ FY}̹ cgǓV` ՎpZ6t&7WmgHU [MQGz :98;RR ן0(C};99&%=n:iWavLD4!DERE&TaE)O>иjq_;uh#WѼxѶF /vl,X`ڨ7r6g $j~Tvyzf=a'S37Z@ihZ#I|-K3CszUڼq}Fz(* FÑ^?^A~B}^&Lo=m;z-)?3ߑrYȱآ[ŭ@qd*Y@|Ԁȑ`[ @t+y N;Me,W.;X8RMy v[)&XjsWpWC˟M$;xFT^ w?wN?~ $`tyO}bqMX}.SZղuXߏuܾ(|w묖~9*2#0?uƃOmNk6>azvikq^5fǿ-ˬ|Ç)֖{q,>bcU{$˯6ݬ QI<﩯'/Ԝi&u7]ި oژzFRD4чx:Ӷ/kC|;Ƣ&,{Ķ9]ܦhnQ}~t}&Eh%…rG-Uˮv&|&ɕR\Ŷ}l(d*i5/愥0/&5ԓ]*~fYLf E\\ܔz<#'6o\,TvsN:X-CKW cwOwߝ4ɢm/| @^wߟXlx_{{xTqwo-zbݔ{`2>լ[ʩ''"/z5FXqcbvcΓw Jh-@.o) .zo!5xjј7"|F cޔϚ?|W7|򅔝Ԯے}fm1?|wm7qBҢY;^ ?kV';ާϾ[{:lZT0eD4#1 8-uǎ_ M6k֞Γ,涭;Т~]\vi^޷mo8.}dw:`1yx4ZUFv3nk@`=ePTX^!W,]znW5O2q(ԢolT5jΘ9EO~0w3kWun%.,-TOYvưO/m%N߼+jzmW|c%ZB6}ʗƖ.ލGsw Ȳ[?~NqUy_~#rȚoOnXY=c:u6 }m W(^eU7%?.vb]o֓g2ֽ7ٿk'f]ޘo@6`'^q}Ѳ1x dGۙ0u\ڼ;~siR*[f~ ZuneWeSAVN:S _1+T>jıKI':g}0&b_ԤsQpu'LxlNlҗ "H⥽6>tM$""jddyxid EiR.^nlqq17P?˿P֨ez%%]{ݷ?A h4MQA9?~o}x:]Z;vpY[9 i=n%RL܇7n Pyc"%(Q(VKkqeM4 W_HlSkfkui=̈́;b@ٮ(aDO=SfȮ[3՟N|أc'['mLGx1_R;oޡTw,Ԟ5lE J~yͻiDT y_OKPzyԞG6l fƜyƎʪrz:MS~Y]ZRwtQA-$MJ+ȍnT]v- L6$nM V-s\jG ]mCt9c-ȧGm`vv+r;nKkm/pp󷽼&Cռm'lim1}ѽ[ݨK%ÔlτIG_XT}^ٿ,O4߀M&󾽻:qbd~3Vm*$AAA>>.ՌpZp8n=?(Gԩ N۝3ӊ@}ѧ-Ӌ fsN8rϗt*mqax\^!becFD^o[fyX-{$Eʴ|쪫}?D8$ezO_NkZ%-g\˽,f<}<><ZiX`_2+6S=].i. %Ǎ㤮׭-nOy7a?%T~ U kʻ>9Ѹ˲7vb][.Qe=;]m$?f8Sr1pm= Uś_Ʀ@pU+wt\kVo͚lּCFV)V klŽ0 GYrܪuҗz"WײJ5b9_Jߖ֮}A@]lm)+`9>7渪_?9@䚐 ή;~uf}Jno;pn{:$)TewQڠ&WJMFUwƧNyj@e7MxejkNF~ptFaaœc-,nG/|=4(lsn轹=@#r33t0q&RC.~ocg*PWkQ9Ǟ1e\,jlyؘ1%CLY}FM$&gYnRZ@ME'ewiFvFS4Y=+Tܴˮvlj. U 51KJϜ S*))#"oݡr E,;vB}}]\%nGETٺ.jSNNXfT-`kt:@*@[ +O"@ k9>%27Ꮎ|w݆-Xr{mZaF;$:@$T|"Y!'7{:Oc߱s\%M4ȵ S.3DнgxXg4r-;=L.!蓹ߖPz H6ʣ\$ iۗ.ƝG>ɇ$$^"R+Ky T >xbvS8`rӖo ulSھrĵϛk`[N'fI@>? )1ޮ器7jZס|˯g'co9 M3Kjpþ-߈VouƶX P(Ow.:g)`6ﭸi]agSMa=X. j`KhIz~ƞZ/F5r\\_ۮW'Rٗo ""VPXp& 1y>2s6Q,)4TxBr$ ~dS̩ڌ!:qLD7^ifV 1M=K.++޲Z5R$ZW9M#1tZ'=1+'WE4 6%9=x]zb6ا rxRRI7^FpeY" ~@<վ>ۻ KLH2ޗݷ%Pcر}tW囎PDrE`߀V-By~g{:X]zG,`Ŀ+r.OnɝouW}~`Mf{s\a}M.:o_ a3֥Ey˃ߟ/ߞ[;cTq_N7fN})nstCpp{Dav+G+%m~ZgT"{s+[3k="i\6'FK•Iߗ6xKoihU_ߴ?fCX1]~~[#\]ž#zi1{+ݴWےsG?~x\l:w M9RY4hIWL^ Sw-7zews;slv/N1VT5F-T Ȕ=0WuGf)IL&NPqTF>3u&jQ[Xvж;Oe[U5X׶_\ $sۣҎgR8e ΊK< %ȑ fhʭesf,f\iz6GI*c+zy ֜(井 (ȅg8yz5!:X=lFh XXd1šoVA*Ly{oOK(\l[;guV)lO5p=g杙zO%8?_yǒRz淟I|o-{=<8Lծ@ȎG$`t`}QaN }O{3l,O1EG}~Mz?`^G>3-CrZ|L/}^7@S8x6+p zdcq揅Dԭ1DDDuیGg;:9']\O֪ZI\ %){ !4`6,!6oZ*T&I(|AErN4Hwɻxh|V\t DJBie*}3yKgb/E?K"jQz$\xR-N _dg)CdG|*eʯn35-& 9+ RLT b`SR.H =-!IRC9L׾FHJ:=W[ ꨡ,tr~ KDw̎~X1Kj[Dݓ]l!`>ۨAbUp B;sJ+ uk0,n"D([R%Tlb |M[~'tpP%`QGp)՗F)< W_SLBJmM5 p#hz.P5*vO 5vA'hz kLYF+*=(bѧ#fKi!]nW[i~ ׃ל^eyՊegO+ڇAؓޱc!TxzMTxXy~{>C^tmX{4_z{#ɃPU0IeߪIDDDDDwG'gU:ug7fORFΘ,Dy;F8WZ ,;0:]˺+& h|L]nDLDDDDtR*#pуʂ:M8plKEi' g. ƁcCeI:< @,DBN;G|:ݭUP[ 8PS ruD^Fc"""""ӈ2b`0l2Zw0x%3!_7kiG,IHͷ₨z~O׍r3j`pP&,8lJ2-lТWg +jDDDDDtG8O[j(2 9YYcmZ_'fKff{݅9)XD.ZiuM5ǒͥZ&ݩ5;58z>^AP?-@L?3^6+w"`4CY[ؘ =<=(:'WTV:f{0ƹ\d]!J&{c.q}{CN@wW[SDDDDDDer{>#NEZL9"A(Pǭu׸G6}l^m`~m{B[{~y3>Z:W;?lT.ݬT1eq::aE (x([m((rq]"e*T~M6+C}"+ ll/Ghr{etzyd\IS~n/"yzپ~@ HyJ[킜௄\e3']K?fƬoRj=j/)n샿![ )X1޾?>ǟRbm.o"O?kIg6[Nخn-ݥK+ jyw޸oʌ32+.][ڧ,VUyd977Fj겿^m܋w\&U7=׸i3l,3ssjiF?| پN>;c;54l1}ʂxaFF9KoDEY _)z֏9{w#lR霚<=DQѝeyDNt{`_7'[;`q;CfCҵɣjw݇8E5"R *"+3gI1>Y<(=_z9cGE{TX>kڿ2 436ݯף?\}P; 4UԀ |&B#lhVٯ۠`OwW*+8sew:yD[3UoHkw 0ou'|AI{dAb䁷ĩ5VqK?MZ\9U7lNM퍿K*8h@i OFdI(GޢV,X K _Bej@h(Å4)T[A&=7!&M:j 4UA# ڂޮgYf*u:ħK' qh#괈MNZP 4VKWrtqB=o1@>+g ."e"Wfv[fsD4P}\_!6]:f(n.sFmC%l%휁!2Z`tq<܄lHa (,1@4?S*XN&ZLY5؍ OoH]}Ia6zDgIg,okjPS H"Գ+,{ >Th _OȀ3RldBUVfNݙxSRXwqQ7E@E`A{]klDcFMEccX)R^nwq nM}bw33ܩŪa|2HullISC,4͖PxAP0`r¯+0d5IlՇk/ԃ-[<ʭ rU?7R43lRw8ׄ""&X3{IJAIj݋+~reM^vB~3t]HO'I͍~=<ٻqvp#EޟZs_abuh)ܢY;G/ngi\h=CCk=:G~j,j;q'fEphY\ȱ도Hc60dDz6/ǟk'{} 1nG3w!AO#g 6fixAmLyvհ<^z`͏lȇtv@"kqӰv~/ l'<ܹoӭ_kƶ,ML8Z*gpߵ;kz% ̌X8j#.TZ3w^վqf% ߑNKqsO7 NIRڨ/)+C;F\z0$&@#= % \8v/}ˣAkkCxzK;~.{="d.<ݽGeTʂ_G'Ƈ54)\nyϩ$]b遚#(zy|h/KwNޣ?A~ 0wy.F^S-mWW4X8S)[7$[rtPܠB7/?/ˬ ZiY:E&7ozebakx# 􏱩Dkbs7:˾=+NDIð8L^F HeaX4\a/NڂFevȧa/dT>?Bw/FO-ǀfw5%aQ6Mxi3rjAcNFv;brTF-rf=ㅻɲ`찛6Mh">_S!iKa)䄮nf~fTl9bŧV &S)-6iui|ճgZ-lSAmJo4l/7K ز|_ʿ ~G xZD%86oQ%Zkp{QsIV E0wKB7ml9u-sg' Je,>nZt8`ЌS6{P˸|_i 弾W7߬Xnrٱv_Z͏.K&ti-X^;aO>%]+gu3#Oh4׸O\~¹-,\>t).siҠ̖ 0t€be3W_-Dصl;($N67+)Sj>:CnJOER2kݠ]`ؤ~s7t7z|dzy%20(6桋TDҮ1I6^[L߰=$卩%l5vmliPw#cͳ1S#dF9f/~8@0HïٚN]z./քZBbs%QS[65/MafRoưR?oFZ*lT4@tnz!?xΔ$ m-7#,;lfKu! M$ 6pꌟYð8L^:}=Ƈ Bu'2âZ}Kq In+?-8nnou =Ata jQjaVv(‹[Yq!Z·}ٝ8zt;wUʋ`:7g_{Ѣs66~4~ԘY|8;&VoຝBN/>ExccK#+ ]ʼnjQ;p3/~EԻT/#(I=񲵑 f}-N-;yQ֥ QuX~?ߠQ~Q! .'o\ڦ9LQ GgsF[eTyex@碑DA'PB<|`#mɛou o '+_$MYvAfީ@1s?{@Nm_~"9u_bǵkLY*N}w_e|UsP(^] CnpB |0JөPɎL 0P-ʭϔ]+|`. 7bzZ\eq\@g܍`ua3@Y:?÷Zj0r"aHCK(N?n9=fWtzG$M FB4<4^m#d."wE"3a.FJθ |Ԣ= x5s4vxrQ>~;ݩ:ܾU 2|0ts@ ʼnOdPh&>=Q0yyGeuvv9IvP*t?i m}|vR3ɒos9888f.1Mܚ745%BAe gGL߹*߲[ڇ%oQjtm%n9wIgJ> (ɌX@}*akihT]G4ZrѽuxwCCgŅ<ۦ<;9 m-./lc 2QeJ.>1ۮ~ Tصe.1?\tS1a/.FVX[TGwqC{u XX>:Vviwet~IN4}BQl 3ww[H1.ZkNt!vhYtGɋ|:]{ES8bX//ɬ;^$iw0*?RiwldtZ 4IZz~m66alYaz~WkK-HXބKڒGBff/0J0|u̗^=!Wς}ڶLZE^fF7f58SXS\dm#^$31Nia0.."cQz^m]M,y<J'4CjADZI==M\_'~JdfOg}T BKS^-(;N=7V< +%*XϬ@O,%Rd]jrfdc"͝ooߟHutxV/*I7K@&7VZ=c76 I/+) ʘp=%TJc6J ]`břj! 8 0]"4*^MG'A$0J°CmIK+ Dg*Z.wpl ()HCUf ݵJvnì/)vV 6I$Tc`@:찛ZRH͔I*6acsiky^s6X A`]:CoPYD'ܺw +FRU*fT>$|E )A-)26(+?K PaJuYE3 $"Mm5(*ֻKGXP>e/.oi,XsUV>9VF< F<9QY_oraK϶_Mcec'\Z(ykTd zl~Hp/U.Sߋ;1 Den A0 4T!Tb)+C]2}LpSa'rHwZ̲*L5tƵy=&34,7h.ؒ+b$Po*D*Fŭvr=.^iaً#uVF~*@G3 <Q92uq&I3jĚbg"h6zÑ7ݿ=VTRv.Z(U"z "COX g >5j@9Sx7m.ݻv^&|P (!1>-l0Hd$̌u 4#ihq>)H M)ھR-'4:7u2!ѢYݐfb$B' 3bTV*~^eR^S*`dc1m]i.hw_he/.߾^u]B?$))+aUB@$1JO GR:ʕ+EI:<^zg;)1? ]?f]Z GrN.B F_:z1@U=62 Vz&Um }Ԡl!o_Ix4rh۲"gK60YɾJS t0,h{]4R^5q&XY <F8ue]&I\ [XmH[ρ(>*.TZv N]ۭQOKT%|8?4$ JT發ڭ6҅jQ.QNCg6={z/ =zl=؁fBj7gKͥg7™eAlҜtl~#)M^~S-̈́քMAeuмܳRd-ΆXoh_NĨ3wהƼqnk}[  [XmRSb  %; Dzp9$oKV.ϖHN;›jpT^i/Dh80fȫ\ gɅ=*|rSs7~P 0e!׵?ބȃMdN[&(c@dXiX&"nNDIվ%xyC'l7\OEPy1VtzL^s{X?e!#N%ۨxy ί_2gKe Dp2!I|3La+['KI{맳-PݤO%`-=xņAy7Q̧#Wl귭>{ЩkG NeR^.ۯ_@`hOD0닥)0#8X RAsl u=e kךe)/ ҥfXa*GD"^)ٸ`S%:lؖ-DzFwDݻٰJpZ+'Ƽ }oj0THw`;/\2(`4d COr ID DD=roۯH"8m ('Kxv R{q~nz4рyi6Nd㶝V%9{ 05Z~uL(;[%U)Y%O>ЯЀv.ⰛdIӥMxnyl>zv?Ѵz 'rlh @^]B\ޝFuϾ$a b79$`~1ؚ41ss0CӇ=p6{S3Aڂ p k &ՏÃ~k^OB_H%0_"&b0%L;낦89tL6$!ҋPaJGC?eԙw LM2+B 訒FdV'dS 0 ׊}m@߃oBZXŅW5 UvcAUV^Ҏ0"k%*5ɾJk U/h^_@Ň@ЊG!=<SߑՆ}Tb[A;0ZX>;4EQP\eV&<L ^cP*vMt3%TRJ_=uQ4T~}! J1~36Kw^T'&J.2H'@|t:⩐F !Zmi:Iw"ATJ? ^r/8LINMl&"3@)/f)Uu`a%ۍf̖g[-vkپzh9.E.C9888(-l9DȆ{Q(,%f:@g?l9888888888888p3[6̖ 7ol9888888888888p3[6rT?//2z IDAT6>+g)ծYcUl5P hccUDU[$pB9RQ9Ng[E= PZRe/[ǙhE>L6Ƽ5}lszPrȺ6B=}H=[K0E@vl8DL)4rpKn Tv@><|=-yjYvqjp!<+YiG2ƄT%!I_`]Z:7oq7ֳn˺?$P=*Up&xĄ,z ͬmmf@F)nH!C.tGKr _AG`?$9J bb{ޞ]݊皺$)Ea0Y`z464>gG[-Xa+#rm_|IzmlPѐf)<Ȇ81FES|@'驔AhdESW/ɲ'uUFYڏS|3Nn^=_`o ÜCU . Ll5騺|ÄzjĨybi2ciꡚvqzi ]kMV=0K%jQ_GЬh2j?;ڻG .|=.of:u;uͯHU5mv|yסѬ4~ri꡺бXϗ0I*Z:jng_t?OKSj8LX65-x a:gg&P <\4wk'=f7_yfmmÉo6l@w4FHӶu'G<`Qv9oIv&n%qÃZ(hUˤ#ץ;N *<䧇~KN¼+a\'H(!;vqcҲ0lLZv*>}%?~SC a(N=Mk2j?;<}Cש6#z\5+0)ɺ3Nq4$b҃OL)\YH.np`q_HaCZ,˘$GEda :7>Ϩ0Us\mH>%~P,}M`#oש?x*@(Ey 2+B phֳ90RoKT>p| `N'vKwlTj\0p'VP7"-H̖ !Dk"cIW Dꭼ3dăWe/5!&|$Pu2{ Lr ʘgٌښ@!ByKn<4"A.$ɇ#$((MK1'ւ01$(k:⪎ Cndn!(W]5-4GmkI[؋\yp5#L|u>XE`P]4{ fڐ :VZDI bp w+3T[դՆE ux jCf3T `E1/s@'U.z<K-KԵW `G™=}=qhupT)P@5RȢ0V$N"%`L"D %=)U84#M\e#+gqzRX VW&"4ݑw>%(dmT1Bİ&mCxI?PЀ ڙ6tE :PiT3lI wQ=)*DZ ԧ Z\ \)3 OkR_2uD<HƖL+d ?3XAKðwIZ /ܔHˡBh0/8Z YthC ,.$Bh,@c;ydFSTd90V@CH=R ^ x рO|> $ I%e7I=1, :Q)LH65o',(BO'Hc"W7lVV6נᓚzxw #>F HgYњwי0G.Fby`EZt7xf||krFT7/dhgؤ|uǃF)g`ґέ3#k&7M3Kf%6Hym8~9 bڐ(c_kƶ,ML8ZzkmeA,OH?>tW^Bl[#p+#$SevryUL'Ԇ),Sjd:ׯŸ|['k9_GSߝ6M\;ّe^^[RdYkfx ԶAk7] +tUXwOv͊вcg 02}AS=En^~0*,pb.aIOHU m,-kD }bG>8_YS$#j|QLԑO,fmٹɽS2&ș`&+VZ}j CΙ u ^Vu7꣙?c32{t7%_S=V(vʍZ`HL6ʥ%{{Mh 2,.r!i^vt8s\{ wo\M}XlBgvٷgʼn(iQ-Il0`x-Q85=)ʍY:hӦm\:d_SѧȩSup;'BuƁą Ow﹮ta]hr, o`Q{6yq40ed7jY Z$jxt7giwᨯ5Mgl̩؝VgRn(kx؜'Bz:{EYs 7cɿlqISk>vk7c\y#=PӒ_zٛV/yvZ3(x`֎|vΤ  Rۡiyu؁ &/^M> &rEĺ?% )kǵ$9f10nðwI -9oAipRF?loHv4&4O~C? zk&l^?.ciά~K@L0N 벏Lo޳sqRFPGZv;JmkHOo7=;fIBK:zƁB`hc&|[s䢴HSr)4IJ\~mV}HA ۲{ M钢},=illFTvøS-!}&}MЬZz38}vĞqWWg̥/Stw! /_yi*=upi_It{Ul|t1]g>lU[OV,qr1%?ˬw"v=R ce񉒩u"ڸW`ŖQ6#7l[H5Wjo zvXp}MvqE![)_eVG'g3zZ-uwob]mw[| ,Km:gnuqʹ g=|_i 弾W7߬XnRw2wKB7mlos@VH'  `u6yؾ㎙N\:B|Yf:TR3[v :⍍-_3`k霧I}3m 5yJtnϾEsllh:#ڳS\ },>z<6S}6$3@tE@Uëm>nZt8`ЌS6{0t€be3W_-DصlKkl(83:[|AX%LΖz].4piP0e]pSg\2yُ1wwxBG5/\3d IhG] 4tyw\r s$7eadr0B.<7"7n6-u\Ik  ^Y;~Aƥsn~A-y^^"6=0m&XIcWT+Ka.GS;Dxʏ_fRͫ?O ޖ~L I }2ix3hn?]F^t*|0qc%55j8z璽A{ B1J{tTfwI C2_m՗k SZ8{d<ß_=iļ39JKr^0pRo?M5k}ъײ}0e8[SO'S= 6N 0x@6ݢ"wsX,>teeԙmjv6.~+=H ܨޱW@ܦit+GݣXroptBˮp'h+N蹣`v@",{R? ;Xv1oaWWӸZ %x`_mV R$MYvAfީ@1s?{@Nm_~ȵ(6R %w2iMQL]*1rj:E0q,QEwE"3a0 t2QI +ܼttǞSHk8;L* e`fRS_Ъr(ҝ'_QG4DSrrSXx--kj KTܢ%ޟmQH EQz$4F[0[ՠd;jDKfaKh`uv*?&.pkFup>(K|sH=ɫyN.a{m(3t9-L J >ӠQ%6~7 9ݯg׈)_Z]";Tyl]'om#I/x;' RU @ғGisOR^f[)ba0]lK:=aMYeY'5>jl Tk0E3bG + ,Ghfc;?{뤺ی^~j0p>}@IꉗۍWOPV*/*>N9$O M&(gNZ6,'U~+Fm<ݵ XGnf}*"p3/5Tn#os:,ArرcX^e5K,.5Jqهy@CSV\VX$ [nV'ې8eHy6.)^-WR В| S8bX/7uPʭ-:IO@XSZ>`J7TdpY%D}:5=ˏqVEiJ^9 jft_?˷bZӦEޑfF7`en kwp{b].ZixAkH5[DKdICw{9q%SIz.Q"q/j4XVNN,W1EOKעswi :Mq̆bT ,Ύx*ίkg3@'|>#{w(zt6\ StZ $3 _hb=bCݽc##OkT&*wIg) j޺XDg!;BwUٓ$=D%y%ec'x#t,c.1Mܚ745%BAЈvg~__g@fc-:mҲcL^U=烛~Y̘_k<9x%ٕs!ɽ{|Ȣo ϕYġպhEy⧄QAfVڥq^P"i{_@kY\Nyo2#g()%?Mqg:& +=r0ʯFnPWc76 I/+) ޻g  "-T AT($L=D7$I Z::I^ zHī9Tb$)H7 0 gO4#_AJs3[|Qv<7H 0d(Ι2IbޖzYvv4( 7"[u쬚m7j`tJB5f nT=$=?Vq*$VGk@V=Z?gADZI=lt:gVd+&F),|cI[Z\.0V ⬏j!Z]bH S )Tq{$@e"#f I.Wo& 5q~_z|`2>'lPՙ3 % @468ܜ-[F~j8[9ԴinFV?+d\ϾxEBǓZ?bI{+m|`a߾e.l2 %~ao>k1f ݩ`QaHy`įziJgjY4Rk&(Rrҹ!?-idݭg Eo(V%`ѴeKCW|].־mױ[s܈xVZjF:=v늦v*ptvM;Tx'Ũ %9Ŕ_P慵?q) +Ŝ5dPN^!A &SzaΑKuZn_J%&R5FV6nf^g;   $6d[0db> 9A̶z"ՄJl0)hh[qדVj'nxӵ Qn&$`B@.q/~E9wcU]Rݣ[.IS[0Pzi (SVB`!OB> Ч闲H|@@DgK&D*~k.2 I{\uyXԻyMN-0~њ¢&݊S [5uч|8̇$/BbD yV^8S (#%4kfgN)|z(Fdŧ,''fߦ.3iVW%su NԺ9?:W%Oh*zHzAߺNd 5| NkkB>z(N99`TZiq1y^{w䯾 "Ho?(Py:}@QX80T]!7R=*%ٙK}<\<1 Be"qz Je\Z]ëgJ9\JFJaF]cQyR=BLMKk]$A[A\ӫA>=9>㿽 ($f_&ddUgwz[mM&.08nۮ]"La<, 2g|U * }N41x ,Ϯ .QJ5N2nT&-}ZKҤ0C>tm:i7zMP c̘̋g?4I- Z( Ϯ}D5m[GSK)lO&S&>uh"KhabҕR3RQ)sA[ip3y[o[m \m Rs9C]_P-[VGgr3٫ )=}}Emfz {77@bŎ%{IMbFk4jDhƎ *vP޷|,uTD?s<;wgggf[LOFZqx.\W2uk:!7j3m֪Ɠ\DK{NQN[hA{CocmnTF4#Vr+.Wת oG,!m7oAAXOjQ=%M:~>ک)3cUi^{{e=2,E }-3A[nz3ΝZf͵$tx)EE0Lvhm̬x΢w:q: aGeϛPz/,ö= 1I4ә)j~ĄݔnCm>&̚ ,X8.rY!UR_Wwꕻ~\}9i m̼&=G ^,Xðߐ2]uCdsBy3iSav촷,r1qul{`>&;Q'̍Wx cT,LK;f@9%~;U=]-1Y;Q Nz0z\>N}JNЫ)B|nӇ4#9f۸S>O>>ȯE֬4E[ NWv.lۿ^KV!Y {EN}}W^ɏNp9}˖o 3*So董믙}3ܞ]h™ ek(ϳNzUX.fϟ4>9ݖlݖ]C< WORE;)KΞB0;vU_ zs_9v҄|pd WUa*(nY ^hdb7ͻI"ߏ]0tjz|㾦{eƌ)Xoq&ڰ.Y1-+iQөJQmM@t.v_o;gUB.Xt՝N6S`0Ϯ%U<HWz]Q#IQ:j`{v#t\g~ yAm[TO>nm׭ۅ'@ x\V#}~P]ybݓM>*pŎ=zS'Kхį?\=@q3c@jD'\ SݝkđjsÏ&k w}e֭_/~#vQElo\TtcB% s7 X(&sb;Df)_"CcءP hz棼0)ax?ÝTN|_ZpAiMv +xdi >w'zLe^t??;`ϓOtkԴӄ--𪌕!z|xw]WFT|R[UW<BشPyKQ48kzr^Ƅye\WDz80n}V@9"~vbto.1pe|~a':\VIjwo=8n\(Cր8ubw}0lnոz;Fʲӥilvz4/~0gCeτ}ֺ"Stܵ}ි ]W)QFɿi Ф?(B e`k ۗ hOl c!u)wW~ҫ|i^s-41ksHᲺ}PJ՗,{H>Ҋ.% `* MuíǿnX;#Ӫ?U 7U\t$P)Q]]Vħ[2ǾVR>+Z}%ӊVI3FyZHpY sa*IPF_azmځR8R0^ i5}TaF|z[m\)(S iQUbawʯS.Q'Q_{Է]o=UʣvFxijs?.w7uةwbO}{2RV~Kdvz7րaI>wUB ;_%ƽm7a #N5Q3OY5 4/4EtT;0u=YcS.3K҃Rle]gb޶8X;Bs$={."gH ^8aȅ1::I(r/O7 Ig["Gh梆!b" $ݡ{.M)`eX[ƪڞBs@t]xa1QE#`mφWL^k߁M:}@ߎ!F%[LO4N_ 9y*%/ȒƤ+GN,S t|2oYjO+:u{3 S]dq5rkoMc "_xbEB2ٺެ.>O?+x wTPaEw^s_!j%sÌ}vRaQe{Kϫ@ zn@ϧ,<$Y[ƨ kߐKt|}5@&4%7*&[Ǎ5s7Ykփ (0%+t͈Θ0EuqyYE뉜D !"/BQsh/{19 )|t6I2a{ l:.\e4P Oz0jdש˾8pJ` j:t`Mui|姾 'JjP!Y wH5}³~t@k:=H)})5PޱL*rtN .Ѕ+wJŧDWXKt0{g[m_Ub \x{ZhZ@ KgzI*+:0^":|A..(]& И5Tp\/haT¢f`-+I*Q|>CҪ<:`~:4>,zKT@QS6 . 3j$`l!^ɖ1a#72 ? 0)"YX1h0J /flKvT0V~ZVNuq1`g5)%xS= +>\~G O'S !{͑T:/<݈!u~qAntwH5p+Hi Xu1g3YmߖY -pGy>%.\6I1ö>,kQkOQ00.3ؼ SIMbWd Z N„Pd_G1U'5@^e]m!/fVc/>/5fn9\ PfUțB!BHm@-!B!ڍ"[B!B!EB!Bj7l !B!nB!B(%B!RQdK!B!vȖB!BHF-!B!ڍ"[B!B!EB!Bj7l !B!nB!B(%B!RQdK!B!vȖB!BHF-!B!ڍ"[B!B!EB!Bj7l !B!nB!B(%B!R- S!B!RohdU L!B!R-t0U{C# j%B!WBK"[B!By%:aʫF'2$B!B!ա@74M !B!.W k llc2B!BL!W%L| Vao%B!B#K,LxK%,L吼>!B!T hO>N!-ja:!B!甫FZ-f9*k f!z}B!By-ZO%FVv¤"Z@+L#B!R) @zj0UzC#׼ !B!:B!BHF-!B!ڍ"[B!B!EB!Bj7l !B!nB!B(%B!RQdK!B!vȖB!BHF-!B!M,L 5H$5s39B!B"ۚK$0ȥqc330q\FF[7/_WG!B E锄)/I$YXXXYYj4 煅j lk89rrj\nhlbbiiݣw_IARwB!4OO/X'HԨ5 S**G5rpC#Ԕ<}V+UvȶqrvQ*;mfRA)_3\-LƒݓN,#BFWLG6^TT5TbllX"..juLtL&ZhAׯ \ƣȶqa-Xal)~,`@@ D`79 [9M?`u}-#BW {وVΦkxho{*US0W<&[}z߸~㜏p"۪0R( iT*R+hفa;Vt|ZK%RAd%nچQe-,Q}pw2_y8BaqѭIB!)̜=M#}kE[f9(a#d]|"`ی'C] #Wܺ}Œ0 w#M!7broq:!TˉZ9|U&frmנ;ojzV$a8mg!rzM rsc (ܯ͛dӕA7 {>//o{ 6"7[s{{wV${ܴkFN'ٙYVJe0E05hki2dz"r{9!ϨX6P]tʭPyTM H!7[|w;@vA)ׁ*tu˗PU}s~^Gd 0rl`?gu/:/0P/j|@OHO400(P]-#x.f$5ò=' Fۻɯ -[ܰaL&q#hٲG!P z)Gk)S'6?n+5EUP(@+ n>q<5%eǹRpCֱx!wq1TaÅyUTA "9]DtM]F 8;0fÌi*՚{RքFl+wcsZa,܄3g0!D?ݹc}7Qh*wLrm- b) GfbG9&*:Rj.#T'RH9dgIu-qxD/ !Bjf`B SUܮk]ko6=+ЦmkHd7.D-'z~tѢ}ѿR6R  ӦN~YXFȶ*BZY4rri߱ qᏪ?eWC}rsrrsB/JS{ىl2ڊe{~5wmrDî$̜~{KZ$ m&'~}CaQF܅w6? IDATSHkߴzY z/Pa~f,;&9:E/ >5bTi3Gw> Yo)& $î `Ld?균DF fv6kݷ38@Lyp<u`8sRAR#W~k޽yvs553t ft2kۯ^OphHQܹr~wߋF=:Ԥc(T1Wf%H;Ez(Ϲ%/eѡQ\T?%P{3rP3G+}֗0m7+o]Gx6 *ş(Jcy+_RHuVǧulil[t|fan%a-@W49V}+4rxlXN,^6-5@Yx*2zȾ}<~巠wJMkֵiӺG{3m꣰Gׄjl"1>>ߏe؆N]!۫GGڪT{KLL0L>۴LMMz҃%U_}Js "v-Rd^H1x;3xc"?6uIﹴvw'MĐL34se7}åH.d͈ l~6 ;ހ-[#UGir^۪CscfqhUO}& <;/ .Bc$1m!g#Vn< #[rZ5z>86K4r|niWt95lc )C/nvt_4 v5q/_ 4j&/à&.iM^BByG;=3.0֧wZq'#F;8&G싰kr̀01x2'_ߎ9~B"%ӻW?Z5p:"Xٵ5NuqP(' C1` J­8aWxFsw3wܰ)m6ls¼T*իW:d̘gi'0,ӰFQ*qO #jQ(>Rєh:t @]z=՜VgNbuc] 9$8"xU>ҋ$ Oxpͤv뺶E ~Tes0;g^6@~fvc @졋>=~:pc*q ':d?Svi"6mt5U{_ Wvc_uݻAmzT)g]ԧC?Wp[\NLqC]!Bj,.?|- ͝ޖ{]7b..lc ǟ\p17DDg{Q,eL1s|s`ҊF ~eq;>ۤY{[~<ߦZ, eLs5+/gz!2ŭoX'6×Z-7[{.{T]<~:s !ka*W pZtt//?<1.hmc#' m<~' ;֕(z9R?ʡ 3wP \?OmGs 51M5^ǼJIII&LLO(SᇖFyO"GD|jA:th~yVFt:DjddIJ%-s wA[:)򶲲Vhrvn?{y LfS/ΊA3)lix @,7P\f[V/+̓E/򀴎mȶ)@ul_A u]=(^P Sמwݦ J3txq#h]pP0& F&yN,7+k\^C9ƩId)BIu7g,Er,VdЪ875S[g= c03$#0@}NЅm\D-As}F%x}%_xWeoÚ(E,Ar5p9>ߨ>?']QFZ|~TAvTa9zpgdK. #arr2AXd2{+uMMM@@. 3j$lҲU붎$߽}+%#`ckԵ90jkꏝ85}mAAx wc&cA}}4^]' %]UЬc&`le#.Nyb`Yx5jЦ()"A+(I)h?gp/]'nWn^}HP=I<}Zgi1&Y0*a+KPk0OMF!iT3Áݱml/s3ejsӊWPg=#*̑[xMM)Ue> U4>Mjb 0ʌ':,{_7%ӐI07+#3UK^DbZhmh^t2Ig bqsVLmU8(6ǫ[7{qPmz1$&>ǀ5e/:*L*cE>r  >YZD,]ԛ\:<@zФӢnr_M9Aqk}Ə'̱dXܾ}+&BƍnR,=ld^~d">>!==¼N:7o ~0Fȶ*-,zvpoQPPpA|Nvuk^@YsqIKqƊ#y2r% iœAo-/+/vnv;]) IN o!Ib1"ങ }v^mGguk hW>kϺJg n_][}a !R×|]Gk4}[qS9[zF]k#1#$42YEX+D2K>-[rėM>Ӡם.T6iŹ\j[Bk#QZ *U^{ʒ7I]{xeum#6gC>ƥ.kg> >( OK%ktQ݃,\Z!xytYMJqc6;!'5Ff_p75F݀jl6m2˗|W kM042yVz^^]-[pᇥgZ߆`ң!l9޿'Ȫ}<[sVJ_f+|NtdDLtdLt䣰1Q5y)~; ݦUXijP^^=vγf͐Hyp9`ffh++K -/#GTCQmUy7o\ 7?ՆB ){ѱ~F602tsO`걈7x"4.plN<*E#yAQ~^9E%<]u$5`]܄-!A3}AمU@?V_GM=f@n~Ih2Jx`VߒBOMe0,IѭSff%3u3\8p43б}Vm SBch55ݔBJ7aä)BdS>i)N.熒ז\=Lnd}qZ.D]qڜB ݺka:foGVlv+("#0;Qǟw#BS;n8W76fvtK s/n{J%sD{0L^UCwӁC9Q>|3=~*0vijũcNJ܎;_,}z:wj5dITf} !L]a׵C6'+*yי^:ȈaڎTyɬ_Y}`RpWCx^x ٳ=U{_ueXwshgܸG RteM\AeFl<$TtK$:Eڟ].p⒛DԈсefLMfD(/8=Fv5|g7~m}U9bt*;,PORE6V6ьO4q=o,],.9mk/ڔII6X}6ὥKWf @18 q7B9eK}X{Fвe+4ꕚ| ߿[6mڴiņuLLM+{{䔓N21݄iKWIS1&ǡ3gDlaa١Z^yZ0QTpg}L[f|{.J9ݨɰWK ۻcּհ5[708D?[Mutmn .+$jMl-5m ݮJDO qRQʴ=sυ.7x6B?^]qRD[jװɝ|%0K?x|WUd琡tR>f?|-E!Dvy>'Ϲ@{wqq^`)cɘYY)| 0ڵۜyMjZ5 ̛fQb v:6|a'C[涼Jg<.XGʃ0h@ \rI"ƺ4>RX!f>R-3*I ],$WN8f; 6,b7ڙW; M@]kMPѲ=tOR…16q)P/[Ls:0&qFae$ h9-K@. waݺ5oZc__ƍo°8%!>aiJh斅y"b.0;3>pX:ҿ;S:1Aֶ>#9Y\]}@Ҝuh oBu:M7ԜKK}%C Xp3Bea'ϡjC@ڴfL|.9 ʛs5b>XeMƈ18\5 jF]` ],tJ5 > .=.¥!Rz}ƍΟ/PN]4EKK;xD9M/䅔l%'L+A\[nV(DG.pQ>mU+Ro*#=[-Z9f KR~O-g cEzzWAE.Z\.]n\";J}\-xpP]"ňut>u[Y.KCBYOU C;^_=]B|yBW&qy%K^)2]B!FLd>/Ia6yqQ99%&gϞnڴOɮI5E5ױ=n\?@y)m\_@٘ls'Eg7f0`27C.Cz]!,D!R\>9#^͟M\jA6w.6 *IJJ2ejMh 9zQodR;rvdc$[ ľ?3 k8o{ϣBư!EDІvj#cpe%ْZH Wǵ%+b32\`D&3ObSNAa B!BHD-XJL/I0f#B!41-)D!BIE!B!VB!B(%B!RQdK!B!vȖB!BHF-!B!ڍ"[B!B!EB!Bj70`ae'L"B!RMSIپL!B!աȄB!Bj7l !B!nB!B(%B!RQdK!B!vȖB!BHF-!B!ڍ"[B!B!EB!Bj7l !B!nbayD"@q B!B(\"A.1`22޺ygN B!Bj#OO/X'HԨ5 S**G5rpC#Ԕ<}V+UvȶqrvQ*;mfRA)_3\- !B( P[^1}E8zQQQ~~ #Cv66$RGb믶8P1ѱ2jѢ͛7_~p"@Ǖbrss cqvf0IkL |>1NBQ {وVΦkxho{*Uӯ~W@ FJn(E}"8WQ0c` G/v2YH{*1*/dEs^YJJ+mXg2L_Ȍﺸ8(w?'t:` Əӧ7( #@꺡KR*{vVk5}N=QUT"x~ ӯ}C 5޽2_Y95l6tma8<}v1\ 8\s6 3T1=m=[،ʸw iMꉇ i%W)ݙ6HaN4%ю+Pnʸm޼I&ubteЍ˛;C u-=*ܚ{xuҸI2)H,i37h$RA#ٙYʂuEmSmdy=ݹN-\媪Tnbs{tU|)/s_8dc[gh\pܚ-~puɝ-KfyIn\ j}4D B$>#D_I 0"#=5<~Qk^՞@_wcE19t4c)ę 1]¹!:5KJtaŠE:Rød!  (bu Ma+c+"y<֡B8 8^m\2ypThB t*6gC ea"CSѺN f@2%)ljLرxEXA@:(e`%B&y999J^2Z #Kq{E~jWwԕ\Kɀmc=MZ)ϚvPGWr]0U:4=鎇\e`։a;SoZZ=ƭZ;`'&YWK,uRpI03Xt`ɦQ/Om?<\սc9'O )1x̸~jD,}'o`͞+4d0Y~56be~͠CCΕ^ mn4љ75&F cZ{yj^ҌOSͭ'Zb^Z%#5{wn}yNvu$iG/nK•*R qnx5m?>'};ttCCf3E2кT=O~ZMQ`QPUa:X{Vڴ4yN3f1#߂n)6֮YצMݻδ^y(@?a6r;uGFWopG/oiR.110o355KT, XQأdac`Lh=`'C`j^~-۸d9qfKkпk~&oyϡQutM~qVǦuf gv6k~;?&EϚj7sR׵ Ogf~B!Yy7|ó?ln惗컴oU7[2DzAnn4_:[tǡV)>|4l|o,Xd$ ;I:`IK7_Ԇ02VX5&niC^q\x,~=U]3o[HNlʲէ#+, х+~IyԈ1$:q4Ө{<-b |5ixi M ̸ȷ[iI>WşW>Oo7IӤ鞔h-PJވ ٠RD"Ye(e-t4istPW9''^|Ϻ|xC7m#9rЋ]^{gMȮ" zjfmmB\uISfC+`δGsSFUm53zU+VU>g;Z]ˍ'`/N~8l }VFzԥe6k5MfFe:0_~YC\Shn=znwzlE@N ַ,󞒾EVh.ƾ9'I.=diT~>wX.;mV\:~4AWa0x^q]!~̏߄鰩vM@ mٰ>{F pza@`q[=Uxz ?QGe Ӽ !T_יXm Ηg;m_,<_beѯ8Z Se[pт9dC9sw΋ٲ Jbi=:am&~̫eA˹2/+jq.p3]'~2倮̖[ٿN2IYr4؟6 ~jZAzLptW,O)}kDlZ yqfn+VRys6Jgحi}Tv˼:wvYa}y>"S!0O%OJ3NH2yT6f֨6\?)vr7GĖߟg*77W_S(sJRYNRiYV Ӷ-lE1b?1L]w֒^WT(Yذ3܋ߧm+!~NW..2[ۺ Z5zlQYK5ɇ*=\Z Q{WYe֝qȴqo7▯~ +RAK`ҙ*Zm`឴i-L?J럿LB!;dmgĚi-NXWʋ4ei f>SG:D\ʵSnkxҟݱ䨇0IkF/gbv2Z@`Pʇmބyi)$xUJXj{"MNJ4xUJf: ܹ/5 Nٷ߯mBʗ9@Nx lpN#XB0ŅF*R嫼T^giPWr,-|)eϞkyu8nڲ @^gkS#/Fj5g4c~.]*MyMS)L6l ڻ[Q/XJS(8ǝ4ë\뛹aˍ\ O0=Ю/#ZS*pM5 q`@QvZ%y懏_&!Bҡ.9A7ndjZ"d(-\@WFh%sDiyP_-`%P};h 3,=DX8)?F_$eV'x`ESgyMaޣXWq `{woky2/a7ѫׅL.=2$e)Y P홫:gFbjy SщhTSeV|(hk`yvAl]9!ĚY$H]v5$Dr÷߿l$Hչ.|EZ C v-65sE֯S2>dee+NN99Om@bBes"ptr9M ZyFbB]Xl[y֨y *Tg Щ̫3׊ NVƩ5ce* ɰbӁ^nJ1E&rc7㵡ZvVm?jR*N `7wf"RFg~bUJU_$BHr>Z+eޘXY^2fW!ϣD]9J⊍~\eEf*]*_yR#8x*skl(TbÚWj)k>%Уw$^SfDž'N٧Gё0|vqWҳ[wTՅgtBygЛ_(H\3{e~SN;vnPҒ:}e֑c{EPr)8yRt޽zl[j9?]_Bj#$11G ~`wZ6uClwޙ .Xs"h6(X\t!)!Nl~*%_J [>fcY΂Iz.qTdf+J |]_i< 7Uˏ\|cs :^ұy9E4q:Tͮx[4Tˬם!R޾?4 5em^@b /ҩvn陒[>W[_=;)w/%&K7LCmPG//Wx^3zw;;i >M\:gQAd~񪰰Yz3T;QgM\8x9BC{[#B͋|:8VF.2frfǡ>gk~l>[fcGWf=joH"#GZ'(H-ׄ$s' S~ME-^{Een斍hИ64aPfm;4o&36>Ɂ4uDL$fjֻO\N%+gNNŀ!F\$oR)fijЀ+gڮ"3QXΌRɻ8_ri^j-r"\A; l!";j}jx׭R,vG-jkX [/223y{7oݻumoW]\ׯ[Ļ@}ǎؘR򫯾~vT(ĸ)>4C|9^6'; N[u 24lxt_HfV߆lw~yFE]rlĄ.W"bK!'_Χ^էk-q4֎]a6E|tĖGޑJVނmF}x a?Ξ>v͍bɾ<*'͙ĐR.:yeO2uydz}{K(dv2FieyZQrGb |VFe7rhՅogyܾ})pb۷OE./Xh_1$$$$+}FOFyyǎpS5F e"x˿c#O9{בM0▓;O+7A^v{CLxVK'k!t}˜m9v:e\Nރ\M-Yms],;s@'_J4N.8v;bdS6E+uueyxCʸX:uථZ1H:=V|͕o?6=S5L0.oJSAn'l{Y7ƪ}B!Ei'уW+? 1f͞ݏΌ+_ϗFq`ϞΛ7m)ҔW(1.C0Vp |')$;7[b767 h .B?z=^cY! :z*%_l a0&WBaL({j>7Էw0[@Ⱥ:.Pc] (Պ` tJ چ c: d0 _Knx6/Y`$7;Ø.U=.]BWstՏ?|?E׭I+Sߙ7_./0+\st.>Y!+1 3}^N>yc Q7`cugBYFAe@[)=ւ5s芭M/bݻFʓUbAupfc g:0vZ%C 3ΌSٝSs}{nܰ<XX0bkcv `D@ ެZ_&c8f\=xE_pZBo5{ ϷPN,Bߢp߮9^S1l+O: U#?? d6R{g<ϩB"ZlcOjn Bf+S(ї. @eb%.W2Vv&V1@eVׄ~yτ.9.lˌrlp<)S>I+M IDATio͚Vf#mNfQ@>7lXdU !?m FZ+0$o`le/"俇f#. \.g7$'[nH!Q/_q)Mxshaq sB! 4Ϙi"E \fue:<|cZٲ?dž>Փȃ vEMZxl]ZB!<,lNWɖ -B;7_ZsᅳDd eZMlA; ͽLkƙ ƼnEVv0-}4f~t$TYs^J{Le8:'faY'GiWwGGds\ޢ1d矋Ģ`,D"ޛM5h4QQ=wƴӦl߱c- =(۷n5kBݿ{YN="cYϹFy{x*g4ѦeK=&uͣ >){+j+sXPOm-lmOYsN@ qazt$Y18aW#HK{"+0ԕ3X~"P÷xufk1g#ۣI%tn [wJ~O2f=867jd!˯hm#'Xyy*1r|q3t`+OO[FXv +2FpFme}xC9x* < !R_X3 a"]ƀT*k[/''0ұ\1܍Ą* 7!D ohY1BN:d'Q%7 `jo.~N9<^"zB]ZciX{?ЙwZt%?,˳YGwX.;mV\:~ԣ+0 8h.k?ljoB}tAi{;Ϧii:mٰ>{F pzI\D0&ĚNF~j,|{tߣ; ?w鼘-4` *V&~;f˼ZN++^G1u'nn_ lڙ{^$StaZeG㚎im?W>9.>U2_A)eBz|x-M8o5QvmŪAsw=of^\i?Vy bҶU>m/ `Wnn>kV0%vRyT!v ^2kT`X;{Msbpd624//EX[GbVWoeQkH$3S'g}|yqb,KԟZ}hz,ww@`A9yONZd \B޵lRmMʥuOF)ެ_ܳݺ3ci~76]jbNKH8Th+S'`tOΈt(TҪGNի$]]Ld*汷8}ʤU߉\齲b7O!N/V NX;Tʋ4eif϶\;Uxҟ6oY ?F8)x%ŹbX7ʛZj4g0͛67/i ^Qܼ}2IJVX$2AgϞ3]ι}mBʗ9@SƏo*( F(jŅWz3n*Uj ŷ:ƥp- &Y=!W `x=l_^X2bIʈUCCq<:$ Ӥ2Eˮ]m_0YY Eeõm@bB&rh̶6;vm.HVGE^P}ݽU@[atثLxmjZFu:U*&ۺJˮ~XÐ*L6l ڻ[Q/XJS(8ǝ4ë\뛹aˍ\O0=Ю/#ZS*pM5 qpeM}>oG?rueśe(+>Wf9{lݚff:/+g`[RyQq{B/c[K>{<Jܸ2PZ`+#9|P_-`%P};h 3,=DX8)?F_$eV'ۻ|,)W6`S ݽ}Fʼ-jG^3[:sTfm$ t*yFmiod*޴::sܕwTt ޢX-jrυ3\F[*ɹ~%&&},N˦YOuppx睹[FA I qgWWm+5q8B(RsGZV0iI-pWqVNcDrS(b7 ot ]5hֲCj{N^ʼn_і6L w\y}G{U ZY 埣RVhh'P>kef3zBnRvGKv^\+ KX󕙮6BG(a+rrTg _yR#8x*sP k^ {rg@}"xMU;gEG^8~OMǁ_~\InAߝSU|G qd-;_Coqj%_m;啠.U ^'OnݻP-{{;k֬J% I .,ZO߲e˖m۶0q ?8iSw),x]ggăY\ȶ6%Ҙˉ q]O,1T"e1۴dooBuc@W /GFoySيl%lzWMU#6<汃gz)n t,e^#Fpiܴ~F!7vΔһШt='FW W+dsp3ìj OvB+o_F~j _6/ sv tJn|U #;L-_٫ǝG-^S}|<١U_ U6SK ZaT223y{7oݻumoW]\ׯ[Ļ@}ǎؘR򫯾~vT(ĸ)a-s=n&I--Bƻ]WZ*^#c`T ۙTjcgE<"o+_ J[JtMZ?;jlbW{jIcX鑰MXdr/0]Ko@@]\ mܽaWz;Ͳ n_+'YDZ S?#ncϟB!"Mn,1bR}Fl^tp(\d͜9̎CE%|hNv|mCR3 |ǎzTU:cH Ӽ㚰|nBW$ajޯk Uܲ1tWkPfm;4o&36>Ɂ4uDL$fjֻO\N%+gNNŀ!F\$/z&v*!H]CD!;vDZLamtՅ f#π\^hbϿcHHHHW꫍?طy@mmX&7w% z=[[ۜ,:Vl%297R2>*3|Wz~hӲWYM|{tɂ²n_/~9bzmXBV[([;viSyMޢG[:bˏvTJ%+oA6><`/ݎzsr?ȟRYuo޾9y@s>)7b?'pA^"%z.u{*kg}oɰ^B~ПeEE_%>=~2?5۷'BHAډuż CC Yg-xcƦ3@yسgM2Emʺ4*(|ź$B nx #$utZf\߆UU(g\[Ck,+$XGO^ ;/1*ENcD0WC8饾=VJU@ՏxupBj@XVcLSzFa64U ,Նm83"+N~׼1ۛa]sץKʕ[$O: U#?? d6R{gѹ|7oPdrtrzg0s{y';}_)M5D`Le֝qe]=UlW[ Ffk\h+7Ћu+OV?ma =u9j M P-CXwF,\n7L;";vcߋuo+KR-jcueݛZ j^}g[_V-'Bȳ7kl-̟o2:EYRԿE{g¯]sԵ5 u[[yb^^ԩUo\||\E%1 ydKS Eܵvnjdj?8ϯVP(b/G]`Q@eb%.W2Vv&V1@eF[&W2>lL좞<[m.6PR,+1 f?/~T]MtwqB!7qvŽYf;ea455Y*?<Qd:|`oFZJ3N4jCU ƍgh2'B |^Cf N=@CT2lta-RzxZ7T[#L, &ZQر#;wZb0!BǰHk^rq$˵  \",B{h62W=u'/3'''2B!kL= _ÿ.̶e"Dc<9!UB!_BcN^UO!Bȿk@!B!4(B!Bi(%B!ҰQdK!B!aȖB!BHF-!B!"[B!B! EB!B6l !B!4lB!Bi(%B!ҰQdK!B!aR|eIENDB`tavern-3.6.0/docs/source/core_concepts/types.md000066400000000000000000000303111520710011500215510ustar00rootroot00000000000000# Strict key checking 'Strict' key checking can be enabled or disabled globally, per test, or per stage. 'Strict' key checking refers to whether extra keys in the response should be ignored or whether they should raise an error. With strict key checking enabled, all keys in dictionaries at all levels have to match or it will raise an error. With it disabled, Extra keys in the response will be ignored as long as the ones in your response block are present. Strict key checking can be controlled individually for the response for the JSON body, the redirect query parameter, [text](../http.md#matching-plain-text-responses), or the headers. By default, strict key checking is _disabled_ for headers and redirect query parameters in the response, but _enabled_ for JSON (as well as when checking for JSON in an mqtt response). This is because although there may be a lot of 'extra' things in things like the response headers (such as server agent headers, cache control headers, etc), the expected JSON body will likely always want to be matched exactly. ### Effect of different settings This is best explained through an example. If we expect this response from a server: ```json { "first": 1, "second": { "nested": 2 } } ``` This is what we would put in our Tavern test: ```yaml ... response: json: first: 1 second: nested: 2 ``` The behaviour of various levels of 'strictness' based on the response: | Response | strict=on | strict=off | |-----------------------------------------------------------|-----------|------------| | `{ "first": 1, "second": { "nested": 2 } }` | PASS | PASS | | `{ "first": 1 }` | FAIL | PASS | | `{ "first": 1, "second": { "another": 2 } }` | FAIL | FAIL | | `{ "first": 1, "second": { "nested": 2, "another": 2 } }` | FAIL | PASS | Turning 'strict' off also means that extra items in lists will be ignored as long as the ones specified in the test response are present. For example, if the response from a server is `[ 1, 2, 3 ]` then strict being on - the default for the JSON response body - will match _only_ `[1, 2, 3]`. With strict being turned off for the body, any of these in the test will pass: - `[1, 2, 3]` - `[1]` - `[2]` - `[3]` - `[1, 2]` - `[2, 3]` - `[1, 3]` But not: - `[2, 4]` - '4' not present in response from the server - `[3, 1]`, `[2, 1]` - items present, but out of order To match the last case you can use the special setting `list_any_order`. This setting can only be used in the 'json' key of a request, but will match list items in any order as long as they are present in the response. ### Changing the setting This setting can be controlled in 3 different ways, the order of priority being: 1. In the test/stage itself 2. Passed on the command line 3. Read from pytest config This means that using the command line option will _not_ override any settings for specific tests. Each of these methods is done by passing a sequence of strings indicating which section (`json`/`redirect_query_params`/`headers`) should be affected, and optionally whether it is on or off. - `json:off headers:on` - turn off for the body, but on for the headers. `redirect_query_params` will stay default off. - `json:off headers:off` - turn body and header strict checking off - `redirect_query_params:on json:on` redirect parameters is turned on and json is kept on (as it is on by default), header strict matching is kept off (as default). Leaving the 'on' or 'off' at the end of each setting will imply 'on' - ie, using `json headers redirect_query_params` as an option will turn them all on. #### Command line There is a command line argument, `--tavern-strict`, which controls the default global strictness setting. ```shell # Enable strict checking for body and headers only py.test --tavern-strict json:on headers:on redirect_query_params:off -- my_test_folder/ ``` #### In the Pytest config file This behaves identically to the command line option, but will be read from whichever configuration file Pytest is using. ```ini [pytest] tavern-strict = json:off headers:on ``` #### Per test Strictness can also be enabled or disabled on a per-test basis. The `strict` key at the top level of the test should a list consisting of one or more strictness setting as described in the previous section. ```yaml --- test_name: Make sure the headers match what I expect exactly strict: - headers:on - json:off stages: - name: Try to get user request: url: "{host}/users/joebloggs" method: GET response: status_code: 200 headers: content-type: application/json content-length: 20 x-my-custom-header: chocolate json: # As long as "id: 1" is in the response, this will pass and other keys will be ignored id: 1 ``` A special option that can be done at the test level (or at the stage level, as described in the next section) is just to pass a boolean. This will turn strict checking on or off for all settings for the duration of that test/stage. ```yaml test_name: Just check for one thing in a big nested dict # completely disable strict key checking for this whole test strict: False stages: - name: Try to get user request: url: "{host}/users/joebloggs" method: GET response: status_code: 200 json: q: x: z: a: 1 ``` #### Per stage Often you have a standard stage before other stages, such as logging in to your server, where you only care if it returns a 200 to indicate that you're logged in. To facilitate this, you can enable or disable strict key checking on a per-stage basis as well. Two examples for doing this - these examples should behave identically: ```yaml --- # Enable strict checking for this test, but disable it for the login stage test_name: Login and create a new user # Force re-enable strict checking, in case it was turned off globally strict: - json:on stages: - name: log in request: url: "{host}/users/joebloggs" method: GET response: # Disable all strict key checking just for this stage strict: False status_code: 200 json: logged_in: True # Ignores any extra metadata like user id, last login, etc. - name: Create a new user request: url: "{host}/users/joebloggs" method: POST json: &create_user first_name: joe last_name: bloggs email: joe@bloggs.com response: status_code: 200 # Because strict was set 'on' at the test level, this must match exactly json: <<: *create_user id: 1 ``` Or if strict json key checking was enabled at the global level: ```yaml --- test_name: Login and create a new user stages: - name: log in request: url: "{host}/users/joebloggs" method: GET response: strict: - json:off status_code: 200 json: logged_in: True - name: Create a new user request: ... ``` ## Matching arbitrary return values in a response Sometimes you want to just make sure that a value is returned, but you don't know (or care) what it is. This can be achieved by using `!anything` as the value to match in the **response** block: ```yaml response: json: # Will assert that there is a 'returned_uuid' key, but will do no checking # on the actual value of it returned_block: !anything ``` This would match both of these response bodies: ```yaml returned_block: hello ``` ```yaml returned_block: nested: value ``` Using the magic `!anything` value should only ever be used inside pre-defined blocks in the response block (for example, `headers`, `params`, and `json` for a HTTP response). **NOTE**: Up until version 0.7.0 this was done by setting the value as `null`. This creates issues if you want to ensure that your server is actually returning a null value. Using `null` is still supported in the current version of Tavern, but will be removed in a future release, and should raise a warning. ### Matching arbitrary specific types in a response If you want to make sure that the key returned is of a specific type, you can use one of the following markers instead: - `!anynumber`: Matches any number (integer or float) - `!anyint`: Matches any integer - `!anyfloat`: Matches any float (note that this will NOT match integers!) - `!anystr`: Matches any string - `!anybool`: Matches any boolean (this will NOT match `null`) - `!anylist`: Matches any list - `!anydict`: Matches any dict/'mapping' ### Matching via a regular expression Sometimes you know something will be a string, but you also want to make sure that the string matches some kind of regular expression. This can be done using external functions, but as a shorthand there is also the `!re_` family of custom YAML tags that can be used to match part of a response. Say that we want to make sure that a UUID returned is a [version 4 UUID](https://tools.ietf.org/html/rfc4122#section-4.1.3), where the third block must start with 4 and the third block must start with 8, 9, "A", or "B". ```yaml - name: Check that uuidv4 is returned request: url: { host }/get_uuid/v4 method: GET response: status_code: 200 json: uuid: !re_fullmatch "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89AB][0-9a-f]{3}-[0-9a-f]{12}" ``` This is using the `!re_fullmatch` variant of the tag - this calls [`re.fullmatch`](https://docs.python.org/3.11/library/re.html#re.fullmatch) under the hood, which means that the regex given needs to match the _entire_ part of the response that is being checked for it to pass. There is also `!re_search` which will pass if it matches _part_ of the thing being checked, or `!re_match` which will match _part_ of the thing being checked, as long as it is at the _beginning_ of the string. See the Python documentation for more details. Another way of doing this is to use the builtin `validate_regex` helper function. For example if we want to get a version that is returned in a 'meta' key in the format `v1.2.3-510c2665d771e1`: ```yaml stages: - name: get a token by id request: url: "{host}/tokens/get" method: GET params: id: 456 response: status_code: 200 json: code: abc123 id: 456 meta: version: !anystr hash: 456 save: $ext: function: tavern.helpers:validate_regex extra_kwargs: expression: "v(?P[\d\.]+)-[\w\d]+" in_jmespath: "meta.version" ``` This is a more flexible version of the helper which can also be used to save values as in the example. If a named matching group is used as shown above, the saved values can then be accessed in subsequent stages by using the `regex.` syntax, eg: ```yaml - name: Reuse thing specified in first request request: url: "{host}/get_version_info" method: GET params: version: "{regex.version}" response: status_code: 200 json: simple_version: "v{regex.version}" made_on: "2020-02-21" ``` ## Type conversions [YAML](http://yaml.org/spec/1.1/current.html#id867381) has some magic variables that you can use to coerce variables to certain types. For example, if we want to write an integer but make sure it gets converted to a string when it's actually sent to the server we can do something like this: ```yaml request: json: an_integer: !!str 1234567890 ``` However, due to the way YAML is loaded this doesn't work when you are using a formatted value. Because of this, Tavern provides similar special constructors that begin with a *single* exclamation mark that will work with formatted values. Say we want to convert a value from an included file to an integer: ```yaml request: json: # an_integer: !!int "{my_integer:d}" # Error an_integer: !int "{my_integer:d}" # Works ``` Because curly braces are automatically formatted, trying to send one in a string might cause some unexpected issues. This can be mitigated by using the `!raw` tag, which will not perform string formatting. *Note*: This is just shorthand for replacing a `{` with a `{{` in the string ```yaml request: json: # Sent as {"raw_braces": "{not_escaped}"} raw_braces: !raw "{not_escaped}" ``` tavern-3.6.0/docs/source/debugging.md000066400000000000000000000205021520710011500175130ustar00rootroot00000000000000# Debugging a test When making a test it's not always going to work first time, and at the time of writing the error reporting is a bit messy because it shows the whole stack trace from pytest is printed out (which can be a few hundred lines, most of which is useless). Figuring out if it's an error in the test, an error in the API response, or even a bug in Tavern can be a bit tricky. ### Setting up logging Tavern has extensive debug logging to help figure out what is going on in tests. When running your tests, it helps a lot to set up logging so that you can check the logs in case something goes wrong. The easiest way to do this is with [dictConfig](https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig) from the Python logging library. It can also be useful to use [colorlog](https://pypi.org/project/colorlog/) to colourize the output so it's easier to see the different log levels. An example logging configuration (note that this requires the `colorlog` package to be installed): ```yaml # log_spec.yaml --- version: 1 formatters: default: # colorlog is really useful (): colorlog.ColoredFormatter format: "%(asctime)s [%(bold)s%(log_color)s%(levelname)s%(reset)s]: (%(bold)s%(name)s:%(lineno)d%(reset)s) %(message)s" style: "%" datefmt: "%X" log_colors: DEBUG: cyan INFO: green WARNING: yellow ERROR: red CRITICAL: red,bg_white handlers: stderr: class: colorlog.StreamHandler formatter: default loggers: tavern: handlers: - stderr level: INFO propagate: false ``` Which is used like this: ```python from logging import config import yaml with open("log_spec.yaml", "r") as log_spec_file: as_dict = yaml.load(log_spec_file.read(), Loader=yaml.SafeLoader) config.dictConfig(as_dict) ``` Making sure this code is called before running your tests (for example, by putting into `conftest.py`) will show the tavern logs if a test fails. By default, recent versions of pytest will print out log messages in the "Captured stderr call" section of the output - if you have set up your own logging, you probably want to disable this by also passing `-p no:logging` to the invocation of pytest. **WARNING**: Tavern will try not to log any response data or request data at the `INFO` level or above (unless it is in an error trace). Logging at the `DEBUG` level will log things like response headers, return values from any external functions etc. If this contains sensitive data, either log at the `INFO` level, or make sure that any data logged is obfuscated, or the logs are not public. ### Setting pytest options Some pytest options can be used to make the test output easier to read. - Using the `-vv` option will show a separate line for each test and whether it has passed or failed as well as showing more information about mismatches in data returned vs data expected - Using `--tb=short` will reduce the amount of data presented in the traceback when a test fails. If logging it set up as above, any important information will be present in the logs. - If you just want to run one test you can use the `-k` flag to make pytest only run that test. ### Example Say we are running against the [http example](https://github.com/taverntesting/tavern/tree/master/example/http) from Tavern but we have an error in the yaml: ```yaml # Log in ... - name: post a number request: url: "{host}/numbers" json: name: smallnumber number: 123 method: POST headers: content-type: application/json Authorization: "bearer {test_login_token:s}" response: status_code: 201 headers: content-type: application/json # This key will not actually be present in the response json: a_key: missing ``` Having full debug output can be a bit too much information, so we set up logging as above but at the `INFO` level rather than `DEBUG`. We run this by doing `py.test --tb=short -p no:logging` and get the following output: ``` _______________________________________________________________ /home/michael/code/tavern/example/http/tests/test_hello.tavern.yaml::Test authenticated /hello ________________________________________________________________ Format variables: test_login_token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXIiLCJhdWQiOiJ0ZXN0c2VydmVyIiwiZXhwIjoxNzcwNTkyODMxfQ.5AZfT6_G0EpEI_mnR5t_JItDvmBvrILa9yK5XaJpbQY' service:s = 'http://localhost:5000' Source test stage (line 18): - name: Authenticated /hello request: url: "{service:s}/hello/Jim" method: GET headers: Content-Type: application/json Authorization: "Bearer {test_login_token}" response: status_code: 200 headers: content-type: application/json json: data: "this test should fail" Formatted stage: name: Authenticated /hello request: headers: Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXIiLCJhdWQiOiJ0ZXN0c2VydmVyIiwiZXhwIjoxNzcwNTkyODMxfQ.5AZfT6_G0EpEI_mnR5t_JItDvmBvrILa9yK5XaJpbQY Content-Type: application/json method: GET url: http://localhost:5000/hello/Jim response: headers: content-type: application/json json: data: this test should fail status_code: 200 Errors: E tavern._core.exceptions.TestFailError: Test 'Authenticated /hello' failed: - Key mismatch: (expected["data"] = 'this test should fail' (type = ), actual["data"] = 'Hello, Jim' (type = )) ---------------------------------------------------------------------------------------------------- Captured stderr call ----------------------------------------------------------------------------------------------------- 22:28:52 [INFO]: (tavern._core.run:177) Running test : Test authenticated /hello 22:28:52 [INFO]: (tavern._core.run:370) Running stage : Unauthenticated /hello 22:28:52 [INFO]: (tavern._plugins.common.response:64) Response: '' 22:28:52 [INFO]: (tavern._core.run:370) Running stage : Login and acquire token 22:28:53 [INFO]: (tavern._plugins.common.response:64) Response: '' 22:28:53 [INFO]: (tavern._core.run:370) Running stage : Authenticated /hello 22:28:53 [INFO]: (tavern._plugins.common.response:64) Response: '' 22:28:53 [ERROR]: (tavern.response:42) Key mismatch: (expected["data"] = 'this test should fail' (type = ), actual["data"] = 'Hello, Jim' (type = )) Traceback (most recent call last): File "/home/michael/code/tavern/tavern/_core/dict_util.py", line 418, in check_keys_match_recursive assert actual_val == expected_val # noqa ^^^^^^^^^^^^^^^^^^^^^^^^^^ AssertionError During handling of the above exception, another exception occurred: Traceback (most recent call last): File "/home/michael/code/tavern/tavern/_core/dict_util.py", line 418, in check_keys_match_recursive assert actual_val == expected_val # noqa ^^^^^^^^^^^^^^^^^^^^^^^^^^ AssertionError The above exception was the direct cause of the following exception: Traceback (most recent call last): File "/home/michael/code/tavern/tavern/response.py", line 107, in recurse_check_key_match check_keys_match_recursive(expected_block, block, [], strict) ~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/michael/code/tavern/tavern/_core/dict_util.py", line 466, in check_keys_match_recursive check_keys_match_recursive( ~~~~~~~~~~~~~~~~~~~~~~~~~~^ expected_val[key], ^^^^^^^^^^^^^^^^^^ ...<2 lines>... strict, ^^^^^^^ ) ^ File "/home/michael/code/tavern/tavern/_core/dict_util.py", line 553, in check_keys_match_recursive raise exceptions.KeyMismatchError(f"Key mismatch: ({full_err()})") from e tavern._core.exceptions.KeyMismatchError: Key mismatch: (expected["data"] = 'this test should fail' (type = ), actual["data"] = 'Hello, Jim' (type = )) ``` When tavern tries to access `a_key` in the response it gets a `KeyError` (shown in the logs), and the `TestFailError` in the stack trace gives a more human-readable explanation as to why the test failed. tavern-3.6.0/docs/source/examples.md000066400000000000000000000177631520710011500174150ustar00rootroot00000000000000# Examples This page contains some examples of how to use Tavern with an example HTTP server. There some self-contained example projects in the examples folder in the repository, including examples of pyproject.toml files, project layout, and more. - More advanced HTTP examples: https://github.com/taverntesting/tavern/tree/master/example/http - MQTT examples: https://github.com/taverntesting/tavern/tree/master/example/mqtt - GraphQL examples: https://github.com/taverntesting/tavern/tree/master/example/graphql - gRPC examples: https://github.com/taverntesting/tavern/tree/master/example/grpc For a deeper dive, there is also the Tavern [integration tests](https://github.com/taverntesting/tavern/tree/master/tests/integration). ## 1) The simplest possible test To show you just how simple a Tavern test can be, here's one which uses the JSON Placeholder API at [jsonplaceholder.typicode.com](https://jsonplaceholder.typicode.com/). To try it, create a new file called `test_minimal.tavern.yaml` with the following: ```yaml test_name: Get some fake data from the JSON placeholder API stages: - name: Make sure we have the right ID request: url: https://jsonplaceholder.typicode.com/posts/1 method: GET response: status_code: 200 json: id: 1 userId: 1 title: "sunt aut facere repellat provident occaecati excepturi optio reprehenderit" body: "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" ``` Next, install Tavern if you have not already: ```bash $ pip install tavern ``` In most circumstances you will be using Tavern with pytest but you can also run it using the Tavern command-line interface, `tavern-ci`, which is installed along with Tavern: ```bash $ tavern-ci test_minimal.tavern.yaml ``` Run `tavern-ci --help` for more usage information. Note that Tavern will only run tests from files whose names follow the pattern `test_*.tavern.yaml` (or `test_*.tavern.yml`) - for example, `test_minimal.tavern.yaml`, `test_another.tavern.yml`. ## 2) Testing a simple server In this example we will create a server with a single route which doubles any number you pass it, and write some simple tests for it. You'll see how simple the YAML-based syntax can be, and the three different ways you can run Tavern tests. Here's what such a server might look like: ```python # server.py from flask import Flask, jsonify, request app = Flask(__name__) @app.route("/double", methods=["POST"]) def double_number(): r = request.get_json() try: number = r["number"] except (KeyError, TypeError): return jsonify({"error": "no number passed"}), 400 try: double = int(number) * 2 except ValueError: return jsonify({"error": "a number was not passed"}), 400 return jsonify({"double": double}), 200 ``` Run the server using Flask: ```bash $ export FLASK_APP=server.py $ flask run ``` There are two key things to test here: first, that it successfully doubles numbers and second, that it returns the correct error codes and messages. To do this we will write two tests, one for the success case and one for the error case. Each test can contain one or more stages, and each stage has a name, a request and an expected response. ```yaml # test_server.tavern.yaml --- test_name: Make sure server doubles number properly stages: - name: Make sure number is returned correctly request: url: http://localhost:5000/double json: number: 5 method: POST headers: content-type: application/json response: status_code: 200 json: double: 10 --- test_name: Check invalid inputs are handled stages: - name: Make sure invalid numbers don't cause an error request: url: http://localhost:5000/double json: number: dkfsd method: POST headers: content-type: application/json response: status_code: 400 json: error: a number was not passed - name: Make sure it raises an error if a number isn't passed request: url: http://localhost:5000/double json: wrong_key: 5 method: POST headers: content-type: application/json response: status_code: 400 json: error: no number passed ``` The tests can be run in three different ways: from Python code, from the command line, or with pytest. The most common way is to use pytest. All three require Tavern to be installed. If you run pytest in a folder containing `test_server.tavern.yaml` it will automatically find the file and run the tests. Otherwise, you will need to point it to the folder containing the integration tests or add it to `setup.cfg/tox.ini/etc` so that Pytest's collection mechanism knows where to look. ```bash $ py.test ============================= test session starts ============================== platform linux -- Python 3.5.2, pytest-3.2.0, py-1.4.34, pluggy-0.4.0 rootdir: /home/developer/project/tests, inifile: setup.cfg plugins: tavern-0.0.1 collected 4 items test_server.tavern.yaml .. ===================== 2 passed, 2 skipped in 0.07 seconds ====================== ``` The command line tool is useful for bash scripting, for example if you want to verify that an API is works before deploying it, or for cron jobs. ```bash $ tavern-ci test_server.tavern.yaml $ echo $? 0 ``` The Python library allows you to include Tavern tests in deploy scripts written in Python, or for use with a continuous integration setup: ```python from tavern.core import run from pytest import ExitCode exit_code = run("test_server.tavern.yaml") if exit_code != ExitCode.OK: print("Error running tests") ``` See the documentation section on global configuration for use of the second argument. ## 3) Multi-stage tests The final example uses a more complex test server which requires the user to log in, save the token it returns and use it for all future requests. It also has a simple database so we can check that data we send to it is successfully returned. [Here is the example server we will be using.](/server) To test this behaviour we can use multiple tests in a row, keeping track of variables between them, and ensuring the server state has been updated as expected. ```yaml test_name: Make sure server saves and returns a number correctly stages: - name: login request: url: http://localhost:5000/login json: user: test-user password: correct-password method: POST headers: content-type: application/json response: status_code: 200 json: $ext: function: tavern.helpers:validate_jwt extra_kwargs: jwt_key: "token" key: CGQgaG7GYvTcpaQZqosLy4 options: verify_signature: true verify_aud: false headers: content-type: application/json save: json: test_login_token: token - name: post a number request: url: http://localhost:5000/numbers json: name: smallnumber number: 123 method: POST headers: content-type: application/json Authorization: "bearer {test_login_token:s}" response: status_code: 201 json: { } headers: content-type: application/json - name: Make sure its in the db request: url: http://localhost:5000/numbers params: name: smallnumber method: GET headers: content-type: application/json Authorization: "bearer {test_login_token:s}" response: status_code: 200 json: number: 123 headers: content-type: application/json ``` This example illustrates three major parts of the Tavern syntax: saving data, using that data in later requests and using validation functions. ## Further reading To see the source code, suggest improvements or even contribute a pull request check out the [GitHub repository](https://github.com/taverntesting/tavern). tavern-3.6.0/docs/source/graphql.md000066400000000000000000000221031520710011500172150ustar00rootroot00000000000000# GraphQL integration testing The GraphQL plugin allows you to test GraphQL APIs using Tavern. It provides specialized request and response handling that understands GraphQL's unique structure and requirements. ## Important Note on Query Formatting **GraphQL queries cannot use Tavern's standard variable formatting (curly braces `{}`)** because GraphQL syntax itself uses curly braces. If you try to use formatting like `{variable}` in your GraphQL queries, it will fail because Tavern will attempt to format them before sending the query. **Instead, use GraphQL variables for any dynamic content:** ```yaml # ✅ GOOD - Use GraphQL variables stages: - name: Query user with variable graphql_request: url: "{graphql_server_url}/graphql" # URL formatting works fine query: | query GetUser($id: ID!) { user(id: $id) { # GraphQL variables work fine id name email } } variables: id: "{user_id}" # Variables in the variables object support formatting # ❌ BAD - Don't use formatting in queries stages: - name: Query user with formatting in query graphql_request: url: "{graphql_server_url}/graphql" query: | query GetUser { user(id: "{user_id}") { # This will fail - formatting in query id name } } ``` ## Configuration If using the default `gql` backend for graphql, default options can be passed to the underlying HTTP transport in the "gql" block at the top level for a test. The GraphQL configuration schema supports these options: ```yaml gql: headers: string: string # Default headers for all requests ``` For example, to make a query with an authorization header for all requests, not just for a single stage like in [the example below](#query-with-headers): ```yaml --- test_name: Query with global authorization header gql: headers: Authorization: "Bearer test-token" stages: - name: Query with authorization header graphql_request: ... ``` ## Requests ### Basic Query ```yaml stages: - name: Get user by ID graphql_request: url: "{graphql_server_url}/graphql" query: | query GetUser($id: ID!) { user(id: $id) { id name email } } variables: id: "1" ``` ### Query with Variables Use variables for dynamic data. Variables support standard Tavern formatting: ```yaml stages: - name: Create user with test variables graphql_request: url: "{graphql_server_url}/graphql" query: | mutation CreateUser($name: String!, $email: String!) { createUser(name: $name, email: $email) { id name email } } variables: name: "{user_name}" # Formatting works in variables email: "{user_email}" ``` ### Query with Headers ```yaml stages: - name: Authenticated query graphql_request: url: "{graphql_server_url}/graphql" headers: Authorization: "Bearer {auth_token}" Content-Type: "application/json" # Automatically added if not present query: | query GetUserData { me { id name } } ``` ### Query with Operation Name ```yaml stages: - name: Named operation graphql_request: url: "{graphql_server_url}/graphql" query: | query GetUserProfile($id: ID!) { user(id: $id) { id name profile { bio avatar } } } operation_name: GetUserProfile # Optional operation name variables: id: "1" ``` ### Multiple Operations in One Query ```yaml stages: - name: Multiple operations graphql_request: url: "{graphql_server_url}/graphql" query: | query GetUser($id: ID!) { user(id: $id) { id name } } mutation UpdateUser($id: ID!, $name: String!) { updateUser(id: $id, name: $name) { id name } } operation_name: GetUser # Specify which operation to execute variables: id: "1" ``` > **Note**: While `operation_name` isn't required for most use cases, it becomes essential when you're using the > `!include` tag to import an external GraphQL file that contains multiple operations. In such cases, `operation_name` > helps identify which specific operation you want to execute from the included file. ### File Uploads GraphQL file uploads are handled through variables using the `Upload` scalar type. You need to: 1. Define a GraphQL mutation with an `Upload` parameter 2. Use the `files:` block in your test to specify which file to upload 3. The variable name in `files:` must match the parameter name in your mutation ```yaml stages: - name: Upload a file graphql_request: url: "{graphql_server_url}/graphql" query: | mutation UploadFile($file: Upload!, $title: String!) { uploadFile(file: $file, title: $title) { id filename url } } variables: title: "My File" files: file: path/to/local/file.txt graphql_response: data: uploadFile: id: !anyint filename: "file.txt" ``` You can upload multiple files by specifying them in the `files:` block: ```yaml stages: - name: Upload multiple files graphql_request: url: "{graphql_server_url}/graphql" query: | mutation UploadFiles($file1: Upload!, $file2: Upload!) { uploadMultiple(file1: $file1, file2: $file2) { id files { filename url } } } variables: # Note: variables here should not contain the file data files: file1: path/to/first/file.txt file2: path/to/second/file.txt graphql_response: json: data: uploadMultiple: id: !anyint files: - filename: "file.txt" - filename: "file.txt" ``` You can also use the **long form** to specify the `content_type` of the file: ```yaml stages: - name: Upload file with custom content type graphql_request: url: "{graphql_server_url}/graphql" query: | mutation UploadFile($myFileName: Upload!, $title: String!) { uploadFile(file: $myFileName, title: $title) { id filename } } variables: title: "Custom File" files: myFileName: file_path: path/to/file.txt content_type: text/plain graphql_response: data: uploadFile: id: !anyint filename: "file.txt" ``` ## Responses GraphQL responses follow the standard GraphQL format with `data` and/or `errors` at the top level: ### Successful Response ```yaml stages: - name: Query user graphql_request: url: "{graphql_server_url}/graphql" query: | query GetUser($id: ID!) { user(id: $id) { id name } } variables: id: "1" graphql_response: json: data: user: id: "1" name: "John Doe" ``` ### Response with Errors ```yaml stages: - name: Invalid query graphql_request: url: "{graphql_server_url}/graphql" query: | query InvalidQuery { invalidField { id } } graphql_response: json: errors: - message: "Cannot query field 'invalidField' on type 'Query'." ``` ## Strictness As described in the [strict key checking](./core_concepts/types.md#strict-key-checking) section in the basics, GraphQL responses can use the `strict` key to check that the response contains all or some of the expected keys. See that section for more details. The `json` strictness setting is reused for the `data` key in GraphQL responses. ## Error Handling The GraphQL plugin provides specific error handling for common GraphQL scenarios: ### Response Structure Validation Tavern automatically validates that GraphQL responses have the correct structure: - Must contain only `data` or `errors` at the top level - Cannot have other top-level keys ### Status Code Handling GraphQL responses should always return HTTP 200 status codes, even for: - Validation errors - Business logic errors - Missing required fields Non-200 status codes indicate HTTP-level problems (authentication, network issues, etc.). ## Limitations ### Current Limitations - **Query Formatting**: Cannot use `{variable}` formatting in GraphQL queries themselves - use GraphQL variables instead - **Streaming**: No support for streaming responses (defer/stream directives) ### Future Plans - Improved error message formatting - Extensions (unsupported by [gql](https://github.com/graphql-python/gql) so we'd need to implement our own) - 'Partial responses' (errors and data in the response) tavern-3.6.0/docs/source/grpc.md000066400000000000000000000135041520710011500165170ustar00rootroot00000000000000# gRPC integration testing ## Example An example of a simple gRPC test which loads the compiled protobuf stubs from a module: ```yaml test_name: Test grpc connection without the 'connect' block includes: - !include common.yaml grpc: proto: module: helloworld_v1_precompiled_pb2_grpc stages: - name: Echo text grpc_request: host: "{grpc_host}:{grpc_port}" service: helloworld.v1.Greeter/SayHello body: name: "John" grpc_response: status: "OK" body: message: "Hello, John!" ``` ## Connection There are 2 ways of specifying the grpc connection, in the `grpc` block at the top of the test similarly to an mqtt connection block, or in the test stage itself. In the `grpc.connect` block: ```yaml grpc: connect: host: localhost port: 50052 ``` In the test stage itself: ```yaml stages: - name: Do a thing grpc_request: host: "localhost: 50052" service: my.cool.service/Waoh body: ... ``` The connection will be established at the beginning of the test and dropped when it finishes. ### SSL connection Tavern currently _defaults to an insecure connection_ when connecting to grpc, to enable SSL connections add the `secure` key in the `connect` block: ```yaml grpc: connect: secure: true ``` ### Metadata Generic metadata can be passed on every message using the `metadata` key: ```yaml grpc: metadata: my-extra-info: something ``` ### Advanced: connection options Generic connection options can be passed as key:value pairs under the `options` block: ```yaml grpc: connect: options: grpc.max_send_message_length: 10000000 ``` See [the gRPC documentation](https://grpc.github.io/grpc/core/group__grpc__arg__keys.html) for a list of possible options, note that some of these may not be implemented in Python. ## Requests The `grpc_request` block requires, at minimum, the name of the service to send the request to ```yaml stages: - name: Say hello grpc_request: service: helloworld.v3.Greeter/SayHello body: name: "John" ``` The 'body' block will be reflected into the protobuf message type expected for the service, if the schema is invalid then an exception will be raised. ## Responses If no response is specified, Tavern will assume that _any_ response with an `OK` status code to be successful. Other status codes are specified using the `status` key. The gRPC status code should be a string matching a [gRPC status code](https://grpc.github.io/grpc/core/md_doc_statuscodes.html), for example `OK`, `NOT_FOUND`, etc. or the numerical value of the code. It can also be a list of codes. ```yaml stages: - name: Echo text grpc_request: service: helloworld.v1.Greeter/SayHello body: name: "John" grpc_response: status: "OK" # Also the default ``` ## Loading protobuf definitions There are 3 different ways Tavern will try to load the appropriate proto definitions: #### Specifying the proto module to use If you already have all the Python gRPC stubs in your repository. Example: ```yaml grpc: proto: module: server/helloworld_pb2_grpc ``` This will attempt to import the given module (it should not be a Python file, but the path to the module containing the existing stubs) and register all the protos in it. #### Specifying a folder with some protos in Example: ```yaml grpc: proto: source: path/to/protos ``` This will attempt to find all files ending in `.proto` in the given folder and compile them using the protoc compiler. It first checks the value of the environment variable `PROTOC` and use that, and if not defined it will then look for a binary called `protoc` in the path. proto files are compiled into a folder called `proto` under the same folder that the Tavern yaml is in. This has a few drawbacks, especially that if it can't find the protoc compiler at runtime it will fail, but it might be useful if you're talking to a Java/Go/other server and you don't want to keep some compiled Python gRPC stubs in your repository. The main downside to this is that Tavern currently depends on `protobuf>=5,<6`. If the version of `protoc` you are using generates outputs that are incompatible with this, it will fail at runtime. Consider using this only as a last resort! #### Server reflection This is obviously the least useful method. If you don't specify a proto source or module, the client can attempt to use [gRPC reflection](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md) to determine what is the appropriate message type for the message you're trying to send. This is not reliable as the server you're trying to talk to might not have reflection turned on. This needs to be specified in the `grpc` block: ```yaml grpc: attempt_reflection: true ``` ## Current limitations / future plans - Should be able to specify channel credentials. - Currently there is no way of doing custom TLS options (like with rest/mqtt) - Better syntax around importing modules - Some way of representing streaming RPCs? This is pretty niche and Tavern is built around a core of only making 1 request which doesn't work well with streaming request RPCs, but streaming response RPCs could be handled like multiple MQTT responses. - Much like the tavern-flask plugin it wouldn't be too difficult to write a plugin which started a Python gRPC server in-process and ran tests against that instead of having to use a remote server - Fix comparing results - currently it serialises with always_print_fields_with_no_presence=True, preserving_proto_field_name=True, Which formats a field like `my_field_name` as `my_field_name` and not `myFieldName` which is what protojson in Go converts it to for example, need to provide a way to allow people to write tests using either one - protos are compiled into a folder based on `tempfile.gettempdir()`, this could be configurable tavern-3.6.0/docs/source/http.md000066400000000000000000000373051520710011500165500ustar00rootroot00000000000000# HTTP integration testing The things specified in this section are only applicable if you are using Tavern to test a HTTP API (ie, unless you are specifically checking MQTT or some other plugin). ## Using multiple status codes If the server you are contacting might return one of a few different status codes depending on it's internal state, you can write a test that has a list of status codes in the expected response. Say for example we want to try and get a user's details from a server - if it exists, it returns a 200. If not, it returns a 404. We don't care which one, as long as it it only one of those two codes. ```yaml --- test_name: Make sure that the server will either return a 200 or a 404 stages: - name: Try to get user request: url: "{host}/users/joebloggs" method: GET response: status_code: - 200 - 404 ``` Note that there is no way to do something like this for the body of the response, so unless you are expecting the same response body for every possible status code, the `json` key should be left blank. ## Sending form encoded data Though Tavern can only currently verify JSON data in the response, data can be sent using `x-www-form-urlencoded` encoding by using the `data` key instead of `json` in a request. An example of sending form data rather than json: ```yaml request: url: "{test_host}/form_data" method: POST data: id: abc123 ``` ## Authorisation ### Persistent cookies Tavern uses [requests](http://docs.python-requests.org/en/master/api/#requests.request) under the hood, and uses a persistent `Session` for each test. This means that cookies are propagated forward to further stages of a test. Cookies can also be required to pass a test. For example, say we have a server that returns a cookie which then needs to be used for future requests: ```yaml --- test_name: Make sure cookie is required to log in includes: - !include common.yaml stages: - name: Try to check user info without login information request: url: "{host}/userinfo" method: GET response: status_code: 401 json: error: "no login information" headers: content-type: application/json - name: login request: url: "{host}/login" json: user: test-user password: correct-password method: POST headers: content-type: application/json response: status_code: 200 cookies: - session-cookie headers: content-type: application/json - name: Check user info request: url: "{host}/userinfo" method: GET response: status_code: 200 json: name: test-user headers: content-type: application/json ``` This test ensures that a cookie called `session-cookie` is returned from the 'login' stage, and this cookie will be sent with all future stages of that test. #### Choosing cookies If you have multiple cookies for a domain, the `cookies` key can also be used in the request block to specify which one to send: ```yaml --- test_name: Test receiving and sending cookie includes: - !include common.yaml stages: - name: Expect multiple cookies returned request: url: "{host}/get_cookie" method: POST response: status_code: 200 cookies: - tavern-cookie-1 - tavern-cookie-2 - name: Only send one cookie request: url: "{host}/expect_cookie" method: GET cookies: - tavern-cookie-1 response: status_code: 200 json: status: ok ``` Trying to specify a cookie which does not exist will fail the stage. To send _no_ cookies, simply use an empty array: ```yaml --- test_name: Test receiving and sending cookie includes: - !include common.yaml stages: - name: get cookie for domain request: url: "{host}/get_cookie" method: POST response: status_code: 200 cookies: - tavern-cookie-1 - name: Send no cookies request: url: "{host}/expect_cookie" method: GET cookies: [ ] response: status_code: 403 json: status: access denied ``` #### Overriding cookies If you want to override the value of a cookie, then instead of passing a string to the `cookies` block in the request, use a mapping of `cookie name: cookie value`: ```yaml - name: Override cookie value request: url: "{host}/expect_cookie" method: GET cookies: - tavern-cookie-2: abc response: status_code: 200 json: status: ok ``` This will create a new cookie with the name `tavern-cookie-2` with the value `abc` and send it in the request. If this cookie already exists from a previous stage, it will be overwritten. Trying to override the cookie multiple times in one stage will cause an error to occur at runtime. ### HTTP Basic Auth For a server that expects HTTP Basic Auth, the `auth` keyword can be used in the request block. This expects a list of two items - the first item is the user name, and the second name is the password: ```yaml --- test_name: Check we can access API with HTTP basic auth includes: - !include common.yaml stages: - name: Get user info request: url: "{host}/userinfo" method: GET auth: - user@api.com - password123 response: status_code: 200 json: user_id: 123 headers: content-type: application/json ``` ### Custom auth via `$ext` For authentication schemes beyond HTTP Basic Auth (such as HTTP Digest Auth or custom token-based auth), use the `$ext` key with the `auth` field. The `$ext` function should return an instance of [`requests.auth.AuthBase`](https://docs.python-requests.org/en/latest/api/#requests.auth.AuthBase) (or any callable that accepts a `PreparedRequest` and returns it). This is useful for HTTP Digest Auth, OAuth, custom header-based auth, or any custom authentication flow. ```yaml --- test_name: Test digest auth via external function includes: - !include common.yaml stages: - name: Send with digest auth request: url: "{host}/digest-endpoint" method: GET auth: $ext: function: ext_functions:get_digest_auth response: status_code: 200 ``` Your external module would return an `AuthBase` instance: ```python from requests.auth import HTTPDigestAuth def get_digest_auth(): return HTTPDigestAuth("myuser", "mypassword") ``` You can also use custom auth classes for non-standard schemes: ```python from requests.auth import AuthBase class TokenAuth(AuthBase): def __init__(self, token): self.token = token def __call__(self, r): r.headers["X-api-token"] = f"Token {self.token}" return r def get_token_auth(): return TokenAuth("abc123") ``` See [the requests documentation](https://requests.readthedocs.io/en/latest/user/advanced/#custom-authentication) for how this works. ### Custom auth header If you're using a form of authorisation not covered by the above examples to authorise against your test server (for example, a JWT-based system), specify a custom `Authorization` header. If you are using a JWT, you can use the built in `validate_jwt` external function as defined above to check that the claims are what you'd expect. ```yaml --- test_name: Check we can login then use a JWT to access the API includes: - !include common.yaml stages: - name: login request: url: "{host}/login" json: user: test-user password: correct-password method: POST headers: content-type: application/json response: status_code: 200 json: $ext: &verify_token function: tavern.helpers:validate_jwt extra_kwargs: jwt_key: "token" key: CGQgaG7GYvTcpaQZqosLy4 options: verify_signature: true verify_aud: true verify_exp: true audience: testserver headers: content-type: application/json save: json: test_login_token: token - name: Get user info request: url: "{host}/userinfo" method: GET headers: Authorization: "Bearer {test_login_token:s}" response: status_code: 200 json: user_id: 123 headers: content-type: application/json ``` ## Controlling secure access ### Running against an unverified server If you're testing against a server which has SSL certificates that fail validation (for example, testing against a local development server with self-signed certificates), the `verify` keyword can be used in the `request` stage to disable certificate checking for that request. ### Using self signed certificates In case you need to use a self-signed certificate to connect to a server, you can use the `cert` key in the request to control which certificates will be used by Requests. If you just want to pass your client certificate with a request, pass the path to it using the `cert` key: ```yaml --- test_name: Access an API which requires a client certificate stages: - name: Get user info request: url: "{host}/userinfo" method: GET cert: "/path/to/certificate" # Or use a format variable: # cert: "{cert_path}" response: ... ``` If you need to pass a SSL key file as well, pass a list of length two with the first element being the certificate and the second being the path to the key: ```yaml --- test_name: Access an API which requires a client certificate stages: - name: Get user info request: url: "{host}/userinfo" method: GET cert: - "/path/to/certificate" - "/path/to/key" response: ... ``` See the [Requests documentation](http://docs.python-requests.org/en/master/api/#requests.request) for more details about this option. ## Uploading files as part of the request To upload a file along with the request, the `files` key can be used: ```yaml --- test_name: Test files can be uploaded with tavern includes: - !include common.yaml stages: - name: Upload multiple files request: url: "{host}/fake_upload_file" method: POST files: test_files: "test_files.tavern.yaml" common: "common.yaml" response: status_code: 200 ``` This expects a mapping of the 'name' of the file in the request to the path on your computer. By default, the sending of files is handled by the Requests library - to see the implementation details, see their [documentation](http://docs.python-requests.org/en/master/user/quickstart/#post-a-multipart-encoded-file). ### Uploading a file as the body of a request In some cases it may be required to upload the entire contents of a file in the request body - for example, when posting a binary data blob from a file. This can be done for JSON and YAML using the `!include` tag, but for other data formats the `file_body` key can be used: ```yaml - name: Upload a file in the request body request: url: "{host}/data_blob" method: POST file_body: "/path/to/blobfile" response: status_code: 200 ``` The path can be absolute or relative to: - the directory of the test file (like `!include` does) - any path in the `TAVERN_INCLUDE` environment variable (colon-separated list of paths) Like the `files` key, this is mutually exclusive with the `json` key. ### Specifying custom content type and encoding If you need to use a custom file type and/or encoding when uploading the file, there is a 'long form' specification for uploading files. Instead of just passing the path to the file to upload, use the `file_path` and `content_type`/`content_encoding` in the block for the file: ```yaml --- test_name: Test files can be uploaded with tavern stages: - name: Upload multiple files request: url: "{host}/fake_upload_file" method: POST files: # simple style - guess the content type and encoding test_files: "test_files.tavern.yaml" # long style - specify them manually common: file_path: "common.yaml" content_type: "application/customtype" content_encoding: "UTF16" ``` To specify the same file multiple times with a different form field name, use a list for the files: ```yaml --- test_name: Test reusing the same file name multiple times stages: - name: Upload multiple files request: url: "{host}/fake_upload_file" method: POST files: - form_field_name: group_2 file_path: OK.txt - form_field_name: group_1 file_path: OK.txt - form_field_name: group_1 file_path: OK.json.gz content_encoding: gzip ``` ## Timeout on requests If you want to specify a timeout for a request, this can be done using the `timeout` parameter: ```yaml --- test_name: Get server info from slow endpoint stages: - name: Get info request: url: "{host}/get-info-slow" method: GET timeout: 0.5 response: status_code: 200 json: n_users: 2048 n_queries: 10000 ``` If this request takes longer than 0.5 seconds to respond, the test will be considered as failed. A 2-tuple can also be passed - the first value will be a _connection_ timeout, and the second value will be the response timeout. By default this uses the Requests implementation of timeouts - see [their documentation](http://docs.python-requests.org/en/master/user/advanced/#timeouts) for more details. ## Redirects By default, Tavern will not follow redirects. This allows you to check whether an endpoint is indeed redirecting a user to a certain page. To disable this behaviour, use either the `--tavern-always-follow-redirects` command line flag or set `tavern-always-follow-redirects` to True in your Pytest settings file. This can also be disabled or enabled on a per-stage basis by using the `follow_redirects` flag: ```yaml --- test_name: Expect a redirect when setting the flag stages: - name: Expect to be redirected request: url: "{host}/redirect/source" follow_redirects: true response: status_code: 200 json: status: successful redirect ``` Specifying `follow_redirects` on a stage will override any global setting, so if you just want to change the behaviour for one stage then use this flag. ## Matching plain text responses If your API returns plain text (non-JSON) responses, you can use the `text` key to validate the response body: ```yaml --- test_name: Test plain text response stages: - name: Get plain text response request: url: "{host}/text_response" method: GET response: status_code: 200 text: "Hello, World!" ``` This also works with multiline text: ```yaml --- test_name: Test ASCII table response stages: - name: Get ASCII table request: url: "{host}/ascii_table" method: GET response: status_code: 200 text: | +----+----------+-------+ | id | name | score | +----+----------+-------+ | 1 | Alice | 95 | | 2 | Bob | 87 | | 3 | Charlie | 92 | +----+----------+-------+ ``` ### Matching text response body from a file If you have a large expected response body, you can store it in a file and use `!include_raw` inside the `text` block to validate the response against the file contents: ```yaml --- test_name: Test response against file content stages: - name: Match response against file request: url: "{host}/ascii_table" method: GET response: status_code: 200 text: !include_raw expected_table.txt ``` The `!include_raw` tag, similar to the `!include` tag, is a special tag that will read the file contents and include them in the test. This will include the file 'as-is' without loading as JSON like the `!include` tag does. tavern-3.6.0/docs/source/mqtt.md000066400000000000000000000223311520710011500165470ustar00rootroot00000000000000# MQTT integration testing ## Testing with MQTT messages Since version `0.4.0` Tavern has supported tests that require sending and receiving MQTT messages. This is a very simple MQTT test that only uses MQTT messages: ```yaml # test_mqtt.tavern.yaml --- test_name: Test mqtt message response paho-mqtt: client: transport: websockets client_id: tavern-tester connect: host: localhost port: 9001 timeout: 3 stages: - name: step 1 - ping/pong mqtt_publish: topic: /device/123/ping payload: ping mqtt_response: topic: /device/123/pong payload: pong timeout: 5 ``` The first thing to notice is the extra `paho-mqtt` block required at the top level. When this block is present, an MQTT client will be started for the current test and is used to publish and receive messages from a broker. ### MQTT connection options The MQTT library used is the [paho-mqtt](https://github.com/eclipse/paho.mqtt.python) Python library, and for the most part the arguments for each block are passed directly through to the similarly-named methods on the `paho.mqtt.client.Client` class. The full list of options for the `mqtt` client block are listed below (`host` is the only required key, though you will almost always require some of the others): - `client`: Passed through to `Client.__init__`. - `transport`: Connection type, optional. `websockets` or `tcp`. Defaults to `tcp`. - `client_id`: MQTT client ID, optional. Defaults to `tavern-tester`. - `clean_session`: Whether to connect with a clean session or not. `true` or `false`. Defaults to `false`. - `connect`: Passed through to `Client.connect`. - `host`: MQTT broker host. - `port`: MQTT broker port. Defaults to 1883 in the paho-mqtt library. - `keepalive`: Keepalive frequency to MQTT broker. Defaults to 60 (seconds) in the paho-mqtt library. Note that some brokers will kick client off after 60 seconds by default (eg VerneMQ), so you might need to lower this if you are kicked off frequently. - `timeout`: How many seconds to try and connect to the MQTT broker before giving up. This is not passed through to paho-mqtt, it is implemented in Tavern. Defaults to 1. - `tls`: Controls TLS connection - as well as `enable`, this accepts all keywords taken by `Client.tls_set()` (see [paho documentation](https://github.com/eclipse/paho.mqtt.python/blob/e9914a759f9f5b8081d59fd65edfd18d229a399e/src/paho/mqtt/client.py#L636-L671) for the meaning of these keywords). - `enable`: Enable TLS connection with broker. If no other `tls` options are passed, using `enable: true` will enable tls without any custom certificates/keys/ciphers. If `enable: false` is used, any other tls options will be ignored. - `ca_certs` - `certfile` - `keyfile` - `cert_reqs` - `tls_version` - `ciphers` - `auth`: Passed through to `Client.username_pw_set`. - `username`: Username to connect to broker with. - `password`: Password to use with username. The above example connects to an MQTT broker on port 9001 using the websockets protocol, and will try to connect for 3 seconds before failing the test. Similar to the persistent `requests` session, the MQTT client is created at the beginning of a test and used for all stages in the test. ### MQTT publishing options Messages can be published using the MQTT broker with the `mqtt_publish` key. In the above example, a message is published on the topic `/device/123/ping`, with the payload `ping`. Like when making HTTP requests, JSON can be sent using the `json` key instead of the `payload` key. ```yaml mqtt_publish: topic: /device/123/ping json: thing_1: abc thing_2: 123 ``` This will result in the MQTT payload `'{"thing_2": 123, "thing_1": "abc"}'` being sent. The full list of keys for this block: - `topic`: The MQTT topic to publish on - `payload` OR `json`: A plain text payload to publish, or a YAML object to serialize into JSON. - `qos`: QoS level for publishing. Defaults to 0 in paho-mqtt. ### Options for receiving MQTT messages The `mqtt_response` key gives a topic and payload which should be received by the end of the test stage, or that stage will be considered a failure. This works by subscribing to the topic specified before running the test, and then waiting after the test for a specified timeout for that message to be sent. If a message on the topic specified with **the same payload** is not received within that timeout period, it is considered a failure. If other messages on the same topic but with a different payload arrive in the meantime, they are ignored and a warning will be logged. ```yaml mqtt_response: topic: /device/123/ping json: thing_1: abc thing_2: 123 ``` The keys which can be used: - `topic`: The MQTT topic to subcribe to - `payload` OR `json`: A plain text payload or a YAML object that will be serialized into JSON that must match the payload of a message published to `topic`. - `timeout`: How many seconds to wait for the message to arrive. Defaults to 3. - `qos`: The level of QoS to subscribe to the topic with. This defaults to 1, and it is unlikely that you will need to ever set this value manually. While the `json` key will follow the same matching rules as HTTP JSON responses, The special 'anything' token can be used with the `payload` key just to check that there was _some_ response on a topic: ```yaml mqtt_response: topic: /device/123/ping payload: !anything ``` Other type tokens such as `!anyint` will _not_ work. ### Unexpected messages If you want to make sure that you do _not_ want to receive a message when a certain request (MQTT or HTTP) is sent, use the 'unexpected' key like so: ```yaml mqtt_response: topic: /device/123/status/response payload: !anything timeout: 3 qos: 1 unexpected: true ``` If this message is received during the test, it will fail it. Be careful when using this as if this message just happened to be sent during the test and not as a result of anything during your test, it will still make the test fail. ## Mixing MQTT tests and HTTP tests If the architecture of your program combines MQTT and HTTP, Tavern can seamlessly test either or both of them in the same test, and even in the same stage. ### MQTT messages in separate stages In this example we have a server that listens for an MQTT message from a device for it to say that a light has been turned on. When it receives this message, it updates a database so that each future request to get the state of the device will return the updated state. ```yaml --- test_name: Make sure posting publishes mqtt message includes: - !include common.yaml # More realistic broker connection options paho-mqtt: &mqtt_spec client: transport: websockets connect: host: an.mqtt.broker.com port: 4687 tls: enable: true auth: username: joebloggs password: password123 stages: - name: step 1 - get device state with lights off request: url: "{host}/get_device_state" params: device_id: 123 method: GET headers: content-type: application/json response: status_code: 200 json: lights: "off" headers: content-type: application/json - name: step 2 - publish an mqtt message saying that the lights are now on mqtt_publish: topic: /device/123/lights qos: 1 payload: "on" delay_after: 2 - name: step 3 - get device state, lights now on request: url: "{host}/get_device_state" params: device_id: 123 method: GET headers: content-type: application/json response: status_code: 200 json: lights: "on" headers: content-type: application/json ``` You can see from this example that when using `mqtt_publish` we don't necessarily need to expect a message to be published in return - We can just send a message and wait for it to be processed with `delay_after`. ### MQTT message in the same stage MQTT blocks and HTTP blocks can be combined in the same test stage to test that sending a HTTP request results in an MQTT message being sent. Say we have a server that takes a device id and publishes an MQTT message to it saying hello: ```yaml --- test_name: Make sure posting publishes mqtt message includes: - !include common.yaml paho-mqtt: *mqtt_spec stages: - name: step 1 - post message trigger request: url: "{host}/send_mqtt_message" json: device_id: 123 payload: "hello" method: POST headers: content-type: application/json response: status_code: 200 json: topic: "/device/123" headers: content-type: application/json mqtt_response: topic: /device/123 payload: "hello" timeout: 5 qos: 2 ``` Before running the `request` in this stage, Tavern will subscribe to `/device/123` with QoS level 2. After making the request (and getting the correct response from the server!), it will wait 5 seconds for a message to be published on that topic. **Note**: You can only have one of `request` or `mqtt_publish` in a test stage. If you need to publish a message and send a HTTP request in sequence, use an approach like the previous example where they are in two separate stages. tavern-3.6.0/docs/source/plugins.md000066400000000000000000000321601520710011500172440ustar00rootroot00000000000000# Plugins Tavern has a simple plugin system which lets you change how requests are made. By default, backends are handled by: - HTTP: [requests](http://docs.python-requests.org/en/master/) - MQTT: [paho-mqtt](https://www.eclipse.org/paho/clients/python/docs/) - gRPC: [grpcio](https://grpc.github.io/grpc/python/) - GraphQL: [gql](https://github.com/graphql-python/gql) However, there are some situations where you might not want to run tests against something other than a live server, or maybe you just want to use curl to extract some better usage statistics out of your requests. Tavern's plugin system can be used to override this default behaviour. The best way to introduce the concepts for making a plugin is by using an example. For this we will be looking at a plugin used to run tests against a local flask server called [tavern_flask](https://github.com/taverntesting/tavern-flask). There is another plugin used to run tests against FastAPI/Starlette `TestClient` called [tavern_fastapi](https://github.com/zaghaghi/tavern-fastapi) which may also be of interest. ## Community plugins The following plugins are maintained outside the core Tavern repository: - [tavern-grpc-web](https://github.com/magomedcoder/tavern-grpc-web): gRPC-Web backend plugin for calling unary RPC methods over HTTP. ## The entry point Plugins are loaded using two setuptools entry points, namely `tavern_http` for HTTP tests and `tavern_mqtt` for MQTT tests. The built-in requests and paho-mqtt functionality is implemented using plugins, so looking at the `_plugins` folder in the Tavern repository will also be useful as a reference when writing a plugin. The entry point needs to point to either a class or a module which defines a preset number of variables. Something like this should be in your `setup.py`, `setup.cfg`, `poetry.toml`, `pyproject.toml`, etc. to make sure Tavern can pick it up at run time: ``` # setup.cfg # A http plugin. tavern_http is the entry point that Tavern searches for, # 'requests' is the name of your plugin which is selected using the # --tavern-http-backend command line flag. This points to a class in the # tavernhook module. tavern_http = requests = tavern._plugins.rest.tavernhook:TavernRestPlugin # An MQTT plugin. Like above, tavern_mqtt is the entry point name and # 'paho-mqtt' is the name of the plugin. This points to a module. tavern_mqtt = paho-mqtt = tavern._plugins.mqtt.tavernhook ``` Examples: - The [requests based](https://github.com/taverntesting/tavern/blob/master/tavern/_plugins/rest/tavernhook.py) http entry point points to a class using the `module.submodule:member` entry point syntax. - The [paho-mqtt plugin](https://github.com/taverntesting/tavern/blob/master/tavern/_plugins/mqtt/tavernhook.py) just uses a module using the `module.submodule` entry point syntax. This loads the schema from the file on import. - The [tavern-flask](https://github.com/taverntesting/tavern-flask/blob/master/tavern_flask/tavernhook.py) plugin also just uses a module. ## Extra schema data If your plugin needs extra metadata in each test to be able to make a request, extra schema data can be added with a `schema` key in your entry point. This should be a dictionary which is merged into the [base schema](https://github.com/taverntesting/tavern/blob/master/tavern/_core/schema/tests.jsonschema.yaml) for tests. Examples: - The [paho-mqtt](https://github.com/taverntesting/tavern/blob/master/tavern/_plugins/mqtt/schema.yaml) plugin defines the `client`, `connect`, etc. keys which are used to connect to an MQTT broker. - [tavern-flask](https://github.com/taverntesting/tavern-flask/blob/master/tavern_flask/schema.yaml) just requires a single key that points to the flask application that will be used to create a test client (see below). - The [gRPC](https://github.com/taverntesting/tavern/blob/master/tavern/_plugins/grpc/schema.yaml) backend defines standard connection options for gRPC, such as the host and port to connect to, as well as extra options for connection metadata and the protobuf source or module to load. ## Session type `session_type` should return a class which describes a "session" which will be used throughout the entire test. It should be a class that fulfils two requirements: 1. It must take the same keyword arguments as the 'base' session object to create an instance for testing. For HTTP tests this is the same arguments as a [requests.Session](http://docs.python-requests.org/en/master/user/advanced/#session-objects) object, and for MQTT tests it is the same arguments as specified in the [MQTT documentation](https://taverntesting.github.io/documentation#mqtt-connection-options). If your plugin does not support some of these arguments, raise a `NotImplementedError` which a short message explaining that it is not supported. 2. After creating the instance, it must be able to be used as a [context manager](https://docs.python.org/3/library/stdtypes.html#typecontextmanager). If you don't need any functionality provided by this, you can define empty `__enter__` and `__exit__` methods on your class like so: ```python class MySession: def __enter__(self): pass def __exit__(self, *args): pass ``` Examples: - [tavern-flask](https://github.com/taverntesting/tavern-flask/blob/master/tavern_flask/client.py) is fairly simple, it just creates a flask test client from the `flask::app` defined for the test (see schema documentation above) and dumps the body data for later use when making the request. - The GraphQL backend initialises a new asyncio loop to run subscriptions in. ## Request `request_type` is a class that encapsulates the concept of a 'request' for your plugin. It takes 3 arguments: - `session` is the session instance created as described above, *for that request type at that stage*. There may be multiple request types per **test**, but only one request is made per **stage**. - `rspec` is a dictionary corresponding to the request at that stage. If you are writing a HTTP plugin, the dictionary will contain the keys as described in the [http request documentation](https://taverntesting.github.io/documentation#request). If it is an MQTT plugin, it will contain keys described in the [MQTT publish documentation](https://taverntesting.github.io/documentation#mqtt-publishing-options). - `test_block_config` is the global configuration for that test. At a minimum it will contain a key called `variables`, which contains all of the current variables that are available for formatting. In the constructor, this request type should validate the input data and format the request variables given the test block config. The class should also have a `run` method, which takes no arguments and is called to run the test. This should return some kind of class encapsulating response data which can be verified by your plugin's response verifier class. Tavern knows which request keyword (eg `request`, `mqtt_publish`) corresponds to your plugin by matching it to the plugin's `request_block_name`. For the moment, this should be hardcoded to `request` for HTTP tests. Examples: - The base [requests](https://github.com/taverntesting/tavern/blob/master/tavern/_plugins/rest/request.py) request object formats the keys and does some extra verification, such as logging a warning if a user tries to send a body with a `GET` request - The [paho-mqtt](https://github.com/taverntesting/tavern/blob/master/tavern/_plugins/mqtt/request.py) request formats the input data and just makes sure that a user is not trying to send two kinds of payloads at a time. - [tavern-flask](https://github.com/taverntesting/tavern-flask/blob/master/tavern_flask/request.py) reuses functionality from Tavern to format the keys and do extra verification. ## Getting the expected response `get_expected_from_request` should be a function that takes 3 arguments: - `stage` is the entire test stage (ie, including the request block, test name, response block, etc) as a dictionary - `test_block_config` is as above - `session` is as above This function should use this input data to calculate the expected response and perform any extra things that need doing based on the request or expected response. This will normally just be formatting the response block based on the variables in the test block config, but you may need to do extra things (such as subscribing to an MQTT topic). Examples: - The [default](https://github.com/taverntesting/tavern/blob/master/tavern/_plugins/rest/tavernhook.py) behaviour is just to make sure that a correct response block is present and format the input data. - An [MQTT](https://github.com/taverntesting/tavern/blob/master/tavern/_plugins/mqtt/tavernhook.py) test requires that the client also checks to see if a response is expected and subscribes to the topic in question. - [tavern-flask](https://github.com/taverntesting/tavern-flask/blob/master/tavern_flask/tavernhook.py) behaves identically to the base Tavern behaviour. ## Response `verifier_type` is a class that encapsulate the concept of verifying a response for your plugin. It should inherit from `tavern.response.base.BaseResponse`, and take 4 arguments: - `session` is as above - `name` is the name of the test stage currently being run. This can be used for logging debug information. - `expected` is the return value from `get_expected_from_request`. - `test_block_config` is as above. It should also define a couple of methods: - `verify` takes one argument, which is the return value from the `run` method on your request class. It should read whatever information is relevant from this response object and verify that it is as expected, then return any values from the response which should be saved into the test block config. A plugin does not need to save anything - just return an empty dictionary if you don't want to save anything. There are some utilities on `BaseResponse` to help with this, including printing errors and checking return values. This should raise a `tavern.exceptions.TestFailError` if verification fails. The easiest way to verify the response is to call `self._adderr` with a string to a list called `self.errors` for every error encountered. If there is anything in this dictionary at the end of `verify`, raise an exception. - `__str__` should return a human-readable string describing the response. This is mainly for debugging, and should only give as much information as you think is required. For example, a HTTP response might be printed as "HTTP 200 OK". Like with a request, Tavern knows which verifier to use by looking at the `response_block_name` key. Examples: - The [base requests verifier](https://github.com/taverntesting/tavern/blob/master/tavern/_plugins/rest/response.py) Checks a variety of things like the expected headers, expected redirect locations, cookies, etc. - The [paho-mqtt](https://github.com/taverntesting/tavern/blob/master/tavern/_plugins/mqtt/response.py) plugin needs to wait for the specified timeout to see if a message was received on a given topic. Note that there does not need to be a response for an MQTT request - a stage might consist of just an `mqtt_publish` block with no expected response. - [tavern-flask](https://github.com/taverntesting/tavern-flask/blob/master/tavern_flask/response.py) just reuses functionality from the base verifier again. Because the flask `Response` object is slightly different from the requests one, some conversion has to be done on the data. ## Advanced - Multiple Responses If your plugin supports multiple responses (e.g., subscribing to multiple MQTT topics or GraphQL subscriptions), you can: 1. Set `has_multiple_responses = True` in your plugin. 2. In `get_expected_from_request`, return a list of expected responses instead of a single one, under a new block name that is distinct from the `response_block_name`. An example from the MQTT plugin: ```python expected = {"mqtt_responses": []} if isinstance(response_block, dict): response_block = [response_block] for response in response_block: # format so we can subscribe to the right topic f_expected = format_keys(response, test_block_config.variables) mqtt_client = session mqtt_client.subscribe(f_expected["topic"], f_expected.get("qos", 1)) expected["mqtt_responses"].append(f_expected) return expected ``` 3. When calling `super().__init__(...)` in your `response_type`, pass `multiple_responses_block=""` where `` is the name of the block you used in step 2. This tells Tavern to expect a list of responses instead of a single response block. When enabled, Tavern will: - Check each response in the list for `strict` settings per response instead of for the whole list - Look for each of these multiple responses when checking for any [external validation functions](./core_concepts/external_code.md#checking-the-response-using-external-functions) and check each response in the list for `verify_response_with` functions. If your plugin does not support multiple responses, set `has_multiple_responses = False` (or omit it - it defaults to `False`) and don't pass `multiple_responses_block` to `super().__init__`.tavern-3.6.0/docs/source/plugins/000077500000000000000000000000001520710011500167205ustar00rootroot00000000000000tavern-3.6.0/docs/source/plugins/custom.md000066400000000000000000000032331520710011500205550ustar00rootroot00000000000000# Custom backends Though tavern supports a few backends out of the box, you may want to extend it to support your own. This can be done by creating a new plugin that implements the necessary functionality and registers it with tavern. First, write your backend and set up the request, response, etc as according to the [standard plugin system](../plugins.md). Note the standard restrictions - there can only be one 'request' per stage. ## Entry Point Configuration In your project's `pyproject.toml`, configure the plugin entry point: ```toml [project.entry-points.tavern_your_backend_name] my_implementation = 'your.package.path:your_backend_module' ``` Then when running tests, specify the extra backend: ```bash pytest --tavern-extra-backends=your_backend_name # Or, to specify an implementation to override the project entrypoint: pytest --tavern-extra-backends=your_backend_name=my_other_implementation ``` Or the equivalent in pyproject.toml or pytest.ini. Note: - The entry point name should start with `tavern_`. - The key of the entrypoint is just a name of the implementation and can be anything. - The `--tavern-extra-backends` flag should *not* be prefixed with `tavern_`. - If Tavern detects multiple entrypoints for a backend, it will raise an error. In this case, you must use the second form to specify which implementation of the backend to use. This is similar to the build-in `--tavern-http-backend` flag. This is because Tavern by default only tries to load "grpc", "http" and "mqtt" backends. The flag registers the custom backend with Tavern, which can then tell [stevedore](https://github.com/openstack/stevedore) to load the plugin from the entrypoint. tavern-3.6.0/example/000077500000000000000000000000001520710011500144425ustar00rootroot00000000000000tavern-3.6.0/example/custom_backend/000077500000000000000000000000001520710011500174235ustar00rootroot00000000000000tavern-3.6.0/example/custom_backend/.gitignore000066400000000000000000000000351520710011500214110ustar00rootroot00000000000000hello.txt some_other_file.txttavern-3.6.0/example/custom_backend/README.md000066400000000000000000000020311520710011500206760ustar00rootroot00000000000000# Tavern Custom Backend Plugin This example demonstrates how to create a custom backend plugin for Tavern, a pytest plugin for API testing. Custom backends allow you to extend Tavern's functionality with your own request/response handling logic. ## Overview This example plugin implements a simple file touch/verification system: - `touch_file` stage: Creates or updates a file timestamp (similar to the Unix `touch` command) - `file_exists` stage: Verifies that a specified file exists Note that it is probably better to give your requests a more descriptive name, like `touch_file_request`. ## Implementation Details The basic operation of plugins is described in the [plugin documentation](https://tavern.readthedocs.io/en/stable/plugins/). ## Example Test ```yaml --- test_name: Test file touched stages: - name: Touch file and check it exists # The 'request' for this stage touches a file touch_file: filename: hello.txt # The 'response' checks that the file exists file_exists: filename: hello.txt ``` tavern-3.6.0/example/custom_backend/my_tavern_plugin/000077500000000000000000000000001520710011500230055ustar00rootroot00000000000000tavern-3.6.0/example/custom_backend/my_tavern_plugin/__init__.py000066400000000000000000000000001520710011500251040ustar00rootroot00000000000000tavern-3.6.0/example/custom_backend/my_tavern_plugin/jsonschema.yaml000066400000000000000000000013551520710011500260270ustar00rootroot00000000000000$schema: "http://json-schema.org/draft-07/schema#" title: file touch schema description: Schema for touching files ### definitions: touch_file: type: object description: touch a file additionalProperties: false required: - filename properties: filename: type: string description: Name of file to touch file_exists: type: object description: name of file which should exist additionalProperties: false required: - filename properties: filename: type: string description: Name of file to check for stage: properties: touch_file: $ref: "#/definitions/touch_file" file_exists: $ref: "#/definitions/file_exists" tavern-3.6.0/example/custom_backend/my_tavern_plugin/plugin.py000066400000000000000000000037121520710011500246600ustar00rootroot00000000000000import pathlib from collections.abc import Iterable from os.path import abspath, dirname, join from typing import Any, Optional, Union import box import yaml from tavern._core import exceptions from tavern._core.pytest.config import TestConfig from tavern.request import BaseRequest from tavern.response import BaseResponse class Session: """No-op session, but must implement the context manager protocol""" def __enter__(self): pass def __exit__(self, exc_type, exc_val, exc_tb): pass class Request(BaseRequest): """Touches a file when the 'request' is made""" def __init__( self, session: Any, rspec: dict, test_block_config: TestConfig ) -> None: self.session = session self._request_vars = rspec @property def request_vars(self) -> box.Box: return self._request_vars def run(self): pathlib.Path(self._request_vars["filename"]).touch() class Response(BaseResponse): def verify(self, response): if not pathlib.Path(self.expected["filename"]).exists(): raise exceptions.BadSchemaError( f"Expected file '{self.expected['filename']}' does not exist" ) return {} def __init__( self, client, name: str, expected: TestConfig, test_block_config: TestConfig, ) -> None: super().__init__(name, expected, test_block_config) session_type = Session request_type = Request request_block_name = "touch_file" verifier_type = Response response_block_name = "file_exists" has_multiple_responses = False def get_expected_from_request( response_block: Union[dict, Iterable[dict]], test_block_config: TestConfig, session: Session, ) -> Optional[dict]: return response_block schema_path: str = join(abspath(dirname(__file__)), "jsonschema.yaml") with open(schema_path, encoding="utf-8") as schema_file: schema = yaml.load(schema_file, Loader=yaml.SafeLoader) tavern-3.6.0/example/custom_backend/pyproject.toml000066400000000000000000000004601520710011500223370ustar00rootroot00000000000000[project] name = "my_tavern_plugin" version = "0.1.0" description = "A custom 'generic' plugin for tavern that touches files and checks if they are created." readme = "README.md" requires-python = ">=3.12" dependencies = [] [project.entry-points.tavern_file] my_tavern_plugin = "my_tavern_plugin.plugin"tavern-3.6.0/example/custom_backend/run_tests.sh000077500000000000000000000003231520710011500220060ustar00rootroot00000000000000#!/bin/bash set -ex if [ ! -d ".venv" ]; then uv venv fi . .venv/bin/activate uv sync if ! command -v bats; then exit 1 fi # Run tests using bats bats --timing --print-output-on-failure "$@" tests.bats tavern-3.6.0/example/custom_backend/tests.bats000066400000000000000000000013351520710011500214420ustar00rootroot00000000000000#!/usr/bin/env bats setup() { if [ ! -d ".venv" ]; then uv venv fi . .venv/bin/activate uv pip install -e . 'tavern @ ../..' } @test "run tavern-ci with --tavern-extra-backends=file" { PYTHONPATH=. run tavern-ci \ --tavern-extra-backends=file \ --debug \ tests [ "$status" -eq 0 ] } @test "run tavern-ci with --tavern-extra-backends=file=my_tavern_plugin" { PYTHONPATH=. run tavern-ci \ --tavern-extra-backends=file=my_tavern_plugin \ --debug \ tests [ "$status" -eq 0 ] } @test "run tavern-ci with --tavern-extra-backends=file=i_dont_exist should fail" { PYTHONPATH=. run tavern-ci \ --tavern-extra-backends=file=i_dont_exist \ --debug \ tests [ "$status" -ne 0 ] }tavern-3.6.0/example/custom_backend/tests/000077500000000000000000000000001520710011500205655ustar00rootroot00000000000000tavern-3.6.0/example/custom_backend/tests/test_file_touched.tavern.yaml000066400000000000000000000015321520710011500264410ustar00rootroot00000000000000--- test_name: Test file touched stages: - name: Touch file and check it exists touch_file: filename: hello.txt file_exists: filename: hello.txt --- test_name: Test file touched - should fail because file doesn't exist marks: - xfail stages: - name: Touch file that doesn't exist touch_file: filename: some_other_file.txt file_exists: filename: nonexistent_file.txt --- test_name: Test with invalid schema - should fail _xfail: verify stages: - name: Test invalid touch_file schema touch_file: nonexistent_field: some_value file_exists: filename: hello.txt --- test_name: Test with invalid response schema - should fail _xfail: verify stages: - name: Test invalid file_exists schema touch_file: filename: hello.txt file_exists: nonexistent_field: some_value tavern-3.6.0/example/generate_from_openapi/000077500000000000000000000000001520710011500207725ustar00rootroot00000000000000tavern-3.6.0/example/generate_from_openapi/pub_tavern.py000066400000000000000000000041311520710011500235100ustar00rootroot00000000000000import sys from urllib.parse import urlparse import yaml from coreapi import Client def generate_tavern_yaml(json_path): client = Client() d = client.get(json_path, format="openapi") output_yaml(d.links) for routes in d.data.keys(): output_yaml(d.data[routes], routes) def output_yaml(links, prefix=""): test_dict = {} for test_name in links.keys(): default_name = get_name( prefix, test_name, links[test_name].action, links[test_name].url ) test_dict["test_name"] = default_name request = { "url": links[test_name].url, "method": str.upper(links[test_name].action), } if links[test_name].encoding: request["headers"] = {"content-type": links[test_name].encoding} json = get_request_placeholders(links[test_name].fields) if json and request["method"] != "GET": request["json"] = json response = {"strict": False, "status_code": 200} inner_dict = {"name": default_name, "request": request, "response": response} test_dict["stages"] = [inner_dict] sys.stdout.write( yaml.dump(test_dict, explicit_start=True, default_flow_style=False) ) def get_request_placeholders(fields): field_dict = {} for field in fields: field_dict[field.name] = "required" if field.required else "optional" return field_dict def get_name(prefix, test_name, action, url): name = f"{action} " if prefix and test_name: name += f"{prefix}/{test_name}" elif test_name: name += test_name elif prefix: name += f"{prefix} " + urlparse(url).path else: name += urlparse(url).path return name def display_help(): sys.stdout.write("pub_tavern.py \n") sys.stdout.write( "eg: pub_tavern.py https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/examples/v2.0/json/petstore-simple.json\n" ) sys.exit(2) if __name__ == "__main__": if len(sys.argv) != 2: display_help() generate_tavern_yaml(sys.argv[1]) tavern-3.6.0/example/generate_from_openapi/readme.md000066400000000000000000000001311520710011500225440ustar00rootroot00000000000000Example script to autogenerate the travern yaml test file from a given openapi.json file tavern-3.6.0/example/generate_from_openapi/test_example_output.tavern.yaml000066400000000000000000000011401520710011500272620ustar00rootroot00000000000000--- stages: - name: get pets/listPets request: method: GET url: http://localhost:8000/pets response: status_code: 200 strict: false test_name: get pets/listPets --- stages: - name: post pets/createPets request: method: POST url: http://localhost:8000/pets response: status_code: 200 strict: false test_name: post pets/createPets --- stages: - name: get pets/showPetById request: method: GET url: http://localhost:8000/pets/{petId} response: status_code: 200 strict: false test_name: get pets/showPetById tavern-3.6.0/example/graphql/000077500000000000000000000000001520710011500161005ustar00rootroot00000000000000tavern-3.6.0/example/graphql/.dockerignore000066400000000000000000000000511520710011500205500ustar00rootroot00000000000000* !pyproject.toml !tavern_graphql_exampletavern-3.6.0/example/graphql/Dockerfile000066400000000000000000000003611520710011500200720ustar00rootroot00000000000000FROM python:3.11-slim-trixie RUN python3 -m pip install uv RUN mkdir /app WORKDIR /app COPY . /app RUN uv sync ENV PYTHONPATH=/app/ CMD ["uv", "run", "uvicorn", "tavern_graphql_example.server:app", "--host", "0.0.0.0", "--port", "5010"] tavern-3.6.0/example/graphql/README.md000066400000000000000000000015021520710011500173550ustar00rootroot00000000000000# GraphQL Example with Strawberry This example demonstrates how to use GraphQL with Tavern for testing, using a server implementation built with [Strawberry](https://strawberry.rocks/). ## Server Features The example GraphQL server (`tavern_graphql_example/server.py`) implements: - **Query operations**: Fetch users, posts, and user posts - **Mutation operations**: Create and update users and posts - **Subscription support**: Real-time updates for user changes - **Authentication**: Bearer token authentication on certain endpoints ## Tests The `tests/` directory contains various Tavern test files demonstrating: - Basic GraphQL queries - Mutations with variables - Subscription handling - Authentication flows - Error handling scenarios You can run the tests using Tavern to verify the GraphQL server behaves as expected.tavern-3.6.0/example/graphql/docker-compose.yaml000066400000000000000000000001601520710011500216730ustar00rootroot00000000000000--- services: server: build: context: . dockerfile: Dockerfile ports: - "5010:5010" tavern-3.6.0/example/graphql/pyproject.toml000066400000000000000000000012501520710011500210120ustar00rootroot00000000000000[project] name = "tavern_graphql_example" version = "0.1.0" description = "Tavern GraphQL example project" readme = "README.md" requires-python = ">=3.11" dependencies = [ "tavern[graphql]", "uvicorn[standard]", "fastapi", "starlette", "strawberry-graphql[fastapi]", "strawberry-sqlalchemy-mapper", "cross-web", "sqlalchemy>=2,<3" ] [project.entry-points.tavern_graphql] gql = "tavern._plugins.graphql.tavernhook" [tool.pytest.ini_options] norecursedirs = [ "tavern_graphql_example", ] addopts = [ "-vv", "--strict-markers", "--tb=short", "--color=yes", ] [tool.setuptools.packages.find] include = ["tavern_graphql_example"]tavern-3.6.0/example/graphql/tavern_graphql_example/000077500000000000000000000000001520710011500226305ustar00rootroot00000000000000tavern-3.6.0/example/graphql/tavern_graphql_example/server.py000066400000000000000000000233341520710011500245150ustar00rootroot00000000000000"""Simple GraphQL test server for integration testing using SQLite and strawberry-graphql with subscriptions""" import asyncio import csv import logging from collections.abc import AsyncGenerator import starlette import starlette.authentication import starlette.middleware.authentication import starlette.websockets import strawberry import uvicorn from fastapi import FastAPI from sqlalchemy import Column, ForeignKey, Integer, String, create_engine, select, text from sqlalchemy.orm import declarative_base, relationship, sessionmaker from starlette.background import BackgroundTask from starlette.datastructures import UploadFile from starlette.requests import HTTPConnection from starlette.responses import Response from strawberry.exceptions import ConnectionRejectionError from strawberry.fastapi import GraphQLRouter from strawberry.file_uploads import Upload from strawberry_sqlalchemy_mapper import StrawberrySQLAlchemyMapper strawberry_sqlalchemy_mapper = StrawberrySQLAlchemyMapper() Base = declarative_base() class models: """dummy module to contains models so they dont have to be a in a separate file It seems like there is undocumented(?) feature of strawberry that the original model has to have the same name as the graphql type """ class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True) name = Column(String(100), nullable=False) email = Column(String(100), nullable=False) class Post(Base): __tablename__ = "posts" id = Column(Integer, primary_key=True) title = Column(String(200), nullable=False) content = Column(String, nullable=False) author_id = Column(Integer, ForeignKey("users.id"), nullable=False) author = relationship("User", backref="posts") @strawberry_sqlalchemy_mapper.type(models.User) class User: pass @strawberry_sqlalchemy_mapper.type(models.Post) class Post: pass @strawberry.type class Query: @strawberry.field def user(self, id: strawberry.ID) -> User: user = global_db_session.get(models.User, int(id)) if user is None: raise Exception("User not found") return user @strawberry.field def users(self) -> list[User]: return global_db_session.execute(select(models.User)).scalars().all() @strawberry.field def post(self, id: strawberry.ID) -> Post: post = global_db_session.get(models.Post, int(id)) if post is None: raise Exception("Post not found") return post @strawberry.field def posts(self) -> list[Post]: return global_db_session.execute(select(models.Post)).scalars().all() @strawberry.field def user_posts(self, author_id: strawberry.ID) -> list[Post]: posts_by_author = list( global_db_session.execute( select(models.Post).filter_by(author_id=int(author_id)) ) .scalars() .all() ) if not posts_by_author: raise Exception("No posts found for that author") return posts_by_author @strawberry.field async def users_from_csv( self, csv_file: Upload, info: strawberry.Info ) -> list[User]: content = (await csv_file.read()).decode("utf-8") csv_reader = csv.reader(content.splitlines()) first_column_values = [row[0] for row in csv_reader if row] matching_users = [] for name in first_column_values: user = ( global_db_session.execute( select(models.User).where(models.User.name == name) ) .scalars() .first() ) if user: matching_users.append(user) return matching_users @strawberry.type class Mutation: @strawberry.mutation def create_user(self, name: str, email: str, info: strawberry.Info) -> User: user = models.User(name=name, email=email) global_db_session.add(user) global_db_session.commit() q.put_nowait(user) return user @strawberry.mutation def create_post( self, title: str, content: str, author_id: str, info: strawberry.Info ) -> Post: post = models.Post(title=title, content=content, author_id=int(author_id)) global_db_session.add(post) global_db_session.commit() return post @strawberry.mutation async def create_post_from_file( self, title: str, my_file_name: Upload, author_id: str, info: strawberry.Info ) -> Post: post = models.Post( title=title, content=(await my_file_name.read()).decode("utf-8"), author_id=int(author_id), ) logging.info(f"Creating post {post.title}") global_db_session.add(post) global_db_session.commit() return post @strawberry.mutation def update_user( self, id: strawberry.ID, name: str, email: str, info: strawberry.Info ) -> User: user = global_db_session.get(models.User, int(id)) if user is None: raise Exception("User not found") user.name = name user.email = email global_db_session.commit() q.put_nowait(user) return user q = asyncio.Queue() @strawberry.type class Subscription: @strawberry.subscription(graphql_type=User) async def user(self, id: strawberry.ID) -> AsyncGenerator[User, None]: logging.info(f"starting subscription for user {id}") while True: user = await q.get() logging.info(f"User {user.name} updated") if user.id != int(id): continue yield user strawberry_sqlalchemy_mapper.finalize() schema = strawberry.Schema( query=Query, mutation=Mutation, subscription=Subscription, scalar_overrides={UploadFile: Upload}, ) async def lifespan(_): logging.basicConfig(level=logging.DEBUG) yield app = FastAPI(title="GraphQL Test Server", lifespan=lifespan) @app.middleware("http") async def some_middleware(request, call_next): req_body = await request.body() # await set_body(request, req_body) # not needed when using FastAPI>=0.108.0. response = await call_next(request) chunks = [] async for chunk in response.body_iterator: chunks.append(chunk) res_body = b"".join(chunks) def log_info(req_body, res_body): logging.info(req_body) logging.info(res_body) task = BackgroundTask(log_info, req_body, res_body) return Response( content=res_body, status_code=response.status_code, headers=dict(response.headers), media_type=response.media_type, background=task, ) class BasicAuthBackend(starlette.authentication.AuthenticationBackend): async def authenticate( self, conn: HTTPConnection ) -> ( tuple[ starlette.authentication.AuthCredentials, starlette.authentication.SimpleUser, ] | None ): # Check if Authorization header exists headers = conn.headers return check_has_right_header(headers) def check_has_right_header(headers): """Checks if the authorization header is set to the correct thing""" if "authorization" not in headers: logging.info("No authorization header found") return None auth_header = headers["authorization"] logging.info(f"Got authorization header: {auth_header}") try: scheme, credentials = auth_header.split() if scheme.lower() != "bearer": return None if credentials == "test-token": # Return authenticated user with credentials return starlette.authentication.AuthCredentials( ["authenticated"] ), starlette.authentication.SimpleUser("tavern") except Exception as e: logging.error(f"Authentication failed: {e}") raise return None app.add_middleware( starlette.middleware.authentication.AuthenticationMiddleware, backend=BasicAuthBackend(), ) class AuthenticatedGraphqlRouter(GraphQLRouter): async def on_ws_connect(self, context: dict[str, object]): request: starlette.websockets.WebSocket = context["request"] if request.auth is None: logging.info("No authentication found") raise ConnectionRejectionError({"message": "Unauthorized"}) logging.info(f"Got authenticated request from {request.client.host}") # Accept without a acknowledgment payload return await super().on_ws_connect(context) def is_request_allowed(self, request): if not super().is_request_allowed(request): return False # This feels like a terrible hack but I dont think strawberry provides another way to do this if check_has_right_header(request.headers) is None: from cross_web import HTTPException raise HTTPException(401, "Unauthorized") return True app.include_router( GraphQLRouter(schema, multipart_uploads_enabled=True), prefix="/graphql" ) app.include_router( AuthenticatedGraphqlRouter(schema), prefix="/graphql_authenticated", ) @app.post("/reset") async def reset_db(): global_db_session.execute(text("delete from users")) global_db_session.execute(text("delete from posts")) global_db_session.commit() while not q.empty(): q.get_nowait() logging.info("Database reset") @app.get("/health") async def health(): return {"status": "healthy"} # DB setup engine = create_engine("sqlite:////tmp/test.db", echo=False) Session = sessionmaker(bind=engine) global_db_session = Session() Base.metadata.create_all(bind=engine) if __name__ == "__main__": uvicorn.run( app, host="0.0.0.0", port=5010, log_level="info", ) tavern-3.6.0/example/graphql/testdata/000077500000000000000000000000001520710011500177115ustar00rootroot00000000000000tavern-3.6.0/example/graphql/testdata/test_file_content.txt000066400000000000000000000000201520710011500241520ustar00rootroot00000000000000Lorem Ipsum etc.tavern-3.6.0/example/graphql/testdata/test_users.csv000066400000000000000000000000451520710011500226250ustar00rootroot00000000000000Alice Johnson Bob Smith Charlie Browntavern-3.6.0/example/graphql/tests/000077500000000000000000000000001520710011500172425ustar00rootroot00000000000000tavern-3.6.0/example/graphql/tests/conftest.py000066400000000000000000000003131520710011500214360ustar00rootroot00000000000000import pytest @pytest.fixture(scope="function", autouse=True) def reset_db(): """Reset the database between tests""" yield import requests requests.post("http://localhost:5010/reset") tavern-3.6.0/example/graphql/tests/graphql_config.yaml000066400000000000000000000004751520710011500231170ustar00rootroot00000000000000--- name: GraphQL test configuration description: Configuration for GraphQL integration tests variables: graphql_server_url: http://localhost:5010 user_id: "1" user_name: "Bob Wilson" user_email: "bob@example.com" post_title: "Variable Test Post" post_content: "Testing variable substitution in GraphQL" tavern-3.6.0/example/graphql/tests/multiple_operations.graphql000066400000000000000000000011111520710011500247120ustar00rootroot00000000000000query GetUser($id: ID!) { user(id: $id) { id name email } } query GetUsers { users { id name email } } mutation CreateUser($name: String!, $email: String!) { createUser(name: $name, email: $email) { id name email } } mutation CreatePost($title: String!, $content: String!, $authorId: String!) { createPost(title: $title, content: $content, authorId: $authorId) { id title content authorId } } query GetUserPosts($authorId: ID!) { userPosts(authorId: $authorId) { id title content authorId } } tavern-3.6.0/example/graphql/tests/test_auth.tavern.yaml000066400000000000000000000036711520710011500234330ustar00rootroot00000000000000--- test_name: GraphQL authentication - Query with authorization header includes: - !include graphql_config.yaml stages: - name: Query with authorization header graphql_request: url: "{graphql_server_url}/graphql_authenticated" query: | query GetUser($id: ID!) { user(id: $id) { id name email } } variables: id: "1" headers: Authorization: "Bearer test-token" graphql_response: errors: - message: User not found --- test_name: GraphQL authentication - Query with authorization header in default client settings includes: - !include graphql_config.yaml gql: headers: Authorization: "Bearer test-token" stages: - name: Query with authorization header graphql_request: url: "{graphql_server_url}/graphql_authenticated" query: | query GetUser($id: ID!) { user(id: $id) { id name email } } variables: id: "1" graphql_response: errors: - message: User not found --- test_name: GraphQL authentication - Query with incorrect authorization header includes: - !include graphql_config.yaml # This is distinct from a graphql 'error' which returns 200 but 'errors' in the response. # This is basically testing the HTTP transport layer. # TODO: Maybe allow status_code in the response to be checked? _xfail: run stages: - name: Query with authorization header graphql_request: url: "{graphql_server_url}/graphql_authenticated" query: | query GetUser($id: ID!) { user(id: $id) { id name email } } variables: id: "1" headers: Authorization: "Bearer wrong-token" graphql_response: data: user: id: !anyint name: "John Doe" email: "john@example.com" tavern-3.6.0/example/graphql/tests/test_basic_query.tavern.yaml000066400000000000000000000124741520710011500250010ustar00rootroot00000000000000--- test_name: GraphQL basic query - Get user by ID includes: - !include graphql_config.yaml stages: - name: Create initial user graphql_request: url: "{graphql_server_url}/graphql" query: | mutation CreateUser($name: String!, $email: String!) { createUser(name: $name, email: $email) { id name email } } variables: name: "Alice Smith" email: "alice@example.com" graphql_response: data: createUser: id: !anyint name: "Alice Smith" email: "alice@example.com" save: data: user_id: data.createUser.id - name: Get user by ID graphql_request: url: "{graphql_server_url}/graphql" query: | query GetUser($id: ID!) { user(id: $id) { id name email } } variables: id: "{user_id}" graphql_response: data: user: id: !int "{user_id}" name: !anystr email: !anystr --- test_name: GraphQL basic query - Get all users includes: - !include graphql_config.yaml stages: - name: Create user 1 graphql_request: url: "{graphql_server_url}/graphql" query: | mutation CreateUser($name: String!, $email: String!) { createUser(name: $name, email: $email) { id name email } } variables: name: "Alice Smith" email: "alice@example.com" graphql_response: data: createUser: id: !anyint name: "Alice Smith" email: "alice@example.com" - name: Create user 2 graphql_request: url: "{graphql_server_url}/graphql" query: | mutation CreateUser($name: String!, $email: String!) { createUser(name: $name, email: $email) { id name email } } variables: name: "Jane Smith" email: "jane@example.com" graphql_response: data: createUser: id: !anyint name: "Jane Smith" email: "jane@example.com" - name: Get all users graphql_request: url: "{graphql_server_url}/graphql" query: | query GetUsers { users { id name email } } graphql_response: data: users: - id: !anyint name: "Alice Smith" email: "alice@example.com" - id: !anyint name: "Jane Smith" email: "jane@example.com" --- test_name: GraphQL basic query - Get posts for a user includes: - !include graphql_config.yaml stages: - name: Create user graphql_request: url: "{graphql_server_url}/graphql" query: | mutation CreateUser($name: String!, $email: String!) { createUser(name: $name, email: $email) { id name email } } variables: name: "Test User" email: "test@example.com" graphql_response: data: createUser: id: !anyint name: "Test User" email: "test@example.com" save: data: user_id: data.createUser.id - name: Create post 1 for user graphql_request: url: "{graphql_server_url}/graphql" query: | mutation CreatePost($title: String!, $content: String!, $authorId: String!) { createPost(title: $title, content: $content, authorId: $authorId) { id title content authorId } } variables: title: "First Test Post" content: "This is my first test post" authorId: "{user_id}" graphql_response: data: createPost: id: !anyint title: "First Test Post" content: "This is my first test post" authorId: !int "{user_id}" - name: Create post 2 for user graphql_request: url: "{graphql_server_url}/graphql" query: | mutation CreatePost($title: String!, $content: String!, $authorId: String!) { createPost(title: $title, content: $content, authorId: $authorId) { id title content authorId } } variables: title: "Second Test Post" content: "This is my second test post" authorId: "{user_id}" graphql_response: data: createPost: id: !anyint title: "Second Test Post" content: "This is my second test post" authorId: !int "{user_id}" - name: Get posts for the user graphql_request: url: "{graphql_server_url}/graphql" query: | query GetPosts($authorId: ID!) { userPosts(authorId: $authorId) { id title content authorId } } variables: authorId: "{user_id}" graphql_response: data: userPosts: - id: !anyint title: "First Test Post" content: "This is my first test post" authorId: !int "{user_id}" - id: !anyint title: "Second Test Post" content: "This is my second test post" authorId: !int "{user_id}" tavern-3.6.0/example/graphql/tests/test_errors.tavern.yaml000066400000000000000000000027251520710011500240050ustar00rootroot00000000000000--- test_name: Test non-existent user includes: - !include graphql_config.yaml stages: - name: Query non-existent user graphql_request: url: "{graphql_server_url}/graphql" query: | query GetUser($id: ID!) { user(id: $id) { id name email } } variables: id: "999" graphql_response: errors: - message: "User not found" --- test_name: Test invalid query includes: - !include graphql_config.yaml stages: - name: Invalid query graphql_request: url: "{graphql_server_url}/graphql" query: | query InvalidQuery { invalidField { id } } graphql_response: errors: - message: Cannot query field 'invalidField' --- test_name: Test empty query includes: - !include graphql_config.yaml _xfail: run stages: - name: Missing query parameter graphql_request: url: "{graphql_server_url}/graphql" query: "" graphql_response: errors: - message: "No query provided" --- test_name: Test invalid JSON payload includes: - !include graphql_config.yaml _xfail: verify stages: - name: Invalid JSON payload graphql_request: url: "{graphql_server_url}/graphql" method: POST headers: Content-Type: "application/json" json: invalid: "payload" graphql_response: errors: - message: "No query provided" tavern-3.6.0/example/graphql/tests/test_files.tavern.yaml000066400000000000000000000077731520710011500236030ustar00rootroot00000000000000--- test_name: GraphQL files - query with a file includes: - !include graphql_config.yaml stages: - name: Create user with CSV test name graphql_request: url: "{graphql_server_url}/graphql" query: | mutation CreateUser($name: String!, $email: String!) { createUser(name: $name, email: $email) { id name email } } variables: name: "Alice Johnson" email: "alice@example.com" graphql_response: data: createUser: id: !anyint name: "Alice Johnson" email: "alice@example.com" save: data: alice_id: data.createUser.id - name: Upload CSV and query users graphql_request: url: "{graphql_server_url}/graphql" query: | query UploadCSV($csvFile: Upload!) { usersFromCsv(csvFile: $csvFile) { id name email } } files: csvFile: testdata/test_users.csv graphql_response: data: usersFromCsv: - id: !anyint name: "Alice Johnson" email: "alice@example.com" --- test_name: GraphQL files - mutation with a file includes: - !include graphql_config.yaml stages: - name: Create user graphql_request: url: "{graphql_server_url}/graphql" query: | mutation CreateUser($name: String!, $email: String!) { createUser(name: $name, email: $email) { id name email } } variables: name: "Test User" email: "test@example.com" graphql_response: data: createUser: id: !anyint name: "Test User" email: "test@example.com" save: data: user_id: data.createUser.id - name: Create post 1 for user from a file graphql_request: url: "{graphql_server_url}/graphql" query: | mutation CreatePostFromFile($title: String!, $myFileName: Upload!, $authorId: String!) { createPostFromFile(title: $title, myFileName: $myFileName, authorId: $authorId) { id title content authorId } } variables: title: "First Test Post" authorId: "{user_id}" files: myFileName: testdata/test_file_content.txt graphql_response: data: createPostFromFile: id: !anyint title: "First Test Post" content: Lorem Ipsum etc. authorId: !int "{user_id}" - name: Create post 2 for user from a file using long format graphql_request: url: "{graphql_server_url}/graphql" query: | mutation CreatePostFromFile($title: String!, $myFileName: Upload!, $authorId: String!) { createPostFromFile(title: $title, myFileName: $myFileName, authorId: $authorId) { id title content authorId } } variables: title: "Second Test Post" authorId: "{user_id}" files: myFileName: file_path: testdata/test_file_content.txt content_type: text/plain graphql_response: data: createPostFromFile: id: !anyint title: "Second Test Post" content: Lorem Ipsum etc. authorId: !int "{user_id}" - name: Get posts for the user graphql_request: url: "{graphql_server_url}/graphql" query: | query GetPosts($authorId: ID!) { userPosts(authorId: $authorId) { id title content authorId } } variables: authorId: "{user_id}" graphql_response: data: userPosts: - id: !anyint title: "First Test Post" content: Lorem Ipsum etc. authorId: !int "{user_id}" - id: !anyint title: "Second Test Post" content: Lorem Ipsum etc. authorId: !int "{user_id}" tavern-3.6.0/example/graphql/tests/test_multiple_operations.tavern.yaml000066400000000000000000000106331520710011500265640ustar00rootroot00000000000000--- test_name: GraphQL multiple operations test - Using operation_name with included file includes: - !include graphql_config.yaml stages: - name: create user by operation_name graphql_request: url: "{graphql_server_url}/graphql" query: !include multiple_operations.graphql operation_name: CreateUser variables: name: "John Doe" email: "john@example.com" graphql_response: data: createUser: id: !anyint name: "John Doe" email: "john@example.com" save: data: user_id: data.createUser.id - name: Get user by ID using operation_name graphql_request: url: "{graphql_server_url}/graphql" query: !include multiple_operations.graphql operation_name: GetUser variables: id: "{user_id}" graphql_response: data: user: id: !int "{user_id}" name: "John Doe" email: "john@example.com" --- test_name: GraphQL multiple operations test - Get all users with operation_name includes: - !include graphql_config.yaml stages: - name: create user 1 by operation_name graphql_request: url: "{graphql_server_url}/graphql" query: !include multiple_operations.graphql operation_name: CreateUser variables: name: "John Doe" email: "john@example.com" graphql_response: data: createUser: id: !anyint name: "John Doe" email: "john@example.com" - name: create user 2 by operation_name graphql_request: url: "{graphql_server_url}/graphql" query: !include multiple_operations.graphql operation_name: CreateUser variables: name: "Jane Smith" email: "jane@example.com" graphql_response: data: createUser: id: !anyint name: "Jane Smith" email: "jane@example.com" - name: Get all users using operation_name graphql_request: url: "{graphql_server_url}/graphql" query: !include multiple_operations.graphql operation_name: GetUsers graphql_response: data: users: - id: !anyint name: "John Doe" email: "john@example.com" - id: !anyint name: "Jane Smith" email: "jane@example.com" --- test_name: GraphQL basic query - Get posts for a user includes: - !include graphql_config.yaml stages: - name: Create user graphql_request: url: "{graphql_server_url}/graphql" query: !include multiple_operations.graphql operation_name: CreateUser variables: name: "Test User" email: "test@example.com" graphql_response: data: createUser: id: !anyint name: "Test User" email: "test@example.com" save: data: user_id: data.createUser.id - name: Create post 1 for user graphql_request: url: "{graphql_server_url}/graphql" query: !include multiple_operations.graphql operation_name: CreatePost variables: title: "First Test Post" content: "This is my first test post" authorId: "{user_id}" graphql_response: data: createPost: id: !anyint title: "First Test Post" content: "This is my first test post" authorId: !int "{user_id}" - name: Create post 2 for user graphql_request: url: "{graphql_server_url}/graphql" query: !include multiple_operations.graphql operation_name: CreatePost variables: title: "Second Test Post" content: "This is my second test post" authorId: "{user_id}" graphql_response: data: createPost: id: !anyint title: "Second Test Post" content: "This is my second test post" authorId: !int "{user_id}" - name: Get posts for the user graphql_request: url: "{graphql_server_url}/graphql" query: !include multiple_operations.graphql operation_name: GetUserPosts variables: authorId: "{user_id}" graphql_response: data: userPosts: - id: !anyint title: "First Test Post" content: "This is my first test post" authorId: !int "{user_id}" - id: !anyint title: "Second Test Post" content: "This is my second test post" authorId: !int "{user_id}" tavern-3.6.0/example/graphql/tests/test_subscriptions.tavern.yaml000066400000000000000000000354521520710011500254030ustar00rootroot00000000000000--- test_name: test user subscription update includes: - !include graphql_config.yaml stages: - name: Create initial user graphql_request: url: "{graphql_server_url}/graphql" query: | mutation CreateUser($name: String!, $email: String!) { createUser(name: $name, email: $email) { id name email } } variables: name: "Alice Smith" email: "alice@example.com" graphql_response: data: createUser: id: !anyint name: "Alice Smith" email: "alice@example.com" save: data: user_id: data.createUser.id - name: Subscribe to user updates graphql_request: operation_name: GetUser url: "{graphql_server_url}/graphql" query: | subscription GetUser($id: ID!) { user(id: $id) { id name email } } variables: id: "1" - name: Update user and expect subscription notification graphql_request: url: "{graphql_server_url}/graphql" query: | mutation UpdateUser($id: ID!, $name: String!, $email: String!) { updateUser(id: $id, name: $name, email: $email) { id name email } } variables: id: "1" name: "Alice Johnson" email: "alice.j@example.com" graphql_response: - subscription: GetUser data: user: id: !anyint name: "Alice Johnson" email: "alice.j@example.com" - data: updateUser: id: !anyint name: "Alice Johnson" email: "alice.j@example.com" --- test_name: test user subscription update on authenticated endpoint includes: - !include graphql_config.yaml stages: - name: Create initial user graphql_request: url: "{graphql_server_url}/graphql" query: | mutation CreateUser($name: String!, $email: String!) { createUser(name: $name, email: $email) { id name email } } variables: name: "Alice Smith" email: "alice@example.com" graphql_response: data: createUser: id: !anyint name: "Alice Smith" email: "alice@example.com" save: data: user_id: data.createUser.id - name: Subscribe to user updates graphql_request: operation_name: GetUser url: "{graphql_server_url}/graphql_authenticated" query: | subscription GetUser($id: ID!) { user(id: $id) { id name email } } variables: id: "1" - name: Update user and expect subscription notification graphql_request: url: "{graphql_server_url}/graphql" query: | mutation UpdateUser($id: ID!, $name: String!, $email: String!) { updateUser(id: $id, name: $name, email: $email) { id name email } } variables: id: "1" name: "Alice Johnson" email: "alice.j@example.com" graphql_response: - subscription: GetUser data: user: id: !anyint name: "Alice Johnson" email: "alice.j@example.com" - data: updateUser: id: !anyint name: "Alice Johnson" email: "alice.j@example.com" --- test_name: test subscription no update includes: - !include graphql_config.yaml stages: - name: Subscribe to non-existent user graphql_request: operation_name: GetUser url: "{graphql_server_url}/graphql" query: | subscription GetUser($id: ID!) { user(id: $id) { id name } } variables: id: "999" - name: Perform unrelated mutation graphql_request: url: "{graphql_server_url}/graphql" query: | mutation { createUser(name: "Bob", email: "bob@example.com") { id } } graphql_response: - data: user: id: !anyint - subscription: GetUser timeout: 1 data: user: id: "1" _xfail: run: Timed out waiting for subscription message on 'GetUser' --- test_name: test multiple concurrent subscriptions includes: - !include graphql_config.yaml marks: - skip # FIXME: Not sure why this is failin but the second subscription seems to be delayed so never received any updated stages: - name: Create first user graphql_request: url: "{graphql_server_url}/graphql" query: | mutation CreateUser($name: String!, $email: String!) { createUser(name: $name, email: $email) { id name email } } variables: name: "First User" email: "first@example.com" graphql_response: data: createUser: id: !anyint name: "First User" email: "first@example.com" save: data: first_user_id: data.createUser.id - name: Create second user graphql_request: url: "{graphql_server_url}/graphql" query: | mutation CreateUser($name: String!, $email: String!) { createUser(name: $name, email: $email) { id name email } } variables: name: "Second User" email: "second@example.com" graphql_response: data: createUser: id: !anyint name: "Second User" email: "second@example.com" save: data: second_user_id: data.createUser.id - name: Subscribe to first user updates graphql_request: operation_name: FirstUserSubscription url: "{graphql_server_url}/graphql" query: | subscription FirstUserSubscription($id: ID!) { user(id: $id) { id name email } } variables: id: "{first_user_id}" - name: Subscribe to second user updates graphql_request: operation_name: SecondUserSubscription url: "{graphql_server_url}/graphql" query: | subscription SecondUserSubscription($id: ID!) { user(id: $id) { id name email } } variables: id: "{second_user_id}" - name: Update first user and verify subscription graphql_request: url: "{graphql_server_url}/graphql" query: | mutation UpdateUser($id: ID!, $name: String!, $email: String!) { updateUser(id: $id, name: $name, email: $email) { id name email } } variables: id: "{first_user_id}" name: "First User Updated" email: "first.updated@example.com" graphql_response: - subscription: FirstUserSubscription data: user: id: !int "{first_user_id}" name: "First User Updated" email: "first.updated@example.com" - data: updateUser: id: !int "{first_user_id}" name: "First User Updated" email: "first.updated@example.com" - name: Update second user and verify subscription graphql_request: url: "{graphql_server_url}/graphql" query: | mutation UpdateUser($id: ID!, $name: String!, $email: String!) { updateUser(id: $id, name: $name, email: $email) { id name email } } variables: id: "{second_user_id}" name: "Second User Updated" email: "second.updated@example.com" graphql_response: - subscription: SecondUserSubscription data: user: id: !int "{second_user_id}" name: "Second User Updated" email: "second.updated@example.com" - data: updateUser: id: !int "{second_user_id}" name: "Second User Updated" email: "second.updated@example.com" --- test_name: test invalid subscription scenarios - invalid ID format includes: - !include graphql_config.yaml stages: - name: Attempt to subscribe with invalid ID type graphql_request: operation_name: InvalidSubscription url: "{graphql_server_url}/graphql" query: | subscription GetUser($id: ID!) { user(id: $id) { id name email } } variables: id: "invalid_id_format" --- test_name: test invalid subscription scenarios - empty ID includes: - !include graphql_config.yaml stages: - name: Subscribe with empty ID graphql_request: operation_name: EmptyIdSubscription url: "{graphql_server_url}/graphql" query: | subscription GetUser($id: ID!) { user(id: $id) { id name email } } variables: id: "" --- test_name: test invalid subscription scenarios - negative ID includes: - !include graphql_config.yaml stages: - name: Subscribe with negative ID graphql_request: operation_name: NegativeIdSubscription url: "{graphql_server_url}/graphql" query: | subscription GetUser($id: ID!) { user(id: $id) { id name email } } variables: id: "-1" --- test_name: test error handling includes: - !include graphql_config.yaml stages: - name: Create user for error testing graphql_request: url: "{graphql_server_url}/graphql" query: | mutation CreateUser($name: String!, $email: String!) { createUser(name: $name, email: $email) { id name email } } variables: name: "Error Test User" email: "error@example.com" graphql_response: data: createUser: id: !anyint name: "Error Test User" email: "error@example.com" save: data: error_user_id: data.createUser.id - name: Subscribe to user updates graphql_request: operation_name: ErrorTestSubscription url: "{graphql_server_url}/graphql" query: | subscription ErrorTestSubscription($id: ID!) { user(id: $id) { id name email } } variables: id: "{error_user_id}" - name: Attempt to update non-existent user with error graphql_request: url: "{graphql_server_url}/graphql" query: | mutation UpdateNonExistentUser($id: ID!, $name: String!, $email: String!) { updateUser(id: $id, name: $name, email: $email) { id name email } } variables: id: "999999" name: "Non-existent user" email: "nonexistent@example.com" graphql_response: errors: - message: "User not found" - name: Update valid user and verify subscription still works graphql_request: url: "{graphql_server_url}/graphql" query: | mutation UpdateUser($id: ID!, $name: String!, $email: String!) { updateUser(id: $id, name: $name, email: $email) { id name email } } variables: id: "{error_user_id}" name: "Error Test User Updated" email: "error.updated@example.com" graphql_response: - subscription: ErrorTestSubscription data: user: id: !int "{error_user_id}" name: "Error Test User Updated" email: "error.updated@example.com" - data: updateUser: id: !int "{error_user_id}" name: "Error Test User Updated" email: "error.updated@example.com" --- test_name: test complex subscription scenario with multiple operations includes: - !include graphql_config.yaml stages: - name: Create multiple users for complex test graphql_request: url: "{graphql_server_url}/graphql" query: | mutation CreateUser($name: String!, $email: String!) { createUser(name: $name, email: $email) { id name email } } variables: name: "Complex Test User" email: "complex@example.com" graphql_response: data: createUser: id: !anyint name: "Complex Test User" email: "complex@example.com" save: data: complex_user_id: data.createUser.id - name: Subscribe to user updates with complex subscription graphql_request: operation_name: ComplexSubscription url: "{graphql_server_url}/graphql" query: | subscription ComplexSubscription($id: ID!) { user(id: $id) { id name email } } variables: id: "{complex_user_id}" - name: Multiple operations in sequence - create user graphql_request: url: "{graphql_server_url}/graphql" query: | mutation CreateUser($name: String!, $email: String!) { createUser(name: $name, email: $email) { id name email } } variables: name: "Another User" email: "another@example.com" graphql_response: data: createUser: id: !anyint name: "Another User" email: "another@example.com" - name: Update user that we're subscribed to graphql_request: url: "{graphql_server_url}/graphql" query: | mutation UpdateUser($id: ID!, $name: String!, $email: String!) { updateUser(id: $id, name: $name, email: $email) { id name email } } variables: id: "{complex_user_id}" name: "Complex Test User Updated" email: "complex.updated@example.com" graphql_response: - subscription: ComplexSubscription data: user: id: !int "{complex_user_id}" name: "Complex Test User Updated" email: "complex.updated@example.com" - data: updateUser: id: !int "{complex_user_id}" name: "Complex Test User Updated" email: "complex.updated@example.com" - name: Final verification query graphql_request: url: "{graphql_server_url}/graphql" query: | query GetUser($id: ID!) { user(id: $id) { id name email } } variables: id: "{complex_user_id}" graphql_response: data: user: id: !int "{complex_user_id}" name: "Complex Test User Updated" email: "complex.updated@example.com" tavern-3.6.0/example/graphql/tests/test_variables.tavern.yaml000066400000000000000000000026331520710011500244370ustar00rootroot00000000000000--- test_name: GraphQL mutation create user with multiple variables includes: - !include graphql_config.yaml stages: - name: Create user with multiple variables graphql_request: url: "{graphql_server_url}/graphql" query: | mutation CreateUser($name: String!, $email: String!) { createUser(name: $name, email: $email) { id name email } } variables: name: "{user_name}" email: "{user_email}" graphql_response: data: createUser: id: !anyint name: "{user_name}" email: "{user_email}" --- test_name: GraphQL mutation create post with mixed variables includes: - !include graphql_config.yaml stages: - name: Create post with mixed variables graphql_request: url: "{graphql_server_url}/graphql" query: | mutation CreatePost($title: String!, $content: String!, $authorId: String!) { createPost(title: $title, content: $content, authorId: $authorId) { id title content authorId } } variables: title: "{post_title}" content: "{post_content}" authorId: "{user_id}" graphql_response: data: createPost: id: !anyint title: "{post_title}" content: "{post_content}" authorId: !int "{user_id}" tavern-3.6.0/example/grpc/000077500000000000000000000000001520710011500153755ustar00rootroot00000000000000tavern-3.6.0/example/grpc/.dockerignore000066400000000000000000000000631520710011500200500ustar00rootroot00000000000000* !tavern_grpc_example !helloworld* !pyproject.tomltavern-3.6.0/example/grpc/Dockerfile000066400000000000000000000007761520710011500174010ustar00rootroot00000000000000FROM python:3.11-slim-trixie AS base RUN python3 -m pip install uv RUN mkdir /app WORKDIR /app COPY . /app RUN uv sync FROM base AS protos RUN apt-get update && apt-get install protobuf-compiler --yes --no-install-recommends && apt-get clean COPY *.proto . RUN uv run -m grpc_tools.protoc --proto_path=$(pwd) --pyi_out=$(pwd) --python_out=$(pwd) --grpc_python_out=$(pwd) *.proto FROM base COPY --from=protos /app/*.py /app/ ENV PYTHONPATH=. CMD ["uv", "run", "/app/tavern_grpc_example/server.py"] tavern-3.6.0/example/grpc/README.md000066400000000000000000000026401520710011500166560ustar00rootroot00000000000000# gRPC example This example demonstrates how to use Tavern to test gRPC services. It includes a simple "Greeter" service that responds with a hello message. The example shows three different approaches for handling protobuf definitions: 1. **Pre-compiled** (`helloworld_v1_precompiled.proto`): The protobuf is compiled ahead of time and the generated Python module is imported directly by Tavern. 2. **Runtime compilation** (`helloworld_v2_compiled.proto`): The `.proto` source file is compiled by Tavern at test time using the `source` key. 3. **Server reflection** (`helloworld_v3_reflected.proto`): No local proto files needed - Tavern discovers the service schema by querying the server's reflection API. The server runs two gRPC endpoints: - Port 50051: Standard server with pre-compiled and runtime-compiled services - Port 50052: Server with reflection enabled for the reflection-based tests ## Running the example 1. Start the server: ```bash docker compose up --build ``` 2. In another terminal, run the tests: ```bash py.test -v ``` The test file (`tests/test_grpc.tavern.yaml`) demonstrates: - Basic gRPC requests and responses using `grpc_request` and `grpc_response` - Saving values from responses for use in subsequent stages - Using the `connect` block for connection settings - Using the `proto` block to specify how to load protobuf definitions - Server reflection for schema discovery tavern-3.6.0/example/grpc/docker-compose.yaml000066400000000000000000000003001520710011500211640ustar00rootroot00000000000000--- services: server: build: context: . dockerfile: Dockerfile ports: - "127.0.0.1:50051:50051/tcp" - "127.0.0.1:50052:50052/tcp" stop_grace_period: "1s" tavern-3.6.0/example/grpc/helloworld_v1_precompiled.proto000066400000000000000000000004471520710011500236330ustar00rootroot00000000000000// Pre compiled and checked into the repo so it can be imported by Tavern at runtime syntax = "proto3"; package helloworld.v1; message HelloRequest { string name = 1; } message HelloReply { string message = 1; } service Greeter { rpc SayHello (HelloRequest) returns (HelloReply) {} } tavern-3.6.0/example/grpc/helloworld_v1_precompiled_pb2.py000066400000000000000000000032601520710011500236570ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE # source: helloworld_v1_precompiled.proto # Protobuf Python Version: 5.29.0 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import runtime_version as _runtime_version from google.protobuf import symbol_database as _symbol_database from google.protobuf.internal import builder as _builder _runtime_version.ValidateProtobufRuntimeVersion( _runtime_version.Domain.PUBLIC, 5, 29, 0, "", "helloworld_v1_precompiled.proto" ) # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( b'\n\x1fhelloworld_v1_precompiled.proto\x12\rhelloworld.v1"\x1c\n\x0cHelloRequest\x12\x0c\n\x04name\x18\x01 \x01(\t"\x1d\n\nHelloReply\x12\x0f\n\x07message\x18\x01 \x01(\t2O\n\x07Greeter\x12\x44\n\x08SayHello\x12\x1b.helloworld.v1.HelloRequest\x1a\x19.helloworld.v1.HelloReply"\x00\x62\x06proto3' ) _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages( DESCRIPTOR, "helloworld_v1_precompiled_pb2", _globals ) if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None _globals["_HELLOREQUEST"]._serialized_start = 50 _globals["_HELLOREQUEST"]._serialized_end = 78 _globals["_HELLOREPLY"]._serialized_start = 80 _globals["_HELLOREPLY"]._serialized_end = 109 _globals["_GREETER"]._serialized_start = 111 _globals["_GREETER"]._serialized_end = 190 # @@protoc_insertion_point(module_scope) tavern-3.6.0/example/grpc/helloworld_v1_precompiled_pb2.pyi000066400000000000000000000011021520710011500240210ustar00rootroot00000000000000from google.protobuf import descriptor as _descriptor from google.protobuf import message as _message from typing import ClassVar as _ClassVar, Optional as _Optional DESCRIPTOR: _descriptor.FileDescriptor class HelloRequest(_message.Message): __slots__ = ("name",) NAME_FIELD_NUMBER: _ClassVar[int] name: str def __init__(self, name: _Optional[str] = ...) -> None: ... class HelloReply(_message.Message): __slots__ = ("message",) MESSAGE_FIELD_NUMBER: _ClassVar[int] message: str def __init__(self, message: _Optional[str] = ...) -> None: ... tavern-3.6.0/example/grpc/helloworld_v1_precompiled_pb2_grpc.py000066400000000000000000000066701520710011500247020ustar00rootroot00000000000000# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! """Client and server classes corresponding to protobuf-defined services.""" import grpc import warnings import helloworld_v1_precompiled_pb2 as helloworld__v1__precompiled__pb2 GRPC_GENERATED_VERSION = "1.71.2" GRPC_VERSION = grpc.__version__ _version_not_supported = False try: from grpc._utilities import first_version_is_lower _version_not_supported = first_version_is_lower( GRPC_VERSION, GRPC_GENERATED_VERSION ) except ImportError: _version_not_supported = True if _version_not_supported: raise RuntimeError( f"The grpc package installed is at version {GRPC_VERSION}," + f" but the generated code in helloworld_v1_precompiled_pb2_grpc.py depends on" + f" grpcio>={GRPC_GENERATED_VERSION}." + f" Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}" + f" or downgrade your generated code using grpcio-tools<={GRPC_VERSION}." ) class GreeterStub(object): """Missing associated documentation comment in .proto file.""" def __init__(self, channel): """Constructor. Args: channel: A grpc.Channel. """ self.SayHello = channel.unary_unary( "/helloworld.v1.Greeter/SayHello", request_serializer=helloworld__v1__precompiled__pb2.HelloRequest.SerializeToString, response_deserializer=helloworld__v1__precompiled__pb2.HelloReply.FromString, _registered_method=True, ) class GreeterServicer(object): """Missing associated documentation comment in .proto file.""" def SayHello(self, request, context): """Missing associated documentation comment in .proto file.""" context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details("Method not implemented!") raise NotImplementedError("Method not implemented!") def add_GreeterServicer_to_server(servicer, server): rpc_method_handlers = { "SayHello": grpc.unary_unary_rpc_method_handler( servicer.SayHello, request_deserializer=helloworld__v1__precompiled__pb2.HelloRequest.FromString, response_serializer=helloworld__v1__precompiled__pb2.HelloReply.SerializeToString, ), } generic_handler = grpc.method_handlers_generic_handler( "helloworld.v1.Greeter", rpc_method_handlers ) server.add_generic_rpc_handlers((generic_handler,)) server.add_registered_method_handlers("helloworld.v1.Greeter", rpc_method_handlers) # This class is part of an EXPERIMENTAL API. class Greeter(object): """Missing associated documentation comment in .proto file.""" @staticmethod def SayHello( request, target, options=(), channel_credentials=None, call_credentials=None, insecure=False, compression=None, wait_for_ready=None, timeout=None, metadata=None, ): return grpc.experimental.unary_unary( request, target, "/helloworld.v1.Greeter/SayHello", helloworld__v1__precompiled__pb2.HelloRequest.SerializeToString, helloworld__v1__precompiled__pb2.HelloReply.FromString, options, channel_credentials, insecure, call_credentials, compression, wait_for_ready, timeout, metadata, _registered_method=True, ) tavern-3.6.0/example/grpc/helloworld_v2_compiled.proto000066400000000000000000000004051520710011500231170ustar00rootroot00000000000000// Not compiled, but compiled at runtime by Tavern syntax = "proto3"; package helloworld.v2; message HelloRequest { string name = 1; } message HelloReply { string message = 1; } service Greeter { rpc SayHello (HelloRequest) returns (HelloReply) {} } tavern-3.6.0/example/grpc/helloworld_v3_reflected.proto000066400000000000000000000004361520710011500232650ustar00rootroot00000000000000// Not compiled, Tavern uses server side reflection to determine the schema syntax = "proto3"; package helloworld.v3; message HelloRequest { string name = 1; } message HelloReply { string message = 1; } service Greeter { rpc SayHello (HelloRequest) returns (HelloReply) {} } tavern-3.6.0/example/grpc/pyproject.toml000066400000000000000000000010671520710011500203150ustar00rootroot00000000000000[project] name = "tavern_grpc_example" version = "0.1.0" description = "Tavern MQTT example project" readme = "README.md" requires-python = ">=3.11" dependencies = [ "tavern[grpc]", "grpcio-tools", "grpc-interceptor", ] [project.entry-points.tavern_grpc] grpc = "tavern._plugins.grpc.tavernhook" [tool.pytest.ini_options] addopts = [ "-r", "xs", "-vv", "--strict-markers", "-p", "no:logging", "--tb=short", "--color=yes", ] norecursedirs = [ "tavern_mqtt_example" ] [tool.uv] constraint-dependencies = ["ruamel-yaml<0.19.0"]tavern-3.6.0/example/grpc/regenerate.sh000077500000000000000000000002701520710011500200540ustar00rootroot00000000000000#!/usr/bin/env bash python3 -m grpc_tools.protoc --proto_path=$(pwd) --pyi_out=$(pwd) --python_out=$(pwd) --grpc_python_out=$(pwd) helloworld_v1_precompiled.proto ruff format *pb2*py tavern-3.6.0/example/grpc/tavern_grpc_example/000077500000000000000000000000001520710011500214225ustar00rootroot00000000000000tavern-3.6.0/example/grpc/tavern_grpc_example/server.py000066400000000000000000000055561520710011500233150ustar00rootroot00000000000000import logging import threading from collections.abc import Callable from concurrent import futures from typing import Any import grpc import helloworld_v1_precompiled_pb2 as helloworld_pb2_v1 import helloworld_v1_precompiled_pb2_grpc as helloworld_pb2_grpc_v1 import helloworld_v2_compiled_pb2 as helloworld_pb2_v2 import helloworld_v2_compiled_pb2_grpc as helloworld_pb2_grpc_v2 import helloworld_v3_reflected_pb2 as helloworld_pb2_v3 import helloworld_v3_reflected_pb2_grpc as helloworld_pb2_grpc_v3 from grpc_interceptor import ServerInterceptor from grpc_interceptor.exceptions import GrpcException from grpc_reflection.v1alpha import reflection class GreeterV1(helloworld_pb2_grpc_v1.GreeterServicer): def SayHello(self, request, context): return helloworld_pb2_v1.HelloReply(message=f"Hello, {request.name}!") class GreeterV2(helloworld_pb2_grpc_v2.GreeterServicer): def SayHello(self, request, context): return helloworld_pb2_v2.HelloReply(message=f"Hello, {request.name}!") class GreeterV3(helloworld_pb2_grpc_v3.GreeterServicer): def SayHello(self, request, context): return helloworld_pb2_v3.HelloReply(message=f"Hello, {request.name}!") class LoggingInterceptor(ServerInterceptor): def intercept( self, method: Callable, request_or_iterator: Any, context: grpc.ServicerContext, method_name: str, ) -> Any: logging.info(f"got request on {method_name}") try: return method(request_or_iterator, context) except GrpcException as e: logging.exception("error processing request") context.set_code(e.status_code) context.set_details(e.details) raise def serve(): interceptors = [LoggingInterceptor()] executor = futures.ThreadPoolExecutor(max_workers=10) # One server which exposes these two server = grpc.server( executor, interceptors=interceptors, ) helloworld_pb2_grpc_v1.add_GreeterServicer_to_server(GreeterV1(), server) helloworld_pb2_grpc_v2.add_GreeterServicer_to_server(GreeterV2(), server) server.add_insecure_port("0.0.0.0:50051") server.start() # One server which exposes the V3 API and has reflection turned on reflecting_server = grpc.server( executor, interceptors=interceptors, ) helloworld_pb2_grpc_v3.add_GreeterServicer_to_server(GreeterV3(), reflecting_server) service_names = ( helloworld_pb2_v3.DESCRIPTOR.services_by_name["Greeter"].full_name, reflection.SERVICE_NAME, ) reflection.enable_server_reflection(service_names, reflecting_server) reflecting_server.add_insecure_port("0.0.0.0:50052") reflecting_server.start() logging.info("Starting grpc server") event = threading.Event() event.wait() if __name__ == "__main__": logging.basicConfig(level=logging.INFO) serve() tavern-3.6.0/example/grpc/tests/000077500000000000000000000000001520710011500165375ustar00rootroot00000000000000tavern-3.6.0/example/grpc/tests/common.yaml000066400000000000000000000002371520710011500207150ustar00rootroot00000000000000--- name: test includes description: used for testing against local server variables: grpc_host: localhost grpc_port: 50051 grpc_reflecting_port: 50052 tavern-3.6.0/example/grpc/tests/conftest.py000066400000000000000000000007071520710011500207420ustar00rootroot00000000000000import os.path import shutil import tempfile import pytest @pytest.fixture() def make_temp_dir(): with tempfile.TemporaryDirectory() as d: yield d @pytest.fixture(autouse=True, scope="session") def single_compiled_proto_for_test(): with tempfile.TemporaryDirectory() as d: proto_filename = "helloworld_v2_compiled.proto" dst = os.path.join(d, proto_filename) shutil.copy(proto_filename, dst) yield dst tavern-3.6.0/example/grpc/tests/test_grpc.tavern.yaml000066400000000000000000000170771520710011500227270ustar00rootroot00000000000000--- test_name: Test grpc message echo importing a module instead of compiling from source includes: - !include common.yaml grpc: connect: &grpc_connect host: "{grpc_host}" port: !int "{grpc_port}" timeout: 3 proto: module: helloworld_v1_precompiled_pb2_grpc stages: - name: Echo text grpc_request: service: helloworld.v1.Greeter/SayHello body: name: "John" grpc_response: status: "OK" body: message: "Hello, John!" --- test_name: Test grpc saving includes: - !include common.yaml grpc: connect: <<: *grpc_connect proto: module: helloworld_v1_precompiled_pb2_grpc stages: - name: Echo text grpc_request: service: helloworld.v1.Greeter/SayHello body: name: "John" grpc_response: status: "OK" body: message: "Hello, John!" save: body: received_message: message - name: Echo text grpc_request: service: helloworld.v1.Greeter/SayHello body: name: "{received_message}" grpc_response: status: "OK" body: message: "Hello, Hello, John!!" --- test_name: Test grpc saving without expected code or response includes: - !include common.yaml grpc: connect: <<: *grpc_connect proto: module: helloworld_v1_precompiled_pb2_grpc stages: - name: Echo text grpc_request: service: helloworld.v1.Greeter/SayHello body: name: "John" grpc_response: save: body: received_message: message - name: Echo text grpc_request: service: helloworld.v1.Greeter/SayHello body: name: "{received_message}" grpc_response: status: "OK" body: message: "Hello, Hello, John!!" --- test_name: Test trying to connect using an invalid option includes: - !include common.yaml grpc: connect: <<: *grpc_connect options: woah: cool proto: module: helloworld_v1_precompiled_pb2_grpc _xfail: run: invalid grpc option 'woah' stages: - name: Echo text grpc_request: service: helloworld.v1.Greeter/SayHello body: name: "John" grpc_response: status: "OK" body: message: "Hello, John!" --- test_name: Test grpc message echo importing a module but its a path to a file includes: - !include common.yaml _xfail: run grpc: connect: <<: *grpc_connect proto: module: helloworld_v1_precompiled_pb2_grpc.py stages: - name: Echo text grpc_request: service: helloworld.v1.Greeter/SayHello body: name: "John" grpc_response: status: "OK" body: message: "Hello, John!" --- test_name: Test grpc connection without the 'connect' block includes: - !include common.yaml grpc: proto: module: helloworld_v1_precompiled_pb2_grpc stages: - name: Echo text grpc_request: host: "{grpc_host}:{grpc_port}" service: helloworld.v1.Greeter/SayHello body: name: "John" grpc_response: status: "OK" body: message: "Hello, John!" --- test_name: Test grpc connection without the 'connect' block, with a bad message includes: - !include common.yaml grpc: proto: module: helloworld_pb2_grpc _xfail: run stages: - name: Echo text grpc_request: host: "{grpc_host}:{grpc_port}" service: helloworld.v1.Greeter/SayHello body: aarg: wooo name: "John" grpc_response: status: "OK" body: message: "Hello, John!" --- test_name: Test grpc message echo compiling proto includes: - !include common.yaml grpc: &grpc_spec connect: <<: *grpc_connect proto: source: "{single_compiled_proto_for_test}" stages: - name: Echo text grpc_request: service: helloworld.v2.Greeter/SayHello body: name: "John" grpc_response: status: "OK" body: message: "Hello, John!" --- test_name: Test grpc message echo compiling folder with multiple protos includes: - !include common.yaml grpc: *grpc_spec stages: - name: Echo text grpc_request: service: helloworld.v2.Greeter/SayHello body: name: "John" grpc_response: status: "OK" body: message: "Hello, John!" --- test_name: Test trying to compile a folder with no protos in it includes: - !include common.yaml marks: - usefixtures: - make_temp_dir _xfail: run: "No protos defined in" grpc: connect: <<: *grpc_connect proto: source: "{make_temp_dir}" stages: - name: Echo text grpc_request: service: helloworld.v2.Greeter/SayHello body: name: "John" grpc_response: status: "OK" body: message: "Hello, John!" --- grpc: attempt_reflection: True connect: host: "{grpc_host}" port: !int "{grpc_reflecting_port}" timeout: 3 test_name: Test server reflection includes: - !include common.yaml stages: - name: Echo text grpc_request: service: helloworld.v3.Greeter/SayHello body: name: "John" grpc_response: status: "OK" body: message: "Hello, John!" --- grpc: attempt_reflection: True test_name: Test grpc connection without the 'connect' block, using server reflection includes: - !include common.yaml stages: - name: Echo text grpc_request: host: "{grpc_host}:{grpc_reflecting_port}" service: helloworld.v3.Greeter/SayHello body: name: "John" grpc_response: status: "OK" body: message: "Hello, John!" --- grpc: attempt_reflection: True test_name: Tried to use grpc reflection but the service did not expose it _xfail: run: "Service coolservice.v9/SayGoodbye was not found on host" includes: - !include common.yaml stages: - name: Echo text grpc_request: host: "{grpc_host}:{grpc_reflecting_port}" service: coolservice.v9/SayGoodbye body: name: "John" grpc_response: status: "OK" body: message: "Hello, John!" --- test_name: Test grpc connection without the 'connect' block, using server reflection, with a bad message includes: - !include common.yaml _xfail: run: error creating request from json body stages: - name: Echo text grpc_request: host: "{grpc_host}:{grpc_reflecting_port}" service: helloworld.v3.Greeter/SayHello body: aarg: wooo name: "John" grpc_response: status: "OK" body: message: "Hello, John!" --- test_name: Test grpc compiling source, with a bad message includes: - !include common.yaml grpc: *grpc_spec _xfail: run: error creating request from json body stages: - name: Echo text grpc_request: host: "{grpc_host}:{grpc_port}" service: helloworld.v2.Greeter/SayHello body: name: "John" A: klk grpc_response: status: "OK" body: message: "Hello, John!" --- test_name: Test grpc message echo importing a module that doesn't exist includes: - !include common.yaml grpc: connect: <<: *grpc_connect proto: module: cool_grpc_server _xfail: run stages: - name: Echo text grpc_request: service: helloworld.v1.Greeter/SayHello body: name: "John" grpc_response: status: "OK" body: message: "Hello, John!" --- test_name: Test cannot use invalid string status includes: - !include common.yaml grpc: *grpc_spec _xfail: verify stages: - name: Echo text grpc_request: service: helloworld.v1.Greeter/SayHello body: name: "Jim" grpc_response: status: "GREETINGS" body: message: "Hello, Jim!" tavern-3.6.0/example/http/000077500000000000000000000000001520710011500154215ustar00rootroot00000000000000tavern-3.6.0/example/http/.dockerignore000066400000000000000000000000461520710011500200750ustar00rootroot00000000000000* !tavern_http_example !pyproject.tomltavern-3.6.0/example/http/Dockerfile000066400000000000000000000003421520710011500174120ustar00rootroot00000000000000FROM python:3.11-slim-trixie RUN python3 -m pip install uv RUN mkdir /app WORKDIR /app COPY . /app RUN uv sync ENV PYTHONPATH=/app/ CMD ["uv", "run", "gunicorn", "--bind", "0.0.0.0:5000", "tavern_http_example.server:app"] tavern-3.6.0/example/http/README.md000066400000000000000000000006211520710011500166770ustar00rootroot00000000000000# Advanced example Now we're using a more complicated example of a server: - server requires a login to do anything - need to save the token it returns and use for future authorization - using a database - persistent storage that will be there between stages of tests Now we use multiple stages of tests in a row and make sure that the server state has been updated as expected between each one. tavern-3.6.0/example/http/docker-compose.yaml000066400000000000000000000001601520710011500212140ustar00rootroot00000000000000--- services: server: build: context: . dockerfile: Dockerfile ports: - "5000:5000" tavern-3.6.0/example/http/pyproject.toml000066400000000000000000000006151520710011500203370ustar00rootroot00000000000000[project] name = "tavern_http_example" version = "0.1.0" description = "Tavern MQTT example project" readme = "README.md" requires-python = ">=3.11" dependencies = [ "tavern", "gunicorn", "flask", "pyjwt", "pydantic", ] [project.entry-points.tavern_http] requests = "tavern._plugins.rest.tavernhook:TavernRestPlugin" [tool.uv] constraint-dependencies = ["ruamel-yaml<0.19.0"]tavern-3.6.0/example/http/tavern_http_example/000077500000000000000000000000001520710011500214725ustar00rootroot00000000000000tavern-3.6.0/example/http/tavern_http_example/models.py000066400000000000000000000013601520710011500233270ustar00rootroot00000000000000"""Pydantic models for HTTP example responses.""" from pydantic import BaseModel, ConfigDict class HelloResponse(BaseModel): """Response model for /hello/ endpoint.""" data: str class PingResponse(BaseModel): """Response model for /ping endpoint.""" data: str class StrictPingResponse(BaseModel): """Response model for /ping endpoint that rejects extra fields.""" model_config = ConfigDict(extra="forbid") data: str class NumberResponse(BaseModel): """Response model for /numbers endpoint.""" number: int class LoginResponse(BaseModel): """Response model for /login endpoint.""" token: str class ErrorResponse(BaseModel): """Response model for error responses.""" error: str tavern-3.6.0/example/http/tavern_http_example/server.py000066400000000000000000000077371520710011500233700ustar00rootroot00000000000000import contextlib import datetime import functools import sqlite3 import jwt from flask import Flask, g, jsonify, request app = Flask(__name__) SECRET = "CGQgaG7GYvTcpaQZqosLy4" DATABASE = "/tmp/test_db" SERVERNAME = "testserver" def get_db(): db = getattr(g, "_database", None) if db is None: db = g._database = sqlite3.connect(DATABASE) with db: with contextlib.suppress(Exception): db.execute( "CREATE TABLE numbers_table (name TEXT NOT NULL, number INTEGER NOT NULL)" ) return db @app.teardown_appcontext def close_connection(exception): db = getattr(g, "_database", None) if db is not None: db.close() @app.route("/login", methods=["POST"]) def login(): r = request.get_json() if r["user"] != "test-user" or r["password"] != "correct-password": return jsonify({"error": "Incorrect username/password"}), 401 payload = { "sub": "test-user", "aud": SERVERNAME, "exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1), } token = jwt.encode(payload, SECRET, algorithm="HS256") return jsonify({"token": token}) def requires_jwt(endpoint): """Makes sure a jwt is in the request before accepting it""" @functools.wraps(endpoint) def check_auth_call(*args, **kwargs): token = request.headers.get("Authorization") # check token is present if not token: return jsonify({"error": "No token"}), 401 token_type, token = token.split(" ") if token_type.lower() != "bearer": return jsonify({"error": "Wrong token type"}), 401 try: jwt.decode(token, SECRET, audience=SERVERNAME, algorithms=["HS256"]) except Exception: return jsonify({"error": "Invalid token"}), 401 return endpoint(*args, **kwargs) return check_auth_call @app.route("/numbers", methods=["POST"]) @requires_jwt def add_number(): r = request.get_json() try: r["number"] r["name"] except (KeyError, TypeError): return jsonify({"error": "missing key"}), 400 db = get_db() with db: db.execute("INSERT INTO numbers_table VALUES (:name, :number)", r) return jsonify({}), 201 @app.route("/numbers", methods=["GET"]) @requires_jwt def get_number(): r = request.args try: r["name"] except (KeyError, TypeError): return jsonify({"error": "missing key"}), 400 db = get_db() with db: row = db.execute("SELECT number FROM numbers_table WHERE name IS :name", r) try: number = next(row)[0] except StopIteration: return jsonify({"error": "Unknown number"}), 404 return jsonify({"number": number}) @app.route("/double", methods=["POST"]) @requires_jwt def double_number(): r = request.get_json() try: r["name"] except (KeyError, TypeError): return jsonify({"error": "no number passed"}), 400 db = get_db() with db: db.execute("UPDATE numbers_table SET number = number*2 WHERE name IS :name", r) row = db.execute("SELECT number FROM numbers_table WHERE name IS :name", r) try: double = next(row)[0] except StopIteration: return jsonify({"error": "Unknown number"}), 404 return jsonify({"number": double}) @app.route("/reset", methods=["POST"]) def reset_db(): db = get_db() with db: db.execute("DELETE FROM numbers_table") return "", 204 @app.route("/hello/", methods=["GET"]) @requires_jwt def hello(name: str): return jsonify({"data": f"Hello, {name}"}), 200 @app.route("/ping", methods=["GET"]) @requires_jwt def ping(): return jsonify({"data": "pong"}), 200 @app.route("/malformed", methods=["GET"]) @requires_jwt def malformed(): """Returns a response with extra fields that won't match strict pydantic models.""" return jsonify( {"data": "test", "extra_field": "unexpected", "another_extra": 123} ), 200 tavern-3.6.0/example/http/tavern_http_example/testing_utils.py000066400000000000000000000012311520710011500247360ustar00rootroot00000000000000import datetime import jwt def assert_quick_response(response): """Make sure that a request doesn't take too long Args: response (requests.Response): response object """ assert response.elapsed < datetime.timedelta(seconds=0.1) def create_bearer_token(): # Authorization: "bearer {test_login_token:s}" SECRET = "CGQgaG7GYvTcpaQZqosLy4" SERVERNAME = "testserver" payload = { "sub": "test-user", "aud": SERVERNAME, "exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1), } token = jwt.encode(payload, SECRET, algorithm="HS256") return {"Authorization": f"Bearer {token}"} tavern-3.6.0/example/http/tests/000077500000000000000000000000001520710011500165635ustar00rootroot00000000000000tavern-3.6.0/example/http/tests/common.yaml000066400000000000000000000001701520710011500207350ustar00rootroot00000000000000--- name: test includes description: used for testing against local server variables: service: http://localhost:5000 tavern-3.6.0/example/http/tests/components/000077500000000000000000000000001520710011500207505ustar00rootroot00000000000000tavern-3.6.0/example/http/tests/components/auth_stage.yaml000066400000000000000000000011161520710011500237570ustar00rootroot00000000000000# components/stage_auth.yaml --- name: Authentication stage description: Reusable test stage for authentication variables: user: user: test-user pass: correct-password stages: - id: login_get_token name: Login and acquire token request: url: "{service:s}/login" json: user: "{user.user:s}" password: "{user.pass:s}" method: POST headers: content-type: application/json response: status_code: 200 headers: content-type: application/json save: json: test_login_token: token tavern-3.6.0/example/http/tests/conftest.py000066400000000000000000000016221520710011500207630ustar00rootroot00000000000000import logging import logging.config import pytest import yaml @pytest.fixture(autouse=True) def setup_logging(): log_cfg = """ --- version: 1 formatters: default: # colorlog is really useful (): colorlog.ColoredFormatter format: "%(asctime)s [%(bold)s%(log_color)s%(levelname)s%(reset)s]: (%(bold)s%(name)s:%(lineno)d%(reset)s) %(message)s" style: "%" datefmt: "%X" log_colors: DEBUG: cyan INFO: green WARNING: yellow ERROR: red CRITICAL: red,bg_white handlers: stderr: class: colorlog.StreamHandler formatter: default loggers: tavern: handlers: - stderr level: INFO propagate: false """ as_dict = yaml.load(log_cfg, Loader=yaml.SafeLoader) logging.config.dictConfig(as_dict) logging.info("Logging set up") tavern-3.6.0/example/http/tests/test_hello.tavern.yaml000066400000000000000000000011671520710011500231140ustar00rootroot00000000000000--- test_name: Test authenticated /hello includes: - !include common.yaml - !include components/auth_stage.yaml stages: - name: Unauthenticated /hello request: url: "{service}/hello/Jim" method: GET response: status_code: 401 - type: ref id: login_get_token - name: Authenticated /hello request: url: "{service:s}/hello/Jim" method: GET headers: Content-Type: application/json Authorization: "Bearer {test_login_token}" response: status_code: 200 headers: content-type: application/json json: data: "Hello, Jim" tavern-3.6.0/example/http/tests/test_ping.tavern.yaml000066400000000000000000000011431520710011500227400ustar00rootroot00000000000000--- test_name: Test authenticated /ping includes: - !include common.yaml - !include components/auth_stage.yaml stages: - name: Unauthenticated /ping request: url: "{service:s}/ping" method: GET response: status_code: 401 - type: ref id: login_get_token - name: Authenticated /ping request: url: "{service:s}/ping" method: GET headers: Content-Type: application/json Authorization: "Bearer {test_login_token}" response: status_code: 200 headers: content-type: application/json json: data: pong tavern-3.6.0/example/http/tests/test_pydantic.tavern.yaml000066400000000000000000000075321520710011500236260ustar00rootroot00000000000000--- test_name: Test pydantic validation on /ping endpoint includes: - !include common.yaml - !include components/auth_stage.yaml stages: - name: Unauthenticated /ping request: url: "{service}/ping" method: GET response: status_code: 401 - type: ref id: login_get_token - name: Authenticated /ping with pydantic validation request: url: "{service}/ping" method: GET headers: Content-Type: application/json Authorization: "Bearer {test_login_token}" response: status_code: 200 headers: content-type: application/json verify_response_with: function: tavern.helpers:validate_pydantic extra_kwargs: model_location: "tavern_http_example.models:PingResponse" --- test_name: Test pydantic validation on /hello endpoint includes: - !include common.yaml - !include components/auth_stage.yaml stages: - type: ref id: login_get_token - name: Authenticated /hello with pydantic validation request: url: "{service}/hello/World" method: GET headers: Content-Type: application/json Authorization: "Bearer {test_login_token}" response: status_code: 200 headers: content-type: application/json verify_response_with: function: tavern.helpers:validate_pydantic extra_kwargs: model_location: "tavern_http_example.models:HelloResponse" --- test_name: Test pydantic validation on /numbers endpoint includes: - !include common.yaml - !include components/auth_stage.yaml stages: - name: reset database for test request: url: "{service}/reset" method: POST response: status_code: 204 - type: ref id: login_get_token - name: post a number request: url: "{service}/numbers" json: name: testnumber number: 42 method: POST headers: content-type: application/json Authorization: "bearer {test_login_token:s}" response: status_code: 201 headers: content-type: application/json - name: get number with pydantic validation request: url: "{service}/numbers" params: name: testnumber method: GET headers: content-type: application/json Authorization: "bearer {test_login_token:s}" response: status_code: 200 headers: content-type: application/json verify_response_with: function: tavern.helpers:validate_pydantic extra_kwargs: model_location: "tavern_http_example.models:NumberResponse" --- test_name: Test pydantic validation on /login endpoint includes: - !include common.yaml stages: - name: login with pydantic validation request: url: "{service}/login" json: user: test-user password: correct-password method: POST headers: content-type: application/json response: status_code: 200 headers: content-type: application/json verify_response_with: function: tavern.helpers:validate_pydantic extra_kwargs: model_location: "tavern_http_example.models:LoginResponse" --- test_name: Test pydantic validation fails on malformed response includes: - !include common.yaml - !include components/auth_stage.yaml _xfail: run stages: - type: ref id: login_get_token - name: Malformed response should fail pydantic validation request: url: "{service}/malformed" method: GET headers: Content-Type: application/json Authorization: "Bearer {test_login_token}" response: status_code: 200 headers: content-type: application/json verify_response_with: function: tavern.helpers:validate_pydantic extra_kwargs: model_location: "tavern_http_example.models:StrictPingResponse" tavern-3.6.0/example/http/tests/test_server.tavern.yaml000066400000000000000000000072261520710011500233210ustar00rootroot00000000000000--- test_name: Make sure jwt returned has the expected aud value includes: - !include common.yaml stages: - &login_request name: login request: url: "{service}/login" json: user: test-user password: correct-password method: POST headers: content-type: application/json response: status_code: 200 verify_response_with: &verify_token function: tavern.helpers:validate_jwt extra_kwargs: jwt_key: "token" key: CGQgaG7GYvTcpaQZqosLy4 algorithms: [ HS256 ] options: verify_signature: true verify_aud: true verify_exp: true audience: testserver headers: content-type: application/json save: $ext: <<: *verify_token json: test_login_token: token --- test_name: Make sure server doubles number properly includes: - !include common.yaml stages: - &reset_request name: reset database for test request: url: "{service}/reset" method: POST response: status_code: 204 - *login_request - name: post a number request: url: "{service}/numbers" json: name: smallnumber number: 123 method: POST headers: content-type: application/json Authorization: "bearer {test_login_token:s}" response: status_code: 201 headers: content-type: application/json - name: Make sure its in the db request: url: "{service}/numbers" params: name: smallnumber method: GET headers: content-type: application/json Authorization: "bearer {test_login_token:s}" response: status_code: 200 json: number: 123 headers: content-type: application/json - name: double it request: url: "{service}/double" json: name: smallnumber method: POST headers: content-type: application/json Authorization: "bearer {test_login_token:s}" response: status_code: 200 json: number: 246 verify_response_with: function: tavern_http_example.testing_utils:assert_quick_response headers: content-type: application/json - name: Make sure db has been updated properly request: url: "{service}/numbers" params: name: smallnumber method: GET headers: content-type: application/json Authorization: "bearer {test_login_token:s}" response: status_code: 200 json: number: 246 headers: content-type: application/json --- test_name: Check trying to get a number that we didnt post before returns a 404 includes: - !include common.yaml stages: - *reset_request - *login_request - name: double it request: url: "{service}/numbers" params: name: whatnumber method: GET headers: content-type: application/json Authorization: "bearer {test_login_token:s}" response: status_code: 404 json: error: Unknown number headers: content-type: application/json --- test_name: Create a jwt ourselves to authorize includes: - !include common.yaml stages: - name: post a number and crate our own authorization request: url: "{service}/numbers" json: name: smallnumber number: 123 method: POST headers: content-type: application/json $ext: function: tavern_http_example.testing_utils:create_bearer_token response: status_code: 201 headers: content-type: application/json tavern-3.6.0/example/mqtt/000077500000000000000000000000001520710011500154275ustar00rootroot00000000000000tavern-3.6.0/example/mqtt/.dockerignore000066400000000000000000000001251520710011500201010ustar00rootroot00000000000000* !tavern_mqtt_example !fluent.conf !mosquitto.conf !pyproject.toml !mosquitto_passwdtavern-3.6.0/example/mqtt/README.md000066400000000000000000000015141520710011500167070ustar00rootroot00000000000000# MQTT example This has an even more complicated example of a web server and an MQTT 'listener' that just reacts to MQTT messages, with a fluentd container to catch the Python logs. The listener waits for messages on `/device//lights` with a payload of 'on' or 'off' and updates the database. It also listens on `/device//ping` and responds with a message on `/device//pong`. The server queries this database when a `GET` request is made to `/get_device_state` and returns whether the lights are on or off. The tavern test file includes examples of how to test such a setup, using the keys `mqtt_publish` and `mqtt_response`. Run `docker compose up --build` in one terminal and run `py.test` in another terminal, and output from the mosquitto MQTT broker, the server, and the listener will be shown inline. tavern-3.6.0/example/mqtt/docker-compose.yaml000066400000000000000000000017161520710011500212320ustar00rootroot00000000000000--- services: server: build: context: . dockerfile: server.Dockerfile ports: - "5002:5000" environment: DB_NAME: /data/db volumes: - db-volume:/data/ depends_on: - broker - fluent listener: build: context: . dockerfile: listener.Dockerfile environment: DB_NAME: /data/db volumes: - db-volume:/data/ depends_on: - broker - fluent broker: image: eclipse-mosquitto:2.0.15 ports: - "9001:9001" - "1883:1883" volumes: - target: /mosquitto/config/mosquitto.conf source: ./mosquitto.conf type: bind read_only: true - target: /mosquitto/config/mosquitto_passwd source: ./mosquitto_passwd type: bind read_only: true fluent: image: fluent/fluentd:v1.16 ports: - "24224:24224" volumes: - "./fluent.conf:/fluentd/etc/fluent.conf" volumes: db-volume: tavern-3.6.0/example/mqtt/fluent.conf000066400000000000000000000002071520710011500175720ustar00rootroot00000000000000# vi: ft=fluentd # This file intentionally empty @type forward port 24224 type stdout tavern-3.6.0/example/mqtt/listener.Dockerfile000066400000000000000000000002551520710011500212470ustar00rootroot00000000000000FROM python:3.11-slim-trixie RUN python3 -m pip install uv RUN mkdir /app WORKDIR /app COPY . /app RUN uv sync CMD ["uv", "run", "/app/tavern_mqtt_example/listener.py"] tavern-3.6.0/example/mqtt/mosquitto.conf000066400000000000000000000002511520710011500203400ustar00rootroot00000000000000log_type all listener 9001 protocol websockets listener 1883 allow_anonymous false password_file /mosquitto/config/mosquitto_passwd allow_zero_length_clientid false tavern-3.6.0/example/mqtt/mosquitto_passwd000066400000000000000000000001701520710011500207750ustar00rootroot00000000000000tavern:$7$101$QTtQiXnahWIeGi4h$fgnglssgu7MQObOiQ18VtGpU45sC4pYXCH1IgykbAN6ql4U9CnGK+Q7uj/SQtGHYMPVF1SnqJtX9x9aQyE6JEw== tavern-3.6.0/example/mqtt/pyproject.toml000066400000000000000000000005721520710011500203470ustar00rootroot00000000000000[project] name = "tavern_mqtt_example" version = "0.1.0" description = "Tavern MQTT example project" readme = "README.md" requires-python = ">=3.11" dependencies = [ "tavern[mqtt]", "gunicorn", "flask", "fluent-logger" ] [project.entry-points.tavern_mqtt] paho-mqtt = "tavern._plugins.mqtt.tavernhook" [tool.uv] constraint-dependencies = ["ruamel-yaml<0.19.0"]tavern-3.6.0/example/mqtt/server.Dockerfile000066400000000000000000000003361520710011500207300ustar00rootroot00000000000000FROM python:3.11-slim-trixie RUN python3 -m pip install uv RUN mkdir /app WORKDIR /app COPY . /app RUN uv sync ENV PYTHONPATH=/app/ CMD ["uv", "run", "gunicorn", "--bind", "0.0.0.0:5000", "tavern_mqtt_example.server"] tavern-3.6.0/example/mqtt/tavern_mqtt_example/000077500000000000000000000000001520710011500215065ustar00rootroot00000000000000tavern-3.6.0/example/mqtt/tavern_mqtt_example/listener.py000066400000000000000000000113331520710011500237060ustar00rootroot00000000000000import json import logging import logging.config import os import sqlite3 import paho.mqtt.client as paho import yaml DATABASE = os.environ.get("DB_NAME") def get_client(): mqtt_client = paho.Client(transport="websockets", client_id="listener") mqtt_client.enable_logger() mqtt_client.username_pw_set(username="tavern", password="tavern") mqtt_client.connect_async(host="broker", port=9001) return mqtt_client def get_db(): return sqlite3.connect(DATABASE) def setup_logging(): log_cfg = """ version: 1 disable_existing_loggers: true formatters: fluent_fmt: (): fluent.handler.FluentRecordFormatter format: level: '%(levelname)s' where: '%(filename)s.%(lineno)d' handlers: fluent: class: fluent.handler.FluentHandler formatter: fluent_fmt tag: listener port: 24224 host: fluent loggers: paho: handlers: - fluent level: DEBUG propagate: true '': handlers: - fluent level: DEBUG propagate: true """ as_dict = yaml.load(log_cfg, Loader=yaml.SafeLoader) logging.config.dictConfig(as_dict) logging.info("Logging set up") def assert_device_exists(device_id): db = get_db() with db: row = db.execute( "SELECT device_id from devices_table where device_id IS (?)", (device_id,) ) try: next(row) except StopIteration: raise Exception(f"Device {device_id} is not registered") from None def handle_lights_topic(message): db = get_db() device_id = message.topic.split("/")[-2] assert_device_exists(device_id) if message.payload.decode("utf8") == "on": logging.info("Lights have been turned on") with db: db.execute( "UPDATE devices_table SET lights_on = 1 WHERE device_id IS (?)", (device_id,), ) elif message.payload.decode("utf8") == "off": logging.info("Lights have been turned off") with db: db.execute( "UPDATE devices_table SET lights_on = 0 WHERE device_id IS (?)", (device_id,), ) def handle_status_topic(client, message): device_id = message.topic.split("/")[-2] assert_device_exists(device_id) publish_device_status(client, device_id) def publish_device_status(client, device_id): db = get_db() logging.info("Checking lights status") with db: row = db.execute( "SELECT lights_on FROM devices_table WHERE device_id IS (?)", (device_id,) ) try: status = int(next(row)[0]) except Exception: logging.exception("Error getting status for device '%s'", device_id) else: client.publish( f"/device/{device_id}/status/response", json.dumps({"lights": status}), ) def handle_full_status_topic(client, message): db = get_db() logging.info("all devices reporting status") with db: device_ids = db.execute("SELECT device_id FROM devices_table") for device_id in device_ids: publish_device_status(client, device_id[0]) def handle_ping_topic(client, message): device_id = message.topic.split("/")[-2] assert_device_exists(device_id) client.publish(f"/device/{device_id}/pong") def handle_echo_topic(client, message): device_id = message.topic.split("/")[-2] assert_device_exists(device_id) client.publish(f"/device/{device_id}/echo/response", message.payload) def on_message_callback(client, userdata, message): try: logging.info("Received message on %s", message.topic) if "devices/status" in message.topic: handle_full_status_topic(client, message) elif "lights" in message.topic: handle_lights_topic(message) elif "echo" in message.topic: handle_echo_topic(client, message) elif "ping" in message.topic: handle_ping_topic(client, message) elif "status" in message.topic: handle_status_topic(client, message) else: logging.warning("Got unexpected MQTT topic '%s'", message.topic) except Exception as e: logging.exception(f"error handling message: {e}") def wait_for_messages(): setup_logging() mqtt_client = get_client() mqtt_client.on_message = on_message_callback mqtt_client.reconnect() topics = ["lights", "ping", "echo", "status"] for t in topics: device_topic = f"/device/+/{t}" logging.debug("Subscribing to '%s'", device_topic) mqtt_client.subscribe(device_topic) mqtt_client.subscribe("/devices/status") mqtt_client.loop_forever() if __name__ == "__main__": wait_for_messages() tavern-3.6.0/example/mqtt/tavern_mqtt_example/server.py000066400000000000000000000111221520710011500233630ustar00rootroot00000000000000import contextlib import logging import logging.config import os import sqlite3 import paho.mqtt.client as paho import yaml from flask import Flask, g, jsonify, request app = Flask(__name__) application = app DATABASE = os.environ.get("DB_NAME") @contextlib.contextmanager def get_client(): mqtt_client = paho.Client(transport="websockets", client_id="server") mqtt_client.enable_logger() mqtt_client.username_pw_set(username="tavern", password="tavern") mqtt_client.connect(host="broker", port=9001) try: mqtt_client.loop_start() yield mqtt_client finally: mqtt_client.disconnect() mqtt_client.loop_stop() def get_db(): return sqlite3.connect(DATABASE) def get_cached_db(): db = getattr(g, "_database", None) if db is None: db = g._database = get_db() return db def setup_logging(): log_cfg = """ version: 1 disable_existing_loggers: true formatters: fluent_fmt: (): fluent.handler.FluentRecordFormatter format: level: '%(levelname)s' where: '%(filename)s.%(lineno)d' handlers: fluent: class: fluent.handler.FluentHandler formatter: fluent_fmt tag: server port: 24224 host: fluent loggers: paho: handlers: - fluent level: DEBUG propagate: true '': handlers: - fluent level: DEBUG propagate: true """ as_dict = yaml.load(log_cfg, Loader=yaml.SafeLoader) logging.config.dictConfig(as_dict) logging.info("Logging set up") @app.teardown_appcontext def close_connection(exception): db = getattr(g, "_database", None) if db is not None: db.close() @app.route("/send_mqtt_message", methods=["POST"]) def send_message(): r = request.get_json() try: r["device_id"] r["payload"] except (KeyError, TypeError): return jsonify({"error": "missing key"}), 400 topic = "/device/{}".format(r["device_id"]) logging.debug("Publishing '%s' on '%s'", r["payload"], topic) try: with get_client() as mqtt_client: mqtt_client.publish(topic, r["payload"], r.get("qos", 0)) except Exception: return jsonify({"error": topic}), 500 return jsonify({"topic": topic}), 200 @app.route("/get_device_state", methods=["GET"]) def get_device(): r = request.args try: r["device_id"] except (KeyError, TypeError): return jsonify({"error": "missing key"}), 400 db = get_cached_db() with db: row = db.execute("SELECT * FROM devices_table WHERE device_id IS :device_id", r) try: status = next(row)[1] except StopIteration: return ( jsonify( {"error": "could not find device with id {}".format(r["device_id"])} ), 400, ) onoff = "on" if status else "off" logging.info("Lights are %s", onoff) return jsonify({"lights": onoff}) @app.route("/create_device", methods=["PUT"]) def create_device(): r = request.get_json(force=True) logging.error(r) try: r["device_id"] except (KeyError, TypeError): return jsonify({"error": "missing key device_id"}), 400 db = get_cached_db() with db: row = db.execute( "SELECT device_id from devices_table where device_id is :device_id", r ) try: r["clean"] except TypeError: return jsonify({"error": "checking for clean key"}), 500 except KeyError: try: next(row) except StopIteration: pass else: return jsonify({"error": "device already exists"}), 400 else: with db: db.execute("DELETE FROM devices_table") new_device = dict(lights_on=False, **r) logging.info("Creating new device: %s", new_device) with db: db.execute( "INSERT INTO devices_table (device_id, lights_on) VALUES (:device_id, :lights_on)", new_device, ) return jsonify({"status": "created device {device_id}".format(**r)}), 201 @app.route("/reset", methods=["POST"]) def reset_db(): db = get_cached_db() return _reset_db(db) def _reset_db(db): with db: def attempt(query): with contextlib.suppress(Exception): db.execute(query) attempt("DELETE FROM devices_table") attempt( "CREATE TABLE devices_table (device_id TEXT NOT NULL, lights_on INTEGER NOT NULL)" ) return "", 204 if __name__ == "__main__": setup_logging() db = get_db() _reset_db(db) tavern-3.6.0/example/mqtt/tavern_mqtt_example/testing_utils.py000066400000000000000000000003051520710011500247530ustar00rootroot00000000000000def message_says_hello(msg): """Make sure that the response was friendly""" assert msg.msg.payload.get("message") == "hello world" def return_hello(_=None): return {"hello": "there"} tavern-3.6.0/example/mqtt/tests/000077500000000000000000000000001520710011500165715ustar00rootroot00000000000000tavern-3.6.0/example/mqtt/tests/common.yaml000066400000000000000000000002361520710011500207460ustar00rootroot00000000000000--- name: test includes description: used for testing against local server variables: host: http://localhost:5002 mqtt_host: localhost mqtt_port: 9001 tavern-3.6.0/example/mqtt/tests/conftest.py000066400000000000000000000050271520710011500207740ustar00rootroot00000000000000import logging import logging.config import random import pytest import requests import yaml from tavern._core.pytest.item import YamlItem logging_initialised = False def setup_logging(): log_cfg = """ version: 1 disable_existing_loggers: true formatters: fluent_fmt: (): fluent.handler.FluentRecordFormatter format: level: '%(levelname)s' where: '%(filename)s.%(lineno)d' default: (): colorlog.ColoredFormatter format: "%(asctime)s [%(bold)s%(log_color)s%(levelname)s%(reset)s]: (%(bold)s%(name)s:%(lineno)d%(reset)s) %(message)s" style: "%" datefmt: "%X" log_colors: DEBUG: cyan INFO: green WARNING: yellow ERROR: red CRITICAL: red,bg_white handlers: fluent: class: fluent.handler.FluentHandler formatter: fluent_fmt tag: tavern port: 24224 host: localhost level: INFO stderr: class: colorlog.StreamHandler formatter: default level: DEBUG loggers: paho: &log handlers: - stderr level: DEBUG propagate: False tavern: <<: *log tavern.mqtt: &reduced_log handlers: - stderr - fluent level: INFO propagate: False tavern.response.mqtt: <<: *reduced_log tavern.request.mqtt: <<: *reduced_log """ as_dict = yaml.load(log_cfg, Loader=yaml.SafeLoader) logging.config.dictConfig(as_dict) logging.info("Logging set up") global logging_initialised # noqa logging_initialised = True def pytest_runtest_setup(item): """Hack to get around pytest bug pytest doesn't appear to run 'autouse' fixtures unless the test being run is actually a normal python test, not for our custom tests - this runs setup_logging once for tavern tests """ if isinstance(item, YamlItem) and not logging_initialised: setup_logging() return False @pytest.fixture(scope="session", autouse=True) def reset_devices(): requests.post("http://localhost:5002/reset") @pytest.fixture def get_publish_topic(random_device_id): return f"/device/{random_device_id}/echo" @pytest.fixture def get_response_topic_suffix(): return "response" @pytest.fixture(scope="function", autouse=True) def random_device_id(): return str(random.randint(100, 10000)) @pytest.fixture(scope="function", autouse=True) def random_device_id_2(): return str(random.randint(100, 10000)) tavern-3.6.0/example/mqtt/tests/test_mqtt.tavern.yaml000066400000000000000000000364321520710011500230070ustar00rootroot00000000000000--- test_name: Test mqtt message echo includes: - !include common.yaml paho-mqtt: &mqtt_spec auth: username: tavern password: tavern # tls: # enable: true connect: host: localhost port: 9001 timeout: 3 client: transport: websockets client_id: tavern-tester-{random_device_id} stages: - &setup_device_for_test name: create device request: url: "{host}/create_device" method: PUT json: device_id: "{random_device_id}" clean: True response: status_code: 201 - name: Echo text mqtt_publish: topic: /device/{random_device_id}/echo payload: hello world mqtt_response: topic: /device/{random_device_id}/echo/response payload: hello world timeout: 5 qos: 1 --- test_name: Test mqtt message echo json includes: - !include common.yaml paho-mqtt: *mqtt_spec stages: - *setup_device_for_test - name: Echo json mqtt_publish: topic: /device/{random_device_id}/echo json: message: hello world mqtt_response: topic: /device/{random_device_id}/echo/response json: message: hello world timeout: 5 qos: 1 --- test_name: Test mqtt wildcard subscription includes: - !include common.yaml paho-mqtt: *mqtt_spec stages: - *setup_device_for_test - name: Echo json mqtt_publish: topic: /device/{random_device_id}/echo json: message: hello world mqtt_response: topic: /device/+/echo/response json: message: hello world timeout: 5 qos: 1 --- test_name: Test mqtt message echo json formatted topic name marks: - usefixtures: - get_publish_topic - get_response_topic_suffix includes: - !include common.yaml paho-mqtt: *mqtt_spec stages: - *setup_device_for_test - name: Echo json mqtt_publish: topic: '{get_publish_topic}' json: message: hello world mqtt_response: topic: '/device/{random_device_id}/echo/{get_response_topic_suffix}' json: message: hello world timeout: 5 qos: 1 --- test_name: Test mqtt message echo json with non strict checking includes: - !include common.yaml paho-mqtt: *mqtt_spec strict: - json:off stages: - *setup_device_for_test - name: Check that at least part of response is echoed mqtt_publish: topic: /device/{random_device_id}/echo json: key_1: message1 key_2: message2 mqtt_response: topic: /device/{random_device_id}/echo/response json: key_1: message1 timeout: 5 qos: 1 --- test_name: Test mqtt message match fails with strict on and non matching response includes: - !include common.yaml paho-mqtt: *mqtt_spec strict: - json:on _xfail: run stages: - *setup_device_for_test - name: Check that at least part of response is echoed mqtt_publish: topic: /device/{random_device_id}/echo json: key_1: message1 key_2: message2 mqtt_response: topic: /device/{random_device_id}/echo/response json: key_1: message1 timeout: 5 qos: 1 --- test_name: Test ext functions work with MQTT includes: - !include common.yaml paho-mqtt: *mqtt_spec stages: - *setup_device_for_test - name: Echo json mqtt_publish: topic: /device/{random_device_id}/echo json: message: hello world mqtt_response: topic: /device/{random_device_id}/echo/response json: message: hello world timeout: 5 qos: 1 verify_response_with: function: tavern_mqtt_example.testing_utils:message_says_hello --- test_name: Test retain key includes: - !include common.yaml paho-mqtt: *mqtt_spec stages: - *setup_device_for_test - name: Echo text with retain force on mqtt_publish: topic: /device/{random_device_id}/echo payload: hello world retain: true qos: 1 # NOTE: not subcribing to .../echo here # - name: Dummy ping request # # wait to avoid false positive test from race condition # delay_before: 1 # # This stage just exists to test subscription and retained messages # mqtt_publish: # topic: /device/123/ping # # This is retained from the previous test # mqtt_response: # topic: /device/123/echo/response # payload: hello world # timeout: 5 # qos: 1 --- test_name: Test ping/pong with no payload includes: - !include common.yaml paho-mqtt: *mqtt_spec stages: - *setup_device_for_test - name: step 1 - ping/pong mqtt_publish: topic: /device/{random_device_id}/ping mqtt_response: topic: /device/{random_device_id}/pong timeout: 5 qos: 1 --- test_name: Test ping/pong with ignored payload includes: - !include common.yaml paho-mqtt: *mqtt_spec stages: - *setup_device_for_test - name: step 1 - ping/pong mqtt_publish: topic: /device/{random_device_id}/ping payload: blaeruhg mqtt_response: topic: /device/{random_device_id}/pong timeout: 5 qos: 1 --- test_name: Make sure can handle multiple types of responses includes: - !include common.yaml paho-mqtt: *mqtt_spec stages: - *setup_device_for_test - name: step 1 - post message trigger request: url: "{host}/send_mqtt_message" json: device_id: "{random_device_id}" payload: "hello" method: POST headers: content-type: application/json response: status_code: 200 json: topic: "/device/{random_device_id}" headers: content-type: application/json mqtt_response: topic: /device/{random_device_id} payload: "hello" timeout: 5 --- test_name: Test posting mqtt message updates state on server includes: - !include common.yaml paho-mqtt: *mqtt_spec stages: - *setup_device_for_test - name: step 1 - get device state with lights off request: url: "{host}/get_device_state" params: device_id: "{random_device_id}" method: GET headers: content-type: application/json response: status_code: 200 json: lights: "off" headers: content-type: application/json - name: step 2 - publish an mqtt message saying that the lights are now on mqtt_publish: topic: /device/{random_device_id}/lights qos: 1 payload: "on" delay_after: 2 - name: step 3 - get device state, lights now on request: url: "{host}/get_device_state" params: device_id: "{random_device_id}" method: GET headers: content-type: application/json response: status_code: 200 json: lights: "on" headers: content-type: application/json --- test_name: Test empty payload includes: - !include common.yaml paho-mqtt: *mqtt_spec stages: - *setup_device_for_test - name: Check lights status is off mqtt_publish: topic: /device/{random_device_id}/status mqtt_response: topic: /device/{random_device_id}/status/response json: lights: 0 timeout: 3 qos: 1 - name: Turn lights on mqtt_publish: topic: /device/{random_device_id}/lights qos: 1 payload: "on" delay_after: 2 - name: Check lights status is on mqtt_publish: topic: /device/{random_device_id}/status mqtt_response: topic: /device/{random_device_id}/status/response json: lights: 1 timeout: 3 qos: 1 --- test_name: Test can handle type tokens includes: - !include common.yaml paho-mqtt: *mqtt_spec stages: - *setup_device_for_test - name: Test checking for lights status with anyint mqtt_publish: topic: /device/{random_device_id}/status mqtt_response: topic: /device/{random_device_id}/status/response json: lights: !anyint timeout: 3 qos: 1 --- test_name: Test can handle anything token includes: - !include common.yaml paho-mqtt: *mqtt_spec stages: - *setup_device_for_test - name: Test checking for lights status with anything with json mqtt_publish: topic: /device/{random_device_id}/status mqtt_response: topic: /device/{random_device_id}/status/response json: lights: !anything timeout: 3 qos: 1 --- test_name: Test type token on non-json payload includes: - !include common.yaml paho-mqtt: *mqtt_spec stages: - *setup_device_for_test - name: Test checking for lights status with anything with payload mqtt_publish: topic: /device/{random_device_id}/status mqtt_response: topic: /device/{random_device_id}/status/response payload: !anything timeout: 3 qos: 1 --- test_name: Test raw token formatting in response includes: - !include common.yaml paho-mqtt: *mqtt_spec stages: - *setup_device_for_test - name: Echo json mqtt_publish: topic: /device/{random_device_id}/echo json: message: !raw "Hello {world}" mqtt_response: topic: /device/{random_device_id}/echo/response json: message: !raw "Hello {world}" timeout: 5 qos: 1 --- test_name: Test can use type token with mqtt port includes: - !include common.yaml paho-mqtt: auth: username: tavern password: tavern client: transport: websockets client_id: tavern-tester-{random_device_id} connect: host: "{mqtt_host}" port: !int "{mqtt_port:d}" timeout: 3 stages: - *setup_device_for_test - name: step 1 - ping/pong mqtt_publish: topic: /device/{random_device_id}/ping mqtt_response: topic: /device/{random_device_id}/pong timeout: 5 qos: 1 --- test_name: Test get both statuses includes: - !include common.yaml paho-mqtt: *mqtt_spec stages: - *setup_device_for_test - name: create device 2 request: url: "{host}/create_device" method: PUT json: device_id: "{random_device_id_2}" - name: step 1 - ping/pong mqtt_publish: topic: /devices/status mqtt_response: - topic: /device/{random_device_id}/status/response payload: !anything timeout: 3 qos: 1 - topic: /device/{random_device_id_2}/status/response payload: !anything timeout: 3 qos: 1 --- test_name: Test out of order responses includes: - !include common.yaml paho-mqtt: *mqtt_spec stages: - *setup_device_for_test - name: create device 2 request: url: "{host}/create_device" method: PUT json: device_id: "{random_device_id_2}" - name: step 1 - ping/pong mqtt_publish: topic: /devices/status mqtt_response: - topic: /device/{random_device_id_2}/status/response payload: !anything timeout: 3 qos: 1 - topic: /device/{random_device_id}/status/response payload: !anything timeout: 3 qos: 1 --- test_name: Save something and reuse it, one response includes: - !include common.yaml paho-mqtt: *mqtt_spec stages: - *setup_device_for_test - name: step 1 - ping/pong mqtt_publish: topic: /devices/status mqtt_response: topic: /device/{random_device_id}/status/response json: lights: !anything timeout: 3 qos: 1 save: json: lights_status: lights - name: Echo text mqtt_publish: topic: /device/{random_device_id}/echo payload: "{lights_status}" mqtt_response: topic: /device/{random_device_id}/echo/response payload: "{lights_status}" timeout: 5 qos: 1 --- test_name: Save something and reuse it, multiple responses, saved from both includes: - !include common.yaml paho-mqtt: *mqtt_spec stages: - *setup_device_for_test - name: Turn lights on for first device mqtt_publish: topic: /device/{random_device_id}/lights qos: 1 payload: "on" delay_after: 2 - name: create device 2 request: url: "{host}/create_device" method: PUT json: device_id: "{random_device_id_2}" - name: Get device statuses mqtt_publish: topic: /devices/status mqtt_response: - topic: /device/{random_device_id}/status/response timeout: 3 qos: 1 json: lights: 1 save: json: device_1_lights: lights - topic: /device/{random_device_id_2}/status/response timeout: 3 qos: 1 json: lights: 0 save: json: device_2_lights: lights - name: Ensure can use saved values 1 request: url: "{host}/send_mqtt_message" json: device_id: "{random_device_id}" payload: "{device_1_lights}" method: POST headers: content-type: application/json response: status_code: 200 json: topic: "/device/{random_device_id}" headers: content-type: application/json mqtt_response: topic: /device/{random_device_id} payload: "1" timeout: 5 - name: Ensure can use saved values 2 request: url: "{host}/send_mqtt_message" json: device_id: "{random_device_id_2}" payload: "{device_2_lights}" method: POST headers: content-type: application/json response: status_code: 200 json: topic: "/device/{random_device_id_2}" headers: content-type: application/json mqtt_response: topic: /device/{random_device_id_2} payload: "0" timeout: 5 --- test_name: Save something from an ext function and reuse it, one response includes: - !include common.yaml paho-mqtt: *mqtt_spec stages: - *setup_device_for_test - name: step 1 - ping/pong mqtt_publish: topic: /devices/status mqtt_response: topic: /device/{random_device_id}/status/response json: lights: !anything timeout: 3 qos: 1 save: $ext: function: tavern_mqtt_example.testing_utils:return_hello - name: Echo text mqtt_publish: topic: /device/{random_device_id}/echo payload: "{hello}" mqtt_response: topic: /device/{random_device_id}/echo/response payload: "there" timeout: 5 qos: 1 --- test_name: Save something from an ext function and reuse it, multiple response includes: - !include common.yaml paho-mqtt: *mqtt_spec stages: - *setup_device_for_test - name: step 1 - ping/pong mqtt_publish: topic: /devices/status mqtt_response: - topic: /device/{random_device_id}/status/response json: lights: !anything timeout: 3 qos: 1 save: $ext: function: tavern_mqtt_example.testing_utils:return_hello - name: Echo text mqtt_publish: topic: /device/{random_device_id}/echo payload: "{hello}" mqtt_response: topic: /device/{random_device_id}/echo/response payload: "there" timeout: 5 qos: 1 --- test_name: Update an MQTT publish from an ext function includes: - !include common.yaml paho-mqtt: *mqtt_spec stages: - *setup_device_for_test - name: step 1 - ping/pong mqtt_publish: topic: /device/{random_device_id}/echo json: $ext: function: tavern_mqtt_example.testing_utils:return_hello mqtt_response: topic: /device/{random_device_id}/echo/response timeout: 3 qos: 1 json: hello: there tavern-3.6.0/example/mqtt/tests/test_mqtt_failures.tavern.yaml000066400000000000000000000056441520710011500247020ustar00rootroot00000000000000--- test_name: Test trying to subscribe with a too-large qos fails includes: - !include common.yaml paho-mqtt: &mqtt_spec auth: username: tavern password: tavern # tls: # enable: true connect: host: localhost port: 9001 timeout: 3 client: transport: websockets client_id: tavern-tester _xfail: verify stages: - &setup_device_for_test name: create device request: url: "{host}/create_device" method: PUT json: device_id: "{random_device_id}" clean: True response: status_code: 201 - name: step 1 - ping/pong mqtt_publish: topic: /device/123/ping payload: ping mqtt_response: topic: /device/123/pong payload: pong timeout: 5 qos: 3 --- test_name: Test trying to subscribe with an invalid qos fails includes: - !include common.yaml paho-mqtt: *mqtt_spec _xfail: verify stages: - *setup_device_for_test - name: step 1 - ping/pong mqtt_publish: topic: /device/123/ping payload: ping mqtt_response: topic: /device/123/pong payload: pong timeout: 5 qos: weefwe --- test_name: Test trying to connect with an invalid username/password fails includes: - !include common.yaml _xfail: run paho-mqtt: <<: *mqtt_spec auth: username: tavern password: hhehehehh stages: - *setup_device_for_test - name: Echo json mqtt_publish: topic: /device/{random_device_id}/echo json: message: hello world mqtt_response: topic: /device/{random_device_id}/echo/response json: message: hello world timeout: 5 qos: 1 --- test_name: Test incorrect type token fails includes: - !include common.yaml paho-mqtt: *mqtt_spec _xfail: run stages: - *setup_device_for_test - name: Test checking for lights status with anystr fails mqtt_publish: topic: /device/123/status mqtt_response: topic: /device/123/status/response json: lights: !anystr timeout: 3 qos: 1 --- test_name: Test expecting empty payload but receiving a payload fails includes: - !include common.yaml paho-mqtt: *mqtt_spec _xfail: run stages: - *setup_device_for_test - name: Test checking for lights status with anyint mqtt_publish: topic: /device/123/status mqtt_response: topic: /device/123/status/response timeout: 3 qos: 1 --- test_name: Test unexpected message fails includes: - !include common.yaml paho-mqtt: *mqtt_spec _xfail: run stages: - *setup_device_for_test - name: step 1 - ping/pong mqtt_publish: topic: /devices/status mqtt_response: - topic: /device/456/status/response payload: !anything timeout: 2 qos: 1 unexpected: true - topic: /device/{random_device_id}/status/response payload: !anything timeout: 2 qos: 1 unexpected: true tavern-3.6.0/example/mqtt/tests/test_mqtt_merge_down.tavern.yaml000066400000000000000000000016451520710011500252130ustar00rootroot00000000000000--- is_defaults: true paho-mqtt: auth: username: tavern password: tavern # tls: # enable: true connect: host: localhost port: 9001 timeout: 3 client: transport: websockets client_id: tavern-tester-{random_device_id} includes: - !include common.yaml - stages: - id: create_test_device name: create device request: url: "{host}/create_device" method: PUT json: device_id: "{random_device_id}" clean: True response: status_code: 201 --- test_name: Test mqtt message echo json stages: - type: ref id: create_test_device - name: Echo json mqtt_publish: topic: /device/{random_device_id}/echo json: message: hello world mqtt_response: topic: /device/{random_device_id}/echo/response json: message: hello world timeout: 5 qos: 1 tavern-3.6.0/myst.yml000066400000000000000000000030521520710011500145260ustar00rootroot00000000000000# See docs at: https://mystmd.org/guide/frontmatter version: 1 project: id: 639d0ba7-28e9-427a-9d98-0446cee46405 title: Tavern description: Easily test REST, gRPC, GraphQL, and MQTT services keywords: [pytest, rest, grpc, mqtt, graphql, testing] # authors: [michaelboulton@gmail.com] github: https://github.com/taverntesting/tavern # To autogenerate a Table of Contents, run "myst init --write-toc" toc: # Auto-generated by `myst init --write-toc` - file: README.md title: Home - file: docs/source/examples.md - file: docs/source/basics.md children: - file: docs/source/core_concepts/marks.md - file: docs/source/core_concepts/types.md - file: docs/source/core_concepts/external_code.md - file: docs/source/core_concepts/config.md - file: docs/source/core_concepts/flow.md - file: docs/source/core_concepts/reports.md - file: docs/source/core_concepts/from_python.md - title: Request backends children: - file: docs/source/http.md - file: docs/source/graphql.md - file: docs/source/grpc.md - file: docs/source/mqtt.md - file: docs/source/plugins.md children: - file: docs/source/plugins/custom.md - file: docs/source/debugging.md - file: docs/source/cookbook.md - file: CONTRIBUTING.md - file: CHANGELOG.md site: template: book-theme options: favicon: docs/source/_static/favicon.png logo: docs/source/_static/favicon.png # nav: # - title: Internal page # url: /website-metadata tavern-3.6.0/pyproject.toml000066400000000000000000000140421520710011500157240ustar00rootroot00000000000000[build-system] requires = ["flit-core >=3.2,<4"] build-backend = "flit_core.buildapi" [project] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Framework :: Pytest", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Utilities", "Topic :: Software Development :: Testing", "License :: OSI Approved :: MIT License", ] keywords = ["testing", "pytest"] name = "tavern" description = "Simple testing of RESTful APIs" version = "3.6.0" dependencies = [ "PyYAML>=6.0.1,<7", "jmespath>=1,<2", "jsonschema>=4,<5", "pyjwt>=2.5.0,<3", "pykwalify>=1.8.0,<2", "pytest>=8,<10", "python-box>=6,<7", "requests>=2.22.0,<3", "simpleeval>=1.0.3", "stevedore>=4,<5", ] requires-python = ">=3.11" [[project.authors]] name = "Michael Boulton" [project.license] file = "LICENSE" [project.readme] file = "README.md" content-type = "text/markdown" [project.urls] Home = "https://taverntesting.github.io/" Documentation = "https://tavern.readthedocs.io/en/latest/" Source = "https://github.com/taverntesting/tavern" [project.optional-dependencies] grpc = [ "grpcio", "grpcio-reflection", "grpcio-status", "google-api-python-client", "protobuf>=5,<6", "proto-plus", ] mqtt = [ "paho-mqtt>=1.3.1,<=1.6.1", ] graphql = [ "aiohttp", "websockets", "gql>=4.0.0", ] [dependency-groups] dev = [ "Faker", "allure-pytest", "colorlog", "flask>=3,<4", "fluent-logger", "itsdangerous", "coverage[toml]", "flit >=3.2,<4", "wheel", "pre-commit", "pytest-cov", "pytest-xdist", "py", "tox>4.20,<5", "ruff", "uv>=0.9.2", "types-PyYAML", # See https://pypi.org/project/protobuf/#history for compatability between these two "types-protobuf>=5,<6", "protobuf-protoc-bin==29.5", "grpcio-tools", "types-requests", "types-jsonschema", "types-paho-mqtt", "types-jmespath", "grpc-interceptor", # for pytest "exceptiongroup", "tomli", "tbump>=6.10.0", "tox-uv>=1.28.0", "pytest-asyncio>=1.3.0", "hypothesis>=6,<7", "pydantic", "flask-httpauth>=4.8.1,<6", ] # When this is fixed, enable this https://github.com/jupyter-book/mystmd/issues/2082 # docs = ["mystmd"] [project.scripts] tavern-ci = "tavern.entry:main" [project.entry-points.pytest11] tavern = "tavern._core.pytest" [project.entry-points.tavern_http] requests = "tavern._plugins.rest.tavernhook:TavernRestPlugin" [project.entry-points.tavern_mqtt] paho-mqtt = "tavern._plugins.mqtt.tavernhook" [project.entry-points.tavern_grpc] grpc = "tavern._plugins.grpc.tavernhook" [project.entry-points.tavern_graphql] gql = "tavern._plugins.graphql.tavernhook" [tool.mypy] python_version = "3.11" # See https://mypy.readthedocs.io/en/stable/running_mypy.html#mapping-file-paths-to-modules explicit_package_bases = true exclude = [ '_pb2.pyi?$', # generated proto files 'bazel-*', 'venv/', ] [tool.coverage.run] branch = false omit = [ "tests/*", ".eggs/*", "env/*", "build/*", "dist/*", ] source = ["tavern"] [tool.coverage.paths] tavern = [ "tavern/", ".tox/py311-generic/lib/python3.11/site-packages/tavern/", ".tox/py311-mqtt/lib/python3.11/site-packages/tavern", ] [tool.pytest.ini_options] testpaths = ["tavern", "tests/unit"] addopts = [ "--doctest-modules", "-r", "xs", "-vv", "--strict-markers", "--tb=short", "--color=yes", "-m", "not do_not_run" ] norecursedirs = [ ".git", ".tox", ".venv", "example", "node_modules", "dist", "docs", ] markers = [ "slow: A test marker to check if markers work with custom markers", "xdist_group('group1'): A test marker to check if markers work with args", "do_not_run: Test marker for tests that should not be run", ] [tool.ruff] target-version = "py311" extend-exclude = [ "tests/unit/tavern_grpc/test_services_pb2*", "example/grpc/helloworld_v1_precompiled_pb2*" ] [tool.ruff.lint] ignore = [ "UP045", "UP007", # Optional/Union "E501", # line length "RUF005", # union types only valid from 3.10+ "B905", # zip(..., strict=True) only valid from 3.10+ "PLR0912", "PLR0915", "PLR0911", "PLR0913", # too many branches/variables/return values - sometimes this is just unavoidable "PLR2004", # 'magic numbers' "PLW2901", # Loop variable overridden "PLC0415", # import not at top of file ] select = ["E", "F", "B", "W", "I", "S", "C4", "ICN", "T20", "PLE", "RUF", "SIM105", "PL", "U"] [tool.ruff.lint.per-file-ignores] "example/*" = ["S"] "tests/*" = ["S", "RUF"] "scripts/*" = ["S603", "S607"] "tests/unit/tavern_grpc/test_grpc.py" = ["E402"] [tool.ruff.lint.isort] known-first-party = ["tavern"] [tool.ruff.format] exclude = ["*_pb2.py", "*_pb2_grpc.py", "*_pb2.pyi"] docstring-code-format = true [tool.tbump.version] current = "3.6.0" regex = ''' (?P\d+) \. (?P\d+) \. (?P\d+) ((?P[a-zA-Z]+)(?P\d+))? ''' [tool.tbump.git] message_template = "Bump to {new_version}" tag_template = "{new_version}" [[tool.tbump.file]] src = "tavern/__init__.py" [[tool.tbump.file]] src = "pyproject.toml" [[tool.tbump.before_commit]] name = "Update changelog" cmd = "./scripts/update_changelog.py" [[tool.tbump.before_commit]] name = "Update lock" cmd = "uv lock" # TODO: enable # [[tool.tbump.after_push]] # name = "publish" # cmd = "./scripts/release.sh" [tool.tox] runner = "uv-venv-lock-runner" skip_missing_interpreters = true isolated_build = true base_python = "3.11" [tool.uv] constraint-dependencies = ["ruamel-yaml<0.19.0"] [tool.uv.workspace] members = [ "example/mqtt", "example/grpc", "example/http", "example/graphql", ] [tool.uv.sources] tavern = { workspace = true } tavern_mqtt_example = { workspace = true } tavern_grpc_example = { workspace = true } tavern_http_example = { workspace = true } tavern_graphql_example = { workspace = true } tavern-3.6.0/scripts/000077500000000000000000000000001520710011500144765ustar00rootroot00000000000000tavern-3.6.0/scripts/coverage.sh000077500000000000000000000003411520710011500166260ustar00rootroot00000000000000#!/bin/sh set -ex tox -c tox-integration.ini -e py311-generic tox -c tox-integration.ini -e py311-mqtt tox -e py311 coverage combine --append .coverage tests/integration/.coverage example/mqtt/.coverage coverage report -m tavern-3.6.0/scripts/release.sh000077500000000000000000000004201520710011500164510ustar00rootroot00000000000000#!/usr/bin/env bash # Releasing: # 1. tbump --tag-message "" # 2. run this script rm -rf build/ dist/ ./*.egg-info fd pycache -u | xargs rm -rf flit build --format wheel --no-setup-py flit publish --format wheel --no-setup-py --repository pypi tavern-3.6.0/scripts/smoke.bash000077500000000000000000000010351520710011500164550ustar00rootroot00000000000000#!/usr/bin/env bash set -ex # Use prek if available, otherwise default to pre-commit if command -v prek >/dev/null 2>&1; then PRE_COMMIT_CMD="prek" else PRE_COMMIT_CMD="pre-commit" fi uv lock --check || true $PRE_COMMIT_CMD run ruff-check --all-files || true $PRE_COMMIT_CMD run ruff-format --all-files || true TOX_CMD="uv run tox" $TOX_CMD --parallel -c tox.ini \ -e py3check $TOX_CMD --parallel -c tox.ini \ -e py3 $TOX_CMD -c tox-integration.ini \ -e py3-graphql,py3-generic,py3-http,py3-grpc,py3-mqtt tavern-3.6.0/scripts/update_changelog.py000077500000000000000000000045411520710011500203500ustar00rootroot00000000000000#!/usr/bin/env python3 import re import subprocess def get_tags(): """Get git tags sorted by creator date, filtered to exclude certain patterns.""" result = subprocess.run( ["git", "tag", "--sort=creatordate"], capture_output=True, text=True, check=True ) tags = result.stdout.strip().split("\n") # Filter out prerelease tags according to PEP 440 # Matches versions with a/alpha, b/beta, or rc suffixes return [ tag for tag in tags if not re.search(r"(a|alpha|b|beta|rc)\d*$", tag, re.IGNORECASE) ] def get_tag_date(tag): """Get the date of a tag.""" result = subprocess.run( ["git", "log", "-1", "--pretty=format:%ad", "--date=short", tag], capture_output=True, text=True, check=True, ) return result.stdout.strip() def get_tag_name(tag): """Get tag name with message.""" result = subprocess.run( ["git", "tag", "-n1", tag], capture_output=True, text=True, check=True ) return result.stdout.strip() def get_tag_content(tag): """Get tag content (excluding first line).""" result = subprocess.run( ["git", "tag", "-n9", tag], capture_output=True, text=True, check=True ) lines = result.stdout.strip().split("\n") if len(lines) > 1: content = "\n".join(lines[1:]) # Remove leading whitespace from each line return "\n".join(line.lstrip() for line in content.split("\n")) return "" def print_changelog(): """Generate changelog content.""" tags = get_tags() # Reverse to put oldest at bottom tags.reverse() output = ["# Changelog", ""] for current_tag in tags: tag_date = get_tag_date(current_tag) # Determine header level - minor releases (patch == 0) get '#', patch releases get '##' hashes = "##" parts = current_tag.split(".") if len(parts) >= 3 and parts[2] == "0": hashes = "#" tag_name = get_tag_name(current_tag) output.append(f"{hashes} {tag_name} ({tag_date})") tag_content = get_tag_content(current_tag) if tag_content: output.append("") output.append(tag_content) output.append("") return "\n".join(output) if __name__ == "__main__": changelog = print_changelog() with open("CHANGELOG.md", "w") as f: f.write(changelog) tavern-3.6.0/tavern/000077500000000000000000000000001520710011500143065ustar00rootroot00000000000000tavern-3.6.0/tavern/__init__.py000066400000000000000000000001441520710011500164160ustar00rootroot00000000000000"""Stop pytest warning about module already imported: PYTEST_DONT_REWRITE""" __version__ = "3.6.0" tavern-3.6.0/tavern/_core/000077500000000000000000000000001520710011500153755ustar00rootroot00000000000000tavern-3.6.0/tavern/_core/__init__.py000066400000000000000000000000001520710011500174740ustar00rootroot00000000000000tavern-3.6.0/tavern/_core/asyncio.py000066400000000000000000000040701520710011500174150ustar00rootroot00000000000000import asyncio import logging import threading from typing import Any, Optional logger = logging.getLogger(__name__) class ThreadedAsyncLoop(threading.Thread): """A context-managed async event loop running in a separate thread.""" def __init__(self, debug: bool = False): """Initialise the threaded async loop. Args: debug: Whether to enable debug mode for the event loop """ self._loop = asyncio.new_event_loop() if debug: self._loop.set_debug(True) # Thread running the event loop super().__init__() def run(self): """Target function for the thread that runs the event loop.""" asyncio.set_event_loop(self._loop) self._loop.run_forever() # Close the loop after shutdown if not self._loop.is_closed(): self._loop.close() def __enter__(self): """Enter the context manager. Returns: The ThreadedAsyncLoop instance. """ self.start() return self def __exit__(self, exc_type, exc_val, exc_tb): """Exit the context manager and shut down the event loop.""" # Schedule the loop to stop in a thread-safe manner self._loop.call_soon_threadsafe(self._loop.stop) # Join the thread with a timeout to prevent indefinite blocking self.join(timeout=5.0) # Check if thread is still alive after timeout if self.is_alive(): logger.warning("ThreadedAsyncLoop thread did not terminate within timeout") # Ensure the loop is closed if not self._loop.is_closed(): self._loop.close() def run_coroutine(self, coro, timeout: Optional[int | float] = None) -> Any: """Run a coroutine in the thread's event loop. Args: coro: The coroutine to run timeout: Optional timeout for the operation Returns: The result of the coroutine """ future = asyncio.run_coroutine_threadsafe(coro, self._loop) return future.result(timeout=timeout) tavern-3.6.0/tavern/_core/dict_util.py000066400000000000000000000501151520710011500177310ustar00rootroot00000000000000import functools import logging import os import re import string import typing from collections.abc import Collection, Iterator, Mapping, Sequence from typing import Any, Union import box import jmespath from box.box import Box from tavern._core import exceptions from tavern._core.loader import ( ANYTHING, ForceIncludeToken, RegexSentinel, TypeConvertToken, TypeSentinel, ) from .formatted_str import FormattedString from .strict_util import StrictSetting, StrictSettingKinds, extract_strict_setting logger: logging.Logger = logging.getLogger(__name__) def _check_and_format_values(to_format: str, box_vars: Box) -> str: """Checks and formats a string with the given variables. Parses the input string to identify format placeholders and verifies that all required variables exist in the provided box_vars. Performs string formatting after validation, optionally ignoring missing format variables. Args: to_format: String with format placeholders to be formatted box_vars: Box object containing variables for formatting Raises: MissingFormatError: If a required format variable is not found in box_vars (and dangerously_ignore_string_format_errors is False) Returns: Formatted string with variables replaced by their values """ formatter = string.Formatter() try: would_format = list(formatter.parse(to_format)) except ValueError as e: raise exceptions.BadSchemaError( f"Format string '{to_format}' contains invalid syntax (unmatched '{{' or '}}')." " Escape literal braces as '{{{{' and '}}}}' if they are not format placeholders." ) from e for _, field_name, _, _ in would_format: if field_name is None: continue try: would_replace = formatter.get_field(field_name, [], box_vars)[0] except KeyError as e: logger.error( "Failed to resolve string '%s' with variables '%s'", to_format, box_vars ) logger.error("Key(s) not found in format: %s", field_name) raise exceptions.MissingFormatError(field_name) from e except IndexError as e: logger.error("Empty format values are invalid") raise exceptions.MissingFormatError(field_name) from e else: if not isinstance(would_replace, str | int | float): logger.warning( "Formatting '%s' will result in it being coerced to a string (it is a %s)", field_name, type(would_replace), ) return to_format.format(**box_vars) def _attempt_find_include(to_format: str, box_vars: box.Box) -> str | None: """Attempts to find and return a value to include based on a format string. This function parses the format string expecting exactly one format placeholder and retrieves the corresponding value from the box variables. It supports conversion specifiers and validates the format string against expected patterns. Args: to_format: String with format placeholders to be parsed box_vars: Box object containing variables to retrieve values from Raises: InvalidFormattedJsonError: If the format string doesn't meet the requirements for inclusion (e.g., has multiple format values) Returns: The retrieved value after applying any conversion, or None if not found """ formatter = string.Formatter() try: would_format = list(formatter.parse(to_format)) except ValueError as e: raise exceptions.BadSchemaError( f"Format string '{to_format}' contains invalid syntax (unmatched '{{' or '}}')." " Escape literal braces as '{{{{' and '}}}}' if they are not format placeholders." ) from e yaml_tag = ForceIncludeToken.yaml_tag if len(would_format) != 1: raise exceptions.InvalidFormattedJsonError( f"When using {yaml_tag}, there can only be one exactly format value, but got {len(would_format)}" ) (_, field_name, format_spec, conversion) = would_format[0] if field_name is None: raise exceptions.InvalidFormattedJsonError( f"Invalid string used for {yaml_tag}" ) pattern = r"{" + field_name + r".*}" if not re.match(pattern, to_format): raise exceptions.InvalidFormattedJsonError( f"Invalid format specifier '{to_format}' for {yaml_tag}" ) if format_spec: logger.warning( "Conversion specifier '%s' will be ignored for %s", format_spec, to_format ) would_replace = formatter.get_field(field_name, [], box_vars)[0] if conversion is None: return would_replace return formatter.convert_field(would_replace, conversion) T = typing.TypeVar("T", str, dict, list, tuple) def format_keys( val: T, variables: Mapping | Box, *, no_double_format: bool = True, dangerously_ignore_string_format_errors: bool = False, ) -> T: """recursively format a dictionary with the given values Args: val: Input thing to format variables: Dictionary of keys to format it with no_double_format: Whether to use the 'inner formatted string' class to avoid double formatting This is required if passing something via pytest-xdist, such as markers: https://github.com/taverntesting/tavern/issues/431 dangerously_ignore_string_format_errors: whether to ignore any string formatting errors. This will result in broken output, only use for debugging purposes. Raises: MissingFormatError: if a format variable was not found in variables Returns: recursively formatted values """ format_keys_ = functools.partial( format_keys, dangerously_ignore_string_format_errors=dangerously_ignore_string_format_errors, ) if not isinstance(variables, Box): box_vars = Box(variables) else: box_vars = variables if isinstance(val, dict): return {key: format_keys_(val[key], box_vars) for key in val} elif isinstance(val, tuple): return tuple(format_keys_(item, box_vars) for item in val) elif isinstance(val, list): return [format_keys_(item, box_vars) for item in val] elif isinstance(val, FormattedString): logger.debug("Already formatted %s, not double-formatting", val) elif isinstance(val, str): formatted = val try: formatted = _check_and_format_values(val, box_vars) except exceptions.MissingFormatError: if not dangerously_ignore_string_format_errors: raise if no_double_format: formatted = FormattedString(formatted) # type: ignore return formatted elif isinstance(val, TypeConvertToken): logger.debug("Got type convert token '%s'", val) if isinstance(val, ForceIncludeToken): return _attempt_find_include(val.value, box_vars) elif isinstance(val.constructor, tuple): raise exceptions.BadSchemaError( f"Can not use {val.yaml_tag} for formatting as it has multiple possible constructors" ) else: value = format_keys_(val.value, box_vars) return val.constructor(value) else: logger.debug("Not formatting something of type '%s'", type(val)) return val def recurse_access_key(data: Union[list, Mapping], query: str) -> Any: """ Search for something in the given data using the given query. Example: >>> recurse_access_key({"a": "b"}, "a") 'b' >>> recurse_access_key({"a": {"b": ["c", "d"]}}, "a.b[0]") 'c' Args: data: Data to search in query: Query to run Raises: JMESError: if there was an error parsing the query Returns: Whatever was found by the search """ try: from_jmespath = jmespath.search(query, data) except jmespath.exceptions.ParseError as e: raise exceptions.JMESError("Invalid JMES query") from e return from_jmespath def deep_dict_merge(initial_dct: dict, merge_dct: Mapping) -> dict: """Recursive dict merge. Instead of updating only top-level keys, dict_merge recurses down into dicts nested to an arbitrary depth and returns the merged dict. Keys values present in merge_dct take precedence over values in initial_dct. Params: initial_dct: dict onto which the merge is executed merge_dct: dct merged into dct Returns: recursively merged dict """ initial_box = Box(initial_dct) initial_box.merge_update(merge_dct) return initial_box.to_dict() _CanCheck = Sequence | Mapping | set | Collection def check_expected_keys(expected: _CanCheck, actual: _CanCheck) -> None: """Check that a set of expected keys is a superset of the actual keys Args: expected: keys we expect actual: keys we have got from the input Raises: UnexpectedKeysError: If not actual <= expected """ expected = set(expected) keyset = set(actual) if not keyset <= expected: unexpected = keyset - expected logger.debug("Valid keys = %s, actual keys = %s", expected, keyset) msg = f"Unexpected keys {unexpected}" logger.error(msg) raise exceptions.UnexpectedKeysError(msg) def yield_keyvals(block: Union[list, dict]) -> Iterator[tuple[list, str, str]]: """Return indexes, keys and expected values for matching recursive keys Given a list or dict, return a 3-tuple of the 'split' key (key split on dots), the original key, and the expected value. If the input is a list, it is enumerated so the 'keys' are just [0, 1, 2, ...] Example: Matching a dictionary with a couple of keys: >>> gen = yield_keyvals({"a": {"b": "c"}}) >>> next(gen) (['a'], 'a', {'b': 'c'}) Matching nested key access: >>> gen = yield_keyvals({"a.b.c": "d"}) >>> next(gen) (['a', 'b', 'c'], 'a.b.c', 'd') Matching a list of items: >>> gen = yield_keyvals(["a", "b", "c"]) >>> next(gen) (['0'], '0', 'a') >>> next(gen) (['1'], '1', 'b') >>> next(gen) (['2'], '2', 'c') Args: block: input matches Yields: iterable of (key split on dots, key, expected value) """ if isinstance(block, dict): for joined_key, expected_val in block.items(): split_key = joined_key.split(".") yield split_key, joined_key, expected_val else: for idx, val in enumerate(block): sidx = str(idx) yield [sidx], sidx, val Checked = typing.TypeVar("Checked", dict, Collection, str) def check_keys_match_recursive( expected_val: Checked, actual_val: Checked, keys: list[Union[str, int]], strict: StrictSettingKinds = True, ) -> None: """Utility to recursively check response values expected and actual both have to be of the same type or it will raise an error. Example: >>> check_keys_match_recursive({"a": {"b": "c"}}, {"a": {"b": "c"}}, []) is None True >>> check_keys_match_recursive( ... {"a": {"b": "c"}}, {"a": {"b": "d"}}, [] ... ) # doctest: +IGNORE_EXCEPTION_DETAIL Traceback (most recent call last): File "/home/michael/code/tavern/tavern/tavern/_core.util/dict_util.py", line 223, in check_keys_match_recursive tavern._core.exceptions.KeyMismatchError: Key mismatch: (expected["a"]["b"] = 'c', actual["a"]["b"] = 'd') Todo: This could be turned into a single-dispatch function for cleaner code and to remove a load of the isinstance checks Args: expected_val: expected value actual_val: actual value keys: any keys which have been recursively parsed to get to this point. Used for debug output. strict: Whether 'strict' key checking should be done. If this is False, a mismatch in dictionary keys between the expected and the actual values will not raise an error (but a mismatch in value will raise an error) Raises: KeyMismatchError: expected_val and actual_val did not match """ def full_err(): """Get error in the format: a["b"]["c"] = 4, b["b"]["c"] = {'key': 'value'} """ def _format_err(which): return "{}{}".format(which, "".join(f'["{key}"]' for key in keys)) e_formatted = _format_err("expected") a_formatted = _format_err("actual") return f"{e_formatted} = '{expected_val}' (type = {type(expected_val)}), {a_formatted} = '{actual_val}' (type = {type(actual_val)})" actual_type = type(actual_val) if expected_val is ANYTHING: # Match anything. We could just early exit here but having the debug # logging below is useful expected_matches = True elif isinstance(expected_val, TypeSentinel): # If the 'expected' type is actually just a sentinel for another type, # then it should match if isinstance(expected_val.allowed_types, tuple): expected_matches = actual_type in expected_val.allowed_types else: expected_matches = actual_type == expected_val.allowed_types else: # Normal matching expected_matches = ( # If they are the same type isinstance(expected_val, actual_type) or # Handles the case where, for example, the 'actual type' returned by # a custom backend returns an OrderedDict, which is a subclass of # dict but will raise a confusing error if the contents are # different issubclass(actual_type, type(expected_val)) ) strict_bool, strict_setting = extract_strict_setting(strict) try: assert actual_val == expected_val # noqa except AssertionError as e: # At this point, there is likely to be an error unless we're using any # of the type sentinels if expected_val is not ANYTHING: if not expected_matches: if isinstance(expected_val, RegexSentinel): msg = f"Expected a string to match regex '{expected_val.compiled}' ({full_err()})" else: msg = f"Type of returned data was different than expected ({full_err()})" raise exceptions.KeyMismatchError(msg) from e if isinstance(expected_val, dict): ekeys = set(expected_val.keys()) akeys = set(actual_val.keys()) # type:ignore if akeys != ekeys: extra_actual_keys = akeys - ekeys extra_expected_keys = ekeys - akeys msg = "" if extra_actual_keys: msg += f" - Extra keys in response: {extra_actual_keys}" if extra_expected_keys: msg += f" - Keys missing from response: {extra_expected_keys}" full_msg = f"Structure of returned data was different than expected {msg} ({full_err()})" # If there are more keys in 'expected' compared to 'actual', # this is still a hard error and we shouldn't continue if extra_expected_keys or strict_bool: raise exceptions.KeyMismatchError(full_msg) from e else: logger.debug( "Mismatch in returned data, continuing due to strict=%s: %s", strict_bool, full_msg, exc_info=True, ) # If strict is True, an error will be raised above. If not, recurse # through both sets of keys and just ignore missing ones to_recurse = akeys | ekeys for key in to_recurse: try: check_keys_match_recursive( expected_val[key], actual_val[key], # type:ignore keys + [key], strict, ) except KeyError: logger.debug( "Skipping comparing missing key '%s' due to strict=%s", key, strict_bool, ) elif isinstance(expected_val, list): if not strict_bool: missing = [] actual_iter = iter(actual_val) # Iterate over list items to see if any of them match _IN ORDER_ for i, e_val in enumerate(expected_val): while 1: try: current_response_val = next(actual_iter) except StopIteration: # Still iterating checking for a value, but ran out of response values logger.debug("Ran out of list response items to check") missing.append(e_val) break else: logger.debug( "Got '%s' from response to check against '%s' from expected", current_response_val, e_val, ) # Found one - check if it matches try: check_keys_match_recursive( e_val, current_response_val, keys + [i], strict ) except exceptions.KeyMismatchError: # Doesn't match what we're looking for logger.debug( "%s did not match next response value %s", e_val, current_response_val, ) else: logger.debug("'%s' present in response", e_val) if strict_setting == StrictSetting.LIST_ANY_ORDER: actual_iter = iter(actual_val) break if missing: msg = f"List item(s) not present in response: {missing}" raise exceptions.KeyMismatchError(msg) from e logger.debug("All expected list items present") else: if len(expected_val) != len(actual_val): raise exceptions.KeyMismatchError( f"Length of returned list was different than expected - expected {len(expected_val)} items from got {len(actual_val)} ({full_err()}" ) from e for i, (e_val, a_val) in enumerate(zip(expected_val, actual_val)): try: check_keys_match_recursive(e_val, a_val, keys + [i], strict) except exceptions.KeyMismatchError as sub_e: # This should _ALWAYS_ raise an error (unless the reason it didn't match was the # 'anything' sentinel), but it will be more obvious where the error came from # (in python 3 at least) and will take ANYTHING into account raise sub_e from e elif expected_val is ANYTHING: logger.debug("Actual value = '%s' - matches !anything", actual_val) elif isinstance(expected_val, TypeSentinel) and expected_matches: if isinstance(expected_val, RegexSentinel): if not expected_val.passes(actual_val): raise exceptions.KeyMismatchError( f"Regex mismatch: ({full_err()})" ) from e logger.debug( "Actual value = '%s' - matches !any%s", actual_val, expected_val.allowed_types, ) else: raise exceptions.KeyMismatchError(f"Key mismatch: ({full_err()})") from e def get_tavern_box() -> box.Box: """Get the 'tavern' box""" return Box({"tavern": {"env_vars": dict(os.environ)}}) tavern-3.6.0/tavern/_core/exceptions.py000066400000000000000000000115651520710011500201400ustar00rootroot00000000000000from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from tavern._core.pytest.config import TestConfig class TavernException(Exception): """Base exception Fields are internal and might change in future without warning Attributes: is_final: whether this exception came from a 'finally' block stage: stage that caused this issue test_block_config: config for stage """ stage: Optional[dict] test_block_config: Optional["TestConfig"] is_final: bool = False class BadSchemaError(TavernException): """Schema mismatch""" class EvalError(TavernException): """Error parsing or running a simpleeval program""" class TestFailError(TavernException): """Test failed somehow""" def __init__(self, msg, failures=None) -> None: super().__init__(msg) self.failures = failures or [] class KeyMismatchError(TavernException): """Mismatch found while validating keys in response""" class UnexpectedKeysError(TavernException): """Unexpected keys used in request specification""" class DuplicateKeysError(TavernException): """Duplicate key in request specification""" class MissingKeysError(TavernException): """Missing key in request specification""" class MissingFormatError(TavernException): """Tried to use a variable in a format string but it was not in the available variables """ class MissingSettingsError(TavernException): """Wanted to send an MQTT message but no settings were given""" class MQTTError(TavernException): """Some kind of error returned from paho library""" class MissingCookieError(TavernException): """Tried to use a cookie in a request that was not present in the session cookie jar """ class RestRequestException(TavernException): """Error making requests in RestRequest()""" class GRPCRequestException(TavernException): """Error making requests in GRPCRequest()""" class GRPCServiceException(TavernException): """Some kind of error when trying to get the gRPC service""" class ProtoCompilerException(TavernException): """Some kind of error using protoc""" class MQTTRequestException(TavernException): """Error making requests in MQTTRequest()""" class MQTTTopicException(TavernException): """Internal (?) error with subscriptions""" class MQTTTLSError(TavernException): """Error with TLS arguments to MQTT client""" class PluginLoadError(TavernException): """Error loading a plugin""" class InvalidExtFunctionError(TavernException): """Error loading an external function for validation/plugin use""" class JMESError(TavernException): """Error in JMES matching""" class InvalidStageReferenceError(TavernException): """Error loading stage reference""" class DuplicateStageDefinitionError(TavernException): """Stage with the specified ID previously defined""" class InvalidSettingsError(TavernException): """Configuration was passed incorrectly in some fashion""" class KeySearchNotFoundError(TavernException): """Trying to search for a key in the response but was not found""" class InvalidQueryResultTypeError(TavernException): """Searched for a value in data but it was not a 'simple' type""" class UnexpectedDocumentsError(TavernException): """Multiple documents were found in a YAML file when only one was expected""" class DuplicateCookieError(TavernException): """User tried to reuse a cookie from a previous request and override it in the same request""" class InvalidConfigurationException(TavernException): """A configuration value (from the cli or the ini file) was invalid""" class InvalidFormattedJsonError(TavernException): """Tried to use the magic json format tag in an invalid way""" class MisplacedExtBlockException(TavernException): """Tried to use the '$ext' block in a place it is no longer valid to use it""" def __init__(self, block) -> None: super().__init__( f"$ext function found in block {block} - this has been moved to verify_response_with block - see documentation" ) class InvalidRetryException(TavernException): """Invalid spec for max_retries""" class RegexAccessError(TavernException): """Error accessing a key via regex""" class DuplicateStrictError(TavernException): """Tried to set stage strictness for multiple responses""" class ConcurrentError(TavernException): """Error while processing concurrent future""" class IncludedFileNotFoundError(TavernException, FileNotFoundError): """A file referenced via file_body or include could not be found in the include path (test file directory, current working directory, or TAVERN_INCLUDE paths)""" class UnexpectedExceptionError(TavernException): """We expected a certain kind of exception in check_exception_raised but it was something else""" class TinctureError(TavernException): """Badly specified tincture.""" tavern-3.6.0/tavern/_core/extfunctions.py000066400000000000000000000113741520710011500205060ustar00rootroot00000000000000import functools import importlib import logging from collections.abc import Callable, Iterable, Mapping from typing import Any from tavern._core import exceptions from .dict_util import deep_dict_merge def is_ext_function(block: Any) -> bool: """ Whether the given object is an ext function block Args: block: Any object Returns: If it is an ext function style dict """ return isinstance(block, dict) and block.get("$ext", None) is not None def get_pykwalify_logger(module: str | None) -> logging.Logger: """Get logger for this module Have to do it like this because the way that pykwalify load extension modules means that getting the logger the normal way just result sin it trying to get the root logger which won't log correctly Args: module: name of module to get logger for Returns: logger for given module """ return logging.getLogger(module) def _getlogger() -> logging.Logger: """Get logger for this module""" return get_pykwalify_logger("tavern._core.extfunctions") def import_ext_function(entrypoint: str) -> Callable: """Given a function name in the form of a setuptools entry point, try to dynamically load and return it Args: entrypoint: setuptools-style entrypoint in the form module.submodule:function Returns: function loaded from entrypoint Raises: InvalidExtFunctionError: If the module or function did not exist """ logger = _getlogger() try: module, funcname = entrypoint.split(":") except ValueError as e: msg = "Expected entrypoint in the form module.submodule:function" logger.exception(msg) raise exceptions.InvalidExtFunctionError(msg) from e try: imported = importlib.import_module(module) except ImportError as e: msg = f"Error importing module {module}" logger.exception(msg) raise exceptions.InvalidExtFunctionError(msg) from e try: function = getattr(imported, funcname) except AttributeError as e: msg = f"No function named {funcname} in {module}" logger.exception(msg) raise exceptions.InvalidExtFunctionError(msg) from e return function def get_wrapped_response_function(ext: Mapping) -> Callable: """Wraps a ext function with arguments given in the test file This is similar to functools.wrap, but this makes sure that 'response' is always the first argument passed to the function Args: ext: $ext function dict with function, extra_args, and extra_kwargs to pass Returns: Wrapped function """ func, args, kwargs = _get_ext_values(ext) @functools.wraps(func) def inner(response): result = func(response, *args, **kwargs) _getlogger().debug("Result of calling '%s': '%s'", func, result) return result inner.func = func # type: ignore return inner def get_wrapped_create_function(ext: Mapping) -> Callable: """Same as get_wrapped_response_function, but don't require a response""" func, args, kwargs = _get_ext_values(ext) @functools.wraps(func) def inner(): result = func(*args, **kwargs) _getlogger().debug("Result of calling '%s': '%s'", func, result) return result inner.func = func # type: ignore return inner def _get_ext_values(ext: Mapping) -> tuple[Callable, Iterable, Mapping]: if not isinstance(ext, Mapping): raise exceptions.InvalidExtFunctionError( f"ext block should be a dict, but it was a {type(ext)}" ) args = ext.get("extra_args") or () kwargs = ext.get("extra_kwargs") or {} try: func = import_ext_function(ext["function"]) except KeyError as e: raise exceptions.BadSchemaError( "No function specified in external function block" ) from e return func, args, kwargs def update_from_ext(request_args: dict, keys_to_check: list[str]) -> None: """ Updates the request_args dict with any values from external functions Args: request_args: dictionary of request args keys_to_check: list of keys in request to possibly update from """ new_args = {} logger = _getlogger() for key in keys_to_check: try: block = request_args[key] except KeyError: logger.debug("No %s block", key) continue try: pop = block.pop("$ext") except (KeyError, AttributeError, TypeError): logger.debug("No ext functions in %s block", key) continue func = get_wrapped_create_function(pop) new_args[key] = func() merged_args = deep_dict_merge(request_args, new_args) request_args.update(**merged_args) tavern-3.6.0/tavern/_core/files.py000066400000000000000000000177741520710011500170710ustar00rootroot00000000000000import dataclasses import logging import mimetypes import os from contextlib import ExitStack from io import IOBase from typing import Any, NamedTuple, Optional, Union from tavern._core import exceptions from tavern._core.dict_util import format_keys from tavern._core.pytest.config import TestConfig logger: logging.Logger = logging.getLogger(__name__) def _get_include_dirs(test_file_path: Optional[str] = None) -> list: """Get directories to search for included files. Args: test_file_path: Optional path to the test file. If provided, the directory containing this file will be used as the first search location. Returns: List of directories to search, in order of priority. """ dirs = [] if test_file_path: try: root = os.path.split(test_file_path)[0] dirs.append(root) except (TypeError, AttributeError) as e: logger.warning( "Invalid test_file_path '%s', skipping test directory: %s", test_file_path, e, ) dirs.append(os.path.curdir) # Check for TAVERN_INCLUDE environment variable env_var_name = "TAVERN_INCLUDE" if env_var_name in os.environ: env_paths = [ os.path.expandvars(path_part) for path_part in os.environ[env_var_name].split(":") ] dirs.extend(env_paths) return dirs def _find_file_in_include_path( filename: str, test_file_path: Optional[str] = None ) -> str: """Locate a file using the include path logic (similar to !include). This searches in the following order: 1. Directory containing the test file (if test_file_path is provided) 2. Current working directory 3. Paths listed in TAVERN_INCLUDE environment variable (colon-separated) Args: filename: The filename or relative path to search for test_file_path: Optional path to the test file being processed Returns: The absolute path to the file if found Raises: exceptions.IncludedFileNotFoundError: If the file cannot be found in any search path """ include_dirs = _get_include_dirs(test_file_path) for directory in include_dirs: full_path = os.path.abspath(os.path.join(directory, filename)) if os.access(full_path, os.R_OK): return full_path raise exceptions.IncludedFileNotFoundError( f"File '{filename}' not found in include path: {include_dirs}" ) @dataclasses.dataclass class _Filespec: """A description of a file for a file upload, possibly as part of a multi part upload""" path: str content_type: str | None = None content_encoding: str | None = None form_field_name: str | None = None def _parse_filespec(filespec: str | dict) -> _Filespec: """ Get configuration for uploading file Args: filespec: Can either be one of - A path to a file - A dict containing 'long' format, possibly including content type/encoding and the multipart 'name' Returns: The parsed file spec Raises: exceptions.BadSchemaError: If the file spec was invalid """ if isinstance(filespec, str): return _Filespec(filespec) elif isinstance(filespec, dict): try: # The one required key path = filespec["file_path"] except KeyError as e: raise exceptions.BadSchemaError( "File spec dict did not contain the required 'file_path' key" ) from e return _Filespec( path, filespec.get("content_type"), filespec.get("content_encoding"), filespec.get("form_field_name"), ) else: # Could remove, also done in schema check raise exceptions.BadSchemaError( f"File specification must be a path or a dictionary but got {type(filespec)}" ) class FileSendSpec(NamedTuple): """A description of a file to send as part of a multipart/form-data upload to requests""" filename: str file_obj: IOBase content_type: Optional[str] = None content_encoding: Optional[str | dict] = None def guess_filespec( filespec: Union[str, dict], stack: ExitStack, test_block_config: TestConfig ) -> tuple[FileSendSpec, Optional[str], str]: """tries to guess the content type and encoding from a file. Args: filespec: a string path to a file or a dictionary of the file path, content type, and encoding. test_block_config: config for test/stage stack: exit stack to add open files context to Returns: A tuple of either length 2 (filename and file object), 3 (as before, with content type), or 4 (as before, with with content encoding). If a group name for the multipart upload was specified, this is also returned. Notes: If a 4-tuple is returned, the last element is a dictionary of headers to send to requests, _not_ the raw encoding value. """ if not mimetypes.inited: mimetypes.init() parsed = _parse_filespec(filespec) resolved_file_path = format_keys(parsed.path, test_block_config.variables) filename = os.path.basename(resolved_file_path) # a 2-tuple ('filename', fileobj) file_spec = [ filename, stack.enter_context(open(resolved_file_path, "rb")), ] # Try to guess as well, but don't override what the user specified guessed_content_type, guessed_encoding = mimetypes.guess_type(resolved_file_path) content_type = parsed.content_type or guessed_content_type encoding = parsed.content_encoding or guessed_encoding # If it doesn't have a mimetype, or can't guess it, don't # send the content type for the file if content_type: # a 3-tuple ('filename', fileobj, 'content_type') logger.debug("content_type for '%s' = '%s'", filename, content_type) file_spec.append(content_type) if encoding: # or a 4-tuple ('filename', fileobj, 'content_type', custom_headers) logger.debug("encoding for '%s' = '%s'", filename, encoding) # encoding is None for no encoding or the name of the # program used to encode (e.g. compress or gzip). The # encoding is suitable for use as a Content-Encoding header. file_spec.append({"Content-Encoding": encoding}) return FileSendSpec(*file_spec), parsed.form_field_name, resolved_file_path # type: ignore def _parse_file_mapping(file_args, stack, test_block_config) -> dict[str, FileSendSpec]: """Parses a simple mapping of uploads where each key is mapped to one form field name which has one file""" files_to_send = {} for key, filespec in file_args.items(): file_spec, form_field_name, _ = guess_filespec( filespec, stack, test_block_config ) # If it's a dict then the key is used as the name, at least to maintain backwards compatability if form_field_name: logger.warning( f"Specified 'form_field_name' as '{form_field_name}' in file spec, but the file name was inferred to be '{key}' from the mapping - the form_field_name will be ignored" ) files_to_send[key] = file_spec return files_to_send def _parse_file_list( file_args: list, stack: ExitStack, test_block_config: TestConfig, ) -> list[tuple[str, FileSendSpec]]: """Parses a case where there may be multiple files uploaded as part of one form field""" files_to_send: list[Any] = [] for filespec in file_args: file_spec, form_field_name, _ = guess_filespec( filespec, stack, test_block_config ) if not form_field_name: raise exceptions.BadSchemaError( "If specifying a list of files to upload for a multi part upload, the 'form_field_name' key must also be specified for each file to upload" ) files_to_send.append( ( form_field_name, file_spec, ) ) return files_to_send tavern-3.6.0/tavern/_core/formatted_str.py000066400000000000000000000001401520710011500206170ustar00rootroot00000000000000class FormattedString(str): """Wrapper class for things that have already been formatted""" tavern-3.6.0/tavern/_core/general.py000066400000000000000000000017241520710011500173700ustar00rootroot00000000000000import logging import os from typing import Union from tavern._core.loader import load_single_document_yaml from .dict_util import deep_dict_merge logger: logging.Logger = logging.getLogger(__name__) def load_global_config(global_cfg_paths: list[Union[str, os.PathLike]]) -> dict: """Given a list of file paths to global config files, load each of them and return the joined dictionary. This does a deep dict merge. Args: global_cfg_paths: List of filenames to load from Returns: joined global configs """ global_cfg: dict = {} if global_cfg_paths: logger.debug("Loading global config from %s", global_cfg_paths) for filename in global_cfg_paths: contents = load_single_document_yaml(filename) global_cfg = deep_dict_merge(global_cfg, contents) return global_cfg valid_http_methods = [ "GET", "PUT", "POST", "DELETE", "PATCH", "OPTIONS", "HEAD", ] tavern-3.6.0/tavern/_core/jmesutils.py000066400000000000000000000050041520710011500177650ustar00rootroot00000000000000import operator import re from collections.abc import Sized from typing import Any from tavern._core import exceptions def test_type(val, mytype) -> bool: """Check value fits one of the types, if so return true, else false""" typelist = TYPES.get(str(mytype).lower()) if typelist is None: raise TypeError( f"Type {str(mytype).lower()} is not a valid type to test against!" ) try: for testtype in typelist: if isinstance(val, testtype): # type: ignore return True return False except TypeError: return isinstance(val, typelist) # type: ignore COMPARATORS = { "count_eq": lambda x, y: safe_length(x) == y, "lt": operator.lt, "less_than": operator.lt, "eq": operator.eq, "equals": operator.eq, "str_eq": lambda x, y: operator.eq(str(x), str(y)), "ne": operator.ne, "not_equals": operator.ne, "gt": operator.gt, "greater_than": operator.gt, "contains": lambda x, y: x and operator.contains(x, y), # is y in x "contained_by": lambda x, y: y and operator.contains(y, x), # is x in y "regex": lambda x, y: regex_compare(str(x), str(y)), "type": test_type, } TYPES: dict[str, list[Any]] = { "none": [type(None)], "number": [int, float], "int": [int], "float": [float], "bool": [bool], "str": [str], "list": [list], "dict": [dict], } def regex_compare(_input, regex) -> bool: return bool(re.search(regex, _input)) def safe_length(var: Sized) -> int: """Exception-safe length check, returns -1 if no length on type or error""" try: return len(var) except TypeError: return -1 def validate_comparison(each_comparison: dict[Any, Any]): if extra := set(each_comparison.keys()) - {"jmespath", "operator", "expected"}: raise exceptions.BadSchemaError( f"Invalid keys given to JMES validation function (got extra keys: {extra})" ) jmespath, _operator, expected = ( each_comparison["jmespath"], each_comparison["operator"], each_comparison["expected"], ) try: COMPARATORS[_operator] except KeyError as e: raise exceptions.BadSchemaError("Invalid comparator given") from e return jmespath, _operator, expected def actual_validation( _operator: str, _actual, expected, _expression, expression ) -> None: if not COMPARATORS[_operator](_actual, expected): raise exceptions.JMESError(f"Validation '{expression}' ({_expression}) failed!") tavern-3.6.0/tavern/_core/loader.py000066400000000000000000000320461520710011500172220ustar00rootroot00000000000000# https://gist.github.com/joshbode/569627ced3076931b02f import dataclasses import logging import os.path import pathlib import re import typing import uuid from abc import abstractmethod from itertools import chain from typing import Optional import pytest import yaml from _pytest.python_api import ApproxBase from yaml.composer import Composer from yaml.constructor import SafeConstructor from yaml.nodes import Node, ScalarNode from yaml.parser import Parser from yaml.reader import Reader from yaml.resolver import Resolver from yaml.scanner import Scanner from tavern._core import exceptions from tavern._core.exceptions import BadSchemaError from tavern._core.strtobool import strtobool logger: logging.Logger = logging.getLogger(__name__) def makeuuid(loader, node) -> str: return str(uuid.uuid4()) class RememberComposer(Composer): """A composer that doesn't forget anchors across documents""" def get_event(self) -> None: ... def compose_document(self) -> Node | None: # Drop the DOCUMENT-START event. self.get_event() # type:ignore # Compose the root node. node = self.compose_node(None, None) # type:ignore # Drop the DOCUMENT-END event. self.get_event() # type:ignore # If we don't drop the anchors here, then we can keep anchors across # documents. # self.anchors = {} return node def create_node_class(cls): class node_class(cls): def __init__(self, x, start_mark, end_mark): cls.__init__(self, x) self.start_mark = start_mark self.end_mark = end_mark node_class.__name__ = f"{cls.__name__}_node" return node_class dict_node = create_node_class(dict) list_node = create_node_class(list) class SourceMappingConstructor(SafeConstructor): # To support lazy loading, the original constructors first yield # an empty object, then fill them in when iterated. Due to # laziness we omit this behaviour (and will only do "deep # construction") by first exhausting iterators, then yielding # copies. def construct_yaml_map(self, node): (obj,) = SafeConstructor.construct_yaml_map(self, node) return dict_node(obj, node.start_mark, node.end_mark) def construct_yaml_seq(self, node): (obj,) = SafeConstructor.construct_yaml_seq(self, node) return list_node(obj, node.start_mark, node.end_mark) SourceMappingConstructor.add_constructor( # type: ignore "tag:yaml.org,2002:map", SourceMappingConstructor.construct_yaml_map ) SourceMappingConstructor.add_constructor( # type: ignore "tag:yaml.org,2002:seq", SourceMappingConstructor.construct_yaml_seq ) yaml.add_representer(dict_node, yaml.representer.SafeRepresenter.represent_dict) yaml.add_representer(list_node, yaml.representer.SafeRepresenter.represent_list) class IncludeLoader( Reader, Scanner, Parser, RememberComposer, Resolver, SourceMappingConstructor, SafeConstructor, ): """YAML Loader with `!include` constructor and which can remember anchors between documents""" def __init__(self, stream): try: self._root = os.path.split(stream.name)[0] except AttributeError: self._root = os.path.curdir Reader.__init__(self, stream) Scanner.__init__(self) Parser.__init__(self) RememberComposer.__init__(self) SafeConstructor.__init__(self) Resolver.__init__(self) SourceMappingConstructor.__init__(self) env_path_list: Optional[list] = None env_var_name = "TAVERN_INCLUDE" def _get_include_dirs(loader): loader_list = [loader._root] if IncludeLoader.env_path_list is None: if IncludeLoader.env_var_name in os.environ: IncludeLoader.env_path_list = [ os.path.expandvars(path_part) for path_part in os.environ[IncludeLoader.env_var_name].split(":") ] else: IncludeLoader.env_path_list = [] return chain(loader_list, IncludeLoader.env_path_list) def find_include(loader, node) -> str: """Locate an include file and return the abs path.""" for directory in _get_include_dirs(loader): filename = os.path.abspath( os.path.join(directory, loader.construct_scalar(node)) ) if os.access(filename, os.R_OK): return filename raise BadSchemaError( f"{loader.construct_scalar(node)} not found in include path: {[str(d) for d in _get_include_dirs(loader)]}" ) def construct_include(loader, node: yaml.ScalarNode): """Include file referenced at node.""" filename = find_include(loader, node) resolved_path = pathlib.Path(filename) extension = resolved_path.suffix.lstrip(".").lower() if extension in ("yaml", "yml", "json"): return load_single_document_yaml(filename) elif extension == "graphql": return resolved_path.read_text(encoding="utf-8") raise BadSchemaError( f"Unknown filetype '{filename}' (included files must be in YAML, JSON, or GraphQL format with extensions .yaml, .yml, .json, or .graphql)" ) def construct_include_raw(loader, node: yaml.ScalarNode): """Include any file as raw text. This is useful for including text files for response body validation. """ filename = find_include(loader, node) resolved_path = pathlib.Path(filename) if not resolved_path.exists(): raise BadSchemaError(f"Include file not found: '{filename}'") return resolved_path.read_text(encoding="utf-8") IncludeLoader.add_constructor("!include", construct_include) IncludeLoader.add_constructor("!include_raw", construct_include_raw) IncludeLoader.add_constructor("!uuid", makeuuid) class TypeSentinel(yaml.YAMLObject): """This is a sentinel for expecting a type in a response. Any value associated with these is going to be ignored - these are only used as a 'hint' to the validator that it should expect a specific type in the response. """ yaml_loader = IncludeLoader allowed_types: typing.ClassVar @classmethod def from_yaml(cls, loader, node) -> "TypeSentinel": return cls() def __str__(self) -> str: return f"" @classmethod def to_yaml(cls, dumper, data) -> ScalarNode: node = yaml.nodes.ScalarNode(cls.yaml_tag, "", style=cls.yaml_flow_style) return node class NumberSentinel(TypeSentinel): yaml_tag = "!anynumber" # Tuple of allowed types - this needs special handling wherever it's used allowed_types = (int, float) # type:ignore class IntSentinel(TypeSentinel): yaml_tag = "!anyint" allowed_types = int class FloatSentinel(TypeSentinel): yaml_tag = "!anyfloat" allowed_types = float class StrSentinel(TypeSentinel): yaml_tag = "!anystr" allowed_types = str class BoolSentinel(TypeSentinel): yaml_tag = "!anybool" allowed_types = bool class ListSentinel(TypeSentinel): yaml_tag = "!anylist" allowed_types = list class DictSentinel(TypeSentinel): yaml_tag = "!anydict" allowed_types = dict @dataclasses.dataclass class RegexSentinel(TypeSentinel): """Sentinel that matches a regex in a part of the response This shouldn't be used directly and instead one of the below match/fullmatch/search tokens will be used """ allowed_types = str compiled: re.Pattern def __str__(self) -> str: return f"" @property def yaml_tag(self): raise NotImplementedError @abstractmethod def passes(self, string): raise NotImplementedError @classmethod def from_yaml(cls, loader, node) -> "RegexSentinel": return cls(re.compile(node.value)) class _RegexMatchSentinel(RegexSentinel): yaml_tag = "!re_match" def passes(self, string) -> bool: return self.compiled.match(string) is not None class _RegexFullMatchSentinel(RegexSentinel): yaml_tag = "!re_fullmatch" def passes(self, string) -> bool: return self.compiled.fullmatch(string) is not None class _RegexSearchSentinel(RegexSentinel): yaml_tag = "!re_search" def passes(self, string) -> bool: return self.compiled.search(string) is not None class AnythingSentinel(TypeSentinel): yaml_tag = "!anything" constructor = str allowed_types = "" @classmethod def from_yaml(cls, loader, node): return ANYTHING def __deepcopy__(self, memo): """Return ANYTHING when doing a deep copy This is required because the checks in various parts of the code assume that ANYTHING is a singleton, but doing a deep copy creates a new object by default """ return ANYTHING # One instance of this (see above) ANYTHING = AnythingSentinel() class TypeConvertToken(yaml.YAMLObject): """This is a sentinel for something that should be converted to a different type. The rough load order is: 1. Test data is loaded for schema validation 2. Test data is dumped again so that pykwalify can read it (the actual values don't matter at all at this point, because we're just checking that the structure is correct) 3. Test data is loaded and formatted So this preserves the actual value that the type should be up until the point that it actually needs to be formatted """ yaml_loader = IncludeLoader @staticmethod def constructor(_): raise NotImplementedError def __init__(self, value) -> None: self.value = value @classmethod def from_yaml(cls, loader, node): value = loader.construct_scalar(node) try: # See if it's already a valid value (eg, if we do `!int "2"`) converted = cls.constructor(value) except ValueError: # If not (eg, `!int "{int_value:d}"`) return cls(value) else: return converted @classmethod def to_yaml(cls, dumper, data) -> ScalarNode: return yaml.nodes.ScalarNode( cls.yaml_tag, data.value, style=cls.yaml_flow_style ) class IntToken(TypeConvertToken): yaml_tag = "!int" constructor = int class FloatToken(TypeConvertToken): yaml_tag = "!float" constructor = float class StrToBoolConstructor: """Using `bool` as a constructor directly will evaluate all strings to `True`.""" def __new__(cls, s: str) -> bool: # type:ignore return strtobool(s) class BoolToken(TypeConvertToken): yaml_tag = "!bool" constructor = StrToBoolConstructor class StrToRawConstructor: """Used when we want to ignore brace formatting syntax""" def __new__(cls, s) -> str: # type:ignore return str(s.replace("{", "{{").replace("}", "}}")) class RawStrToken(TypeConvertToken): yaml_tag = "!raw" constructor = StrToRawConstructor class ForceIncludeToken(TypeConvertToken): """Magic tag that changes the way string formatting works""" yaml_tag = "!force_original_structure" @staticmethod def constructor(_): raise ValueError class DeprecatedForceIncludeToken(ForceIncludeToken): """Old name for the above""" yaml_tag = "!force_format_include" @staticmethod def constructor(_): raise ValueError # Sort-of hack to try and avoid future API changes ApproxScalar = type(pytest.approx(1.0)) class ApproxSentinel(yaml.YAMLObject, ApproxScalar): # type:ignore yaml_tag = "!approx" yaml_loader = IncludeLoader @classmethod def from_yaml(cls, loader, node) -> ApproxBase: try: val = float(node.value) except (ValueError, TypeError) as e: logger.error( "Could not coerce '%s' to a float for use with !approx", type(node.value), ) raise BadSchemaError from e else: return pytest.approx(val) @classmethod def to_yaml(cls, dumper, data) -> ScalarNode: return yaml.nodes.ScalarNode( "!approx", str(data.expected), style=cls.yaml_flow_style ) # Apparently this isn't done automatically? yaml.dumper.Dumper.add_representer(ApproxScalar, ApproxSentinel.to_yaml) def load_single_document_yaml(filename: str | os.PathLike) -> dict: """ Load a yaml file and expect only one document Args: filename: path to document Returns: content of file Raises: UnexpectedDocumentsError: If more than one document was in the file """ with open(filename, encoding="utf-8") as fileobj: try: contents = yaml.load(fileobj, Loader=IncludeLoader) # type:ignore # noqa except yaml.composer.ComposerError as e: msg = "Expected only one document in this file but found multiple" raise exceptions.UnexpectedDocumentsError(msg) from e return contents def error_on_empty_scalar(self, mark): location = f"{mark.name:s}:{mark.line:d} - column {mark.column:d}" error = f"Error at {location} - cannot define an empty value in test - either give it a value or explicitly set it to None" raise exceptions.BadSchemaError(error) tavern-3.6.0/tavern/_core/plugins.py000066400000000000000000000304311520710011500174310ustar00rootroot00000000000000"""VERY simple skeleton for plugin stuff This is here mainly to make MQTT easier, this will almost defintiely change significantly if/when a proper plugin system is implemented! """ import dataclasses import logging from collections.abc import Callable, Mapping from functools import partial from typing import Any, Optional, Protocol import stevedore import stevedore.extension from tavern._core import exceptions from tavern._core.dict_util import format_keys from tavern._core.pytest.config import TestConfig from tavern.request import BaseRequest from tavern.response import BaseResponse logger: logging.Logger = logging.getLogger(__name__) class PluginHelperBase: """Base for plugins""" def plugin_load_error(mgr, entry_point, err): """Handle import errors""" msg = f"Error loading plugin {entry_point} - {err}" raise exceptions.PluginLoadError(msg) from err class _TavernPlugin(Protocol): """A tavern plugin""" session_type: type[Any] request_type: type[BaseRequest] verifier_type: type[BaseResponse] response_block_name: str request_block_name: str schema: Mapping has_multiple_responses: bool def get_expected_from_request( self, response_block: BaseResponse, test_block_config: TestConfig, session: Any ) -> Any: ... def is_valid_reqresp_plugin(ext: stevedore.extension.Extension) -> bool: """Whether this is a valid 'reqresp' plugin Requires certain functions/variables to be present Todo: Not all of these are required for all request/response types probably Args: ext: class or module plugin object Returns: Whether the plugin has everything we need to use it """ required = [ # MQTTClient, requests.Session "session_type", # RestRequest, MQTTRequest "request_type", # request, mqtt_publish, grpc_request "request_block_name", # Some function that returns a dict "get_expected_from_request", # MQTTResponse, RestResponse "verifier_type", # response, mqtt_response, grpc_request "response_block_name", # dictionary with pykwalify schema "schema", # whether plugin supports multiple responses (e.g., graphql_responses, mqtt_responses) "has_multiple_responses", ] plugin: _TavernPlugin = ext.plugin return all(hasattr(plugin, i) for i in required) class _Plugin: """Wrapped tavern plugin for convenience""" name: str plugin: _TavernPlugin @dataclasses.dataclass class _PluginCache: plugins: list[_Plugin] = dataclasses.field(default_factory=list) def __call__(self, config: Optional[TestConfig] = None) -> list[_Plugin]: if self.plugins: return self.plugins if config: # NOTE: This is reloaded every time self.plugins = self._load_plugins(config) return self.plugins raise exceptions.PluginLoadError("No config to load plugins from") def _load_plugins(self, test_block_config: TestConfig) -> list[_Plugin]: """Load plugins from the 'tavern' entrypoint namespace This can be a module or a class as long as it defines the right things Todo: - Limit which plugins are loaded based on some config/command line option - Different plugin names Args: test_block_config: available config for test Raises: exceptions.MissingSettingsError: invalid entry points set Returns: Loaded plugins, can be a class or a module """ plugins = [] discovered_plugins: dict[str, list[str]] = {} def is_plugin_backend_enabled( current_backend: str, ext: stevedore.extension.Extension ) -> bool: """Checks if a plugin backend is enabled based on configuration. If no specific backend is configured, defaults to enabled. Adds enabled plugins to discovered_plugins tracking dictionary. Args: current_backend: The backend being checked (e.g. 'http', 'mqtt') ext: The stevedore extension object representing the plugin Returns: Whether the plugin backend is enabled """ if test_block_config.tavern_internal.backends[current_backend] is None: # Use whatever default - will raise an error if >1 is discovered is_enabled = True logger.debug(f"Using default backend for {ext.name}") else: is_enabled = ( ext.name == test_block_config.tavern_internal.backends[current_backend] ) logger.debug( f"Is {current_backend} for {ext.name} enabled? {is_enabled}" ) if is_enabled: if current_backend not in discovered_plugins: discovered_plugins[current_backend] = [] discovered_plugins[current_backend].append(ext.name) return is_enabled for backend in test_block_config.tavern_internal.backends.keys(): logger.debug("loading backend for %s", backend) namespace = f"tavern_{backend}" manager = stevedore.EnabledExtensionManager( namespace=namespace, check_func=partial(is_plugin_backend_enabled, backend), verify_requirements=True, on_load_failure_callback=plugin_load_error, ) manager.propagate_map_exceptions = True validation_results = manager.map(is_valid_reqresp_plugin) invalid_plugins = [ ext.name for ext, is_valid in zip(manager.extensions, validation_results) if not is_valid ] if invalid_plugins: raise exceptions.PluginLoadError( f"Plugin(s) {invalid_plugins} failed validation: " f"missing required 'has_multiple_responses' field or other required attributes. " f"Required fields: session_type, request_type, request_block_name, " f"get_expected_from_request, verifier_type, response_block_name, schema, " f"has_multiple_responses" ) if len(manager.extensions) != 1: raise exceptions.MissingSettingsError( f"Expected exactly one entrypoint in 'tavern-{backend}' namespace but got {len(manager.extensions)}" ) plugins.extend(manager.extensions) for plugin, enabled in discovered_plugins.items(): if len(enabled) > 1: raise exceptions.PluginLoadError( f"Multiple plugins enabled for '{plugin}' backend: {enabled}" ) return plugins load_plugins = _PluginCache() def get_extra_sessions(test_spec: Mapping, test_block_config: TestConfig) -> dict: """Get extra 'sessions' for any extra test types Args: test_spec: Spec for the test block test_block_config: available config for test Returns: mapping of name to session. Session should be a context manager. """ sessions = {} plugins: list[_Plugin] = load_plugins(test_block_config) for p in plugins: if any( (p.plugin.request_block_name in i or p.plugin.response_block_name in i) for i in test_spec["stages"] ): logger.debug( "Initialising session for %s (%s)", p.name, p.plugin.session_type ) session_spec: dict = test_spec.get(p.name, {}) formatted: dict = format_keys(session_spec, test_block_config.variables) sessions[p.name] = p.plugin.session_type(**formatted) return sessions def get_request_type( stage: Mapping, test_block_config: TestConfig, sessions: Mapping, ) -> BaseRequest: """Get the request object for this stage there can only be one Args: stage: spec for this stage test_block_config: variables for this test run sessions: all available sessions Returns: request object with a run() method Raises: exceptions.DuplicateKeysError: More than one kind of request specified exceptions.MissingKeysError: No request type specified """ plugins = load_plugins(test_block_config) keys = {} for p in plugins: keys[p.plugin.request_block_name] = p.plugin.request_type if len(set(keys) & set(stage)) > 1: raise exceptions.DuplicateKeysError( f"Can only specify 1 request type but got {set(keys)}" ) elif not list(set(keys) & set(stage)): raise exceptions.MissingKeysError( f"Need to specify one of valid request types: '{set(keys.keys())}'" ) # We've validated that 1 and only 1 is there, so just loop until the first # one is found request_class: type[BaseRequest] | None = None for p in plugins: try: request_args = stage[p.plugin.request_block_name] except KeyError: pass else: session = sessions[p.name] request_class = p.plugin.request_type logger.debug( "Initialising request class for %s (%s)", p.name, request_class ) break if not request_class: raise exceptions.MissingSettingsError("No request type found") request_maker = request_class(session, request_args, test_block_config) return request_maker class ResponseVerifier(dict): plugin_name: str def _foreach_response( stage: Mapping, test_block_config: TestConfig, action: Callable[[_Plugin, str], dict], ) -> dict[str, dict]: """Do something for each response Args: stage: Stage of test test_block_config: Config for test action: function that takes (plugin, response block) Returns: mapping of plugin name to list of expected (normally length 1) """ plugins = load_plugins(test_block_config) retvals = {} for p in plugins: response_block = stage.get(p.plugin.response_block_name) if response_block is not None: retvals[p.name] = action(p, response_block) return retvals def get_expected( stage: Mapping, test_block_config: TestConfig, sessions: Mapping, ): """Get expected responses for each type of request Though only 1 request can be made, it can cause multiple responses. Because we need to subcribe to MQTT topics, which might be formatted from keys from included files, the 'expected'/'response' needs to be formatted BEFORE running the request. Args: stage: test stage test_block_config: available configuration for this test sessions: all available sessions Returns: mapping of request type to expected response dict """ def action(p, response_block): plugin_expected = p.plugin.get_expected_from_request( response_block, test_block_config, sessions[p.name] ) if plugin_expected: plugin_expected = ResponseVerifier(**plugin_expected) plugin_expected.plugin_name = p.name return plugin_expected else: return None return _foreach_response(stage, test_block_config, action) def get_verifiers( stage: Mapping, test_block_config: TestConfig, sessions: Mapping, expected: Mapping, ): """Get one or more response validators for this stage Args: stage: spec for this stage test_block_config: variables for this test run sessions: all available sessions expected: expected responses for this stage Returns: response validator object with a verify(response) method """ def action(p, _): session = sessions[p.name] logger.debug( "Initialising verifier for %s (%s)", p.name, p.plugin.verifier_type ) verifiers = [] plugin_expected = expected[p.name] verifier = p.plugin.verifier_type( session, stage["name"], plugin_expected, test_block_config, ) verifiers.append(verifier) return verifiers return _foreach_response(stage, test_block_config, action) tavern-3.6.0/tavern/_core/pytest/000077500000000000000000000000001520710011500167255ustar00rootroot00000000000000tavern-3.6.0/tavern/_core/pytest/__init__.py000066400000000000000000000004231520710011500210350ustar00rootroot00000000000000from .hooks import pytest_addhooks, pytest_addoption, pytest_collect_file from .newhooks import call_hook from .util import add_parser_options __all__ = [ "add_parser_options", "call_hook", "pytest_addhooks", "pytest_addoption", "pytest_collect_file", ] tavern-3.6.0/tavern/_core/pytest/config.py000066400000000000000000000053141520710011500205470ustar00rootroot00000000000000import copy import dataclasses import logging from importlib.util import find_spec from typing import Any from tavern._core.strict_util import StrictLevel logger: logging.Logger = logging.getLogger(__name__) @dataclasses.dataclass(frozen=True) class TavernInternalConfig: """Internal config that should be used only by tavern""" pytest_hook_caller: Any backends: dict @dataclasses.dataclass(frozen=True) class TestConfig: __test__ = False """Tavern configuration - there is a global config, then test-specific config, and finally stage-specific config, but they all use this structure Attributes: follow_redirects: whether the test should follow redirects variables: variables available for use in the stage strict: Strictness for test/stage stages: Any extra stages imported from other config files test_file_path: Optional path to the test file being run (used for resolving relative paths) tavern_internal: Internal config that should be used only by tavern tinctures: Global tinctures to apply to all test stages """ variables: dict strict: StrictLevel follow_redirects: bool stages: list tavern_internal: TavernInternalConfig tinctures: list | dict | None = None test_file_path: str | None = None def copy(self) -> "TestConfig": """Returns a shallow copy of self""" return copy.copy(self) def with_new_variables(self) -> "TestConfig": """Returns a shallow copy of self but with the variables copied. This stops things being copied between tests. Can't use deepcopy because the variables might contain things that can't be pickled and hence can't be deep copied.""" copied = self.copy() return dataclasses.replace(copied, variables=copy.copy(self.variables)) def with_strictness(self, new_strict: StrictLevel) -> "TestConfig": """Create a copy of the config but with a new strictness setting""" return dataclasses.replace(self, strict=new_strict) @staticmethod def backends() -> list[str]: available_backends = ["http"] if has_module("paho.mqtt"): available_backends.append("mqtt") if all( has_module(module) for module in ("grpc_status", "grpc_reflection", "grpc", "google.protobuf") ): available_backends.append("grpc") if has_module("gql"): available_backends.append("graphql") logger.debug(f"available request backends: {available_backends}") return available_backends def has_module(module: str) -> bool: try: return find_spec(module) is not None except ModuleNotFoundError: return False tavern-3.6.0/tavern/_core/pytest/error.py000066400000000000000000000176511520710011500204420ustar00rootroot00000000000000import copy import dataclasses import json import logging import re import typing from io import StringIO from typing import Any import yaml from _pytest._code.code import FormattedExcinfo, TerminalRepr from _pytest._io import TerminalWriter from tavern._core import exceptions from tavern._core.dict_util import format_keys if typing.TYPE_CHECKING: from tavern._core.pytest.item import YamlItem from tavern._core.report import prepare_yaml from tavern._core.stage_lines import ( end_mark, get_stage_lines, read_relevant_lines, start_mark, ) logger: logging.Logger = logging.getLogger(__name__) @dataclasses.dataclass class ReprdError(TerminalRepr): exce: Any item: "YamlItem" def _get_available_format_keys(self) -> dict: """Try to get the format variables for the stage If we can't get the variable for this specific stage, just return the global config which will at least have some format variables Returns: variables for formatting test """ try: keys = self.exce._excinfo[1].test_block_config.variables except AttributeError: logger.warning("Unable to read stage variables - error output may be wrong") keys = self.item.global_cfg.variables return keys def _print_format_variables( self, tw: TerminalWriter, code_lines: list[str] ) -> list[str]: """Print a list of the format variables and their value at this stage If the format variable is not defined, print it in red as '???' Args: tw: Pytest TW instance code_lines: Source lines for this stage Returns: List of all missing format variables """ def read_formatted_vars(lines): """Go over all lines and try to find format variables""" for line in lines: for match in re.finditer( r"(.*?:\s+!raw)?(?(1).*|.*?(?P(? None: """Print the direct source lines from this test stage If we couldn't get the stage for some reason, print the entire test out. If there are any lines which have missing format variables, higlight them in red. Args: tw: Pytest TW instance code_lines: Raw source for this stage missing_format_vars: List of all missing format variables for this stage line_start: Source line of this stage """ if line_start: tw.line(f"Source test stage (line {line_start}):", white=True, bold=True) else: tw.line("Source test stages:", white=True, bold=True) for line in code_lines: if any(i in line for i in missing_format_vars): tw.line(line, red=True) else: tw.line(line, white=True) def _print_formatted_stage(self, tw: TerminalWriter, stage: dict) -> None: """Print the 'formatted' stage that Tavern will actually use to send the request/process the response Args: tw: Pytest TW instance stage: The 'final' stage used by Tavern """ tw.line("Formatted stage:", white=True, bold=True) keys = self._get_available_format_keys() # Format stage variables recursively if stage.get("graphql_request"): # Format the graphql request in a special way to avoid formatting errors from curly braces in graphql stage_copy = copy.deepcopy(stage) from tavern._plugins.graphql.request import _format_graphql_request stage_copy["graphql_request"] = _format_graphql_request( stage_copy["graphql_request"], keys, ) formatted_stage = format_keys( stage_copy, keys, dangerously_ignore_string_format_errors=True ) else: formatted_stage = format_keys( stage, keys, dangerously_ignore_string_format_errors=True ) # Replace formatted strings with strings for dumping prepared_stage = prepare_yaml(formatted_stage) # Dump formatted stage to YAML format formatted_lines = yaml.dump(prepared_stage, default_flow_style=False).split( "\n" ) for line in formatted_lines: if not line: continue tw.line(f" {line}", white=True) def _print_errors(self, tw: TerminalWriter) -> None: """Print any errors in the 'normal' Pytest style Args: tw: Pytest TW instance """ tw.line("Errors:", white=True, bold=True) # Sort of hack, just use this to directly extract the exception format. # If this breaks in future, just re-implement it e = FormattedExcinfo() lines = e.get_exconly(self.exce) for line in lines: tw.line(line, red=True, bold=True) def toterminal(self, tw: TerminalWriter) -> None: """Print out a custom error message to the terminal""" # Try to get the stage so we can print it out. I'm not sure if the stage # will ever NOT be present, but better to check just in case try: stage = self.exce._excinfo[1].stage except AttributeError: stage = None # Fallback, we don't know which stage it is stages = self.item.spec["stages"] first_line = start_mark(stages[0]).line - 1 last_line = end_mark(stages[-1]).line line_start = None else: first_line, last_line, line_start = get_stage_lines(stage) code_lines = list(read_relevant_lines(self.item.spec, first_line, last_line)) missing_format_vars = self._print_format_variables(tw, code_lines) tw.line("") self._print_test_stage(tw, code_lines, missing_format_vars, line_start) tw.line("") if not stage: tw.line( "[Could not determine which stage was running]", red=True, bold=True ) elif missing_format_vars: tw.line("Missing format vars for stage", red=True, bold=True) else: self._print_formatted_stage(tw, stage) tw.line("") self._print_errors(tw) @property def longreprtext(self) -> str: # information. io = StringIO() tw = TerminalWriter(file=io) self.toterminal(tw) return io.getvalue().strip() def __str__(self) -> str: return self.longreprtext tavern-3.6.0/tavern/_core/pytest/file.py000066400000000000000000000451501520710011500202230ustar00rootroot00000000000000import ast import copy import functools import itertools import logging import re import typing from collections.abc import Callable, Iterable, Iterator, Mapping from typing import Any, Union import pytest import yaml.parser from box import Box from pytest import Mark from tavern._core import exceptions from tavern._core.dict_util import deep_dict_merge, format_keys, get_tavern_box from tavern._core.extfunctions import get_wrapped_create_function, is_ext_function from tavern._core.loader import IncludeLoader from tavern._core.schema.files import verify_tests from .item import YamlItem from .util import load_global_cfg logger: logging.Logger = logging.getLogger(__name__) T = typing.TypeVar("T") _format_without_inner: Callable[[T, Mapping], T] = functools.partial( # type:ignore format_keys, no_double_format=False ) def _ast_node_to_literal(node: ast.AST) -> Any: """Convert an AST node to its literal value""" if isinstance(node, ast.Constant): return node.value elif isinstance(node, ast.List): return [_ast_node_to_literal(elem) for elem in node.elts] elif isinstance(node, ast.Dict): result = {} for k, v in zip(node.keys, node.values): # Handle None keys (e.g., from dictionary unpacking) if k is None: continue key = _ast_node_to_literal(k) value = _ast_node_to_literal(v) result[key] = value return result elif isinstance(node, ast.Tuple): return tuple([_ast_node_to_literal(elem) for elem in node.elts]) elif isinstance(node, ast.Name): # Handle special constants like True, False, None if node.id in ("True", "False", "None"): return ast.literal_eval(node.id) raise ValueError(f"Unsupported variable reference: {node.id}") else: raise ValueError(f"Unsupported AST node type: {type(node)}") def _parse_func_mark(fmt_vars: Mapping, m: str) -> pytest.Mark: """Parse a function-style mark string and return a pytest Mark object. This function takes a string representation of a pytest mark with arguments (e.g., "skipif(condition)") and parses it into a proper pytest Mark object. The arguments are formatted using the provided format variables before parsing. Args: fmt_vars: A mapping of format variables to use when formatting the mark arguments m: The mark string to parse, expected to be in format "mark_name(args)" Returns: A pytest Mark object with the parsed arguments Raises: exceptions.BadSchemaError: If the mark string cannot be parsed or if there are issues with the AST parsing """ try: # Extract mark name and arguments string mark_name = m.split("(", maxsplit=1)[0] args_str = m[len(mark_name) + 1 : -1] # Format the arguments string formatted_args_str = _format_without_inner(args_str, fmt_vars) # Wrap in a function call for parsing tree = ast.parse(f"func({formatted_args_str})", mode="eval") call = tree.body if isinstance(call, ast.Call): # Extract positional arguments as literals posargs = [_ast_node_to_literal(arg) for arg in call.args] # Extract keyword arguments as literals kwargs = { kw.arg: _ast_node_to_literal(kw.value) for kw in call.keywords if kw.arg is not None } # Create the mark with parsed arguments mark = getattr(pytest.mark, mark_name) evaluated = mark(*posargs, **kwargs) return evaluated else: raise Exception(f"unexpected type {type(call)} from parsing function call") except Exception as e: msg = f"Tried to use mark '{m}' but it could not be parsed: {e!s}" logger.error(msg) raise exceptions.BadSchemaError(msg) from e def _format_test_marks( original_marks: Iterable[str | dict], fmt_vars: Mapping, test_name: str ) -> tuple[list[Mark], list[dict]]: """Given the 'raw' marks from the test and any available format variables, generate new marks for this test Args: original_marks: Raw string from test - should correspond to either a pytest builtin mark or a custom user mark fmt_vars: dictionary containing available format variables test_name: Name of test (for error logging) Returns: first element is normal pytest mark objects, second element is all marks which were formatted (no matter their content) Todo: Fix doctests below - failing due to missing pytest markers Example: # >>> _format_test_marks([], {}, 'abc') # ([], []) # >>> _format_test_marks(['tavernmarker'], {}, 'abc') # (['tavernmarker'], []) # >>> _format_test_marks(['{formatme}'], {'formatme': 'tavernmarker'}, 'abc') # (['tavernmarker'], []) # >>> _format_test_marks([{'skipif': '{skiptest}'}], {'skiptest': true}, 'abc') # (['tavernmarker'], []) """ pytest_marks: list[Mark] = [] formatted_marks: list[dict] = [] for m in original_marks: if isinstance(m, str): # a normal mark if re.match(r"^\w+\(.*\)$", m.strip()): pytest_marks.append(_parse_func_mark(fmt_vars, m)) else: # This is a mark without arguments m = _format_without_inner(m, fmt_vars) pytest_marks.append(getattr(pytest.mark, m)) elif isinstance(m, dict): # skipif or parametrize (for now) for markname, extra_arg in m.items(): # NOTE # cannot do 'skipif' and rely on a parametrized # argument. try: extra_arg = _format_without_inner(extra_arg, fmt_vars) except exceptions.MissingFormatError as e: msg = f"Tried to use mark '{markname}' (with value '{extra_arg}') in test '{test_name}' but one or more format variables was not in any configuration file used by the test" # NOTE # we could continue and let it fail in the test, but # this gives a better indication of what actually # happened (even if it is difficult to test) raise exceptions.MissingFormatError(msg) from e else: if markname != "parametrize": # Handle parametrize marks specially in _generate_parametrized_test_items pytest_marks.append(getattr(pytest.mark, markname)(extra_arg)) formatted_marks.append({markname: extra_arg}) else: raise exceptions.BadSchemaError(f"Unexpected mark type '{type(m)}'") return pytest_marks, formatted_marks def _maybe_load_ext(pair): """Try to load ext values""" key, value = pair if is_ext_function(value): # If it is an ext function, load the new (or supplemental) value[s] ext = value.pop("$ext") f = get_wrapped_create_function(ext) new_value = f() if len(value) == 0: # Use only this new value return key, new_value elif isinstance(new_value, dict): # Merge with some existing data. At this point 'value' is known to be a dict. return key, deep_dict_merge(value, f()) else: # For example, if it's defined like # # - testkey: testval # $ext: # function: mod:func # # and 'mod:func' returns a string, it's impossible to 'merge' with the existing data. logger.error("Values still in 'val': %s", value) raise exceptions.BadSchemaError( f"There were extra key/value pairs in the 'val' for this parametrize mark, but the ext function {ext} returned '{new_value}' (of type {type(new_value)}) that was not a dictionary. It is impossible to merge these values." ) return key, value def _generate_parametrized_test_items( keys: Iterable[Union[str, list, tuple]], vals_combination: Iterable[tuple[str, str]] ) -> tuple[Mapping[str, Any], str]: """Generate test name from given key(s)/value(s) combination Args: keys: list of keys to format name with vals_combination this combination of values for the key Returns: tuple of the variables for the stage and the generated stage name """ flattened_values: list[Iterable[str]] = [] variables: dict[str, Any] = {} # combination of keys and the values they correspond to for pair in zip(keys, vals_combination): key, value = pair # NOTE: If test is invalid, test names generated here will be # very weird looking if isinstance(key, str): variables[key] = value flattened_values.append(value) else: if not isinstance(value, list | tuple): value = [value] if len(value) != len(key): raise exceptions.BadSchemaError( f"Invalid match between numbers of keys and number of values in parametrize mark ({key} keys, {value} values)" ) for subkey, subvalue in zip(key, value): variables[subkey] = subvalue flattened_values.append(subvalue) variables = dict(map(_maybe_load_ext, variables.items())) logger.debug("Variables for this combination: %s", variables) logger.debug("Values for this combination: %s", flattened_values) # Use for formatting parametrized values - eg {}-{}, {}-{}-{}, etc. inner_fmt = "-".join(["{}"] * len(flattened_values)) inner_formatted = inner_fmt.format(*flattened_values) return variables, inner_formatted def _get_parametrized_items( parent: pytest.File, test_spec: dict, parametrize_marks: list[dict], pytest_marks: list[pytest.Mark], ) -> Iterator[YamlItem]: """Return new items with new format values available based on the mark This will change the name from something like 'test a thing' to 'test a thing[param1]', 'test a thing[param2]', etc. This probably messes with -k """ logger.debug("parametrize marks: %s", parametrize_marks) # These should be in the same order as specified in the input file vals = [i["parametrize"]["vals"] for i in parametrize_marks] logger.debug("(possibly wrapped) values: %s", vals) def unwrap_map(value): if is_ext_function(value): ext = value.pop("$ext") f = get_wrapped_create_function(ext) new_value = f() return new_value return value vals = list(map(unwrap_map, vals)) try: combined = itertools.product(*vals) except TypeError as e: raise exceptions.BadSchemaError( "Invalid match between numbers of keys and number of values in parametrize mark" ) from e keys: list[str] = [i["parametrize"]["key"] for i in parametrize_marks] for vals_combination in combined: logger.debug("Generating test for %s/%s", keys, vals_combination) if len(vals_combination) != len(keys): raise exceptions.BadSchemaError( "Invalid match between numbers of keys and number of values in parametrize mark" ) variables, inner_formatted = _generate_parametrized_test_items( keys, vals_combination ) # Change the name spec_new = copy.deepcopy(test_spec) spec_new["test_name"] = test_spec["test_name"] + f"[{inner_formatted}]" logger.debug("New test name: %s", spec_new["test_name"]) # Make this new thing available for formatting spec_new.setdefault("includes", []).append( { "name": f"parametrized[{inner_formatted}]", "description": "autogenerated by Tavern", "variables": variables, } ) # And create the new item item_new = YamlItem.yamlitem_from_parent( spec_new["test_name"], parent, spec_new, parent.path ) item_new.add_markers(pytest_marks) yield item_new class YamlFile(pytest.File): """Custom `File` class that loads each test block as a different test""" def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) # This (and the FakeObj below) are to make pytest-pspec not error out. # The 'docstring' for this is the filename, the 'docstring' for each # individual test is the actual test name. class FakeObj: __doc__ = str(self.path) self.obj = FakeObj def _get_test_fmt_vars(self, test_spec: Mapping) -> dict: """Get any format variables that can be inferred for the test at this point Args: test_spec: Test specification, possibly with included config files Returns: available format variables """ # Get included variables so we can do things like: # skipif: {my_integer} > 2 # skipif: 'https' in '{hostname}' # skipif: '{hostname}'.contains('ignoreme') fmt_vars: dict = {} global_cfg = load_global_cfg(self.config) fmt_vars.update(**global_cfg.variables) included = test_spec.get("includes", []) for i in included: fmt_vars.update(**i.get("variables", {})) if self.session.config.option.collectonly: tavern_box = Box(default_box=True) else: # Needed if something in a config file uses tavern.env_vars tavern_box = get_tavern_box() try: fmt_vars = _format_without_inner(fmt_vars, tavern_box) except exceptions.MissingFormatError as e: # eg, if we have {tavern.env_vars.DOESNT_EXIST} msg = "Tried to use tavern format variable that did not exist" raise exceptions.MissingFormatError(msg) from e tavern_box.merge_update(**fmt_vars) return tavern_box def _generate_items(self, test_spec: dict) -> Iterator[YamlItem]: """Modify or generate tests based on test spec If there are any 'parametrize' marks, this will generate extra tests based on the values Args: test_spec: Test specification Yields: Tavern YAML test """ item = YamlItem.yamlitem_from_parent( test_spec["test_name"], self, test_spec, self.path ) original_marks = test_spec.get("marks", []) if original_marks: fmt_vars = self._get_test_fmt_vars(test_spec) pytest_marks, formatted_marks = _format_test_marks( original_marks, fmt_vars, test_spec["test_name"] ) # Do this after we've added all the other marks so doing # things like selecting on mark names still works even # after parametrization parametrize_marks = [ i for i in formatted_marks if isinstance(i, dict) and "parametrize" in i ] if parametrize_marks: yield from _get_parametrized_items( self, test_spec, parametrize_marks, pytest_marks ) # Only yield the parametrized ones return else: item.add_markers(pytest_marks) yield item def collect(self) -> Iterator[YamlItem]: """Load each document in the given input file into a different test Yields: Pytest 'test objects' """ try: # Convert to a list so we can catch parser exceptions all_tests: Iterable[dict] = list( yaml.load_all( self.path.open(encoding="utf-8"), Loader=IncludeLoader, # type:ignore ) ) except yaml.parser.ParserError as e: raise exceptions.BadSchemaError from e defaults_doc = None for document_idx, test_spec in enumerate(all_tests): if not test_spec: logger.warning("Empty document in input file '%s'", self.path) continue # Check for explicit defaults marker and validate position is_defaults: bool = test_spec.pop("is_defaults", False) if is_defaults and document_idx > 0: raise exceptions.BadSchemaError( f"'is_defaults' can only be used in the first YAML document, " f"but found it in document {document_idx + 1} of '{self.path}'" ) # Determine if this document is a test or defaults is_test_doc = "test_name" in test_spec and "stages" in test_spec if document_idx == 0 and is_defaults: if is_test_doc: raise exceptions.BadSchemaError( f"First document in '{self.path}' is marked as defaults but also contains a 'test_name' and 'stages'" ) # First document is explicitly marked as defaults logger.info( "Using first document as defaults for %s", self.path, ) defaults_doc = test_spec continue elif not is_test_doc: # Document is neither a valid test nor valid defaults if document_idx == 0: raise exceptions.BadSchemaError( f"First document in '{self.path}' is missing 'test_name' or 'stages'. " f"If this is meant to be defaults for the file, add 'is_defaults: true'. " f"If this is meant to be a test, add both 'test_name' and 'stages'." ) else: raise exceptions.BadSchemaError( f"Document {document_idx + 1} in '{self.path}' is missing 'test_name' or 'stages'" ) # Merge defaults into test spec if defaults were defined if defaults_doc: test_spec = deep_dict_merge(defaults_doc, test_spec) try: for i in self._generate_items(test_spec): i.initialise_fixture_attrs() yield i except (TypeError, KeyError) as e: # If there was one of these errors, we can probably figure out # if the error is from a bad test layout by calling verify_tests try: verify_tests(test_spec, with_plugins=False) except Exception as e2: raise e2 from e else: raise tavern-3.6.0/tavern/_core/pytest/hooks.py000066400000000000000000000056431520710011500204320ustar00rootroot00000000000000import logging import logging.config import os import pathlib import re import typing from textwrap import dedent from typing import Optional if typing.TYPE_CHECKING: from .file import YamlFile import pytest import yaml from tavern._core import exceptions from .util import add_ini_options, add_parser_options, get_option_generic if pytest.version_tuple >= (9, 0, 0): def pytest_collect_file(parent, file_path: pathlib.Path) -> Optional["YamlFile"]: # type:ignore return _pytest_collect_file(parent, file_path) else: def pytest_collect_file(parent, path: os.PathLike) -> Optional["YamlFile"]: # type:ignore return _pytest_collect_file(parent, pathlib.Path(path)) def pytest_addoption(parser: pytest.Parser) -> None: add_parser_options(parser.addoption, with_defaults=False) add_ini_options(parser) def _pytest_collect_file(parent, file_path: pathlib.Path) -> Optional["YamlFile"]: """On collecting files, get any files that end in .tavern.yaml or .tavern.yml as tavern test files """ if int(pytest.__version__.split(".", maxsplit=1)[0]) < 7: raise exceptions.TavernException("Only pytest >=7 is supported") try: setup_initial_logging = get_option_generic( parent.config, "tavern-setup-init-logging", False ) except ValueError: pass else: if setup_initial_logging: cfg = dedent( """ --- version: 1 formatters: default: format: "%(asctime)s [%(levelname)s]: (%(name)s:%(lineno)d) %(message)s" style: "%" datefmt: "%X" handlers: stderr: class : logging.StreamHandler level : DEBUG formatter: default stream : ext://sys.stderr loggers: tavern: handlers: - stderr level: DEBUG """ ) settings = yaml.load(cfg, Loader=yaml.SafeLoader) logging.config.dictConfig(settings) pattern = get_option_generic( parent.config, "tavern-file-path-regex", r".+\.tavern\.ya?ml$" ) if isinstance(pattern, list): if len(pattern) != 1: raise exceptions.InvalidConfigurationException( "tavern-file-path-regex must have exactly one option" ) pattern = pattern[0] try: compiled = re.compile(pattern) except Exception as e: raise exceptions.InvalidConfigurationException(e) from e match_tavern_file = compiled.search from .file import YamlFile if match_tavern_file(str(file_path)): return YamlFile.from_parent(parent, path=file_path) return None def pytest_addhooks(pluginmanager) -> None: """Add our custom tavern hooks""" from . import newhooks pluginmanager.add_hookspecs(newhooks) tavern-3.6.0/tavern/_core/pytest/item.py000066400000000000000000000302241520710011500202360ustar00rootroot00000000000000import dataclasses import logging import pathlib from collections.abc import Callable, Iterable, MutableMapping import pytest import yaml from _pytest._code.code import ExceptionInfo, TerminalRepr from _pytest.fixtures import FixtureDef try: from _pytest.fixtures import PseudoFixtureDef except ImportError: PseudoFixtureDef = FixtureDef from _pytest.nodes import Node from _pytest.scope import Scope from pytest import Mark, MarkDecorator from tavern._core import exceptions from tavern._core.loader import error_on_empty_scalar from tavern._core.plugins import load_plugins from tavern._core.pytest import call_hook from tavern._core.pytest.error import ReprdError from tavern._core.report import attach_text from tavern._core.run import run_test from tavern._core.schema.files import verify_tests from tavern._core.stage_lines import start_mark from .config import TestConfig from .util import load_global_cfg logger: logging.Logger = logging.getLogger(__name__) class _TavernFixtureRequest(pytest.FixtureRequest): def __init__(self, pyfuncitem: "YamlItem", *, _ispytest: bool = False) -> None: super().__init__( fixturename=None, pyfuncitem=pyfuncitem, arg2fixturedefs=pyfuncitem._fixtureinfo.name2fixturedefs.copy(), fixture_defs={}, _ispytest=_ispytest, ) def _check_scope( self, requested_fixturedef: FixtureDef[object] | PseudoFixtureDef[object], requested_scope: Scope, ) -> None: pass @property def node(self): return self._pyfuncitem def addfinalizer(self, finalizer: Callable[[], object]) -> None: self.node.addfinalizer(finalizer) @property def _scope(self) -> Scope: return Scope.Function def _fillfixtures(self) -> None: item = self._pyfuncitem for argname in item.fixturenames: if argname not in item.funcargs: item.funcargs[argname] = self.getfixturevalue(argname) class YamlItem(pytest.Item): """Simple wrapper around new test type that can report errors more accurately than the default pytest reporting stuff At the time of writing this doesn't print the error very nicely, but it should be enough to track down what went wrong Attributes: path: filename that this test came from spec: The whole dictionary of the test global_cfg: configuration for test """ # See https://github.com/taverntesting/tavern/issues/825 _patched_yaml = False global_cfg: TestConfig def __init__( self, *, name: str, parent, spec: MutableMapping, path: pathlib.Path, **kwargs ) -> None: if "grpc" in spec: logger.warning("Tavern grpc support is in an experimental stage") super().__init__(name, parent, **kwargs) self.path = path self.spec = spec if not YamlItem._patched_yaml: yaml.parser.Parser.process_empty_scalar = ( # type:ignore error_on_empty_scalar ) YamlItem._patched_yaml = True @classmethod def yamlitem_from_parent(cls, name, parent: Node, spec, path: pathlib.Path): return cls.from_parent(parent, name=name, spec=spec, path=path) def initialise_fixture_attrs(self) -> None: # Prevent pytest from inspecting this item to try and find arguments, # which doesn't work because this isn't a Python function self.funcargs = {} # type: ignore # _get_direct_parametrize_args checks parametrize arguments in Python # functions, but we don't care about that in Tavern. self.session._fixturemanager._get_direct_parametrize_args = ( # type: ignore lambda _: [] # type: ignore ) # type: ignore fixtureinfo = self.session._fixturemanager.getfixtureinfo( self, self.obj, type(self), ) self._fixtureinfo = fixtureinfo self.fixturenames = fixtureinfo.names_closure self._request = _TavernFixtureRequest(self, _ispytest=True) @property def location(self): """get location in file""" location = super().location location = (location[0], start_mark(self.spec).line, location[2]) return location # Hack to stop issue with pytest-rerunfailures _initrequest = initialise_fixture_attrs def setup(self) -> None: super().setup() self._request._fillfixtures() @property def obj(self): stages = [] for i, stage in enumerate(self.spec["stages"]): name = "" if "name" in stage: name = stage["name"] elif "id" in stage: name = stage["id"] stages.append(f"{i + 1:d}: {name:s}") # This needs to be a function or skipif breaks def fakefun(): pass fakefun.__doc__ = self.name + ":\n" + "\n".join(stages) return fakefun @property def _obj(self): return self.obj def add_markers(self, pytest_marks: Iterable[MarkDecorator]) -> None: for pm in pytest_marks: if pm.name == "usefixtures": if not isinstance(pm.mark.args, list | tuple) or len(pm.mark.args) == 0: logger.error( "'usefixtures' was an invalid type (should" " be a list of fixture names)" ) continue # Need to do this here because we expect a list of markers from # usefixtures, which pytest then wraps in a tuple. we need to # extract this tuple so pytest can use both fixtures. if isinstance(pm.mark.args[0], list | tuple): new_mark = Mark( name=pm.mark.name, args=tuple(pm.mark.args[0]), kwargs=pm.mark.kwargs, param_ids_from=pm.mark._param_ids_from, param_ids_generated=pm.mark._param_ids_generated, _ispytest=True, ) pm = dataclasses.replace(pm, mark=new_mark, _ispytest=True) # type:ignore elif isinstance(pm.mark.args[0], dict): # We could raise a TypeError here instead, but then it's a # failure at collection time (which is a bit annoying to # deal with). Instead just don't add the marker and it will # raise an exception at test verification. logger.error( "'usefixtures' was an invalid type (should" " be a list of fixture names)" ) continue self.add_marker(pm) def _load_fixture_values(self): fixture_markers = self.iter_markers("usefixtures") values = {} for m in fixture_markers: if isinstance(m.args, list | tuple): mark_values = {f: self.funcargs[f] for f in m.args} elif isinstance(m.args, str): # Not sure if this can happen if validation is working # correctly, but it appears to be slightly broken so putting # this check here just in case mark_values = {m.args: self.funcargs[m.args]} else: raise exceptions.BadSchemaError( f"Can't handle 'usefixtures' spec of '{m.args}'." " There appears to be a bug in pykwalify so verification of" " 'usefixtures' is broken - it should be a list of fixture" " names" ) if any(mv in values for mv in mark_values): logger.warning("Overriding value for %s", mark_values) values.update(mark_values) # Use autouse fixtures as well for name in self.fixturenames: if name in values: logger.debug("%s already explicitly used", name) continue mark_values = {name: self.funcargs[name]} values.update(mark_values) return values def runtest(self) -> None: self.global_cfg = load_global_cfg(self.config) load_plugins(self.global_cfg) # INTERNAL xfail = self.spec.get("_xfail", False) try: fixture_values = self._load_fixture_values() self.global_cfg.variables.update(fixture_values) call_hook( self.global_cfg, "pytest_tavern_beta_before_every_test_run", test_dict=self.spec, variables=self.global_cfg.variables, ) verify_tests(self.spec) for stage in self.spec["stages"]: if not stage.get("name"): if not stage.get("id"): # Should never actually reach here, should be caught at schema check time raise exceptions.BadSchemaError( "One of name or ID must be specified" ) stage["name"] = stage["id"] run_test(self.path, self.spec, self.global_cfg) except exceptions.BadSchemaError as e: if isinstance(xfail, dict): if msg := xfail.get("verify"): if msg not in str(e): raise Exception( f"error message did not match: expected '{msg}', got '{e!s}'" ) from e logger.info("xfailing test while verifying schema") self.add_marker(pytest.mark.xfail, True) else: logger.warning("internal error checking 'xfail'") elif xfail == "verify": logger.info("xfailing test while verifying schema") self.add_marker(pytest.mark.xfail, True) raise except exceptions.TavernException as e: if isinstance(xfail, dict): if msg := xfail.get("run"): if msg not in str(e): raise Exception( f"error message did not match: expected '{msg}', got '{e!s}'" ) from e logger.info("xfailing test when running") self.add_marker(pytest.mark.xfail, True) else: logger.warning("internal error checking 'xfail'") elif xfail == "run" and not e.is_final: logger.info("xfailing test when running") self.add_marker(pytest.mark.xfail, True) elif xfail == "finally" and e.is_final: logger.info("xfailing test when finalising") self.add_marker(pytest.mark.xfail, True) raise else: if xfail: raise Exception(f"internal: xfail test did not fail '{xfail}'") finally: call_hook( self.global_cfg, "pytest_tavern_beta_after_every_test_run", test_dict=self.spec, variables=self.global_cfg.variables, ) def repr_failure( self, excinfo: ExceptionInfo[BaseException], style: str | None = None ) -> TerminalRepr | str | ReprdError: """called when self.runtest() raises an exception. By default, will raise a custom formatted traceback if it's a tavern error. if not, will use the default python traceback """ if ( self.config.getini("tavern-use-default-traceback") or self.config.getoption("tavern_use_default_traceback") or not issubclass(excinfo.type, exceptions.TavernException) or issubclass(excinfo.type, exceptions.BadSchemaError) ): return super().repr_failure(excinfo) if style is not None: logger.info("Ignoring style '%s", style) error = ReprdError(excinfo, self) attach_text(str(error), name="error_output") return error def reportinfo(self) -> tuple[pathlib.Path, int, str]: return ( self.path, 0, f"{self.path}::{self.name:s}", ) tavern-3.6.0/tavern/_core/pytest/newhooks.py000066400000000000000000000043121520710011500211340ustar00rootroot00000000000000import logging from collections.abc import MutableMapping from typing import Any from tavern._core.pytest.config import TestConfig logger: logging.Logger = logging.getLogger(__name__) def pytest_tavern_beta_before_every_test_run(test_dict: dict, variables: dict) -> None: """Called: - directly after fixtures are loaded for a test - directly before verifying the schema of the file - Before formatting is done on values - After global configuration has been loaded - After plugins have been loaded Modify the test in-place if you want to do something to it. Args: test_dict: Test to run variables: Available variables """ def pytest_tavern_beta_after_every_test_run(test_dict: dict, variables: dict) -> None: """Called: - After test run Args: test_dict: Test to run variables: Available variables """ def pytest_tavern_beta_after_every_response(expected: Any, response: Any) -> None: """Called after every _response_ - including MQTT/HTTP/etc Note: - The response object type and the expected dict depends on what plugin you're using, and which kind of response it is! - MQTT responses will call this hook multiple times if multiple messages are received Args: expected: Response block in stage response: Response object. """ def pytest_tavern_beta_before_every_request(request_args: MutableMapping) -> None: """Called just before every request - including MQTT/HTTP/etc Note: - The request object type depends on what plugin you're using, and which kind of request it is! Args: request_args: Arguments passed to the request function. By default, this is Session.request for HTTP and Client.publish for MQTT """ def call_hook(test_block_config: TestConfig, hookname: str, **kwargs) -> None: """Utility to call the hooks""" try: hook = getattr(test_block_config.tavern_internal.pytest_hook_caller, hookname) except AttributeError: logger.critical("Error getting tavern hook!") raise try: hook(**kwargs) except AttributeError: logger.error("Unexpected error calling tavern hook") raise tavern-3.6.0/tavern/_core/pytest/util.py000066400000000000000000000216171520710011500202630ustar00rootroot00000000000000import logging from functools import lru_cache from pathlib import Path from typing import Any, Optional, TypeVar, Union import pytest from tavern._core import exceptions from tavern._core.dict_util import format_keys, get_tavern_box from tavern._core.general import load_global_config from tavern._core.pytest.config import TavernInternalConfig, TestConfig from tavern._core.strict_util import StrictLevel logger: logging.Logger = logging.getLogger(__name__) def add_parser_options(parser_addoption, with_defaults: bool = True) -> None: """Add argparse options This is shared between the CLI and pytest (for now) See also _core.pytesthook.hooks.pytest_addoption """ parser_addoption( "--tavern-global-cfg", help="One or more global configuration files to include in every test", nargs="+", ) parser_addoption( "--tavern-http-backend", help="Which http backend to use", default="requests" if with_defaults else None, ) parser_addoption( "--tavern-mqtt-backend", help="Which mqtt backend to use", default="paho-mqtt" if with_defaults else None, ) parser_addoption( "--tavern-grpc-backend", help="Which grpc backend to use", default="grpc" if with_defaults else None, ) parser_addoption( "--tavern-graphql-backend", help="Which graphql backend to use", default="gql" if with_defaults else None, ) parser_addoption( "--tavern-strict", help="Default response matching strictness", default=None, nargs="+", ) parser_addoption( "--tavern-use-default-traceback", help="Use normal python-style traceback", default=False, action="store_true", ) parser_addoption( "--tavern-always-follow-redirects", help="Always follow HTTP redirects", default=False, action="store_true", ) parser_addoption( "--tavern-file-path-regex", help="Regex to search for Tavern YAML test files", default=r".+\.tavern\.ya?ml$", action="store", nargs=1, ) parser_addoption( "--tavern-setup-init-logging", help="Set up a simple logger for tavern initialisation. Only for internal use and debugging, may be removed in future with no warning.", default=False, action="store_true", ) parser_addoption( "--tavern-extra-backends", help="list of extra backends to register", default="", type=str, action="store", ) def add_ini_options(parser: pytest.Parser) -> None: """Add an option to pass in a global config file for tavern See also _core.pytesthook._core.util.add_parser_options """ parser.addini( "tavern-global-cfg", help="One or more global configuration files to include in every test", type="linelist", default=[], ) parser.addini( "tavern-http-backend", help="Which http backend to use", default="requests", ) parser.addini( "tavern-mqtt-backend", help="Which mqtt backend to use", default="paho-mqtt", ) parser.addini( "tavern-grpc-backend", help="Which grpc backend to use", default="grpc", ) parser.addini( "tavern-graphql-backend", help="Which graphql backend to use", default="gql", ) parser.addini( "tavern-strict", help="Default response matching strictness", type="args", default=None, ) parser.addini( "tavern-use-default-traceback", help="Use normal python-style traceback", type="bool", default=False, ) parser.addini( "tavern-always-follow-redirects", help="Always follow HTTP redirects", type="bool", default=False, ) parser.addini( "tavern-file-path-regex", help="Regex to search for Tavern YAML test files", default=r".+\.tavern\.ya?ml$", type="args", ) parser.addini( "tavern-setup-init-logging", help="Set up a simple logger for tavern initialisation. Only for internal use and debugging, may be removed in future with no warning.", type="bool", default=False, ) parser.addini( "tavern-extra-backends", help="list of extra backends to register", type="args", default=[], ) def load_global_cfg(pytest_config: pytest.Config) -> TestConfig: return _load_global_cfg(pytest_config).with_new_variables() @lru_cache def _load_global_cfg(pytest_config: pytest.Config) -> TestConfig: """Load globally included config files from cmdline/cfg file arguments Args: pytest_config: Pytest config object Returns: variables/stages/etc from global config files Raises: exceptions.UnexpectedKeysError: Invalid settings in one or more config files detected """ # Load ini first ini_global_cfg_paths = pytest_config.getini("tavern-global-cfg") or [] # THEN load command line, to allow overwriting of values cmdline_global_cfg_paths = pytest_config.getoption("tavern_global_cfg") or [] all_paths = ini_global_cfg_paths + cmdline_global_cfg_paths global_cfg_dict = load_global_config(all_paths) variables: dict = {} try: loaded_variables = global_cfg_dict["variables"] except KeyError: logger.debug("Nothing to format in global config files") else: tavern_box = get_tavern_box() variables = format_keys(loaded_variables, tavern_box) global_cfg = TestConfig( variables=variables, strict=_load_global_strictness(pytest_config), follow_redirects=_load_global_follow_redirects(pytest_config), tavern_internal=TavernInternalConfig( pytest_hook_caller=pytest_config.hook, backends=_load_global_backends(pytest_config), ), stages=global_cfg_dict.get("stages", []), tinctures=global_cfg_dict.get("tinctures"), ) return global_cfg def _load_global_backends(pytest_config: pytest.Config) -> dict[str, Any]: """Load which backend should be used If a plugin option is passed like '--tavern-extra-backends=my_backend' then during plugin loading it will attempt to load whatever the user has registered as the default backend for 'my_backend'. If it's passed like `--tavern-extra-backends=my_backend=my_plugin` then it will override the default backend for 'my_backend' to use 'my_plugin' instead. See 'is_plugin_backend_enabled' in plugins.py for more details """ backends: dict[str, str | None] = { b: get_option_generic(pytest_config, f"tavern-{b}-backend", None) for b in TestConfig.backends() } extra_backends: list[str] = get_option_generic( pytest_config, "tavern-extra-backends", [] ) for backend in extra_backends: split = backend.split("=") if len(split) == 1: backends[split[0]] = None elif len(split) == 2: key, value = split backends[key] = value else: raise exceptions.BadSchemaError( f"extra backends must be in the form 'name' or 'name=value', got '{backend}'" ) return backends def _load_global_strictness(pytest_config: pytest.Config) -> StrictLevel: """Load the global 'strictness' setting""" options: list = get_option_generic(pytest_config, "tavern-strict", []) return StrictLevel.from_options(options) def _load_global_follow_redirects(pytest_config: pytest.Config) -> bool: """Load the global 'follow redirects' setting""" return get_option_generic(pytest_config, "tavern-always-follow-redirects", False) T = TypeVar("T", bound=Optional[Union[str, list, list[Path], list[str], bool]]) def get_option_generic( pytest_config: pytest.Config, flag: str, default: T, ) -> T: """Get a configuration option or return the default Priority order is cmdline, then ini, then default""" cli_flag = flag.replace("-", "_") ini_flag = flag # Lowest priority use = default # Middle priority if ini := pytest_config.getini(ini_flag): if isinstance(default, list): if isinstance(ini, list): use = default[:] # type:ignore use.extend(ini) # type:ignore else: raise ValueError( f"Expected list for {ini_flag} option, got {ini} of type {type(ini)}" ) else: use = ini # Top priority if cli := pytest_config.getoption(cli_flag): if isinstance(default, list): if isinstance(cli, list): cli_list = cli else: cli_list = cli.split(",") use = list(use) # type:ignore use.extend(cli_list) # type:ignore else: use = cli return use tavern-3.6.0/tavern/_core/report.py000066400000000000000000000043111520710011500172610ustar00rootroot00000000000000import logging from textwrap import dedent from typing import Union import yaml try: from allure import attach, step from allure import attachment_type as at yaml_type = at.YAML except ImportError: yaml_type = None def attach(*args, **kwargs) -> None: logger.debug("Not attaching anything as allure is not installed") def step(name): def call(step_func): return step_func return call from tavern._core.formatted_str import FormattedString from tavern._core.stage_lines import get_stage_lines, read_relevant_lines logger: logging.Logger = logging.getLogger(__name__) def prepare_yaml(val: Union[dict, set, list, tuple, str]) -> Union[dict, list, str]: """Sanitises the formatted string into a format safe for dumping""" if isinstance(val, dict): prepared = {} # formatted = {key: format_keys(val[key], box_vars) for key in val} for key in val: if isinstance(key, FormattedString): key = str(key) prepared[key] = prepare_yaml(val[key]) return prepared elif isinstance(val, list | tuple | set): return [prepare_yaml(item) for item in val] elif isinstance(val, FormattedString): return str(val) try: # Check if it's a basic type that yaml can handle yaml.safe_dump(val) except yaml.representer.RepresenterError: # For objects that can't be yaml dumped (e.g. custom auth classes), # return their repr so we can still see what they are return repr(val) return val def attach_stage_content(stage: dict) -> None: first_line, last_line, _ = get_stage_lines(stage) code_lines = list(read_relevant_lines(stage, first_line, last_line)) joined = dedent("\n".join(code_lines)) attach_text(joined, "stage_yaml", yaml_type) def attach_yaml(payload, name: str) -> None: prepared = prepare_yaml(payload) dumped = yaml.safe_dump(prepared) return attach_text(dumped, name, yaml_type) def attach_text(payload, name: str, attachment_type=None) -> None: return attach(payload, name=name, attachment_type=attachment_type) def wrap_step(allure_name: str, partial): return step(allure_name)(partial) tavern-3.6.0/tavern/_core/run.py000066400000000000000000000347411520710011500165640ustar00rootroot00000000000000import copy import dataclasses import functools import logging import pathlib from collections.abc import Mapping, MutableMapping from contextlib import ExitStack from copy import deepcopy from typing import Any import box from tavern._core import exceptions from tavern._core.plugins import ( PluginHelperBase, get_expected, get_extra_sessions, get_request_type, get_verifiers, load_plugins, ) from tavern._core.strict_util import StrictLevel from .dict_util import format_keys, get_tavern_box from .pytest import call_hook from .pytest.config import TestConfig from .report import attach_stage_content, wrap_step from .skip import eval_skip from .strtobool import strtobool from .testhelpers import delay, retry from .tincture import Tinctures, get_stage_tinctures logger: logging.Logger = logging.getLogger(__name__) def _resolve_test_stages( stages: list[Mapping], available_stages: Mapping ) -> list[Mapping]: """Looks for 'ref' stages in the given stages and returns any resolved stages Args: stages: list of stages to possibly replace available_stages: included stages to possibly use in replacement Returns: list of stages that were included, if any """ # Need to get a final list of stages in the tests (resolving refs) test_stages = [] if not isinstance(stages, list): raise exceptions.BadSchemaError("stages should have been a list") for raw_stage in stages: stage = raw_stage if stage.get("type") == "ref": if "id" in stage: ref_id = stage["id"] if ref_id in available_stages: # Make sure nothing downstream can change the globally # defined stage. Just give the test a local copy. stage = deepcopy(available_stages[ref_id]) logger.debug("found stage reference: %s", ref_id) else: logger.error("Bad stage: unknown stage referenced: %s", ref_id) raise exceptions.InvalidStageReferenceError( f"Unknown stage reference: {ref_id}" ) else: logger.error("Bad stage: 'ref' type must specify 'id'") raise exceptions.BadSchemaError("'ref' stage type must specify 'id'") test_stages.append(stage) return test_stages def _get_included_stages( tavern_box: box.Box, test_block_config: TestConfig, test_spec: Mapping, available_stages: list[dict], ) -> list[dict]: """ Get any stages which were included via config files which will be available for use in this test Args: tavern_box: Available parameters for formatting at this point test_block_config: Current test config dictionary test_spec: Specification for current test available_stages: List of stages which already exist Returns: Fully resolved stages """ def stage_ids(s): return [i["id"] for i in s] if test_spec.get("includes"): # Need to do this separately here so there is no confusion between global and included stages for included in test_spec["includes"]: for stage in included.get("stages", {}): if stage["id"] in stage_ids(available_stages): raise exceptions.DuplicateStageDefinitionError( "Stage id '{}' defined in stage-included test which was already defined in global configuration".format( stage["id"] ) ) included_stages = [] # type: ignore for included in test_spec["includes"]: if "variables" in included: formatted_include = format_keys(included["variables"], tavern_box) test_block_config.variables.update(formatted_include) for stage in included.get("stages", []): if stage["id"] in stage_ids(included_stages): raise exceptions.DuplicateStageDefinitionError( "Stage with specified id already defined: {}".format( stage["id"] ) ) included_stages.append(stage) else: included_stages = [] return included_stages def run_test( in_file: pathlib.Path, test_spec: MutableMapping, global_cfg: TestConfig, ) -> None: """Run a single tavern test Note that each tavern test can consist of multiple requests (log in, create, update, delete, etc). The global configuration is copied and used as an initial configuration for this test. Any values which are saved from any tests are saved into this test block and can be used for formatting in later stages in the test. Args: in_file: filename containing this test test_spec: The specification for this test global_cfg: Any global configuration for this test Raises: TavernException: If any of the tests failed """ # Initialise test config for this test with the global configuration before # starting test_block_config = global_cfg.copy() default_global_strictness = global_cfg.strict # Store the test file path for relative path resolution (e.g., file_body) test_block_config = dataclasses.replace( test_block_config, test_file_path=str(in_file) ) tavern_box = get_tavern_box() if not test_spec: logger.warning("Empty test block in %s", in_file) return # Get included stages and resolve any into the test spec dictionary available_stages = test_block_config.stages included_stages = _get_included_stages( tavern_box, test_block_config, test_spec, available_stages ) all_stages = {s["id"]: s for s in available_stages + included_stages} test_spec["stages"] = _resolve_test_stages(test_spec["stages"], all_stages) finally_stages = _resolve_test_stages(test_spec.get("finally", []), all_stages) test_block_config.variables["tavern"] = tavern_box["tavern"] test_block_name = test_spec["test_name"] logger.info("Running test : %s", test_block_name) with ExitStack() as stack: sessions = get_extra_sessions(test_spec, test_block_config) for name, session in sessions.items(): logger.debug("Entering context for %s", name) stack.enter_context(session) def getonly(stage): o = stage.get("only") if o is None: return False elif isinstance(o, bool): return o else: return strtobool(o) has_only = any(getonly(stage) for stage in test_spec["stages"]) runner = _TestRunner( default_global_strictness, sessions, test_block_config, test_spec ) try: # Run tests in a path in order for idx, stage in enumerate(test_spec["stages"]): if content := stage.get("skip"): if content is True: # If it's a literal boolean true or false continue if not isinstance(content, str): raise exceptions.BadSchemaError( f"Unexpected '{type(content)}' in skip key" ) # See if it's a basic string like "true" or "no" first try: if strtobool(content): continue except ValueError: logger.debug( "Not a literal boolean: %s, checking if it can be evaluated", content, ) if eval_skip(content, test_block_config): continue if has_only and not getonly(stage): continue runner.run_stage(idx, stage) if getonly(stage): break finally: if finally_stages: logger.info( "Running finally stages: %s", [s["name"] for s in finally_stages] ) if not isinstance(finally_stages, list): raise exceptions.BadSchemaError( f"finally block should be a list of dicts but was {type(finally_stages)}" ) for idx, stage in enumerate(finally_stages): if not isinstance(stage, dict): raise exceptions.BadSchemaError( f"finally block should be a dict but was {type(stage)}" ) runner.run_stage(idx, stage, is_final=True) else: logger.debug("no 'finally' stages to run") def _calculate_stage_strictness( stage: dict, test_block_config: TestConfig, test_spec: Mapping ) -> StrictLevel: """Figure out the strictness for this stage Can be overridden per stage, or per test Priority is global (see pytest _core.util file) <= test <= stage Todo: Does this actually need to enforce one strictness for all responses? seems like it could be done for each one? Raises: exceptions.DuplicateStrictError: If strictness is set for multiple responses. """ # What to use as the strictness for this stage stage_strictness: str | bool | None = None new_strict = test_block_config.strict if test_spec.get("strict", None) is not None: stage_strictness = test_spec["strict"] logger.debug("Getting test level strict setting: %s", stage_strictness) # Whether the strictness has been set for a particular response, out of possible multiple responses. stage_strictness_set: Any | None = None def update_stage_options(new_option: str) -> str: if stage_strictness_set: raise exceptions.DuplicateStrictError logger.debug("Setting stage level strict setting: %s", new_option) return new_option # Load plugins to get response block names and multiple response support plugins = load_plugins(test_block_config) for p in plugins: response_block_name = p.plugin.response_block_name response_block = stage.get(response_block_name) if response_block is not None: if getattr(p.plugin, "has_multiple_responses", None) and isinstance( response_block, list ): strict_values = [ response["strict"] for response in response_block if response.get("strict", None) is not None ] if len(strict_values) > 1: raise exceptions.DuplicateStrictError if strict_values: stage_strictness_set = stage_strictness = update_stage_options( strict_values[0] ) elif isinstance(response_block, dict): if response_block.get("strict", None) is not None: stage_strictness_set = stage_strictness = update_stage_options( response_block["strict"] ) else: raise exceptions.BadSchemaError( f"{response_block_name} was invalid type {type(response_block)}" ) if stage_strictness is not None: if stage_strictness is True: new_strict = StrictLevel.all_on() elif stage_strictness is False: new_strict = StrictLevel.all_off() else: new_strict = StrictLevel.from_options(stage_strictness) else: logger.debug("Global default strictness used for this stage") logger.debug("Strict key checking for this stage is '%s'", test_block_config.strict) return new_strict @dataclasses.dataclass(frozen=True) class _TestRunner: default_global_strictness: StrictLevel sessions: dict[str, PluginHelperBase] test_block_config: TestConfig test_spec: Mapping def run_stage(self, idx: int, stage, *, is_final: bool = False) -> None: tinctures = get_stage_tinctures(stage, self.test_spec, self.test_block_config) stage_config = self.test_block_config.with_strictness( self.default_global_strictness ) stage_config = stage_config.with_strictness( _calculate_stage_strictness(stage, stage_config, self.test_spec) ) # Wrap run_stage with retry helper run_stage_with_retries = retry(stage, stage_config)(self.wrapped_run_stage) partial = functools.partial( run_stage_with_retries, stage, stage_config, tinctures ) allure_name = "Stage {}: {}".format( idx, format_keys(stage["name"], stage_config.variables) ) step = wrap_step(allure_name, partial) try: step() except exceptions.TavernException as e: e.stage = stage e.test_block_config = stage_config e.is_final = is_final raise def wrapped_run_stage( self, stage: dict, stage_config: TestConfig, tinctures: Tinctures ) -> None: """Run one stage from the test Args: stage: specification of stage to be run stage_config: available variables for test tinctures: tinctures for this stage/test """ stage = copy.deepcopy(stage) name = stage["name"] attach_stage_content(stage) r = get_request_type(stage, stage_config, self.sessions) tavern_box = stage_config.variables["tavern"] tavern_box.update(request_vars=r.request_vars) expected = get_expected(stage, stage_config, self.sessions) delay(stage, "before", stage_config.variables) logger.info("Running stage : %s", name) call_hook( stage_config, "pytest_tavern_beta_before_every_request", request_args=r.request_vars, ) verifiers = get_verifiers(stage, stage_config, self.sessions, expected) tinctures.start_tinctures(stage) response = r.run() tinctures.end_tinctures(expected, response) for response_type, response_verifiers in verifiers.items(): logger.debug("Running verifiers for %s", response_type) for v in response_verifiers: saved = v.verify(response) stage_config.variables.update(saved) tavern_box.pop("request_vars") delay(stage, "after", stage_config.variables) tavern-3.6.0/tavern/_core/schema/000077500000000000000000000000001520710011500166355ustar00rootroot00000000000000tavern-3.6.0/tavern/_core/schema/__init__.py000066400000000000000000000000001520710011500207340ustar00rootroot00000000000000tavern-3.6.0/tavern/_core/schema/extensions.py000066400000000000000000000360501520710011500214120ustar00rootroot00000000000000import os import re from collections.abc import Callable, Mapping from typing import TYPE_CHECKING, Any, Union from pykwalify.types import is_bool, is_float, is_int from tavern._core import exceptions from tavern._core.exceptions import BadSchemaError from tavern._core.extfunctions import ( get_pykwalify_logger, import_ext_function, is_ext_function, ) from tavern._core.general import valid_http_methods from tavern._core.loader import ( ApproxScalar, BoolToken, FloatToken, IntToken, TypeConvertToken, ) from tavern._core.strict_util import StrictLevel if TYPE_CHECKING: from tavern._plugins.grpc.response import GRPCCode # To extend pykwalify's type validation, extend its internal functions # These return boolean values def validate_type_and_token( validate_type: Callable[[Any], bool], token: type[TypeConvertToken] ): def validate(value): return validate_type(value) or isinstance(value, token) return validate is_int_like = validate_type_and_token(is_int, IntToken) is_float_like = validate_type_and_token(is_float, FloatToken) is_bool_like = validate_type_and_token(is_bool, BoolToken) # These plug into the pykwalify extension function API def validator_like(validate: Callable[[Any], bool], description: str): def validator(value, rule_obj, path): if validate(value): return True else: err_msg = f"expected '{description}' type at '{path}', got '{value}'" raise BadSchemaError(err_msg) return validator int_variable = validator_like(is_int_like, "int-like") float_variable = validator_like(is_float_like, "float-like") bool_variable = validator_like(is_bool_like, "bool-like") def _validate_one_extension(input_value: Mapping) -> None: expected_keys = {"function", "extra_args", "extra_kwargs"} extra = set(input_value) - expected_keys if extra: raise BadSchemaError(f"Unexpected keys passed to $ext: {extra}") if "function" not in input_value: raise BadSchemaError("No function specified for validation") try: import_ext_function(input_value["function"]) except Exception as e: raise BadSchemaError("Couldn't load {}".format(input_value["function"])) from e extra_args = input_value.get("extra_args") extra_kwargs = input_value.get("extra_kwargs") if extra_args and not isinstance(extra_args, list): raise BadSchemaError(f"Expected a list of extra_args, got {type(extra_args)}") if extra_kwargs and not isinstance(extra_kwargs, dict): raise BadSchemaError(f"Expected a dict of extra_kwargs, got {type(extra_args)}") def validate_extensions(value, rule_obj, path) -> bool: """Given a specification for calling a validation function, make sure that the arguments are valid (ie, function is valid, arguments are of the correct type...) Arguments/return values are sort of pykwalify internals (this function is only called from pykwalify) so not listed Todo: Because this is loaded by pykwalify as a file, we need some kind of entry point to set up logging. Or just fork pykwalify and fix the various issues in it. We should also check the function signature using the `inspect` module Raises: BadSchemaError: Something in the validation function spec was wrong """ if isinstance(value, list): for vf in value: _validate_one_extension(vf) elif isinstance(value, dict): _validate_one_extension(value) return True def validate_status_code_is_int_or_list_of_ints(value: Mapping, rule_obj, path) -> bool: err_msg = f"status_code has to be an integer or a list of integers (got {value})" if not isinstance(value, list) and not is_int_like(value): raise BadSchemaError(err_msg) if isinstance(value, list): if not all(is_int_like(i) for i in value): raise BadSchemaError(err_msg) return True def check_usefixtures(value: Mapping, rule_obj, path) -> bool: err_msg = "'usefixtures' has to be a list with at least one item" if not isinstance(value, list | tuple): raise BadSchemaError(err_msg) if not value: raise BadSchemaError(err_msg) return True def validate_grpc_status_is_valid_or_list_of_names( value: "GRPCCode", rule_obj, path ) -> bool: """Validate GRPC statuses https://github.com/grpc/grpc/blob/master/doc/statuscodes.md""" # pylint: disable=unused-argument err_msg = f"status has to be an valid grpc status code, name, or list (got {value})" if isinstance(value, str | int): if not to_grpc_status(value): raise BadSchemaError(err_msg) elif isinstance(value, list): if not all(to_grpc_status(i) for i in value): raise BadSchemaError(err_msg) else: raise BadSchemaError(err_msg) return True def to_grpc_status(value: str | int): from grpc import StatusCode if isinstance(value, str): value = value.upper() for status in StatusCode: if status.name == value: return status.name elif isinstance(value, int): for status in StatusCode: if status.value[0] == value: return status.name return None def verify_oneof_id_name(value: Mapping, rule_obj, path) -> bool: """Checks that if 'name' is not present, 'id' is""" if not (name := value.get("name")): if name == "": raise BadSchemaError("Name cannot be empty") if not value.get("id"): raise BadSchemaError("If 'name' is not specified, 'id' must be specified") return True def check_parametrize_marks(value, rule_obj, path) -> bool: key_or_keys = value["key"] vals = value["vals"] # At this point we can assume vals is a list - check anyway if not (isinstance(vals, list) or is_ext_function(vals)): raise BadSchemaError("'vals' should be a list") if isinstance(key_or_keys, str): # Vals can be anything return True elif isinstance(key_or_keys, list): err_msg = "If 'key' is a list, 'vals' must be a list of lists where each list is the same length as 'key'" # Checking for whether the ext function actually returns the correct # values has to be deferred until the point where the function is # actually called if not is_ext_function(vals): # broken example: # - parametrize: # key: # - edible # - fruit # vals: # a: b if not isinstance(vals, list): raise BadSchemaError(err_msg) # example: # - parametrize: # key: # - edible # - fruit # vals: # - [rotten, apple] # - [fresh, orange] # - [unripe, pear] for v in vals: if not isinstance(v, list): # This catches the case like # # - parametrize: # key: # - edible # - fruit # vals: # - fresh # - orange # # This will parametrize 'edible' as [f, r, e, s, h] which is almost certainly not desired raise BadSchemaError(err_msg) if len(v) != len(key_or_keys): # If the 'vals' list has more or less keys raise BadSchemaError(err_msg) else: raise BadSchemaError("'key' must be a string or a list") return True def validate_data_key(value, rule_obj, path: str) -> bool: """Validate the 'data' key in a http request From requests docs: > data - (optional) Dictionary or list of tuples [(key, value)] (will be > form-encoded), bytes, or file-like object to send in the body of the > Request. We could handle lists of tuples, but it seems entirely pointless to maintain compatibility for something which is more verbose and does the same thing """ if isinstance(value, dict): # Fine pass elif isinstance(value, str | bytes): # Also fine - might want to do checking on this for encoding etc? pass elif isinstance(value, list): raise BadSchemaError(f"Error at {path} - expected a dict, str, or !!binary") else: raise BadSchemaError(f"Error at {path} - expected a dict, str, or !!binary") return True def validate_request_json(value, rule_obj, path) -> bool: """Performs the above match, but also matches a dict or a list. This it just because it seems like you can't match a dict OR a list in pykwalify """ def nested_values(d): if isinstance(d, dict): for v in d.values(): if isinstance(v, dict): yield from v.values() else: yield v else: yield d if any(isinstance(i, ApproxScalar) for i in nested_values(value)): # If this is a request data block if not re.search(r"^/stages/\d/(response/json|mqtt_response/json)", path): raise BadSchemaError( f"Error at {path} - Cannot use a '!approx' in anything other than an expected http response body or mqtt response json" ) return True def validate_json_with_ext(value, rule_obj, path) -> bool: """Validate json with extensions""" validate_request_json(value, rule_obj, path) if isinstance(value, dict): maybe_ext_val = value.get("$ext", None) if isinstance(maybe_ext_val, dict): validate_extensions(maybe_ext_val, rule_obj, path) elif maybe_ext_val is not None: raise BadSchemaError(f"Unexpected $ext key in block at {path}") return True def check_strict_key(value: Union[list, bool], rule_obj, path) -> bool: """Make sure the 'strict' key is either a bool or a list""" if not isinstance(value, list) and not is_bool_like(value): raise BadSchemaError("'strict' has to be either a boolean or a list") elif isinstance(value, list): try: # Reuse validation here StrictLevel.from_options(value) except exceptions.InvalidConfigurationException as e: raise BadSchemaError from e # Might be a bool as well, in which case it's processed further down the line - no validation required return True def validate_timeout_tuple_or_float(value: Union[list, tuple], rule_obj, path) -> bool: """Make sure timeout is a float/int or a tuple of floats/ints""" err_msg = f"'timeout' must be either a float/int or a 2-tuple of floats/ints - got '{value}' (type {type(value)})" logger = get_pykwalify_logger("tavern.schemas.extensions") def check_is_timeout_val(v): if v is True or v is False or not (is_float_like(v) or is_int_like(v)): logger.debug("'timeout' value not a float/int") raise BadSchemaError(err_msg) if isinstance(value, list | tuple): if len(value) != 2: raise BadSchemaError(err_msg) for v in value: check_is_timeout_val(v) else: check_is_timeout_val(value) return True def validate_verify_bool_or_str(value: bool | str, rule_obj, path) -> bool: """Make sure the 'verify' key is either a bool or a str""" if not isinstance(value, bool | str) and not is_bool_like(value): raise BadSchemaError( "'verify' has to be either a boolean or the path to a CA_BUNDLE file or directory with certificates of trusted CAs" ) return True def validate_cert_tuple_or_str(value, rule_obj, path) -> bool: """Make sure the 'cert' key is either a str or tuple""" err_msg = ( "The 'cert' key must be the path to a single file (containing the private key and the certificate) " "or as a tuple of both files" ) if not isinstance(value, str | tuple | list): raise BadSchemaError(err_msg) if isinstance(value, list | tuple): if len(value) != 2: raise BadSchemaError(err_msg) elif not all(isinstance(i, str) for i in value): raise BadSchemaError(err_msg) return True def validate_file_spec(value: dict, rule_obj, path) -> bool: """Validate file upload arguments""" logger = get_pykwalify_logger("tavern.schema.extensions") if not isinstance(value, dict): raise BadSchemaError( f"File specification must be a mapping of file names to file specs, got {value}" ) if value.get("file_path"): # If the file spec was a list, this function will be called for each item. Just call this # function recursively to check each item. return validate_file_spec({"file": value}, rule_obj, path) for _, filespec in value.items(): if isinstance(filespec, str): file_path = filespec elif isinstance(filespec, dict): valid = {"file_path", "content_type", "content_encoding", "form_field_name"} extra = set(filespec.keys()) - valid if extra: raise BadSchemaError( f"Invalid extra keys passed to file upload block: {extra}" ) try: file_path = filespec["file_path"] except KeyError as e: raise BadSchemaError( "When using 'long form' file upload spec, the file_path must be present" ) from e else: raise BadSchemaError( "File specification must be a file path or a dictionary" ) if not os.path.exists(file_path): if re.search(".*{.+}.*", file_path): logger.debug( "Could not find file path, but it might be a format variable, so continuing" ) else: raise BadSchemaError( f"Path to file to upload '{file_path}' was not found" ) return True def raise_body_error(value, rule_obj, path): """Raise an error about the deprecated 'body' key""" msg = "The 'body' key has been replaced with 'json' in 1.0 to make it more in line with other blocks. see https://github.com/taverntesting/tavern/issues/495 for details." raise BadSchemaError(msg) def retry_variable(value: int, rule_obj, path) -> bool: """Check retry variables""" int_variable(value, rule_obj, path) if isinstance(value, int): if value < 0: raise BadSchemaError("max_retries must be greater than 0") return True def validate_http_method(value: str, rule_obj, path) -> bool: """Check http method""" if not isinstance(value, str): raise BadSchemaError("HTTP method should be a string") if value not in valid_http_methods: logger = get_pykwalify_logger("tavern.schemas.extensions") logger.debug( "Givern HTTP method '%s' was not one of %s - assuming it will be templated", value, valid_http_methods, ) return True tavern-3.6.0/tavern/_core/schema/files.py000066400000000000000000000111301520710011500203050ustar00rootroot00000000000000import contextlib import copy import logging import os import tempfile from collections.abc import Mapping import box import pykwalify import yaml from pykwalify import core from tavern._core.exceptions import BadSchemaError from tavern._core.loader import load_single_document_yaml from tavern._core.plugins import load_plugins from tavern._core.schema.jsonschema import verify_jsonschema logger: logging.Logger = logging.getLogger(__name__) class SchemaCache: """Caches loaded schemas""" def __init__(self) -> None: self._loaded: dict[str, dict] = {} def _load_base_schema(self, schema_filename): try: return self._loaded[schema_filename] except KeyError: self._loaded[schema_filename] = load_single_document_yaml(schema_filename) logger.debug("Loaded schema from %s", schema_filename) return self._loaded[schema_filename] def _load_schema_with_plugins(self, schema_filename: str) -> dict: mangled = f"{schema_filename}-plugins" try: return self._loaded[mangled] except KeyError: pass plugins = load_plugins() base_schema = copy.deepcopy(self._load_base_schema(schema_filename)) logger.debug("Adding plugins to schema: %s", [p.name for p in plugins]) for p in plugins: try: plugin_schema = p.plugin.schema except AttributeError: # Don't require a schema logger.debug("No schema defined for %s", p.name) else: for key in ["properties", "definitions"]: if key not in plugin_schema: continue if key not in base_schema: base_schema[key] = box.Box() value = box.Box(plugin_schema[key]) value.merge_update(base_schema[key]) base_schema[key] = value self._loaded[mangled] = base_schema return self._loaded[mangled] def __call__(self, schema_filename: str, with_plugins: bool): """Load the schema file and cache it for future use Args: schema_filename: filename of schema with_plugins: Whether to load plugin schema into this schema as well Returns: loaded schema """ if with_plugins: schema = self._load_schema_with_plugins(schema_filename) else: schema = self._load_base_schema(schema_filename) return schema load_schema_file = SchemaCache() def verify_pykwalify(to_verify, schema) -> None: """Verify a generic file against a given pykwalify schema Args: to_verify: Filename of source tests to check schema: Schema to verify against Raises: BadSchemaError: Schema did not match """ logger.debug("Verifying %s against %s", to_verify, schema) here = os.path.dirname(os.path.abspath(__file__)) extension_module_filename = os.path.join(here, "extensions.py") verifier = core.Core( source_data=to_verify, schema_data=schema, extensions=[extension_module_filename], ) try: verifier.validate() except pykwalify.errors.PyKwalifyException as e: logger.exception("Error validating %s", to_verify) raise BadSchemaError() from e @contextlib.contextmanager def wrapfile(to_wrap): """Wrap a dictionary into a temporary yaml file Args: to_wrap: Dictionary to write to temporary file Yields: filename: name of temporary file object that will be destroyed at the end of the context manager """ with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as wrapped_tmp: # put into a file dumped = yaml.dump(to_wrap, default_flow_style=False) wrapped_tmp.write(dumped.encode("utf8")) wrapped_tmp.close() try: yield wrapped_tmp.name finally: os.remove(wrapped_tmp.name) def verify_tests(test_spec: Mapping, with_plugins: bool = True) -> None: """Verify that a specific test block is correct Todo: Load schema file once. Requires some caching of the file Args: test_spec: Test in dictionary form with_plugins: Whether to load plugin schema into this schema as well Raises: BadSchemaError: Schema did not match """ here = os.path.dirname(os.path.abspath(__file__)) schema_filename = os.path.join(here, "tests.jsonschema.yaml") schema = load_schema_file(schema_filename, with_plugins) verify_jsonschema(test_spec, schema) tavern-3.6.0/tavern/_core/schema/jsonschema.py000066400000000000000000000160411520710011500213430ustar00rootroot00000000000000import logging import re from collections.abc import Mapping import jsonschema from jsonschema import Draft7Validator, ValidationError from jsonschema.validators import extend from tavern._core import exceptions from tavern._core.dict_util import recurse_access_key from tavern._core.exceptions import BadSchemaError from tavern._core.loader import ( AnythingSentinel, BoolToken, FloatToken, IntToken, RawStrToken, TypeConvertToken, TypeSentinel, ) from tavern._core.pytest.config import has_module from tavern._core.schema.extensions import ( check_parametrize_marks, check_strict_key, retry_variable, validate_file_spec, validate_grpc_status_is_valid_or_list_of_names, validate_http_method, validate_json_with_ext, validate_request_json, ) from tavern._core.stage_lines import ( get_stage_filename, get_stage_lines, read_relevant_lines, ) logger: logging.Logger = logging.getLogger(__name__) def is_str_or_bytes_or_token(checker, instance): return Draft7Validator.TYPE_CHECKER.is_type(instance, "string") or isinstance( instance, bytes | RawStrToken | AnythingSentinel ) def is_number_or_token(checker, instance): return Draft7Validator.TYPE_CHECKER.is_type(instance, "number") or isinstance( instance, IntToken | FloatToken | AnythingSentinel ) def is_integer_or_token(checker, instance): return Draft7Validator.TYPE_CHECKER.is_type(instance, "integer") or isinstance( instance, IntToken | AnythingSentinel ) def is_boolean_or_token(checker, instance): return Draft7Validator.TYPE_CHECKER.is_type(instance, "boolean") or isinstance( instance, BoolToken | AnythingSentinel ) def is_object_or_sentinel(checker, instance): return ( Draft7Validator.TYPE_CHECKER.is_type(instance, "object") or isinstance(instance, TypeSentinel | TypeConvertToken) or instance is None ) def oneOf(validator: Draft7Validator, oneOf, instance, schema): """Patched version of 'oneof' that does not complain if something is matched by multiple branches""" subschemas = enumerate(oneOf) all_errors = [] for index, subschema in subschemas: errs = list(validator.descend(instance, subschema, schema_path=index)) if not errs: first_valid = subschema break all_errors.extend(errs) else: yield ValidationError( f"{instance!r} is not valid under any of the given schemas", context=all_errors, ) more_valid = [ s for i, s in subschemas if validator.evolve(schema=s).is_valid(instance) ] if more_valid: more_valid.append(first_valid) reprs = ", ".join(repr(schema) for schema in more_valid) logger.debug("%r is valid under each of %s", instance, reprs) CustomValidator = extend( Draft7Validator, type_checker=Draft7Validator.TYPE_CHECKER.redefine("object", is_object_or_sentinel) .redefine("string", is_str_or_bytes_or_token) .redefine("boolean", is_boolean_or_token) .redefine("integer", is_integer_or_token) .redefine("number", is_number_or_token), validators={ "oneOf": oneOf, }, ) def verify_jsonschema(to_verify: Mapping, schema: Mapping) -> None: """Verify a generic file against a given jsonschema Args: to_verify: Test in dictionary form schema: Schema to verify against Raises: BadSchemaError: Schema did not match """ validator = CustomValidator(schema) if "grpc" in to_verify and not has_module("grpc"): raise exceptions.BadSchemaError( "Tried to use grpc connection string, but grpc was not installed. Reinstall Tavern with the grpc extra like `pip install tavern[grpc]`" ) if "mqtt" in to_verify and not has_module("paho.mqtt"): raise exceptions.BadSchemaError( "Tried to use mqtt connection string, but mqtt was not installed. Reinstall Tavern with the mqtt extra like `pip install tavern[mqtt]`" ) try: validator.validate(to_verify) except jsonschema.ValidationError as e: real_context = [] # ignore these strings because they're red herrings for c in e.context: description = c.schema.get("description", "") if description == "Reference to another stage from an included config file": continue instance = c.instance filename = get_stage_filename(instance) if filename is None: # Depending on what block raised the error, it mightbe difficult to tell what it was, so check the parent too instance = e.instance filename = get_stage_filename(instance) if filename: with open(filename, encoding="utf-8") as infile: n_lines = len(infile.readlines()) first_line, last_line, _ = get_stage_lines(instance) first_line = max(first_line - 2, 0) last_line = min(last_line + 2, n_lines) reg = re.compile(r"^\s*$") lines = read_relevant_lines(instance, first_line, last_line) lines = [line for line in lines if not reg.match(line.strip())] content = "\n".join(list(lines)) real_context.append( f""" {c.message} {filename}: line {first_line}-{last_line}: {content} """ ) else: real_context.append( f""" {c.message} """ ) logger.debug("original exception from jsonschema: %s", e) msg = "\n---\n" + "\n---\n".join([str(i) for i in real_context]) raise BadSchemaError(msg) from None extra_checks = { "stages[*].mqtt_publish.json[]": validate_request_json, "stages[*].mqtt_response.payload[]": validate_request_json, "stages[*].request.json[]": validate_request_json, "stages[*].request.data[]": validate_request_json, "stages[*].request.params[]": validate_request_json, "stages[*].request.headers[]": validate_request_json, "stages[*].grpc_response.status[]": validate_grpc_status_is_valid_or_list_of_names, "stages[*].request.method[]": validate_http_method, "stages[*].request.save[]": validate_json_with_ext, "stages[*].request.files[]": validate_file_spec, "stages[*].graphql_request.files[]": validate_file_spec, "marks[*].parametrize[]": check_parametrize_marks, "stages[*].response.strict[]": validate_json_with_ext, "stages[*].max_retries[]": retry_variable, "strict": check_strict_key, } for path, func in extra_checks.items(): data = recurse_access_key(to_verify, path) if data: if path.endswith("[]"): if not isinstance(data, list): raise BadSchemaError for element in data: func(element, None, path) else: func(data, None, path) tavern-3.6.0/tavern/_core/schema/tests.jsonschema.yaml000066400000000000000000000106001520710011500230110ustar00rootroot00000000000000$schema: "http://json-schema.org/draft-07/schema#" $id: "https://raw.githubusercontent.com/taverntesting/tavern/master/tavern/schemas/tests.jsonschema.yaml" title: Tavern description: "Schema for Tavern test files" ### definitions: strict_block: oneOf: - type: string - type: boolean - type: array items: type: string verify_block: type: object additionalProperties: false required: - function properties: function: type: string description: Path to function in the form import.path:name extra_args: type: array extra_kwargs: type: object any_json: oneOf: - type: array - type: object - type: number - type: string - type: boolean included_file: type: object additionalProperties: false properties: tinctures: type: array description: Tinctures for stage items: $ref: "#/definitions/verify_block" name: type: string description: Name for this included file description: type: string description: Extra description for included file variables: type: object description: Variables to use in tests stages: type: array description: Stages to reference from tests items: $ref: "#/definitions/stage" stage_ref: type: object description: Reference to another stage from an included config file additionalProperties: false required: - type - id properties: type: type: string pattern: ^ref$ id: type: string stage: type: object description: One stage in a test additionalProperties: false required: - name properties: tinctures: type: array description: Tinctures for stage items: $ref: "#/definitions/verify_block" id: type: string description: ID of stage for use in stage references max_retries: type: integer description: Number of times to retry this request default: 0 skip: oneOf: - type: boolean description: Whether to skip this stage default: false - type: string description: CEL expression saying whether to skip this stage only: type: boolean description: Only run this stage default: false delay_before: type: number description: How long to delay before running stage delay_after: type: number description: How long to delay after running stage name: type: string description: Name of this stage ### type: object additionalProperties: false properties: test_name: type: string description: Name of test is_defaults: type: boolean description: Whether this document contains default values to be merged with subsequent test documents default: false _xfail: oneOf: - type: string enum: - verify - run - finally - type: object properties: verify: type: string run: type: string marks: type: array description: Pytest marks to use on test items: anyOf: - type: string - type: object additionalProperties: false properties: filterwarnings: type: string skipif: type: string usefixtures: type: array items: type: string parametrize: type: object required: - key - vals tinctures: type: array description: Tinctures for whole test items: $ref: "#/definitions/verify_block" strict: $ref: "#/definitions/strict_block" includes: type: array minItems: 1 items: $ref: "#/definitions/included_file" stages: type: array description: Stages in test minItems: 1 items: oneOf: - $ref: "#/definitions/stage" - $ref: "#/definitions/stage_ref" finally: type: array description: Stages to run after test finishes items: oneOf: - $ref: "#/definitions/stage" - $ref: "#/definitions/stage_ref" tavern-3.6.0/tavern/_core/schema/tests.schema.yaml000066400000000000000000000173671520710011500221400ustar00rootroot00000000000000--- name: Test schema desc: Matches test blocks # http://www.kuwata-lab.com/kwalify/ruby/users-guide.01.html # https://pykwalify.readthedocs.io/en/unstable/validation-rules.html schema;any_request_json: func: validate_request_json type: any required: false schema;any_json_with_ext: func: validate_json_with_ext type: any required: false schema;any_map: func: validate_request_json type: map required: false mapping: re;(.*): type: any schema;stage: type: map required: true func: verify_oneof_id_name mapping: tinctures: func: validate_extensions type: any id: type: str required: false unique: true name: type: str required: true unique: true max_retries: type: any func: retry_variable required: false unique: true skip: type: any func: bool_variable required: false only: type: any func: bool_variable required: false delay_before: type: any func: float_variable required: false delay_after: type: any func: float_variable required: false mqtt_publish: type: map required: false mapping: topic: type: str required: true payload: # Only a string type: str required: false json: include: any_json_with_ext qos: type: any func: int_variable required: false retain: type: any func: bool_variable required: false mqtt_response: type: map required: false mapping: unexpected: type: bool required: false topic: type: str required: true payload: type: any required: false json: include: any_json_with_ext timeout: type: any func: float_variable required: false qos: type: any func: int_variable required: false enum: - 0 - 1 - 2 verify_response_with: func: validate_extensions type: any save: include: any_json_with_ext mapping: json: type: any grpc_request: type: map required: false mapping: host: type: str required: false service: type: str required: true body: include: any_json_with_ext retain: type: any func: bool_variable required: false grpc_response: type: map required: false mapping: status: type: any func: validate_grpc_status_is_valid_or_list_of_names details: type: any required: false body: type: any required: false json: include: any_json_with_ext required: false timeout: type: any func: float_variable required: false verify_response_with: func: validate_extensions type: any save: include: any_json_with_ext mapping: json: type: any request: type: map required: false mapping: url: type: str required: true follow_redirects: type: bool required: false re;(params|headers): include: any_map data: type: any func: validate_data_key required: false stream: type: any func: bool_variable required: false auth: func: validate_json_with_ext type: seq required: false sequence: - type: str cookies: type: seq required: false sequence: - type: str - type: map mapping: re;(.*): type: str json: include: any_json_with_ext body: type: any func: raise_body_error files: required: false type: map mapping: re;(.*): type: any func: validate_file_spec file_body: type: str required: false method: type: str func: validate_http_method timeout: type: any required: false func: validate_timeout_tuple_or_float cert: func: validate_cert_tuple_or_str type: any required: false verify: type: any func: validate_verify_bool_or_str required: false clear_session_cookies: type: any func: bool_variable required: false response: type: map required: false mapping: strict: func: check_strict_key type: any status_code: type: any func: validate_status_code_is_int_or_list_of_ints cookies: type: seq required: false sequence: - type: str unique: true re;(headers|redirect_query_params): include: any_map json: include: any_json_with_ext save: include: any_json_with_ext mapping: re;(\$ext|json|headers|redirect_query_params): type: any verify_response_with: func: validate_extensions type: any schema;stage_ref: type: map required: true mapping: type: required: true type: str pattern: ^ref$ id: required: true type: str type: map mapping: test_name: required: true type: str marks: type: seq matching: "any" sequence: - type: str # bug? in pykwalify - this doesn't work # unique: true - type: map mapping: filterwarnings: type: str skipif: type: str usefixtures: type: seq # Depending on what is actually given for usefixtures this function # may or may not be called. I think this is a bug in pykwalify. # See pytesthook.py func: check_usefixtures sequence: - type: str required: true parametrize: type: map func: check_parametrize_marks mapping: key: type: any required: true vals: type: seq required: true sequence: - type: str - type: bool - type: int - type: float - type: seq sequence: - type: any - include: any_map _xfail: type: str enum: - verify - run - finally strict: func: check_strict_key type: any includes: required: false type: seq sequence: - type: map required: false mapping: name: required: true type: str description: required: true type: str variables: type: map required: false mapping: re;(.*): type: any stages: type: seq required: false sequence: - include: stage stages: type: seq required: true sequence: - include: stage - include: stage_ref tavern-3.6.0/tavern/_core/skip.py000066400000000000000000000030421520710011500167140ustar00rootroot00000000000000import logging import simpleeval from tavern._core import exceptions from tavern._core.dict_util import format_keys from tavern._core.pytest.config import TestConfig logger: logging.Logger = logging.getLogger(__name__) functions = simpleeval.DEFAULT_FUNCTIONS.copy() functions["len"] = len def eval_skip(content: str, test_block_config: TestConfig) -> bool: """Run a simpleeval expression to determine if a test should be skipped. Args: content: The unformatted simpleeval string to evaluate test_block_config: Configuration containing variables to use in simpleeval evaluation Returns: Result of simpleeval evaluation Raises: exceptions.BadSchemaError: If simpleeval expression is invalid """ formatted = format_keys(content, test_block_config.variables) logger.debug("simpleeval expression to evaluate: %s", formatted) try: result = simpleeval.simple_eval( formatted, names=test_block_config.variables, functions=functions ) except simpleeval.NameNotDefined as e: raise exceptions.EvalError("Undefined variable used in program") from e except SyntaxError as e: raise exceptions.EvalError("Error evaluating program") from e except TypeError as e: raise exceptions.EvalError("Error running program") from e if not isinstance(result, bool | type(None)): raise exceptions.EvalError( f"'skip' program did not evaluate to True/False (got {result} of type {type(result)})", ) return bool(result) tavern-3.6.0/tavern/_core/stage_lines.py000066400000000000000000000032071520710011500202460ustar00rootroot00000000000000import dataclasses import logging from collections.abc import Iterable, Mapping from typing import ( Protocol, Union, ) logger: logging.Logger = logging.getLogger(__name__) @dataclasses.dataclass class YamlMark: """A pyyaml mark""" line: int = 0 name: str | None = None class _WithMarks(Protocol): """Things loaded by pyyaml have these""" start_mark: YamlMark end_mark: YamlMark PyYamlDict = Union[_WithMarks, Mapping] def get_stage_lines(stage: PyYamlDict) -> tuple[int, int, int]: first_line = start_mark(stage).line - 1 last_line = end_mark(stage).line line_start = first_line + 1 return first_line, last_line, line_start def read_relevant_lines( yaml_block: PyYamlDict, first_line: int, last_line: int ) -> Iterable[str]: """Get lines between start and end mark""" filename = get_stage_filename(yaml_block) if filename is None: logger.warning("unable to read yaml block") return with open(filename, encoding="utf8") as testfile: for idx, line in enumerate(testfile.readlines()): if first_line < idx < last_line: yield line.split("#", 1)[0].rstrip() def get_stage_filename(yaml_block: PyYamlDict) -> str | None: return start_mark(yaml_block).name def start_mark(yaml_block: PyYamlDict) -> Union[type[YamlMark], YamlMark]: try: return yaml_block.start_mark # type:ignore except AttributeError: return YamlMark() def end_mark(yaml_block: PyYamlDict) -> Union[type[YamlMark], YamlMark]: try: return yaml_block.end_mark # type:ignore except AttributeError: return YamlMark() tavern-3.6.0/tavern/_core/strict_util.py000066400000000000000000000135531520710011500203230ustar00rootroot00000000000000import dataclasses import enum import logging import re from typing import Union from tavern._core import exceptions from tavern._core.strtobool import strtobool logger: logging.Logger = logging.getLogger(__name__) class StrictSetting(enum.Enum): """The actual setting for a particular block""" ON = 1 OFF = 2 UNSET = 3 LIST_ANY_ORDER = 4 valid_keys = ["json", "headers", "redirect_query_params", "text"] valid_switches = ["on", "off", "list_any_order"] def strict_setting_factory(str_setting: str | None) -> StrictSetting: """Converts from cmdline/setting file to an enum""" if str_setting is None: return StrictSetting.UNSET else: if str_setting == "list_any_order": return StrictSetting.LIST_ANY_ORDER parsed = strtobool(str_setting) if parsed: return StrictSetting.ON else: return StrictSetting.OFF @dataclasses.dataclass(frozen=True) class StrictOption: """The section and the setting. The setting is only stored here because json works slightly differently, otherwise it's redundant""" section: str setting: StrictSetting def is_on(self) -> bool: if self.section == "json": # Must be specifically disabled for response body return self.setting not in [StrictSetting.OFF, StrictSetting.LIST_ANY_ORDER] else: # Off by default for everything else return self.setting in [StrictSetting.ON] def validate_and_parse_option(key: str) -> StrictOption: """Parse and validate a strict option configuration string. Args: key: String in format "section[:setting]" where: section: One of "json", "headers", or "redirect_query_params" setting: Optional "on", "off" or "list_any_order" Returns: StrictOption containing the parsed section and setting Raises: InvalidConfigurationException: If the key format is invalid """ regex = re.compile( r""" (?P
{sections}) # The section name (json/headers/redirect_query_params) (?: # Optional non-capturing group for setting : # Literal colon separator (?P{switches}) # The setting value (on/off/list_any_order) )? # End optional group """.format(sections="|".join(valid_keys), switches="|".join(valid_switches)), re.X, ) match = regex.fullmatch(key) if not match: raise exceptions.InvalidConfigurationException( "Invalid value for 'strict' given - expected one of {}, got '{}'".format( [f"{key}[:on/off]" for key in valid_keys], key ) ) as_dict = match.groupdict() if as_dict["section"] != "json" and as_dict["setting"] == "list_any_order": logger.warning( "Using 'list_any_order' key outside of 'json' section has no meaning" ) return StrictOption(as_dict["section"], strict_setting_factory(as_dict["setting"])) @dataclasses.dataclass(frozen=True) class StrictLevel: """Strictness settings for every block in a response TODO: change the name of this class, it's awful""" json: StrictOption = dataclasses.field( default=StrictOption("json", strict_setting_factory(None)) ) headers: StrictOption = dataclasses.field( default=StrictOption("headers", strict_setting_factory(None)) ) redirect_query_params: StrictOption = dataclasses.field( default=StrictOption("redirect_query_params", strict_setting_factory(None)) ) text: StrictOption = dataclasses.field( default=StrictOption("text", strict_setting_factory(None)) ) @classmethod def from_options(cls, options: Union[list[str], str]) -> "StrictLevel": if isinstance(options, str): options = [options] elif not isinstance(options, list): raise exceptions.InvalidConfigurationException( "'strict' setting should be a list of strings" ) logger.debug("Parsing options to strict level: %s", options) parsed = [validate_and_parse_option(key) for key in options] return cls(**{i.section: i for i in parsed}) def option_for(self, section: str) -> StrictOption: """Provides a string-based way of getting strict settings for a section""" try: return getattr(self, section) except AttributeError as e: raise exceptions.InvalidConfigurationException( f"No setting for '{section}'" ) from e @classmethod def all_on(cls) -> "StrictLevel": return cls.from_options([i + ":on" for i in valid_keys]) @classmethod def all_off(cls) -> "StrictLevel": return cls.from_options([i + ":off" for i in valid_keys]) StrictSettingKinds = Union[None, bool, StrictSetting, StrictOption] def extract_strict_setting(strict: StrictSettingKinds) -> tuple[bool, StrictSetting]: """Takes either a bool, StrictOption, or a StrictSetting and return the bool representation and StrictSetting representation""" logger.debug("Parsing a '%s': %s", type(strict), strict) if isinstance(strict, StrictSetting): strict_setting = strict strict = strict == StrictSetting.ON elif isinstance(strict, StrictOption): strict_setting = strict.setting strict = strict.is_on() elif isinstance(strict, bool): strict_setting = strict_setting_factory(str(strict)) elif strict is None: strict = False strict_setting = strict_setting_factory("false") else: raise exceptions.InvalidConfigurationException( f"Unable to parse strict setting '{strict}' of type '{type(strict)}'" ) logger.debug("Got strict as '%s', setting as '%s'", strict, strict_setting) return strict, strict_setting tavern-3.6.0/tavern/_core/strtobool.py000066400000000000000000000005741520710011500200040ustar00rootroot00000000000000def strtobool(val: str) -> bool: """Copied and slightly modified from distutils as it's being removed in a future version of Python""" val = val.lower() if val in ("y", "yes", "t", "true", "on", "1"): return True elif val in ("n", "no", "f", "false", "off", "0"): return False else: raise ValueError(f"invalid truth value {val!r}") tavern-3.6.0/tavern/_core/testhelpers.py000066400000000000000000000076351520710011500203240ustar00rootroot00000000000000import logging import time from collections.abc import Callable, Mapping from functools import wraps from tavern._core import exceptions from tavern._core.dict_util import format_keys from tavern._core.pytest.config import TestConfig logger: logging.Logger = logging.getLogger(__name__) def delay(stage: Mapping, when: str, variables: Mapping) -> None: """Look for delay_before/delay_after and sleep Args: stage: test stage when: 'before' or 'after' variables: Variables to format with """ try: length = format_keys(stage[f"delay_{when}"], variables) except KeyError: pass else: logger.debug("Delaying %s request for %.2f seconds", when, length) time.sleep(length) def retry(stage: Mapping, test_block_config: TestConfig) -> Callable: """Look for retry and try to repeat the stage `retry` times. Args: stage: test stage test_block_config: Configuration for current test """ if r := stage.get("max_retries", None): max_retries = maybe_format_max_retries(r, test_block_config) else: max_retries = 0 if max_retries == 0: def catch_wrapper(fn): @wraps(fn) def wrapped(*args, **kwargs): res = fn(*args, **kwargs) logger.debug("Stage '%s' succeeded.", stage["name"]) return res return wrapped return catch_wrapper else: def retry_wrapper(fn): @wraps(fn) def wrapped(*args, **kwargs): i = 0 res = None for i in range(max_retries + 1): try: res = fn(*args, **kwargs) except exceptions.BadSchemaError: raise except exceptions.TavernException as e: if i < max_retries: logger.info( "Stage '%s' failed for %i time. Retrying.", stage["name"], i + 1, ) delay(stage, "after", test_block_config.variables) else: logger.error( "Stage '%s' did not succeed in %i retries.", stage["name"], max_retries, ) if isinstance(e, exceptions.TestFailError): raise else: raise exceptions.TestFailError( "Test '{}' failed: stage did not succeed in {} retries.".format( stage["name"], max_retries ) ) from e else: break logger.debug("Stage '%s' succeed after %i retries.", stage["name"], i) return res return wrapped return retry_wrapper def maybe_format_max_retries( max_retries: str | int, test_block_config: TestConfig ) -> int: """Possibly handle max_retries validation""" # Probably a format variable, or just invalid (in which case it will fail further down) max_retries = int(format_keys(max_retries, test_block_config.variables)) # type:ignore # Missing type token will mean that max_retries is still a string and will fail here # Could auto convert here as well, but keep it consistent and just fail if not isinstance(max_retries, int): raise exceptions.InvalidRetryException( f"Invalid type for max_retries - was {type(max_retries)}" ) if max_retries < 0: raise exceptions.InvalidRetryException("max_retries must be greater than 0") return max_retries tavern-3.6.0/tavern/_core/tincture.py000066400000000000000000000064721520710011500176150ustar00rootroot00000000000000import collections.abc import dataclasses import inspect import logging from collections.abc import Generator from typing import TYPE_CHECKING, Any from tavern._core import exceptions from tavern._core.extfunctions import get_wrapped_response_function if TYPE_CHECKING: from tavern._core.pytest.config import TestConfig logger: logging.Logger = logging.getLogger(__name__) @dataclasses.dataclass class Tinctures: tinctures: list[Any] needs_response: list[Generator[None, tuple[Any, Any], None]] = dataclasses.field( default_factory=list ) def start_tinctures(self, stage: collections.abc.Mapping) -> None: results = [t(stage) for t in self.tinctures] for r in results: if inspect.isgenerator(r): # Store generator and start it self.needs_response.append(r) next(r) def end_tinctures(self, expected: collections.abc.Mapping, response) -> None: """ Send the response object to any tinctures that want it Args: expected: 'expected' from initial test - type varies depending on backend response: The response from 'run' for the stage - type varies depending on backend Raises: exceptions.TinctureError: If a tincture yields more than once """ if self.needs_response is None: raise RuntimeError( "should not be called before accumulating tinctures which need a response" ) for n in self.needs_response: try: n.send((expected, response)) except StopIteration: pass else: raise exceptions.TinctureError("Tincture had more than one yield") def get_stage_tinctures( stage: collections.abc.Mapping, test_spec: collections.abc.Mapping, global_cfg: "TestConfig | None" = None, ) -> Tinctures: """Get all tinctures for a stage, including global tinctures. Tinctures are collected and run in order: test-level, stage-level, global. Args: stage: Stage test_spec: Whole test spec global_cfg: Global configuration (optional) Returns: Tinctures: All tinctures for the stage, including global tinctures """ stage_tinctures = [] def add_tinctures_from_block(maybe_tinctures, blockname: str): logger.debug("Trying to add tinctures from %s", blockname) def inner_yield(): if maybe_tinctures is not None: if isinstance(maybe_tinctures, list): for vf in maybe_tinctures: yield get_wrapped_response_function(vf) elif isinstance(maybe_tinctures, dict): yield get_wrapped_response_function(maybe_tinctures) else: raise exceptions.BadSchemaError( f"Badly formatted 'tinctures' block in {blockname}" ) stage_tinctures.extend(inner_yield()) add_tinctures_from_block(test_spec.get("tinctures"), "test") add_tinctures_from_block(stage.get("tinctures"), "stage") if global_cfg is not None: add_tinctures_from_block(global_cfg.tinctures, "global") logger.debug("%d tinctures for stage %s", len(stage_tinctures), stage["name"]) return Tinctures(stage_tinctures) tavern-3.6.0/tavern/_plugins/000077500000000000000000000000001520710011500161265ustar00rootroot00000000000000tavern-3.6.0/tavern/_plugins/__init__.py000066400000000000000000000000001520710011500202250ustar00rootroot00000000000000tavern-3.6.0/tavern/_plugins/common/000077500000000000000000000000001520710011500174165ustar00rootroot00000000000000tavern-3.6.0/tavern/_plugins/common/response.py000066400000000000000000000123011520710011500216230ustar00rootroot00000000000000import contextlib import logging from collections.abc import Mapping from typing import Any, Optional, Protocol from requests.status_codes import _codes # type:ignore from tavern._core import exceptions from tavern._core.dict_util import deep_dict_merge from tavern._core.pytest.config import TestConfig from tavern.response import BaseResponse logger: logging.Logger = logging.getLogger(__name__) class ResponseLike(Protocol): """Protocol for response-like objects""" headers: Mapping[str, str] @property def text(self) -> str: ... def json(self) -> Any: ... class CommonResponse(BaseResponse): """Common response verification functionality shared by REST and GraphQL""" def __init__( self, session, name: str, expected: dict[str, Any], test_block_config: TestConfig, default_status_code: int = 200, multiple_responses_block: str | None = None, ) -> None: defaults = {"status_code": default_status_code} super().__init__( name, deep_dict_merge(defaults, expected), test_block_config, multiple_responses_block=multiple_responses_block, ) def check_code(code: int) -> None: if int(code) not in _codes: logger.warning("Unexpected status code '%s'", code) in_file = self.expected["status_code"] try: if isinstance(in_file, list): for code_ in in_file: check_code(code_) else: check_code(in_file) except TypeError as e: raise exceptions.BadSchemaError("Invalid code") from e def __str__(self) -> str: if self.response: return self.response.text.strip() else: return "" def _verbose_log_response(self, response: ResponseLike) -> None: """Verbosely log the response object, with query params etc.""" logger.info("Response: '%s'", response) def log_dict_block(block, name): if block: to_log = name + ":" if isinstance(block, list): for v in block: to_log += f"\n - {v}" elif isinstance(block, dict): for k, v in block.items(): to_log += f"\n {k}: {v}" else: to_log += f"\n {block}" logger.debug(to_log) if hasattr(response, "headers"): log_dict_block(response.headers, "Headers") with contextlib.suppress(ValueError): log_dict_block(response.json(), "Body") def _validate_block( self, blockname: str, block: Mapping, read_from: Optional[dict] = None ) -> None: """Validate a block of the response Args: blockname: which part of the response is being checked block: The actual part being checked read_from: The block to read from, or self.expected if not specified """ if read_from is None: read_from = self.expected try: expected_block = read_from[blockname] except KeyError: expected_block = None if isinstance(expected_block, dict): if expected_block.pop("$ext", None): raise exceptions.MisplacedExtBlockException( blockname, ) if blockname == "headers" and expected_block is not None: # Special case for headers. These need to be checked in a case # insensitive manner block = {i.lower(): j for i, j in block.items()} expected_block = {i.lower(): j for i, j in expected_block.items()} logger.debug("Validating response '%s' against %s", blockname, expected_block) test_strictness = self.test_block_config.strict if blockname == "data": block_strictness = test_strictness.option_for("json") else: block_strictness = test_strictness.option_for(blockname) self.recurse_check_key_match(expected_block, block, blockname, block_strictness) def _validate_text(self, response_text: str) -> None: """Validate response body as plain text Args: response_text: The actual response text (response.text) """ expected_text = self.expected.get("text") if expected_text is None: return logger.debug("Validating response text against expected text") test_strictness = self.test_block_config.strict block_strictness = test_strictness.option_for("text") self.recurse_check_key_match( expected_text, response_text, "text", block_strictness ) def _common_verify_save( self, body: Any, response: ResponseLike, ) -> dict: """Common save functionality""" saved: dict = {} logger.debug(f"Saving response to variables with {body} and {self.expected}") if body is not None: saved.update(self.maybe_get_save_values_from_save_block("json", body)) saved.update(self.maybe_get_save_values_from_ext(response, self.expected)) return saved tavern-3.6.0/tavern/_plugins/graphql/000077500000000000000000000000001520710011500175645ustar00rootroot00000000000000tavern-3.6.0/tavern/_plugins/graphql/__init__.py000066400000000000000000000000631520710011500216740ustar00rootroot00000000000000"""GraphQL plugin for Tavern testing framework.""" tavern-3.6.0/tavern/_plugins/graphql/client.py000066400000000000000000000262201520710011500214160ustar00rootroot00000000000000import asyncio import json import logging from collections.abc import AsyncGenerator from dataclasses import dataclass, field from typing import Any, Optional, Union from gql import Client, gql from gql.transport.aiohttp import AIOHTTPTransport from gql.transport.exceptions import TransportQueryError, TransportServerError from gql.transport.websockets import WebsocketsTransport from graphql import ExecutionResult from tavern._core.asyncio import ThreadedAsyncLoop from tavern._plugins.common.response import ResponseLike logger: logging.Logger = logging.getLogger(__name__) _SubResponse = ( AsyncGenerator[dict[str, Any], None] | AsyncGenerator[ExecutionResult, None] ) """The type of a response from a subscription.""" ResultOrErr = Union[ExecutionResult, TransportQueryError] """The type returned from a gql query""" class ClientCacheKey: """Cache key for GraphQL clients. Uses a tuple of sorted header items to ensure hashability. """ url: str headers: tuple[tuple[str, str], ...] timeout: int def __init__(self, url: str, headers: dict[str, str], timeout: int): """Initialize ClientCacheKey with conversion of headers dict to tuple. Args: url: The URL for the transport headers: Headers dictionary timeout: Timeout in seconds """ # Convert headers dict to sorted tuple for hashability self.url = url self.headers = tuple(sorted(headers.items())) self.timeout = timeout def __hash__(self) -> int: """Make ClientCacheKey hashable.""" return hash((self.url, self.headers, self.timeout)) def __eq__(self, other: object) -> bool: """Compare two ClientCacheKey instances.""" if not isinstance(other, ClientCacheKey): return NotImplemented return ( self.url == other.url and self.headers == other.headers and self.timeout == other.timeout ) def __repr__(self) -> str: """String representation of ClientCacheKey.""" return f"ClientCacheKey(url={self.url!r}, headers={self.headers!r}, timeout={self.timeout})" @dataclass(kw_only=True) class GraphQLResponseLike(ResponseLike): """A response-like object implementing the ResponseLike protocol for GraphQL responses""" result: ResultOrErr headers: dict[str, str] = field(default_factory=dict) @property def text(self) -> str: """Return the JSON serialized representation of the GraphQL result data. Returns: A JSON string representation of the result data. """ if not self.result: raise ValueError("No GraphQL result to return") return json.dumps(self.result.data) def json(self) -> Any: """Parse and return the JSON content of the response""" return self.result.data class GraphQLClient: """GraphQL client for HTTP requests and subscriptions over WebSocket""" _subscriptions: dict[str, _SubResponse] """Dictionary of active subscriptions, keyed by subscription name.""" _to_close: list[AsyncGenerator] """List of subscription generators to close when the client is closed.""" _gql_client_cache: dict[ClientCacheKey, Client] """Cache of GraphQL clients to avoid creating new ones for the same URL.""" _transport_cache: dict[ClientCacheKey, AIOHTTPTransport] """Cache of HTTP transports associated with each client.""" def __init__(self, **kwargs): """Initialize the GraphQL client.""" self.default_headers = kwargs.get("headers", {}) self.timeout = kwargs.get("timeout", 30) self._subscriptions = {} self._to_close = [] self._gql_client_cache = {} self._transport_cache = {} self._threaded_async_loop = ThreadedAsyncLoop() def __enter__(self): """Enter the context manager. Returns: The GraphQLClient instance. """ self._threaded_async_loop.__enter__() return self def __exit__(self, exc_type, exc_val, exc_tb): """Exit the context manager and close WebSocket connections.""" async def _close_subscriptions(): """Close all active subscription generators.""" await asyncio.gather(*(s.aclose() for s in self._subscriptions.values())) await asyncio.gather(*(s.aclose() for s in self._to_close)) await asyncio.gather(*(s.close() for s in self._transport_cache.values())) try: # Schedule the closing of subscriptions in the event loop and # wait for the closing operations to complete with a timeout self._threaded_async_loop.run_coroutine(_close_subscriptions(), timeout=5.0) except TimeoutError: logger.warning("Timed out waiting for subscriptions to close") # Join the thread with a timeout to prevent indefinite blocking self._threaded_async_loop.__exit__(exc_type, exc_val, exc_tb) def make_request( self, url: str, query: str, variables: Optional[dict[str, Any]] = None, operation_name: Optional[str] = None, headers: Optional[dict] = None, has_files: bool = False, ) -> ResponseLike: """Execute GraphQL query/mutation over HTTP using gql. Args: url: The GraphQL endpoint URL. query: The GraphQL query string. variables: Optional variables for the query. operation_name: Optional name of the operation to execute. headers: any headers to send with the request has_files: whether the request contains files Returns: A GraphQLResponseLike object containing the query result. Raises: Exception: If the request fails or returns errors. """ headers = headers or {} headers = dict(self.default_headers, **headers) # Create a cache key for the client client_key = ClientCacheKey( url=url, headers=headers, timeout=self.timeout, ) # Get or create the GraphQL client http_client = self._gql_client_cache.get(client_key) if http_client is None: transport = AIOHTTPTransport( url=url, headers=headers, timeout=self.timeout, ) http_client = Client(transport=transport) self._gql_client_cache[client_key] = http_client self._transport_cache[client_key] = transport logger.debug(f"Created new GraphQL client for {url}") else: logger.debug(f"Reusing cached GraphQL client for {url}") query_gql = gql(query) query_gql.variable_values = variables or {} query_gql.operation_name = operation_name try: result: ExecutionResult = http_client.execute( query_gql, get_execution_result=True, upload_files=has_files, ) except (TransportQueryError, TransportServerError) as e: logger.debug(f"GraphQL request failed: {e}", exc_info=True) raise return GraphQLResponseLike(result=result) def start_subscription( self, url: str, query: str, variables: dict, operation_name: str ) -> None: """Start a GraphQL subscription over WS using gql WebSockets transport. Args: url: The GraphQL WebSocket endpoint URL. query: The GraphQL subscription query string. variables: Variables for the subscription query. operation_name: Name of the subscription operation. Raises: ValueError: If operation_name is not provided. Exception: If the subscription fails to start. """ if operation_name is None: raise ValueError("operation_name required for subscriptions") if operation_name in self._subscriptions: raise ValueError( f"Subscription with name '{operation_name}' already exists" ) # Prepare headers headers = dict(self.default_headers) # Create WebSocket transport ws_url = url.replace("http://", "ws://").replace("https://", "wss://") ws_transport = WebsocketsTransport( url=ws_url, headers=headers, connect_timeout=self.timeout, ) # Create client with WebSocket transport ws_client = Client(transport=ws_transport) # Parse the GraphQL query query_gql = gql(query) query_gql.variable_values = variables or {} query_gql.operation_name = operation_name # Execute the subscription - this returns a generator try: async def subscribe_async_wrapper() -> AsyncGenerator: """Wrapper for subscribe_async because that method does not close subscriptions properly.""" async with ws_client as session: generator = session.subscribe(query_gql) self._to_close.append(generator) async for result in generator: yield result # Using the subscription as a generator that yields results # Run the async subscription in the event loop async def _create_subscription(): self._subscriptions[operation_name] = subscribe_async_wrapper() self._threaded_async_loop.run_coroutine( _create_subscription(), timeout=5.0, ) logger.debug(f"Started subscription {operation_name}") except Exception as e: logger.error(f"Failed to start subscription: {e}") raise async def get_next_message( self, op_name: str, timeout: float = 5.0 ) -> Optional[_SubResponse]: """Get next message from subscription generator. Args: op_name: The name of the subscription operation. timeout: Timeout in seconds to wait for the next message. Returns: The next message from the subscription, or None if the subscription ended. Raises: ValueError: If the subscription name is not found. TimeoutError: If the timeout is reached while waiting. Exception: If an error occurs while getting the next message. """ if op_name not in self._subscriptions: raise ValueError( f"Subscription with name '{op_name}' not found (have: {self._subscriptions.keys()} )" ) subscription_generator = self._subscriptions[op_name] # Get the next item from the async iterator try: return await asyncio.wait_for( anext(subscription_generator), timeout=timeout ) except StopAsyncIteration: logger.error( f"got unexpected StopAsyncIteration from subscription {op_name}" ) return None except TimeoutError as e: logger.error(f"Timeout getting next message from subscription: {e}") raise except Exception as e: logger.error(f"Error getting next message from subscription: {e}") raise tavern-3.6.0/tavern/_plugins/graphql/jsonschema.yaml000066400000000000000000000053271520710011500226110ustar00rootroot00000000000000$schema: "http://json-schema.org/draft-07/schema#" title: GraphQL schema description: Schema for GraphQL API testing ### type: object additionalProperties: false definitions: graphql_request: type: object description: GraphQL request to perform as part of stage additionalProperties: false required: - url - query properties: url: description: URL to make GraphQL request to oneOf: - type: string - type: object properties: "$ext": $ref: "#/definitions/verify_block" query: type: string description: GraphQL query string variables: description: Variables for GraphQL query oneOf: - type: object - type: array - type: string - type: number - type: boolean files: type: object description: Files to send as part of the request. Mapping of graphql variable name to file path, or to 'long form' files headers: description: Headers for GraphQL request type: object operation_name: description: Operation name for GraphQL request type: string graphql_response: type: object description: Expected GraphQL response additionalProperties: false properties: data: description: The data returned by the GraphQL query type: object errors: description: Errors returned by the GraphQL query type: array items: type: object verify_response_with: oneOf: - $ref: "#/definitions/verify_block" - type: array items: $ref: "#/definitions/verify_block" save: type: object description: Which objects to save from response subscription: description: Name of subscription that the response is expected for type: string timeout: type: number description: Request timeout in seconds default: 5 stage: properties: graphql_request: $ref: "#/definitions/graphql_request" graphql_response: oneOf: - $ref: "#/definitions/graphql_response" - type: array items: $ref: "#/definitions/graphql_response" properties: gql: description: Arguments to pass to the GQL HTTP transport constructor type: object additionalProperties: false properties: timeout: type: number description: Request timeout in seconds default: 30 headers: type: object description: Default headers to include in all requests additionalProperties: type: string tavern-3.6.0/tavern/_plugins/graphql/request.py000066400000000000000000000147421520710011500216360ustar00rootroot00000000000000import logging from contextlib import ExitStack from functools import cached_property import box import gql.transport.exceptions from gql import FileVar from tavern._core import exceptions from tavern._core.dict_util import deep_dict_merge, format_keys from tavern._core.files import guess_filespec from tavern._core.formatted_str import FormattedString from tavern._core.pytest.config import TestConfig from tavern.request import BaseRequest from .client import GraphQLClient, GraphQLResponseLike logger: logging.Logger = logging.getLogger(__name__) def get_file_arguments(file_args: dict, test_block_config: TestConfig) -> dict: """Get correct arguments for anything that should be passed as a file to gql Args: file_args: dict of files to upload test_block_config: config for test Returns: mapping of 'files' block to pass directly to gql """ # Note: Not actually using the opened files gql_file_vars = {} with ExitStack() as stack: for var_name, file_path_or_long_format in file_args.items(): file_spec, form_field_name, resolved_file_path = guess_filespec( file_path_or_long_format, stack, test_block_config ) if form_field_name is None: form_field_name = var_name if form_field_name in gql_file_vars: raise exceptions.BadSchemaError( f"Cannot upload multiple files with the same name '{form_field_name}'" ) gql_file_var = FileVar( f=resolved_file_path, filename=form_field_name, content_type=file_spec.content_type, ) gql_file_vars[var_name] = gql_file_var return gql_file_vars def _format_graphql_request(rspec: dict, variables: dict) -> dict: """Format a GraphQL request spec, excluding the query field from formatting. GraphQL queries contain curly braces which are mistakenly interpreted as format placeholders by the standard format_keys function. This function formats all fields except the query field to preserve the GraphQL syntax. Args: rspec: Request specification dictionary variables: Variables to format with Returns: Formatted request specification with query field unchanged """ formatted_rspec = {} for key, value in rspec.items(): if key == "query": # Skip formatting for GraphQL queries to preserve { } syntax formatted_rspec[key] = FormattedString(value) else: # Format all other fields normally formatted_rspec[key] = format_keys(value, variables) return formatted_rspec class GraphQLRequest(BaseRequest): """GraphQL request implementation""" def __init__( self, session: GraphQLClient, rspec: dict, test_block_config: TestConfig ): self.session = session self.rspec = rspec self.test_block_config = test_block_config # Format request spec with test variables, excluding query from formatting self._formatted_rspec = _format_graphql_request( rspec, test_block_config.variables ) # Validate required fields self._validate_request() def _validate_request(self): """Validate GraphQL request structure""" if "query" not in self._formatted_rspec: raise exceptions.MissingKeysError( "GraphQL request must contain 'query' field" ) if "url" not in self._formatted_rspec: raise exceptions.MissingKeysError( "GraphQL request must contain 'url' field" ) if self.is_subscription_query and "operation_name" not in self._formatted_rspec: raise exceptions.MissingKeysError( "operation_name is required for subscription requests" ) @property def request_vars(self) -> box.Box: """Variables used in the request""" return box.Box( { "url": self._formatted_rspec["url"], "query": self._formatted_rspec["query"], "variables": self._formatted_rspec.get("variables", {}), "operation_name": self._formatted_rspec.get("operation_name"), "headers": self._formatted_rspec.get("headers", {}), } ) def run(self): """Execute GraphQL request""" try: url = str(self._formatted_rspec["url"]) query = str(self._formatted_rspec["query"]) variables = self._formatted_rspec.get("variables", {}) or {} operation_name = self._formatted_rspec.get("operation_name") headers = {} files = self._formatted_rspec.get("files") if files: variables.update(get_file_arguments(files, self.test_block_config)) else: headers["Content-Type"] = "application/json" logger.debug(f"graphql variables: {variables}") headers = deep_dict_merge(headers, self._formatted_rspec.get("headers", {})) if self.is_subscription_query: self.session.start_subscription(url, query, variables, operation_name) fake_resp = GraphQLResponseLike(result=None) logger.debug( "Subscription '%s' started, fake 101 response", operation_name ) return fake_resp # Execute regular GraphQL query/mutation response = self.session.make_request( url=url, query=query, variables=variables, operation_name=operation_name, headers=headers, has_files=files is not None, ) logger.debug("GraphQL response: %s", response.text) return response except gql.transport.exceptions.TransportQueryError as e: logger.debug("graphql error while making request: %s", e) return GraphQLResponseLike(result=e) except Exception as e: logger.exception("Error executing GraphQL request") raise exceptions.TavernException(f"GraphQL request failed: {e}") from e @cached_property def is_subscription_query(self) -> bool: """Check if the query is a subscription""" query = self._formatted_rspec.get("query", "").strip() # Simple check for subscription keyword at the start of the query return query.lower().startswith("subscription") tavern-3.6.0/tavern/_plugins/graphql/response.py000066400000000000000000000251411520710011500217770ustar00rootroot00000000000000import logging from typing import Any from box.box import Box from tavern._core import exceptions from tavern._core.pytest import call_hook from tavern._core.report import attach_yaml from tavern._plugins.common.response import CommonResponse from tavern._plugins.graphql.client import ( GraphQLClient, GraphQLResponseLike, ResultOrErr, ) logger: logging.Logger = logging.getLogger(__name__) class GraphQLResponse(CommonResponse): """GraphQL response verification for HTTP and WS""" session: GraphQLClient def __init__( self, session, name: str, expected: dict, test_block_config, ): """Initialize GraphQL response validator. Args: session: GraphQL client instance name: Name of the test block expected: Expected response configuration test_block_config: Test block configuration """ self.session = session sync_responses = 0 for resp in expected.get("graphql_responses", []): if resp.get("subscription") is None: sync_responses += 1 if sync_responses > 1: raise exceptions.BadSchemaError( "Only one graphql_response can be synchronous" ) expected["save"] = expected.get("save", {}) for e in expected.get("graphql_responses", []): save_block: dict if save_block := e.get("save", {}): if not isinstance(save_block, dict): raise exceptions.BadSchemaError( "save block for graphql_response must be a dict" ) for to_save in save_block: if to_save in expected["save"]: raise exceptions.BadSchemaError( f"save block for graphql_response cannot contain duplicate keys: {to_save}" ) expected["save"].update(save_block) super().__init__( session, name, expected, test_block_config, multiple_responses_block="graphql_responses", ) def _validate_graphql_response_structure(self, body: Any) -> None: """Validate GraphQL response structure: data or errors top-level Checks if the response body contains valid GraphQL response structure with either 'data' or 'errors' keys at the top level. Args: body: Response body to validate """ if body is None: self._adderr("GraphQL response body missing") return if not isinstance(body, dict): self._adderr("GraphQL body not dict: %r", body) return allowed = {"data", "errors"} if not allowed & set(body): self._adderr( "Response must contain either 'data' or 'errors' at the top level" ) if set(body) - allowed: self._adderr( "Invalid GraphQL top-level keys: %s. Only 'data' and 'errors' are allowed", set(body) - allowed, ) def verify(self, response: GraphQLResponseLike) -> dict: """Verify response against expected values and returns any values that we wanted to save for use in future requests. Args: response: The GraphQL response to verify Returns: Dictionary of saved values from the response Raises: TestFailError: If verification fails with collected errors """ graphql_responses = self.expected.get("graphql_responses", []) sync_responses_list: list[dict] = [ resp for resp in graphql_responses if "subscription" not in resp ] # Process synchronous responses (will actually be length 1) saved = {} for expected_resp in sync_responses_list: saved.update(self._check_sync_response(expected_resp, response)) sub_responses_list = [ resp for resp in graphql_responses if "subscription" in resp ] # Process subscription responses if sub_responses_list: try: to_save: dict = self.session._threaded_async_loop.run_coroutine( self._handle_subscription_responses(sub_responses_list), timeout=30.0, ) except TimeoutError as e: self._adderr(f"Timed out waiting for subscription responses: {e}") else: saved.update(to_save) if self.errors: raise exceptions.TestFailError( f"Test '{self.name:s}' failed:\n{self._str_errors():s}", failures=self.errors, ) return saved async def _handle_subscription_responses( self, sub_responses_list: list[dict] ) -> dict: """Handle subscription responses concurrently. Processes subscription-based GraphQL responses by waiting for messages on each subscription and validating them against expected responses. Args: sub_responses_list: List of subscription response configurations Returns: Dictionary of saved values from subscription responses """ async def get_subscription_results( expected_resp, ) -> ResultOrErr | None: """Get subscription message result for an expected response. Waits for the next message on a subscription operation and returns the result. Args: expected_resp: Expected response configuration for subscription Returns: response or None if error occurred """ op_name = expected_resp["subscription"] timeout: int | float = expected_resp.get("timeout", 3.0) try: response = await self.session.get_next_message(op_name, timeout) except TimeoutError: self._adderr( f"Timed out waiting for subscription message on '{op_name}'" ) return None except Exception as e: logger.exception( f"Error getting subscription message on '{op_name}': {e}" ) self._adderr(f"Error getting subscription message on '{op_name}': {e}") return None if response is None: self._adderr(f"Subscription message on '{op_name}' was None") return None return response saved = {} async def async_generator_wrapper(): """Asynchronous generator that yields expected responses with results. For each expected subscription response, gets the subscription result and yields the pair for processing. Yields: Tuple of (expected_resp, response) for each subscription """ for resp in sub_responses_list: yield resp, await get_subscription_results(resp) # Process the results using async for async for expected_resp, response in async_generator_wrapper(): if response is None: continue op_name = expected_resp["subscription"] # Process successful subscription response body = response if "json" in expected_resp: self._validate_block("json", body) attach_yaml( {"ws_op": op_name, "body": body}, name="graphql_ws_response", ) call_hook( self.test_block_config, "pytest_tavern_beta_after_every_response", expected=expected_resp, response=response, ) self._maybe_run_validate_functions(response) saved.update(self._common_verify_save(body, response)) saved.update( self.maybe_get_save_values_from_save_block("data", {"data": body}) ) return saved def _check_sync_response( self, expected_resp: dict, response: GraphQLResponseLike, ) -> dict[Any, Any]: """Check a synchronous GraphQL response against expected values. Validates a synchronous (non-subscription) GraphQL response by checking for expected errors and validating the response data. Args: expected_resp: Expected response configuration response: Actual GraphQL response to validate Returns: Dictionary of saved values from the response """ call_hook( self.test_block_config, "pytest_tavern_beta_after_every_response", expected=expected_resp, response=response, ) # Regular HTTP GraphQL response logger.info(f"response: {response}") expected_errors: list[str] if expected_errors := expected_resp.get("errors", []): if not response.result.errors: self._adderr("Expected errors but got none") return {} if len(expected_errors) != len(response.result.errors): self._adderr( f"Expected {len(expected_errors)} errors but got {len(response.result.errors)}" ) # Continue and do a best effort check got_error_messages = [ Box(error).message for error in response.result.errors ] expected_errors = [Box(error).message for error in expected_errors] for expected_error in expected_errors: found = False for got_error in got_error_messages: if expected_error in got_error: found = True if not found: self._adderr( f"error message '{expected_error}' not found in returned error messages (had {got_error_messages})" ) elif response.result.errors: self._adderr( f"got errors when none were expected: {response.result.errors}" ) return {} body = response.result.data self._validate_block("data", body, read_from=expected_resp) # type:ignore attach_yaml( { "body": body, }, name="graphql_response", ) self._maybe_run_validate_functions(response) saved = {} saved.update(self._common_verify_save(body, response)) saved.update(self.maybe_get_save_values_from_save_block("data", {"data": body})) return saved tavern-3.6.0/tavern/_plugins/graphql/tavernhook.py000066400000000000000000000024211520710011500223150ustar00rootroot00000000000000from collections.abc import Iterable from os.path import abspath, dirname, join from typing import Optional, Union import yaml from tavern._core.dict_util import format_keys from tavern._core.pytest.config import TestConfig from .client import GraphQLClient from .request import GraphQLRequest from .response import GraphQLResponse session_type = GraphQLClient request_type = GraphQLRequest request_block_name = "graphql_request" verifier_type = GraphQLResponse response_block_name = "graphql_response" has_multiple_responses = True def get_expected_from_request( response_block: Union[dict, Iterable[dict]], test_block_config: TestConfig, session: GraphQLClient, ) -> Optional[dict]: if response_block is None: return None expected: dict[str, list] = {"graphql_responses": []} if isinstance(response_block, dict): response_block = [response_block] for resp_block in response_block: f_expected = format_keys(resp_block, test_block_config.variables) expected["graphql_responses"].append(f_expected) return expected # Schema validation schema_path: str = join(abspath(dirname(__file__)), "jsonschema.yaml") with open(schema_path, encoding="utf-8") as schema_file: schema = yaml.load(schema_file, Loader=yaml.SafeLoader) tavern-3.6.0/tavern/_plugins/grpc/000077500000000000000000000000001520710011500170615ustar00rootroot00000000000000tavern-3.6.0/tavern/_plugins/grpc/__init__.py000066400000000000000000000011161520710011500211710ustar00rootroot00000000000000import warnings # Shut up warnings caused by proto libraries warnings.filterwarnings( "ignore", category=DeprecationWarning, module="pkg_resources", lineno=2804 ) warnings.filterwarnings( "ignore", category=DeprecationWarning, module="pkg_resources", lineno=2309 ) warnings.filterwarnings( "ignore", category=DeprecationWarning, module="pkg_resources", lineno=2870 ) warnings.filterwarnings( "ignore", category=DeprecationWarning, module="pkg_resources", lineno=2349 ) warnings.filterwarnings( "ignore", category=DeprecationWarning, module="pkg_resources", lineno=20 ) tavern-3.6.0/tavern/_plugins/grpc/client.py000066400000000000000000000244751520710011500207250ustar00rootroot00000000000000import dataclasses import logging import warnings from collections.abc import Mapping from typing import Any import grpc import grpc_reflection import proto.message from google._upb._message import DescriptorPool from google.protobuf import ( descriptor_pb2, json_format, message_factory, symbol_database, ) from google.protobuf.json_format import ParseError from grpc_reflection.v1alpha import reflection_pb2, reflection_pb2_grpc from grpc_status import rpc_status from tavern._core import exceptions from tavern._core.dict_util import check_expected_keys from tavern._plugins.grpc.protos import _generate_proto_import, _import_grpc_module logger: logging.Logger = logging.getLogger(__name__) with warnings.catch_warnings(): warnings.simplefilter("ignore") warnings.warn("deprecated", DeprecationWarning) # noqa: B028 _ProtoMessageType = type[proto.message.Message] @dataclasses.dataclass class _ChannelVals: channel: grpc.UnaryUnaryMultiCallable input_type: _ProtoMessageType output_type: _ProtoMessageType class GRPCClient: def __init__(self, **kwargs) -> None: logger.debug("Initialising GRPC client with %s", kwargs) expected_blocks = { "connect": {"host", "port", "options", "timeout", "secure"}, "proto": {"source", "module"}, "metadata": {}, "attempt_reflection": {}, } # check main block first check_expected_keys(expected_blocks.keys(), kwargs) _connect_args = kwargs.pop("connect", {}) check_expected_keys(expected_blocks["connect"], _connect_args) metadata = kwargs.pop("metadata", {}) self._metadata = list(metadata.items()) _proto_args = kwargs.pop("proto", {}) check_expected_keys(expected_blocks["proto"], _proto_args) self._attempt_reflection = bool(kwargs.pop("attempt_reflection", False)) if default_host := _connect_args.get("host"): self.default_host = default_host if port := _connect_args.get("port"): self.default_host += f":{port}" self.timeout = int(_connect_args.get("timeout", 5)) self.secure = bool(_connect_args.get("secure", False)) self._options: list[tuple[str, Any]] = [] for key, value in _connect_args.pop("options", {}).items(): if not key.startswith("grpc."): raise exceptions.GRPCServiceException( f"invalid grpc option '{key}' - must be in the form 'grpc.option_name'" ) self._options.append((key, value)) self.channels: dict[str, grpc.Channel] = {} # Using the default symbol database is a bit undesirable because it means that things being imported from # previous tests will affect later ones which can mask bugs. But there isn't a nice way to have a # self-contained symbol database, because then you need to transitively import all dependencies of protos and # add them to the database. self.sym_db = symbol_database.Default() if proto_source := _proto_args.get("source"): _generate_proto_import(proto_source) if proto_module := _proto_args.get("module"): try: _import_grpc_module(proto_module) except (ImportError, ModuleNotFoundError) as e: logger.exception(f"could not import {proto_module}") raise exceptions.GRPCServiceException( "error importing gRPC modules" ) from e def _register_file_descriptor( self, service_proto: grpc_reflection.v1alpha.reflection_pb2.FileDescriptorResponse, ) -> None: for file_descriptor_proto in service_proto.file_descriptor_proto: descriptor = descriptor_pb2.FileDescriptorProto() descriptor.ParseFromString(file_descriptor_proto) self.sym_db.pool.Add(descriptor) def _get_reflection_info( self, channel, service_name: str | None = None, file_by_filename=None ) -> None: logger.debug( "Getting GRPC protobuf for service %s from reflection", service_name ) ref_request = reflection_pb2.ServerReflectionRequest( file_containing_symbol=service_name, file_by_filename=file_by_filename ) reflection_stub = reflection_pb2_grpc.ServerReflectionStub(channel) ref_response = reflection_stub.ServerReflectionInfo( iter([ref_request]), metadata=self._metadata ) for response in ref_response: self._register_file_descriptor(response.file_descriptor_response) def _get_grpc_service( self, channel: grpc.Channel, service: str, method: str ) -> _ChannelVals | None: full_service_name = f"{service}/{method}" try: input_type, output_type = self.get_method_types(full_service_name) except KeyError as e: logger.debug(f"could not find types: {e}") return None logger.info(f"reflected info for {service}: {full_service_name}") grpc_method = channel.unary_unary( "/" + full_service_name, request_serializer=input_type.SerializeToString, response_deserializer=output_type.FromString, ) return _ChannelVals(grpc_method, input_type, output_type) def get_method_types( self, full_method_name: str ) -> tuple[_ProtoMessageType, _ProtoMessageType]: """Uses the builtin symbol pool to try and find the input and output types for the given method Args: full_method_name: full RPC name in the form 'pkg.ServiceName/Method' Returns: input and output types (class objects) for the RPC Raises: KeyError: If the types are not registered. Should ideally never happen? """ logger.debug(f"looking up types for {full_method_name}") service, method = full_method_name.split("/") pool: DescriptorPool = self.sym_db.pool grpc_service = pool.FindServiceByName(service) method = grpc_service.FindMethodByName(method) input_type = message_factory.GetMessageClass(method.input_type) # type: ignore output_type = message_factory.GetMessageClass(method.output_type) # type: ignore return input_type, output_type def _make_call_request(self, host: str, full_service: str) -> _ChannelVals | None: full_service = full_service.replace("/", ".") service_method = full_service.rsplit(".", 1) if len(service_method) != 2: raise exceptions.GRPCRequestException( f"Invalid service/method name {full_service}" ) service = service_method[0] method = service_method[1] logger.debug( "Make call for host %s service %s method %s", host, service, method ) if host not in self.channels: if self.secure: credentials = grpc.ssl_channel_credentials() self.channels[host] = grpc.secure_channel( host, credentials, options=self._options, ) else: self.channels[host] = grpc.insecure_channel( host, options=self._options, ) channel = self.channels[host] if channel_vals := self._get_grpc_service(channel, service, method): return channel_vals if not self._attempt_reflection: logger.error( "could not find service and gRPC reflection disabled, cannot continue" ) raise exceptions.GRPCServiceException( f"Service {service} was not registered for host {host}" ) logger.info("service not registered, doing reflection from server") try: self._get_reflection_info(channel, service_name=service) except grpc.RpcError as rpc_error: code = details = None try: code = rpc_error.code() details = rpc_error.details() except AttributeError: status = rpc_status.from_call(rpc_error) if status is None: logger.warning("Unknown error occurred in RPC call", exc_info=True) else: code = status.code details = status.details if code and details: logger.warning( "Unable get %s service reflection information code %s detail %s", service, code, details, exc_info=True, ) raise exceptions.GRPCRequestException from rpc_error return self._get_grpc_service(channel, service, method) def __enter__(self) -> "GRPCClient": logger.debug("Connecting to GRPC") return self def call( self, service: str, host: str | None = None, body: Mapping | None = None, timeout: int | None = None, ) -> grpc.Future: """Makes the request and returns a future with the response.""" if host is None: if getattr(self, "default_host", None) is None: raise exceptions.GRPCRequestException( "no host specified in request and no default host in settings" ) host = self.default_host if timeout is None: timeout = self.timeout channel_vals = self._make_call_request(host, service) if channel_vals is None: raise exceptions.GRPCServiceException( f"Service {service} was not found on host {host}" ) request = channel_vals.input_type() if body is not None: try: request = json_format.ParseDict(body, request) except ParseError as e: raise exceptions.GRPCRequestException( "error creating request from json body" ) from e logger.debug("Sending request %s", request) return channel_vals.channel.future( request, metadata=self._metadata, timeout=timeout ) def __exit__(self, *args) -> None: logger.debug("Disconnecting from GRPC") for v in self.channels.values(): v.close() self.channels = {} tavern-3.6.0/tavern/_plugins/grpc/jsonschema.yaml000066400000000000000000000042041520710011500220770ustar00rootroot00000000000000--- $schema: "http://json-schema.org/draft-07/schema#" title: gRPC schema description: Schema for Python gRPC connection type: object additionalProperties: false required: - grpc definitions: grpc_request: type: object required: - service properties: host: type: string service: type: string body: type: object json: type: object retain: type: boolean grpc_response: type: object properties: status: oneOf: - type: string - type: integer details: type: object proto_body: type: object timeout: type: number verify_response_with: oneOf: - $ref: "#/definitions/verify_block" - type: array items: $ref: "#/definitions/verify_block" stage: properties: grpc_request: $ref: "#/definitions/grpc_request" grpc_response: $ref: "#/definitions/grpc_response" properties: grpc: type: object properties: connect: type: object properties: host: type: string port: type: integer timeout: type: number keepalive: type: integer secure: type: boolean description: use a secure channel using the system default ssl certs options: description: connection options, in map format type: object # TODO # tls: ... attempt_reflection: description: If a gRPC definition could not be found for a service, try to use server reflection to create the gRPC call instead. This can be useful if you do not have the compiled proto definition on hand but you know what the schema is. type: boolean metadata: description: gRPC metadata to send to the server type: object proto: type: object properties: source: description: path to a folder containing proto definitions type: string module: type: string tavern-3.6.0/tavern/_plugins/grpc/protos.py000066400000000000000000000122431520710011500207630ustar00rootroot00000000000000import functools import hashlib import importlib.util import logging import os import string import subprocess import sys import tempfile from distutils.spawn import find_executable from importlib.machinery import ModuleSpec from tavern._core import exceptions logger: logging.Logger = logging.getLogger(__name__) @functools.lru_cache def find_protoc() -> str: # Find the Protocol Compiler. if "PROTOC" in os.environ and os.path.exists(os.environ["PROTOC"]): return os.environ["PROTOC"] if protoc := find_executable("protoc"): return protoc raise exceptions.ProtoCompilerException( "Wanted to dynamically compile a proto source, but could not find protoc" ) @functools.lru_cache def _generate_proto_import(source: str) -> None: """Invokes the Protocol Compiler to generate a _pb2.py from the given .proto file. Does nothing if the output already exists and is newer than the input. """ if not os.path.exists(source): raise exceptions.ProtoCompilerException(f"Can't find required file: {source}") logger.info("Generating protos from %s...", source) # If its a dir, compile them all if not os.path.isdir(source): if not source.endswith(".proto"): raise exceptions.ProtoCompilerException( f"invalid proto source file {source}" ) protos = [source] include_path = os.path.dirname(source) else: protos = [ os.path.join(source, child) for child in os.listdir(source) if (not os.path.isdir(child)) and child.endswith(".proto") ] include_path = source if not protos: raise exceptions.ProtoCompilerException( f"No protos defined in {os.path.abspath(source)}" ) for p in protos: if not os.path.exists(p): raise exceptions.ProtoCompilerException(f"{p} does not exist") def sanitise(s): """Do basic sanitisation for creating a temporary directory based on the name of the input proto file""" return "".join(c for c in s if c in string.ascii_letters) # Create a temporary directory to put the generated protobuf files in output = os.path.join( tempfile.gettempdir(), "tavern_proto", sanitise(protos[0]), hashlib.new("sha3_224", "".join(protos).encode("utf8")).hexdigest(), ) if not os.path.exists(output): os.makedirs(output) protoc = find_protoc() protoc_command = [protoc, "-I" + include_path, "--python_out=" + output] protoc_command.extend(protos) call = subprocess.run(protoc_command, capture_output=True, check=False) # noqa if call.returncode != 0: logger.error(f"Error calling '{protoc_command}'") raise exceptions.ProtoCompilerException(call.stderr.decode("utf8")) logger.info(f"Generated module from protos: {protos}") # Invalidate caches so the module can be loaded sys.path.append(output) importlib.invalidate_caches() _import_grpc_module(output) def _import_grpc_module(python_module_name: str) -> None: """takes an expected python module name and tries to import the relevant file, adding service to the symbol database. """ logger.debug("attempting to import %s", python_module_name) if python_module_name.endswith(".py"): raise exceptions.GRPCServiceException( f"grpc module definitions should not end with .py, but got {python_module_name}" ) if python_module_name.startswith("."): raise exceptions.GRPCServiceException( f"relative imports for Python grpc modules not allowed (got {python_module_name})" ) import_specs: list[ModuleSpec] = [] # Check if its already on the python path if (spec := importlib.util.find_spec(python_module_name)) is not None: logger.debug(f"{python_module_name} on sys path already") import_specs.append(spec) # See if the file exists module_path = python_module_name.replace(".", "/") + ".py" if os.path.exists(module_path): logger.debug(f"{python_module_name} found in file") if ( spec := importlib.util.spec_from_file_location( python_module_name, module_path ) ) is not None: import_specs.append(spec) # If its a dir then load files in the dir instead if os.path.isdir(python_module_name): for s in os.listdir(python_module_name): s = os.path.join(python_module_name, s) if s.endswith(".py"): logger.debug(f"found py file {s}") # Guess a package name if ( spec := importlib.util.spec_from_file_location(s[:-3], s) ) is not None: import_specs.append(spec) if not import_specs: raise exceptions.GRPCServiceException( f"could not determine how to import {python_module_name}" ) # Actually import them to register them in the symbol db for spec in import_specs: mod = importlib.util.module_from_spec(spec) logger.debug(f"loading from {spec.name}") if spec.loader: spec.loader.exec_module(mod) tavern-3.6.0/tavern/_plugins/grpc/request.py000066400000000000000000000047101520710011500211250ustar00rootroot00000000000000import dataclasses import functools import json import logging import grpc from box import Box from tavern._core import exceptions from tavern._core.dict_util import check_expected_keys, format_keys from tavern._core.pytest.config import TestConfig from tavern._plugins.grpc.client import GRPCClient from tavern.request import BaseRequest logger: logging.Logger = logging.getLogger(__name__) def get_grpc_args(rspec: dict, test_block_config: TestConfig) -> dict: """Format GRPC request args""" fspec = format_keys(rspec, test_block_config.variables) # FIXME: Clarify 'json' and 'body' for grpc requests # FIXME 2: also it should allow proto text format. Maybe binary. if "json" in rspec: if "body" in rspec: raise exceptions.BadSchemaError( "Can only specify one of 'body' or 'json' in GRPC request" ) fspec["body"] = json.dumps(fspec.pop("json")) return fspec @dataclasses.dataclass class WrappedFuture: response: grpc.Call | grpc.Future service_name: str class GRPCRequest(BaseRequest): """Wrapper for a single GRPC request on a client Similar to RestRequest, publishes a single message. """ def __init__( self, client: GRPCClient, request_spec: dict, test_block_config: TestConfig ) -> None: expected = {"host", "service", "body"} check_expected_keys(expected, request_spec) grpc_args = get_grpc_args(request_spec, test_block_config) self._prepared = functools.partial(client.call, **grpc_args) try: self._service_name = grpc_args["service"] except KeyError as e: raise exceptions.BadSchemaError("No service specified in request") from e # Need to do this here because get_publish_args will modify the original # input, which we might want to use to format. No error handling because # all the error handling is done in the previous call self._original_request_vars = format_keys( request_spec, test_block_config.variables ) def run(self) -> WrappedFuture: try: return WrappedFuture( response=self._prepared(), service_name=self._service_name ) except ValueError as e: logger.exception("Error executing request") raise exceptions.GRPCRequestException from e @property def request_vars(self) -> Box: return Box(self._original_request_vars) tavern-3.6.0/tavern/_plugins/grpc/response.py000066400000000000000000000140341520710011500212730ustar00rootroot00000000000000import logging from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Optional, TypedDict, Union import grpc import proto.message from google.protobuf import json_format from tavern._core import exceptions from tavern._core.dict_util import check_expected_keys from tavern._core.exceptions import TestFailError from tavern._core.pytest.config import TestConfig from tavern._core.schema.extensions import to_grpc_status from tavern._plugins.grpc.client import GRPCClient from tavern.response import BaseResponse if TYPE_CHECKING: from tavern._plugins.grpc.request import WrappedFuture logger: logging.Logger = logging.getLogger(__name__) GRPCCode = Union[str, int, list[str], list[int]] def _to_grpc_name(status: GRPCCode) -> Union[str, list[str]]: if isinstance(status, list): return [_to_grpc_name(s) for s in status] # type:ignore if status_name := to_grpc_status(status): return status_name.upper() # This should have been verified before this raise exceptions.GRPCServiceException(f"unknown status code '{status}'") class _GRPCExpected(TypedDict): """What the 'expected' block for a grpc response should contain""" status: GRPCCode details: Any body: Mapping class GRPCResponse(BaseResponse): def __init__( self, client: GRPCClient, name: str, expected: _GRPCExpected | Mapping, test_block_config: TestConfig, ) -> None: check_expected_keys({"body", "status", "details", "save"}, expected) super().__init__( name, expected, test_block_config, ) self._client = client def __str__(self): if self.response: return self.response.payload else: return "" def _validate_block(self, blockname: str, block: Mapping) -> None: """Validate a block of the response Args: blockname: which part of the response is being checked block: The actual part being checked """ try: expected_block = self.expected["body"] or {} except KeyError: expected_block = {} if isinstance(expected_block, dict): if expected_block.pop("$ext", None): logger.warning( "$ext function found in block %s - this has been moved to verify_response_with block - see documentation", blockname, ) logger.debug("Validating response %s against %s", blockname, expected_block) test_strictness = self.test_block_config.strict block_strictness = test_strictness.option_for(blockname) self.recurse_check_key_match(expected_block, block, blockname, block_strictness) def verify(self, response: "WrappedFuture") -> Mapping: grpc_response = response.response logger.debug(f"grpc status code: {grpc_response.code()}") logger.debug(f"grpc details: {grpc_response.details()}") verify_status = [grpc.StatusCode.OK.name] if status := self.expected.get("status", None): verify_status = _to_grpc_name(status) # type: ignore if not isinstance(verify_status, list): verify_status = [verify_status] if grpc_response.code().name not in verify_status: self._adderr( "expected status %s, but the actual response '%s'", verify_status, grpc_response.code().name, ) if "details" in self.expected: verify_details = self.expected["details"] if verify_details != grpc_response.details(): self._adderr( "expected details '%s', but the actual response '%s'", verify_details, grpc_response.details(), ) saved = self._handle_grpc_response(grpc_response, response, verify_status) or {} if self.errors: raise TestFailError( f"Test '{self.name:s}' failed:\n{self._str_errors():s}", failures=self.errors, ) return saved def _handle_grpc_response( self, grpc_response: grpc.Call | grpc.Future, response: "WrappedFuture", verify_status: list[str], ) -> Optional[dict[str, Any]]: if grpc_response.code().name != "OK": # TODO: Should allow checking grpc RPC error details etc. logger.info( f"skipping body checking due to {grpc_response.code()} response" ) return None if "body" in self.expected and verify_status != ["OK"]: self._adderr( "'body' was specified in response, but expected status code was not 'OK'" ) return None _, output_type = self._client.get_method_types(response.service_name) result: proto.message.Message = grpc_response.result() if not isinstance(result, output_type): # Note: This is probably unexpected in some cases self._adderr( f"response from server ({type(response)}) was not the same type as expected from the registered definition ({output_type})" ) return None json_result = json_format.MessageToDict( result, always_print_fields_with_no_presence=True, preserving_proto_field_name=True, ) if "body" in self.expected: expected_parsed = output_type() try: json_format.ParseDict(self.expected["body"], expected_parsed) except json_format.ParseError as e: self._adderr(f"response body was not in the right format: {e}", e=e) self._validate_block("json", json_result) self._maybe_run_validate_functions(json_result) saved: dict[str, Any] = {} saved.update(self.maybe_get_save_values_from_save_block("body", json_result)) saved.update(self.maybe_get_save_values_from_ext(json_result, self.expected)) return saved tavern-3.6.0/tavern/_plugins/grpc/tavernhook.py000066400000000000000000000015141520710011500216140ustar00rootroot00000000000000from os.path import abspath, dirname, join import yaml from tavern._core.dict_util import format_keys from tavern._core.pytest.config import TestConfig from .client import GRPCClient from .request import GRPCRequest from .response import GRPCResponse session_type = GRPCClient request_type = GRPCRequest request_block_name = "grpc_request" def get_expected_from_request( response_block, test_block_config: TestConfig, session: GRPCClient ): f_expected = format_keys(response_block, test_block_config.variables) expected = f_expected return expected verifier_type = GRPCResponse response_block_name = "grpc_response" has_multiple_responses = False schema_path: str = join(abspath(dirname(__file__)), "jsonschema.yaml") with open(schema_path) as schema_file: schema = yaml.load(schema_file, Loader=yaml.SafeLoader) tavern-3.6.0/tavern/_plugins/mqtt/000077500000000000000000000000001520710011500171135ustar00rootroot00000000000000tavern-3.6.0/tavern/_plugins/mqtt/__init__.py000066400000000000000000000000001520710011500212120ustar00rootroot00000000000000tavern-3.6.0/tavern/_plugins/mqtt/client.py000066400000000000000000000432431520710011500207510ustar00rootroot00000000000000import copy import dataclasses import logging import ssl import threading import time from collections.abc import Mapping, MutableMapping from queue import Empty, Full, Queue from typing import Any import paho.mqtt.client as paho from paho.mqtt.client import MQTTMessageInfo from tavern._core import exceptions from tavern._core.dict_util import check_expected_keys # MQTT error values _err_vals = { -1: "MQTT_ERR_AGAIN", 0: "MQTT_ERR_SUCCESS", 1: "MQTT_ERR_NOMEM", 2: "MQTT_ERR_PROTOCOL", 3: "MQTT_ERR_INVAL", 4: "MQTT_ERR_NO_CONN", 5: "MQTT_ERR_CONN_REFUSED", 6: "MQTT_ERR_NOT_FOUND", 7: "MQTT_ERR_CONN_LOST", 8: "MQTT_ERR_TLS", 9: "MQTT_ERR_PAYLOAD_SIZE", 10: "MQTT_ERR_NOT_SUPPORTED", 11: "MQTT_ERR_AUTH", 12: "MQTT_ERR_ACL_DENIED", 13: "MQTT_ERR_UNKNOWN", 14: "MQTT_ERR_ERRNO", 15: "MQTT_ERR_QUEUE_SIZE", } logger: logging.Logger = logging.getLogger(__name__) @dataclasses.dataclass class _Subscription: topic: str subscribed: bool = False # Arbitrary number, could just be 1 and only accept 1 message per stages # but we might want to raise an error if more than 1 message is received # during a test stage. queue: Queue = dataclasses.field(default_factory=lambda: Queue(maxsize=30)) def check_file_exists(key, filename) -> None: try: with open(filename, encoding="utf-8"): pass except OSError as e: raise exceptions.MQTTTLSError(f"Couldn't load '{key}' from '{filename}'") from e def _handle_tls_args( tls_args: MutableMapping, ) -> Mapping | None: """Make sure TLS options are valid""" if not tls_args: return None if "enable" in tls_args and not tls_args["enable"]: # if enable=false, return immediately return None _check_and_update_common_tls_args(tls_args, ["certfile", "keyfile"]) return tls_args def _handle_ssl_context_args( ssl_context_args: MutableMapping, ) -> Mapping | None: """Make sure SSL Context options are valid""" if not ssl_context_args: return None _check_and_update_common_tls_args( ssl_context_args, ["certfile", "keyfile", "cafile"] ) return ssl_context_args def _check_and_update_common_tls_args( tls_args: MutableMapping, check_file_keys: list[str] ) -> None: """Checks common args between ssl/tls args""" # could be moved to schema validation stage for key in check_file_keys: if key in tls_args: check_file_exists(key, tls_args[key]) if "keyfile" in tls_args and "certfile" not in tls_args: raise exceptions.MQTTTLSError( "If specifying a TLS keyfile, a certfile also needs to be specified" ) if "cert_reqs" in tls_args: tls_args["cert_reqs"] = getattr(ssl, tls_args["cert_reqs"]) try: tls_args["tls_version"] = getattr(ssl, tls_args["tls_version"]) except AttributeError as e: raise exceptions.MQTTTLSError( "Error getting TLS version from " "ssl module - ssl module had no attribute '{}'. Check the " "documentation for the version of python you're using to see " "if this a valid option.".format(tls_args["tls_version"]) ) from e except KeyError: logger.debug("No tls_version specified in TLS arguments") class MQTTClient: def __init__(self, **kwargs) -> None: expected_blocks = { "client": { "client_id", "clean_session", # Can't really use this easily... # "userdata", # Force mqttv311 - fix if this becomes an issue # "protocol", "transport", }, "connect": {"host", "port", "keepalive", "timeout"}, "tls": { "enable", "ca_certs", "cert_reqs", "certfile", "keyfile", "tls_version", "ciphers", }, "auth": {"username", "password"}, "ssl_context": { "ca_certs", "certfile", "keyfile", "password", "tls_version", "ciphers", "alpn_protocols", }, } sanitised_kwargs = copy.deepcopy(kwargs) if auth := kwargs.get("auth"): if "password" in auth: sanitised_kwargs["auth"]["password"] = "******" # noqa logger.debug("Initialising MQTT client with %s", sanitised_kwargs) # check main block first check_expected_keys(expected_blocks.keys(), kwargs) # then check constructor/connect/tls_set args self._client_args = kwargs.pop("client", {}) check_expected_keys(expected_blocks["client"], self._client_args) self._connect_args = kwargs.pop("connect", {}) check_expected_keys(expected_blocks["connect"], self._connect_args) self._auth_args = kwargs.pop("auth", {}) check_expected_keys(expected_blocks["auth"], self._auth_args) if "host" not in self._connect_args: msg = "Need 'host' in 'connect' block for mqtt" raise exceptions.MissingKeysError(msg) self._connect_timeout = self._connect_args.pop("timeout", 3) # If there is any tls or ssl_context kwarg, configure tls encryption file_tls_args = kwargs.pop("tls", {}) file_ssl_context_args = kwargs.pop("ssl_context", {}) if file_tls_args and file_ssl_context_args: msg = ( "'tls' and 'ssl_context' are both specified but are mutually exclusive" ) raise exceptions.MQTTTLSError(msg) check_expected_keys(expected_blocks["tls"], file_tls_args) self._tls_args = _handle_tls_args(file_tls_args) logger.debug("TLS is %s", "enabled" if self._tls_args else "disabled") # If there is any SSL kwarg, enable tls through the SSL context check_expected_keys(expected_blocks["ssl_context"], file_ssl_context_args) self._ssl_context_args = _handle_ssl_context_args(file_ssl_context_args) logger.debug("Paho client args: %s", self._client_args) self._client = paho.Client(**self._client_args) self._client.enable_logger() if self._auth_args: logger.debug("authenticating as '%s'", self._auth_args.get("username")) self._client.username_pw_set(**self._auth_args) self._client.on_message = self._on_message self._client.on_connect = self._on_connect self._client.on_disconnect = self._on_disconnect self._client.on_connect_fail = self._on_connect_fail self._client.on_socket_open = self._on_socket_open self._client.on_socket_close = self._on_socket_close if self._tls_args: try: self._client.tls_set(**self._tls_args) except ValueError as e: # tls_set only raises ValueErrors directly raise exceptions.MQTTTLSError("Unexpected error enabling TLS") from e except ssl.SSLError as e: # incorrect cipher, etc. raise exceptions.MQTTTLSError( "Unexpected SSL error enabling TLS" ) from e if self._ssl_context_args: # Create SSLContext object tls_version = self._ssl_context_args.get("tls_version") user_specified_tls_version = "tls_version" in self._ssl_context_args if user_specified_tls_version: # User explicitly specified tls_version, use it context = ssl.SSLContext(tls_version) else: # Use create_default_context which handles protocol selection context = ssl.create_default_context() certfile = self._ssl_context_args.get("certfile") keyfile = self._ssl_context_args.get("keyfile") password = self._ssl_context_args.get("password") # Configure context if certfile is not None: context.load_cert_chain(certfile, keyfile, password) cert_reqs = self._ssl_context_args.get("cert_reqs") if cert_reqs == ssl.CERT_NONE and hasattr(context, "check_hostname"): context.check_hostname = False context.verify_mode = ssl.CERT_REQUIRED if cert_reqs is None else cert_reqs ca_certs = self._ssl_context_args.get("ca_certs") if ca_certs is not None: context.load_verify_locations(ca_certs) else: context.load_default_certs() ciphers = self._ssl_context_args.get("ciphers") if ciphers is not None: context.set_ciphers(ciphers) alpn_protocols = self._ssl_context_args.get("alpn_protocols") if alpn_protocols is not None: context.set_alpn_protocols(alpn_protocols) self._client.tls_set_context(context) if cert_reqs != ssl.CERT_NONE: # Default to secure, sets context.check_hostname attribute # if available self._client.tls_insecure_set(False) else: # But with ssl.CERT_NONE, we can not check_hostname self._client.tls_insecure_set(True) # Topics to subscribe to - mapping of subscription message id to subscription object self._subscribed: dict[int, _Subscription] = {} # Lock to ensure there is no race condition when subscribing self._subscribe_lock = threading.RLock() # callback self._client.on_subscribe = self._on_subscribe # Mapping of topic -> subscription id, for indexing into self._subscribed self._subscription_mappings: dict[str, int] = {} self._userdata = { "_subscription_mappings": self._subscription_mappings, "_subscribed": self._subscribed, } self._client.user_data_set(self._userdata) @staticmethod def _on_message( client, userdata: Mapping[str, Any], message: paho.MQTTMessage ) -> None: """Add any messages received to the queue Todo: If the queue is full trigger an error in main thread somehow """ logger.info("Received mqtt message on %s", message.topic) try: for sub_topic, sub_id in userdata["_subscription_mappings"].items(): if paho.topic_matches_sub(sub_topic, message.topic): userdata["_subscribed"][sub_id].queue.put(message) break else: raise exceptions.MQTTTopicException( f"Message received on unregistered topic: {message.topic}" ) except Full: logger.exception("message queue full") @staticmethod def _on_connect(client, userdata, flags, rc: int) -> None: logger.debug( "Client '%s' connected to the broker with result code '%s'", client._client_id.decode(), paho.connack_string(rc), ) @staticmethod def _on_disconnect(client, userdata, rc: int) -> None: if rc == paho.CONNACK_ACCEPTED: logger.debug( "Client '%s' successfully disconnected from the broker with result code '%s'", client._client_id.decode(), paho.connack_string(rc), ) else: logger.warning( "Client %s failed to disconnect cleanly due to %s, possibly from a network error", client._client_id.decode(), paho.connack_string(rc), ) @staticmethod def _on_connect_fail(client, userdata) -> None: logger.error( "Failed to connect client '%s' to the broker", client._client_id.decode() ) @staticmethod def _on_socket_open(client, userdata, socket) -> None: logger.debug("MQTT socket opened") @staticmethod def _on_socket_close(client, userdata, socket) -> None: logger.debug("MQTT socket closed") def message_received( self, topic: str, timeout: float | int = 1 ) -> paho.MQTTMessage | None: """Check that a message is in the message queue Args: topic: topic to fetch message for timeout: How long to wait before signalling that the message was not received. Returns: the message, if one was received, otherwise None Todo: Allow regexes for topic names? Better validation for mqtt payloads """ try: with self._subscribe_lock: queue = self._subscribed[self._subscription_mappings[topic]].queue except KeyError as e: raise exceptions.MQTTTopicException(f"Unregistered topic: {topic}") from e try: msg = queue.get(block=True, timeout=timeout) except Empty: logger.error("Message not received after %d seconds", timeout) return None return msg def publish( self, topic: str, payload: None | bytearray | bytes | float | str = None, qos: int | None = None, retain: bool | None = None, ) -> MQTTMessageInfo: """publish message using paho library""" self._wait_for_subscriptions() logger.debug("Publishing on '%s'", topic) kwargs = {} if qos is not None: kwargs["qos"] = qos if retain is not None: kwargs["retain"] = retain msg = self._client.publish(topic, payload, **kwargs) # Wait for 2*connect timeout which should be plenty to publish the message even with qos 2 # TODO: configurable try: msg.wait_for_publish(self._connect_timeout * 2) except (RuntimeError, ValueError) as e: raise exceptions.MQTTError("could not publish message") from e if not msg.is_published(): raise exceptions.MQTTError( "err {:s}: {:s}".format( _err_vals.get(msg.rc, "unknown"), paho.error_string(msg.rc) ) ) return msg def _wait_for_subscriptions(self) -> None: """Wait for all pending subscriptions to finish""" logger.debug("Checking subscriptions") def not_finished_subscribing_to(): """Get topic names for topics which have not finished subcribing to""" return [i.topic for i in self._subscribed.values() if not i.subscribed] to_wait_for = not_finished_subscribing_to() if to_wait_for: elapsed = 0.0 while elapsed < self._connect_timeout: # TODO # configurable? time.sleep(0.25) elapsed += 0.25 to_wait_for = not_finished_subscribing_to() if not to_wait_for: break logger.debug( "Not finished subscribing to '%s' after %.2f seconds", to_wait_for, elapsed, ) if to_wait_for: logger.warning( "Did not finish subscribing to '%s' before publishing - going ahead anyway", to_wait_for, ) if not to_wait_for: logger.debug("Finished subscribing to all topics") def subscribe(self, topic: str, *args, **kwargs) -> None: """Subscribe to topic should be called for every expected message in mqtt_response """ logger.debug("Subscribing to topic '%s'", topic) (status, mid) = self._client.subscribe(topic, *args, **kwargs) if status == 0: with self._subscribe_lock: self._subscription_mappings[topic] = mid self._subscribed[mid] = _Subscription(topic) else: raise exceptions.MQTTError( f"Error subscribing to '{topic}' (err code {status})" ) def unsubscribe_all(self) -> None: """Unsubscribe from all topics""" with self._subscribe_lock: for subscription in self._subscribed.values(): self._client.unsubscribe(subscription.topic) def _on_subscribe(self, client, userdata, mid: int, granted_qos) -> None: with self._subscribe_lock: if mid in self._subscribed: self._subscribed[mid].subscribed = True logger.debug( "Successfully subscribed to '%s'", self._subscribed[mid].topic ) else: logger.debug("Only tracking: %s", self._subscribed.keys()) logger.warning( "Got SUBACK message with mid '%s', but did not recognise that mid - will try later", mid, ) def __enter__(self) -> "MQTTClient": logger.debug("Connecting to %s", self._connect_args) self._client.connect_async(**self._connect_args) self._client.loop_start() elapsed = 0.0 while elapsed < self._connect_timeout: if self._client.is_connected(): logger.debug("Connected to broker at %s", self._connect_args["host"]) return self else: logger.debug("Not connected after %s seconds - waiting", elapsed) # TODO # configurable? time.sleep(0.25) elapsed += 0.25 self._disconnect() logger.error( "Could not connect to broker after %s seconds", self._connect_timeout ) raise exceptions.MQTTError def __exit__(self, *args) -> None: self._disconnect() def _disconnect(self) -> None: self._client.disconnect() self._client.loop_stop() tavern-3.6.0/tavern/_plugins/mqtt/jsonschema.yaml000066400000000000000000000136161520710011500221400ustar00rootroot00000000000000$schema: "http://json-schema.org/draft-07/schema#" title: Paho MQTT schema description: Schema for paho-mqtt connection and requests/responses ### type: object additionalProperties: false required: - paho-mqtt definitions: mqtt_publish: type: object description: Publish MQTT message additionalProperties: false properties: topic: type: string description: Topic to publish on payload: type: string description: Raw payload to post json: description: JSON payload to post $ref: "#/definitions/any_json" qos: type: integer description: QoS level to use for request default: 0 retain: type: boolean description: Whether the message should be retained default: false mqtt_response: type: object additionalProperties: false description: Expected MQTT response properties: unexpected: type: boolean description: Receiving this message fails the test topic: type: string description: Topic message should be received on payload: description: Expected raw payload in response oneOf: - type: number - type: integer - type: string - type: boolean json: description: Expected JSON payload in response $ref: "#/definitions/any_json" timeout: type: number description: How long to wait for response to arrive qos: type: integer description: QoS level that message should be received on minimum: 0 maximum: 2 verify_response_with: oneOf: - $ref: "#/definitions/verify_block" - type: array items: $ref: "#/definitions/verify_block" save: type: object description: Which objects to save from the response stage: properties: mqtt_publish: $ref: "#/definitions/mqtt_publish" mqtt_response: oneOf: - $ref: "#/definitions/mqtt_response" - type: array items: $ref: "#/definitions/mqtt_response" properties: paho-mqtt: type: object description: Connection options for paho-mqtt additionalProperties: false required: - connect properties: client: description: Arguments to pass to the paho-mqtt client constructor type: object additionalProperties: false properties: client_id: type: string description: MQTT client ID clean_session: type: boolean description: Whether to start a clean session transport: type: string description: Whether to use raw TCP or websockets to connect enum: - tcp - websockets connect: description: Connection options type: object additionalProperties: false required: - host properties: host: type: string description: Host to connect to port: type: integer description: Port to use with connection keepalive: type: number description: How often to send keepalive packets timeout: type: number description: How long to wait for connection before giving up tls: description: Basic custom options to control secure connection type: object additionalProperties: false properties: enable: type: boolean description: Whether to enable TLS default: true ca_certs: type: string description: Path to CA cert bundle certfile: type: string description: Path to certificate for server keyfile: type: string description: Path to private key for client cert_reqs: type: string description: Controls connection with cert enum: - CERT_NONE - CERT_OPTIONAL - CERT_REQUIRED tls_version: type: string description: TLS version to use ciphers: type: string description: Allowed ciphers to use with connection ssl_context: description: Advanced custom options to control secure connection using SSLContext type: object additionalProperties: false properties: ca_certs: type: string description: Path to CA cert bundle certfile: type: string description: Path to certificate for server keyfile: type: string description: Path to private key for client password: type: string description: Password for keyfile cert_reqs: type: string description: Controls connection with cert enum: - CERT_NONE - CERT_OPTIONAL - CERT_REQUIRED tls_version: type: string description: TLS version to use ciphers: type: string description: Allowed ciphers to use with connection alpn_protocols: type: array description: | Which protocols the socket should advertise during the SSL/TLS handshake. See https://docs.python.org/3/library/ssl.html#ssl.SSLContext.set_alpn_protocols auth: description: Username and password for basic authorisation type: object additionalProperties: false required: - username properties: username: type: string password: type: string tavern-3.6.0/tavern/_plugins/mqtt/request.py000066400000000000000000000047231520710011500211630ustar00rootroot00000000000000import functools import json import logging from box.box import Box from tavern._core import exceptions from tavern._core.dict_util import check_expected_keys, format_keys from tavern._core.extfunctions import update_from_ext from tavern._core.pytest.config import TestConfig from tavern._core.report import attach_yaml from tavern._plugins.mqtt.client import MQTTClient from tavern.request import BaseRequest logger: logging.Logger = logging.getLogger(__name__) def get_publish_args(rspec: dict, test_block_config: TestConfig) -> dict: """Format mqtt request args and update using ext functions""" fspec = format_keys(rspec, test_block_config.variables) if "json" in fspec: if "payload" in fspec: raise exceptions.BadSchemaError( "Can only specify one of 'payload' or 'json' in MQTT request" ) update_from_ext(fspec, ["json"]) fspec["payload"] = json.dumps(fspec.pop("json")) return fspec class MQTTRequest(BaseRequest): """Wrapper for a single mqtt request on a client Similar to RestRequest, publishes a single message. """ def __init__( self, client: MQTTClient, rspec: dict, test_block_config: TestConfig ) -> None: expected = {"topic", "payload", "json", "qos", "retain"} check_expected_keys(expected, rspec) publish_args = get_publish_args(rspec, test_block_config) self._publish_args = publish_args self._prepared = functools.partial(client.publish, **publish_args) # Need to do this here because get_publish_args will modify the original # input, which we might want to use to format. No error handling because # all the error handling is done in the previous call self._original_publish_args = format_keys(rspec, test_block_config.variables) # TODO # From paho: # > raise TypeError('payload must be a string, bytearray, int, float or None.') # Need to be able to take all of these somehow, and also match these # against any payload received on the topic def run(self): attach_yaml( self._original_publish_args, name="rest_request", ) try: return self._prepared() except ValueError as e: logger.exception("Error publishing") raise exceptions.MQTTRequestException from e @property def request_vars(self) -> Box: return Box(self._original_publish_args) tavern-3.6.0/tavern/_plugins/mqtt/response.py000066400000000000000000000270711520710011500213320ustar00rootroot00000000000000import concurrent import concurrent.futures import contextlib import json import logging import time from collections.abc import Mapping from dataclasses import dataclass from typing import Optional, Union from paho.mqtt.client import MQTTMessage from tavern._core import exceptions from tavern._core.dict_util import check_keys_match_recursive from tavern._core.loader import ANYTHING from tavern._core.pytest.config import TestConfig from tavern._core.pytest.newhooks import call_hook from tavern._core.report import attach_yaml from tavern._core.strict_util import StrictOption from tavern.response import BaseResponse from .client import MQTTClient logger: logging.Logger = logging.getLogger(__name__) _default_timeout = 1 class MQTTResponse(BaseResponse): response: MQTTMessage def __init__( self, client: MQTTClient, name: str, expected: TestConfig, test_block_config: TestConfig, ) -> None: super().__init__( name, expected, test_block_config, multiple_responses_block="mqtt_responses", ) self._client = client self.received_messages: list = [] def __str__(self) -> str: if self.response: return self.response.payload.decode("utf-8") else: return "" def verify(self, response: MQTTMessage) -> Mapping: """Ensure mqtt message has arrived Args: response: not used except for debug printing """ self.response = response try: return self._await_response() finally: self._client.unsubscribe_all() def _await_response(self) -> Mapping: """Actually wait for response Returns: things to save to variables for the rest of this test """ # Get into class with metadata attached expected = self.expected["mqtt_responses"] # Group by topic using a dict of lists, preserving ALL expectations by_topic: dict[str, list[dict]] = {} for item in expected: by_topic.setdefault(item["topic"], []).append(item) correct_messages: list[_ReturnedMessage] = [] warnings: list[str] = [] with concurrent.futures.ThreadPoolExecutor() as executor: futures = [] for topic, expected_for_topic in by_topic.items(): logger.debug("Starting thread for messages on topic '%s'", topic) futures.append( executor.submit( self._await_messages_on_topic, topic, expected_for_topic ) ) for future in concurrent.futures.as_completed(futures): # for future in futures: try: messages, warnings = future.result() except Exception as e: raise exceptions.ConcurrentError( "unexpected error getting result from future" ) from e else: warnings.extend(warnings) correct_messages.extend(messages) if self.errors: if warnings: self._adderr("\n".join(warnings)) raise exceptions.TestFailError( f"Test '{self.name:s}' failed:\n{self._str_errors():s}", failures=self.errors, ) saved: dict = {} for msg in correct_messages: # Check saving things from the payload and from json saved.update( self.maybe_get_save_values_from_save_block( "payload", msg.msg.payload, outer_save_block=msg.expected, ) ) saved.update( self.maybe_get_save_values_from_save_block( "json", msg.msg.payload, outer_save_block=msg.expected, ) ) saved.update(self.maybe_get_save_values_from_ext(msg.msg, msg.expected)) # Trying to save might have introduced errors, so check again if self.errors: raise exceptions.TestFailError( f"Saving results from test '{self.name:s}' failed:\n{self._str_errors():s}", failures=self.errors, ) return saved def _await_messages_on_topic( self, topic: str, expected: list[dict] ) -> tuple[list["_ReturnedMessage"], list[str]]: """ Waits for the specific message Args: topic: topic to listen on expected: expected response for this block Returns: The correct message (if any) and warnings from processing the message """ timeout = max(m.get("timeout", _default_timeout) for m in expected) # A list of verifiers that can be used to validate messages for this topic verifiers = [_MessageVerifier(self.test_block_config, v) for v in expected] correct_messages = [] warnings = [] time_spent = 0.0 while (time_spent < timeout) and verifiers: t0 = time.time() msg = self._client.message_received(topic, timeout - time_spent) if not msg: # timed out break logger.debug("Seeing if message '%s' matched expected", msg) call_hook( self.test_block_config, "pytest_tavern_beta_after_every_response", expected=expected, response=msg, ) self.received_messages.append(msg) with contextlib.suppress(AttributeError): msg.payload = msg.payload.decode("utf8") attach_yaml( { "topic": msg.topic, "payload": msg.payload, "timestamp": msg.timestamp, }, name="rest_response", ) found: list[int] = [] for i, v in enumerate(verifiers): if v.is_valid(msg): correct_messages.append(_ReturnedMessage(v.expected, msg)) if found: logger.warning( "Message was matched by multiple mqtt_response blocks" ) found.append(i) warnings.extend(v.popwarnings()) verifiers = [v for (i, v) in enumerate(verifiers) if i not in found] time_spent += time.time() - t0 if verifiers: for v in verifiers: if not v.expected.get("unexpected"): self._adderr( "Expected '%s' on topic '%s' but no such message received", v.expected_payload, topic, ) for msg in correct_messages: if msg.expected.get("unexpected"): self._adderr( "Got '%s' on topic '%s' marked as unexpected", msg.expected["payload"], topic, ) self._maybe_run_validate_functions(msg) return correct_messages, warnings @dataclass class _ReturnedMessage: """An actual message returned from the API and it's matching 'expected' block.""" expected: Mapping msg: MQTTMessage class _MessageVerifier: def __init__(self, test_block_config: TestConfig, expected: Mapping) -> None: self.expires = time.time() + expected.get("timeout", _default_timeout) self.expected = expected self.expected_payload, self.expect_json_payload = self._get_payload_vals( expected ) test_strictness = test_block_config.strict self.block_strictness: StrictOption = test_strictness.option_for("json") # Any warnings to do with the request # eg, if a message was received but it didn't match, message had payload, etc. self.warnings: list[str] = [] def is_valid(self, msg: MQTTMessage) -> bool: if time.time() > self.expires: return False topic = self.expected["topic"] def addwarning(w, *args, **kwargs): logger.warning(w, *args, **kwargs) self.warnings.append(w % args) if self.expect_json_payload: try: msg.payload = json.loads(msg.payload) except json.decoder.JSONDecodeError: addwarning( "Expected a json payload but got '%s'", msg.payload, exc_info=True, ) return False if self.expected_payload is None: if msg.payload is None or msg.payload == "": logger.info("Got message with no payload (as expected) on '%s'", topic) return True else: addwarning( "Message had payload '%s' but we expected no payload", msg.payload, ) elif self.expected_payload is ANYTHING: logger.info("Got message on %s matching !anything token", topic) return True elif msg.payload != self.expected_payload: if self.expect_json_payload: try: check_keys_match_recursive( self.expected_payload, msg.payload, [], strict=self.block_strictness, ) except exceptions.KeyMismatchError: # Just want to log the mismatch pass else: logger.info( "Got expected message in '%s' with expected payload", msg.topic, ) logger.debug("Matched payload was '%s", msg.payload) return True addwarning( "Got unexpected payload on topic '%s': '%s' (expected '%s')", msg.topic, msg.payload, self.expected_payload, ) else: logger.info( "Got expected message in '%s' with expected payload", msg.topic, ) logger.debug("Matched payload was '%s", msg.payload) return True return False @staticmethod def _get_payload_vals(expected: Mapping) -> tuple[Optional[Union[str, dict]], bool]: """Gets the payload from the 'expected' block Returns: First element is the expected payload, second element is whether it's expected to be json or not """ # TODO move this check to initialisation/schema checking if "json" in expected: if "payload" in expected: raise exceptions.BadSchemaError( "Can only specify one of 'payload' or 'json' in MQTT response" ) payload = expected["json"] json_payload = True if payload.pop("$ext", None): raise exceptions.MisplacedExtBlockException( "json", ) elif "payload" in expected: payload = expected["payload"] json_payload = False else: payload = None json_payload = False return payload, json_payload def popwarnings(self) -> list[str]: popped = [] while self.warnings: popped.append(self.warnings.pop(0)) return popped tavern-3.6.0/tavern/_plugins/mqtt/tavernhook.py000066400000000000000000000026741520710011500216560ustar00rootroot00000000000000from collections.abc import Iterable from os.path import abspath, dirname, join from typing import Optional, Union import yaml from tavern._core.dict_util import format_keys from tavern._core.pytest.config import TestConfig from .client import MQTTClient from .request import MQTTRequest from .response import MQTTResponse session_type = MQTTClient request_type = MQTTRequest request_block_name = "mqtt_publish" def get_expected_from_request( response_block: Union[dict, Iterable[dict]], test_block_config: TestConfig, session: MQTTClient, ) -> Optional[dict]: expected: Optional[dict] = None # mqtt response is not required if response_block: expected = {"mqtt_responses": []} if isinstance(response_block, dict): response_block = [response_block] for response in response_block: # format so we can subscribe to the right topic f_expected = format_keys(response, test_block_config.variables) mqtt_client = session mqtt_client.subscribe(f_expected["topic"], f_expected.get("qos", 1)) expected["mqtt_responses"].append(f_expected) return expected verifier_type = MQTTResponse response_block_name = "mqtt_response" has_multiple_responses = True schema_path: str = join(abspath(dirname(__file__)), "jsonschema.yaml") with open(schema_path, encoding="utf-8") as schema_file: schema = yaml.load(schema_file, Loader=yaml.SafeLoader) tavern-3.6.0/tavern/_plugins/rest/000077500000000000000000000000001520710011500171035ustar00rootroot00000000000000tavern-3.6.0/tavern/_plugins/rest/__init__.py000066400000000000000000000000001520710011500212020ustar00rootroot00000000000000tavern-3.6.0/tavern/_plugins/rest/jsonschema.yaml000066400000000000000000000105351520710011500221250ustar00rootroot00000000000000$schema: "http://json-schema.org/draft-07/schema#" title: REST schema description: Schema for REST requests ### definitions: http_request: type: object additionalProperties: false description: HTTP request to perform as part of stage required: - url properties: url: description: URL to make request to oneOf: - type: string - type: object properties: "$ext": $ref: "#/definitions/verify_block" cert: description: Certificate to use - either a path to a certificate and key in one file, or a two item list containing the certificate and key separately oneOf: - type: string - type: array minItems: 2 maxItems: 2 items: type: string auth: description: Authorisation to use for request - a list containing username and password, or a $ext block oneOf: - type: array minItems: 2 maxItems: 2 items: type: string - type: object properties: "$ext": $ref: "#/definitions/verify_block" verify: description: Whether to verify the server's certificates oneOf: - type: boolean default: false - type: string method: description: HTTP method to use for request default: GET type: string follow_redirects: type: boolean description: Whether to follow redirects from 3xx responses default: false stream: type: boolean description: Whether to stream the download from the request default: false cookies: type: array description: Which cookies to use in the request items: oneOf: - type: string - type: object json: description: JSON body to send in request body $ref: "#/definitions/any_json" params: description: Query parameters type: object headers: description: Headers for request type: object data: description: Form data to send in request oneOf: - type: object - type: string timeout: description: How long to wait for requests to time out oneOf: - type: number - type: array minItems: 2 maxItems: 2 items: type: number file_body: type: string description: Path to a file to upload as the request body files: oneOf: - type: object - type: array description: Files to send as part of the request clear_session_cookies: description: Whether to clear sesion cookies before running this request type: boolean http_response: type: object additionalProperties: false description: Expected HTTP response properties: strict: $ref: "#/definitions/strict_block" status_code: description: Status code(s) to match oneOf: - type: integer - type: array minItems: 1 items: type: integer cookies: type: array description: Cookies expected to be returned uniqueItems: true minItems: 1 items: type: string text: description: Expected plain text response body type: string json: description: Expected JSON response $ref: "#/definitions/any_json" redirect_query_params: description: Query parameters parsed from the 'location' of a redirect type: object verify_response_with: oneOf: - $ref: "#/definitions/verify_block" - type: array items: $ref: "#/definitions/verify_block" headers: description: Headers expected in response type: object save: type: object description: Which objects to save from the response stage: type: object description: One stage in a test additionalProperties: false required: - name properties: request: $ref: "#/definitions/http_request" response: $ref: "#/definitions/http_response" tavern-3.6.0/tavern/_plugins/rest/request.py000066400000000000000000000424761520710011500211620ustar00rootroot00000000000000import contextlib import json import logging import warnings from collections.abc import Callable, Mapping from contextlib import ExitStack from itertools import filterfalse, tee from typing import ClassVar, Optional, Union from urllib.parse import quote_plus import requests from box.box import Box from requests.cookies import cookiejar_from_dict from requests.utils import dict_from_cookiejar from tavern._core import exceptions from tavern._core.dict_util import check_expected_keys, deep_dict_merge, format_keys from tavern._core.extfunctions import update_from_ext from tavern._core.files import ( _find_file_in_include_path, _parse_file_list, _parse_file_mapping, guess_filespec, ) from tavern._core.general import valid_http_methods from tavern._core.pytest.config import TestConfig from tavern._core.report import attach_yaml from tavern.request import BaseRequest logger: logging.Logger = logging.getLogger(__name__) def get_file_arguments( request_args: dict, stack: ExitStack, test_block_config: TestConfig ) -> dict: """Get correct arguments for anything that should be passed as a file to requests Args: request_args: args passed to requests test_block_config: config for test stack: context stack to add file objects to so they're closed correctly after use Returns: mapping of 'files' block to pass directly to requests """ files_to_send: Optional[Union[dict, list]] = None file_args = request_args.get("files") if isinstance(file_args, dict): files_to_send = _parse_file_mapping(file_args, stack, test_block_config) elif isinstance(file_args, list): files_to_send = _parse_file_list(file_args, stack, test_block_config) elif file_args is not None: raise exceptions.BadSchemaError( f"'files' key in a HTTP request can only be a dict or a list but was {type(file_args)}" ) if files_to_send: return {"files": files_to_send} else: return {} def get_request_args(rspec: dict, test_block_config: TestConfig) -> dict: """Format the test spec given values inthe global config Todo: Add similar functionality to validate/save $ext functions so input can be generated from a function Args: rspec: Test spec test_block_config: Test block config Returns: Formatted test spec Raises: BadSchemaError: Tried to pass a body in a GET request """ if "method" not in rspec: logger.debug("Using default GET method") rspec["method"] = "GET" if "headers" not in rspec: rspec["headers"] = {} content_keys = ["data", "json", "files", "file_body"] in_request = [c for c in content_keys if c in rspec] if len(in_request) > 1: # Explicitly raise an error here # From requests docs: # Note, the json parameter is ignored if either data or files is passed. # However, we allow the data + files case, as requests handles it correctly if set(in_request) != {"data", "files"}: raise exceptions.BadSchemaError( "Can only specify one type of request data in HTTP request (tried to " "send {})".format(" and ".join(in_request)) ) normalised_headers = {k.lower(): v for k, v in rspec["headers"].items()} def get_header(name): return normalised_headers.get(name, None) content_header = get_header("content-type") encoding_header = get_header("content-encoding") if "files" in rspec: if content_header: logger.warning( "Tried to specify a content-type header while sending multipart files - this will be ignored" ) rspec["headers"] = { i: j for i, j in normalised_headers.items() if i.lower() != "content-type" } fspec = format_keys(rspec, test_block_config.variables) if fspec["method"] not in valid_http_methods: raise exceptions.BadSchemaError( "Unknown HTTP method {}".format(fspec["method"]) ) # If the user is using the file_body key, try to guess what type of file/encoding it is. filename = fspec.get("file_body") if filename: # Resolve filename using include path logic (same as !include) resolved_filename = _find_file_in_include_path( filename, test_block_config.test_file_path ) with ExitStack() as stack: file_spec, group_name, _ = guess_filespec( resolved_filename, stack, test_block_config ) # Group name doesn't matter here as it's a single file if group_name: logger.warning( f"'group_name' for the 'file_body' key was specified as '{group_name}' but this will be ignored " ) fspec["file_body"] = resolved_filename if file_spec.content_type: inferred_content_type = file_spec.content_type if content_header: logger.info( "inferred content type '%s' from %s, but using user specified content type '%s'", inferred_content_type, filename, content_header, ) else: fspec["headers"]["content-type"] = inferred_content_type else: logger.debug( "No content type inferred from file_body for %s", filename, ) if file_spec.content_encoding: inferred_content_encoding = file_spec.content_encoding if encoding_header: logger.info( "inferred content encoding '%s' from %s, but using user specified encoding '%s", inferred_content_encoding, filename, encoding_header, ) elif isinstance(inferred_content_encoding, dict): fspec["headers"].update(inferred_content_encoding) else: logger.debug( "No encoding inferred from file_body for %s", filename, ) ######################################### request_args = {} def add_request_args(keys, optional): for key in keys: try: request_args[key] = fspec[key] except KeyError: if optional or (key in request_args): continue # This should never happen raise # Ones that are required and are enforced to be present by the schema required_in_file = ["method", "url"] add_request_args(["file_body"], True) add_request_args(required_in_file, False) add_request_args(RestRequest.optional_in_file, True) if "auth" in fspec: if not isinstance(fspec["auth"], dict): request_args["auth"] = tuple(fspec["auth"]) if "cert" in fspec: if isinstance(fspec["cert"], list): request_args["cert"] = tuple(fspec["cert"]) if "timeout" in fspec: # Needs to be a tuple, it being a list doesn't work if isinstance(fspec["timeout"], list): request_args["timeout"] = tuple(fspec["timeout"]) # If there's any nested json in parameters, urlencode it # if you pass nested json to 'params' then requests silently fails and just # passes the 'top level' key, ignoring all the nested json. I don't think # there's a standard way to do this, but urlencoding it seems sensible # eg https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter # > ...represented in an OAuth 2.0 request as UTF-8 encoded JSON (which ends # > up being form-urlencoded when passed as an OAuth parameter) for key, value in request_args.get("params", {}).items(): if not isinstance(value, str): if key == "$ext": logger.debug("Skipping converting of ext function (%s)", value) continue if isinstance(value, dict): request_args["params"][key] = quote_plus(json.dumps(value)) optional = {"verify", "stream"} for key in optional: if key in fspec: request_args[key] = fspec[key] # TODO # requests takes all of these - we need to parse the input to get them # "cookies", # These verbs _can_ send a body but the body _should_ be ignored according # to the specs - some info here: # https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods if request_args["method"] in ["GET", "HEAD", "OPTIONS"]: if any(i in request_args for i in ["json", "data"]): warnings.warn( # noqa "You are trying to send a body with a HTTP verb that has no semantic use for it", RuntimeWarning, ) if not request_args["headers"]: request_args.pop("headers") return request_args @contextlib.contextmanager def _set_cookies_for_request(session: requests.Session, request_args: Mapping): """ Possibly reset session cookies for a single request then set them back. If no cookies were present in the request arguments, do nothing. This does not use try/finally because if it fails then we don't care about the cookies anyway Args: session: Current session request_args: current request arguments """ if "cookies" in request_args: old_cookies = dict_from_cookiejar(session.cookies) session.cookies = cookiejar_from_dict({}) yield session.cookies = cookiejar_from_dict(old_cookies) else: yield def _check_allow_redirects(rspec: dict, test_block_config: TestConfig): """ Check for allow_redirects flag in settings/stage Args: rspec: request dictionary test_block_config: config available for test Returns: Whether to allow redirects for this stage or not """ # By default, don't follow redirects allow_redirects = False # Then check to see if we should follow redirects based on settings global_follow_redirects = test_block_config.follow_redirects if global_follow_redirects is not None: allow_redirects = global_follow_redirects # ... and test flags test_follow_redirects = rspec.pop("follow_redirects", None) if test_follow_redirects is not None: if global_follow_redirects is not None: logger.info( "Overriding global follow_redirects setting of %s with test-level specification of %s", global_follow_redirects, test_follow_redirects, ) allow_redirects = test_follow_redirects logger.debug("Allow redirects in stage: %s", allow_redirects) return allow_redirects def _read_expected_cookies( session: requests.Session, rspec: Mapping, test_block_config: TestConfig ) -> dict | None: """ Read cookies to inject into request, ignoring others which are present Args: session: session object rspec: test spec test_block_config: config available for test Returns: cookies to use in request, if any """ # Need to do this down here - it is separate from getting request args as # it depends on the state of the session existing_cookies = session.cookies.get_dict() cookies_to_use = format_keys( rspec.get("cookies", None), test_block_config.variables ) if cookies_to_use is None: logger.debug("No cookies specified in request, sending all") return None elif cookies_to_use in ([], {}): logger.debug("Not sending any cookies with request") return {} def partition(pred, iterable): """From itertools documentation""" t1, t2 = tee(iterable) return list(filterfalse(pred, t1)), list(filter(pred, t2)) # Cookies are either a single list item, specitying which cookie to send, or # a mapping, specifying cookies to override expected, extra = partition(lambda x: isinstance(x, dict), cookies_to_use) missing = set(expected) - set(existing_cookies.keys()) if missing: logger.error("Missing cookies") raise exceptions.MissingCookieError( f"Tried to use cookies '{expected}' in request but only had '{existing_cookies}' available" ) # 'extra' should be a list of dictionaries - merge them into one here from_extra = {k: v for mapping in extra for (k, v) in mapping.items()} if len(extra) != len(from_extra): logger.error("Duplicate cookie override values specified") raise exceptions.DuplicateCookieError( "Tried to override the value of a cookie multiple times in one request" ) overwritten = [i for i in expected if i in from_extra] if overwritten: logger.error("Duplicate cookies found in request") raise exceptions.DuplicateCookieError( f"Asked to use cookie {overwritten} from previous request but also redefined it as {from_extra}" ) from_cookiejar = {c: existing_cookies.get(c) for c in expected} return deep_dict_merge(from_cookiejar, from_extra) class RestRequest(BaseRequest): optional_in_file: ClassVar[list[str]] = [ "json", "data", "params", "headers", "files", "timeout", "cert", "auth", ] _request_args: Box def __init__( self, session: requests.Session, rspec: dict, test_block_config: TestConfig ) -> None: """Prepare request Args: session: existing session rspec: test spec test_block_config: Any configuration for this the block of tests Raises: UnexpectedKeysError: If some unexpected keys were used in the test spec. Only valid keyword args to requests can be passed """ if rspec.pop("clear_session_cookies", False): session.cookies.clear_session_cookies() expected = { "method", "url", "headers", "data", "params", "auth", "json", "verify", "files", "file_body", "stream", "timeout", "cookies", "cert", # "hooks", "follow_redirects", } check_expected_keys(expected, rspec) request_args = get_request_args(rspec, test_block_config) update_from_ext( request_args, RestRequest.optional_in_file + ["url"], ) # Used further down, but pop it asap to avoid unwanted side effects file_body: str | None = request_args.pop("file_body", None) # If there was a 'cookies' key, set it in the request expected_cookies = _read_expected_cookies(session, rspec, test_block_config) if expected_cookies is not None: logger.debug("Sending cookies %s in request", expected_cookies.keys()) request_args.update(cookies=expected_cookies) # Check for redirects request_args.update( allow_redirects=_check_allow_redirects(rspec, test_block_config) ) logger.debug("Request args: %s", request_args) self._request_args = Box(request_args) # There is no way using requests to make a prepared request that will # not follow redirects, so instead we have to do this. This also means # that we can't have the 'pre-request' hook any more because we don't # create a prepared request. def prepared_request(): # If there are open files, create a context manager around each so # they will be closed at the end of the request. with ExitStack() as stack: stack.enter_context(_set_cookies_for_request(session, request_args)) # These are mutually exclusive if file_body: # Any headers will have been set in the above function file = stack.enter_context(open(file_body, "rb")) self._request_args.update(data=file) else: files = get_file_arguments( self._request_args, stack, test_block_config ) if files: logger.debug("Sending %d files in request", len(files["files"])) self._request_args.update(files) headers = self._request_args.get("headers", {}) self._request_args["headers"] = { str(k): str(v) for k, v in headers.items() } return session.request(**self._request_args) self._prepared: Callable[[], requests.Response] = prepared_request def run(self) -> requests.Response: """Runs the prepared request and times it Returns: response object """ attach_yaml( self._request_args, name="rest_request", ) try: return self._prepared() except requests.exceptions.RequestException as e: logger.exception("Error running prepared request") raise exceptions.RestRequestException from e @property def request_vars(self) -> Box: return self._request_args tavern-3.6.0/tavern/_plugins/rest/response.py000066400000000000000000000112571520710011500213210ustar00rootroot00000000000000import json import logging from typing import Any, Union from urllib.parse import parse_qs, urlparse import requests from tavern._core import exceptions from tavern._core.pytest import call_hook from tavern._core.report import attach_yaml from tavern._plugins.common.response import CommonResponse from tavern.response import indent_err_text logger: logging.Logger = logging.getLogger(__name__) class RestResponse(CommonResponse): response: requests.Response def _get_redirect_query_params(self, response: requests.Response) -> dict[str, str]: """If there was a redirect header, get any query parameters from it""" try: redirect_url = response.headers["location"] except KeyError as e: if "redirect_query_params" in self.expected.get("save", {}): self._adderr( "Wanted to save %s, but there was no redirect url in response", self.expected["save"]["redirect_query_params"], e=e, ) redirect_query_params = {} else: parsed = urlparse(redirect_url) qp = parsed.query redirect_query_params = {i: j[0] for i, j in parse_qs(qp).items()} return redirect_query_params def _check_status_code(self, status_code: Union[int, list[int]], body: Any) -> None: expected_code = self.expected["status_code"] if (isinstance(expected_code, int) and status_code == expected_code) or ( isinstance(expected_code, list) and (status_code in expected_code) ): logger.debug( "Status code '%s' matched expected '%s'", status_code, expected_code ) return elif isinstance(status_code, int) and 400 <= status_code < 500: # special case if there was a bad request. This assumes that the # response would contain some kind of information as to why this # request was rejected. self._adderr( "Status code was %s, expected %s:\n%s", status_code, expected_code, indent_err_text(json.dumps(body)), ) else: self._adderr("Status code was %s, expected %s", status_code, expected_code) def verify(self, response: requests.Response) -> dict: """Verify response against expected values and returns any values that we wanted to save for use in future requests There are various ways to 'validate' a block - a specific function, just matching values, validating a schema, etc... Args: response: response object Returns: Any saved values Raises: TestFailError: Something went wrong with validating the response """ call_hook( self.test_block_config, "pytest_tavern_beta_after_every_response", expected=self.expected, response=response, ) self._verbose_log_response(response) # type:ignore[arg-type] try: body = response.json() except ValueError: body = None redirect_query_params = self._get_redirect_query_params(response) # Run validation on response self._check_status_code(response.status_code, body) self._validate_block("json", body) # type:ignore[arg-type] self._validate_block("headers", response.headers) self._validate_block("redirect_query_params", redirect_query_params) self._validate_text(response.text) attach_yaml( { "status_code": response.status_code, "headers": dict(response.headers), "body": body, "redirect_query_params": redirect_query_params, }, name="rest_response", ) self._maybe_run_validate_functions(response) # Get any keys to save saved = self._common_verify_save(body, response) # type:ignore[arg-type] saved.update( self.maybe_get_save_values_from_save_block("headers", response.headers) ) saved.update( self.maybe_get_save_values_from_save_block( "redirect_query_params", redirect_query_params ) ) # Check cookies for cookie in self.expected.get("cookies", []): if cookie not in response.cookies: self._adderr("No cookie named '%s' in response", cookie) if self.errors: raise exceptions.TestFailError( f"Test '{self.name:s}' failed:\n{self._str_errors():s}", failures=self.errors, ) return saved tavern-3.6.0/tavern/_plugins/rest/tavernhook.py000066400000000000000000000023051520710011500216350ustar00rootroot00000000000000from os.path import abspath, dirname, join import requests import yaml from tavern._core import exceptions from tavern._core.dict_util import format_keys from tavern._core.plugins import PluginHelperBase from tavern._core.pytest.config import TestConfig from .request import RestRequest from .response import RestResponse class TavernRestPlugin(PluginHelperBase): session_type = requests.Session request_type = RestRequest request_block_name = "request" schema: dict has_multiple_responses = False @staticmethod def get_expected_from_request( response_block: dict, test_block_config: TestConfig, session ): if response_block is None: raise exceptions.MissingSettingsError( "no response block specified for HTTP test stage" ) f_expected = format_keys(response_block, test_block_config.variables) return f_expected verifier_type = RestResponse response_block_name = "response" schema_path: str = join(abspath(dirname(__file__)), "jsonschema.yaml") with open(schema_path, encoding="utf-8") as schema_file: schema = yaml.load(schema_file, Loader=yaml.SafeLoader) TavernRestPlugin.schema = schema tavern-3.6.0/tavern/core.py000066400000000000000000000057631520710011500156230ustar00rootroot00000000000000import os from contextlib import ExitStack import pytest from pytest import ExitCode from tavern._core import exceptions from tavern._core.schema.files import wrapfile def _get_or_wrap_global_cfg(stack: ExitStack, tavern_global_cfg: dict | str) -> str: """ Try to parse global configuration from given argument. Args: stack: context stack for wrapping file if a dictionary is given tavern_global_cfg: path to a file or a dictionary with configuration. Returns: path to global config file Raises: InvalidSettingsError: If global config was not of the right type or a given path does not exist """ if isinstance(tavern_global_cfg, str): if not os.path.exists(tavern_global_cfg): raise exceptions.InvalidSettingsError( f"global config file '{tavern_global_cfg}' does not exist" ) global_filename = tavern_global_cfg elif isinstance(tavern_global_cfg, dict): global_filename = stack.enter_context(wrapfile(tavern_global_cfg)) else: raise exceptions.InvalidSettingsError( f"Invalid format for global settings - must be dict or path to settings file, was {type(tavern_global_cfg)}" ) return global_filename def run( # type:ignore in_file: str, tavern_global_cfg: dict | str | None = None, tavern_mqtt_backend: str | None = None, tavern_http_backend: str | None = None, tavern_grpc_backend: str | None = None, tavern_strict: bool | None = None, pytest_args: list | None = None, ) -> ExitCode | int: """Run all tests contained in a file using pytest.main() Args: in_file: file to run tests on tavern_global_cfg: Extra global config tavern_mqtt_backend: name of MQTT plugin to use. If not specified, uses tavern-mqtt tavern_http_backend: name of HTTP plugin to use. If not specified, use tavern-http tavern_grpc_backend: name of GRPC plugin to use. If not specified, use tavern-grpc tavern_strict: Strictness of checking for responses. See documentation for details pytest_args: List of extra arguments to pass directly to Pytest as if they were command line arguments Returns: Whether ALL tests passed or not """ pytest_args = pytest_args or [] pytest_args += [in_file] if tavern_mqtt_backend: pytest_args += ["--tavern-mqtt-backend", tavern_mqtt_backend] if tavern_http_backend: pytest_args += ["--tavern-http-backend", tavern_http_backend] if tavern_grpc_backend: pytest_args += ["--tavern-grpc-backend", tavern_grpc_backend] if tavern_strict: pytest_args += ["--tavern-strict", tavern_strict] with ExitStack() as stack: if tavern_global_cfg: global_filename = _get_or_wrap_global_cfg(stack, tavern_global_cfg) pytest_args += ["--tavern-global-cfg", global_filename] return pytest.main(args=pytest_args) tavern-3.6.0/tavern/entry.py000066400000000000000000000053011520710011500160200ustar00rootroot00000000000000import argparse import logging.config from argparse import ArgumentParser from textwrap import dedent from .core import run class TavernArgParser(ArgumentParser): def __init__(self) -> None: description = """Parse yaml + make requests against an API Any extra arguments will be passed directly to Pytest. Run py.test --help for a list""" super().__init__( description=dedent(description), formatter_class=argparse.RawDescriptionHelpFormatter, ) self.add_argument("in_file", help="Input file with tests in") self.add_argument( "--log-to-file", help="Log output to a file (tavern.log if no argument is given)", nargs="?", const="tavern.log", ) self.add_argument( "--stdout", help="Log output stdout", action="store_true", default=False ) self.add_argument( "--debug", help="Log debug information (only relevant if --stdout or --log-to-file is passed)", action="store_true", default=False, ) def main() -> None: args, remaining = TavernArgParser().parse_known_args() vargs = vars(args) if vargs.pop("debug"): log_level = "DEBUG" else: log_level = "INFO" # Basic logging config that will print out useful information log_cfg: dict = { "version": 1, "formatters": { "default": { "format": "%(asctime)s [%(levelname)s]: (%(name)s:%(lineno)d) %(message)s", "style": "%", } }, "handlers": { "to_stdout": { "class": "logging.StreamHandler", "formatter": "default", "stream": "ext://sys.stdout", }, "nothing": {"class": "logging.NullHandler"}, }, "loggers": { "tavern": {"handlers": ["nothing"], "level": log_level}, "": {"handlers": ["nothing"], "level": log_level}, }, } log_loc = vargs.pop("log_to_file") if log_loc: log_cfg["handlers"].update( { "to_file": { "class": "logging.FileHandler", "filename": log_loc, "formatter": "default", } } ) log_cfg["loggers"]["tavern"]["handlers"].append("to_file") if vargs.pop("stdout"): log_cfg["loggers"]["tavern"]["handlers"].append("to_stdout") logging.config.dictConfig(log_cfg) in_file = vargs.pop("in_file") global_cfg = vargs.pop("tavern_global_cfg", {}) raise SystemExit(run(in_file, global_cfg, pytest_args=remaining, **vargs)) tavern-3.6.0/tavern/helpers.py000066400000000000000000000213121520710011500163210ustar00rootroot00000000000000import importlib import json import logging import re from collections.abc import Iterable, Mapping import jmespath import jwt import requests from box.box import Box from tavern._core import exceptions from tavern._core.dict_util import check_keys_match_recursive, recurse_access_key from tavern._core.jmesutils import actual_validation, validate_comparison from tavern._core.schema.files import verify_pykwalify logger: logging.Logger = logging.getLogger(__name__) def check_exception_raised( response: requests.Response, exception_location: str ) -> None: """Make sure the result from the server is the same as the exception we expect to raise Args: response: response object exception_location: entry point style location of exception """ dumped = json.loads(response.content.decode("utf8")) module_name, exception_name = exception_location.split(":") module = importlib.import_module(module_name) exception = getattr(module, exception_name) for possible_title in ["title", "error"]: if possible_title in dumped: try: assert dumped[possible_title] == exception.error_title # noqa except AssertionError as e: raise exceptions.UnexpectedExceptionError( "Incorrect title of exception" ) from e actual_description = dumped.get("description", dumped.get("error_description")) expected_description = getattr( exception, "error_description", exception.description ) try: assert actual_description == expected_description # noqa except AssertionError as e: # If it has a format, ignore this error. Would be annoying to say how to # format things in the validator, especially if it's a set/dict which is # unordered # TODO: improve logic? Use a regex like '{.+?}' instead? if not any(i in expected_description for i in "{}"): raise exceptions.UnexpectedExceptionError( "exception description did not match" ) from e try: assert response.status_code == int(exception.status.split()[0]) # noqa except AssertionError as e: raise exceptions.UnexpectedExceptionError( "exception status code did not match" ) from e def validate_jwt( response: requests.Response, jwt_key: str, **kwargs ) -> Mapping[str, Box]: """Make sure a jwt is valid This uses the pyjwt library to decode the jwt, so any keyword args needed should be passed as per that library. You will probably want to use verify_signature=False unless using a HMAC key because it can be a bit verbose to pass in a public key. This also returns the jwt so it can be used both to verify and save jwts - it wraps this in a Box so it can also be used for future formatting Args: response: requests.Response object jwt_key: key of jwt in body of request **kwargs: Any extra arguments to pass to jwt.decode Returns: mapping of jwt: boxed jwt claims """ token = response.json()[jwt_key] decoded = jwt.decode(token, **kwargs) logger.debug("Decoded jwt to %s", decoded) return {"jwt": Box(decoded)} def validate_pykwalify(response: requests.Response, schema: dict) -> None: """Make sure the response matches a given schema Args: response: reqeusts Response object schema: Schema for response """ try: to_verify = response.json() except (TypeError, ValueError, json.JSONDecodeError) as e: raise exceptions.BadSchemaError( "Tried to match a pykwalify schema against a non-json response" ) from e else: verify_pykwalify(to_verify, schema) def validate_regex( response: requests.Response, expression: str, *, header: str | None = None, in_jmespath: str | None = None, ) -> dict[str, Box]: """Make sure the response matches a regex expression Args: response: requests.Response object expression: Regex expression to use header: Match against a particular header instead of the body in_jmespath: if present, jmespath to access before trying to match Returns: mapping of regex to boxed name capture groups """ if header and in_jmespath: raise exceptions.BadSchemaError("Can only specify one of header or jmespath") if header: content = response.headers[header] else: content = response.text if in_jmespath: if not response.headers.get("content-type", "").startswith("application/json"): logger.warning( "Trying to use jmespath match but content type is not application/json" ) try: decoded = json.loads(content) except json.JSONDecodeError as e: raise exceptions.RegexAccessError( "unable to decode json for regex match" ) from e content = recurse_access_key(decoded, in_jmespath) if not isinstance(content, str): raise exceptions.RegexAccessError( f"Successfully accessed {in_jmespath} from response, but it was a {type(content)} and not a string" ) logger.debug("Matching %s with %s", content, expression) match = re.search(expression, content) if match is None: raise exceptions.RegexAccessError("No match for regex") return {"regex": Box(match.groupdict())} def validate_content(response: requests.Response, comparisons: Iterable[dict]) -> None: """Asserts expected value with actual value using JMES path expression Args: response: reqeusts.Response object. comparisons: A list of dict containing the following keys: 1. jmespath : JMES path expression to extract data from. 2. operator : Operator to use to compare data. 3. expected : The expected value to match for """ for each_comparison in comparisons: path, _operator, expected = validate_comparison(each_comparison) logger.debug("Searching for '%s' in '%s'", path, response.json()) actual = jmespath.search(path, response.json()) expession = " ".join([str(path), str(_operator), str(expected)]) parsed_expession = " ".join([str(actual), str(_operator), str(expected)]) try: actual_validation(_operator, actual, expected, parsed_expession, expession) except AssertionError as e: raise exceptions.JMESError("Error validating JMES") from e def check_jmespath_match(parsed_response, query: str, expected: str | None = None): """ Check that the JMES path given in 'query' is present in the given response Args: parsed_response: Response list or dict query: JMES query expected: Possible value to match against. If None, 'query' will just check that _something_ is present """ actual = jmespath.search(query, parsed_response) msg = f"JMES path '{query}' not found in response" if actual is None: raise exceptions.JMESError(msg) if expected is not None: # Reuse dict util helper as it should behave the same check_keys_match_recursive(expected, actual, [], True) elif not actual and not (actual == expected): # This can return an empty list, but it might be what we expect. if not, # raise an exception raise exceptions.JMESError(msg) return actual def validate_pydantic( response: requests.Response, model_location: str, **kwargs ) -> None: """Validate response JSON against a pydantic model Args: response: requests.Response object model_location: Entry point style location of pydantic model (e.g., 'myapp.models:UserModel') **kwargs: Additional keyword arguments passed to model_class.model_validate() Raises: BadSchemaError: If response is not valid JSON or fails model validation ImportError: If pydantic is not installed or model cannot be imported """ # Local import to make pydantic optional from pydantic import ValidationError try: data = response.json() except (TypeError, ValueError, json.JSONDecodeError) as e: raise exceptions.BadSchemaError( "Tried to validate against a pydantic model but response is not JSON" ) from e # Import the model class dynamically module_name, model_name = model_location.split(":") module = importlib.import_module(module_name) model_class = getattr(module, model_name) try: model_class.model_validate(data, **kwargs) except ValidationError as e: raise exceptions.BadSchemaError( f"Response failed pydantic validation: {e}" ) from e tavern-3.6.0/tavern/request.py000066400000000000000000000011671520710011500163550ustar00rootroot00000000000000import abc from typing import Any import box from tavern._core.pytest.config import TestConfig class BaseRequest(metaclass=abc.ABCMeta): @abc.abstractmethod def __init__( self, session: Any, rspec: dict, test_block_config: TestConfig ) -> None: ... @property @abc.abstractmethod def request_vars(self) -> box.Box: """ Variables used in the request What is contained in the return value will change depending on the type of request Returns: box.Box: box of request vars """ @abc.abstractmethod def run(self): """Run test""" tavern-3.6.0/tavern/response.py000066400000000000000000000250141520710011500165200ustar00rootroot00000000000000import abc import dataclasses import logging import traceback from collections.abc import Mapping from textwrap import indent from typing import Any from tavern._core import exceptions from tavern._core.dict_util import ( Checked, check_keys_match_recursive, recurse_access_key, ) from tavern._core.extfunctions import get_wrapped_response_function from tavern._core.pytest.config import TestConfig from tavern._core.strict_util import StrictOption logger: logging.Logger = logging.getLogger(__name__) def indent_err_text(err: str) -> str: if err == "null": err = "" return indent(err, " " * 4) @dataclasses.dataclass class BaseResponse(metaclass=abc.ABCMeta): """Base for all response verifiers. Subclasses must have an __init__ method like: def __init__( self, client: Any, name: str, expected: TestConfig, test_block_config: TestConfig, ) -> None: super().__init__(name, expected, test_block_config) # ...other setup """ name: str expected: Any test_block_config: TestConfig response: Any | None = None multiple_responses_block: str | None = None validate_functions: list[Any] = dataclasses.field(init=False, default_factory=list) errors: list[str] = dataclasses.field(init=False, default_factory=list) def __post_init__(self) -> None: self._check_for_validate_functions(self.expected) def _str_errors(self) -> str: return "- " + "\n- ".join(self.errors) def _adderr(self, msg: str, *args, e=None) -> None: if e: logger.exception(msg, *args) else: logger.error(msg, *args) self.errors += [(msg % args)] @abc.abstractmethod def verify(self, response) -> Mapping: """Verify response against expected values and returns any values that we wanted to save for use in future requests It is expected that anything subclassing this can throw an exception indicating that the response verification failed. """ def recurse_check_key_match( self, expected_block: Checked | None, block: Checked | None, blockname: str, strict: StrictOption, ) -> None: """Valid returned data against expected data Todo: Optionally use a validation library too Args: expected_block: expected data block: actual data blockname: 'name' of this block (params, mqtt, etc) for error messages strict: strictness setting for this block """ if expected_block is None: logger.debug("No expected %s to check against", blockname) return # This should be done _before_ it gets to this point - typically in get_expected_from_request from plugin # expected_block = format_keys( # expected_block, self.test_block_config.variables # ) if block is None: if not expected_block: logger.debug( "No %s in response to check, but not erroring because expected was %s", blockname, expected_block, ) return self._adderr( "expected %s in the %s, but there was no response %s", expected_block, blockname, blockname, ) return if isinstance(block, Mapping): block = dict(block) # type:ignore[assignment] logger.debug("expected = %s, actual = %s", expected_block, block) try: check_keys_match_recursive(expected_block, block, [], strict) except exceptions.KeyMismatchError as e: self._adderr(e.args[0], e=e) def _check_for_validate_functions(self, response_block: Mapping) -> None: """ See if there were any functions specified in the response block and save them for later use Args: response_block: block of external functions to call """ def check_ext_functions(verify_block): if isinstance(verify_block, list): for vf in verify_block: self.validate_functions.append(get_wrapped_response_function(vf)) elif isinstance(verify_block, dict): self.validate_functions.append( get_wrapped_response_function(verify_block) ) elif verify_block is not None: raise exceptions.BadSchemaError( "Badly formatted 'verify_response_with' block" ) # Check for multiple responses if the plugin supports them if responses_block := response_block.get(self.multiple_responses_block, {}): # Look for a list of responses (e.g., mqtt_responses, graphql_responses) if isinstance(responses_block, list): for response in responses_block: check_ext_functions(response.get("verify_response_with", None)) return # If we found the key but it wasn't a list, fall through to check normally # Default: check the top-level verify_response_with check_ext_functions(response_block.get("verify_response_with", None)) def check_deprecated_validate(name): nfuncs = len(self.validate_functions) block = response_block.get(name, {}) if isinstance(block, dict): check_ext_functions(block.get("$ext", None)) if nfuncs != len(self.validate_functions): raise exceptions.MisplacedExtBlockException( name, ) # Could put in an isinstance check here check_deprecated_validate("json") def _maybe_run_validate_functions(self, response: Any) -> None: """Run validation functions if available Note: 'response' will be different depending on where this is called from Args: response: Response type. This could be whatever the response type/plugin uses. """ logger.debug( "Calling ext function from '%s' with response '%s'", type(self), response ) for vf in self.validate_functions: try: vf(response) except Exception as e: self._adderr( "Error calling validate function '%s':\n%s", vf.func, indent_err_text(traceback.format_exc()), e=e, ) def maybe_get_save_values_from_ext( self, response: Any, read_save_from: Mapping ) -> Mapping: """If there is an $ext function in the save block, call it and save the response Args: response: response object. Actual contents depends on which type of response is being checked read_save_from: the expected response (incl body/json/headers/mqtt topic/etc etc) containing a spec for which things should be saved from the response. Actual contents depends on which type of response is being checked Returns: mapping of name to value of things to save """ try: wrapped = get_wrapped_response_function(read_save_from["save"]["$ext"]) except KeyError: logger.debug("No save function for this stage") return {} try: saved = wrapped(response) except Exception as e: self._adderr( "Error calling save function '%s':\n%s", wrapped.func, # type:ignore indent_err_text(traceback.format_exc()), e=e, ) return {} logger.debug("saved %s from ext function", saved) if isinstance(saved, dict): return saved elif saved is not None: self._adderr( "Unexpected return value '%s' from $ext save function (expected a dict or None)", saved, ) return {} def maybe_get_save_values_from_save_block( self, key: str, save_from: Mapping | None, *, outer_save_block: Mapping | None = None, ) -> Mapping: """Save a value from a specific block in the response. See docs for maybe_get_save_values_from_given_block for more info Args: key: Name of key being used to save, for debugging save_from: An element of the response from which values are being saved outer_save_block: Read things to save from this block instead of self.expected """ logger.debug("save from: %s", save_from) read_save_from = outer_save_block or self.expected logger.debug("save spec: %s", read_save_from.get("save")) try: to_save = read_save_from["save"][key] except KeyError: logger.debug("Nothing expected to save for %s", key) return {} return self.maybe_get_save_values_from_given_block(key, save_from, to_save) def maybe_get_save_values_from_given_block( self, key: str, save_from: Mapping | None, to_save: Mapping, ) -> Mapping: """Save a value from a specific block in the response. This is different from maybe_get_save_values_from_ext - depends on the kind of response Args: key: Name of key being used to save, for debugging save_from: An element of the response from which values are being saved to_save: block containing information about things to save Returns: mapping of save_name: value, where save_name is the key we wanted to save this value as """ saved = {} if not save_from: self._adderr("No %s in response (wanted to save %s)", key, to_save) return {} for save_as, joined_key in to_save.items(): try: saved[save_as] = recurse_access_key(save_from, joined_key) except ( exceptions.InvalidQueryResultTypeError, exceptions.KeySearchNotFoundError, ) as e: self._adderr( "Wanted to save '%s' from '%s', but it did not exist in the response", joined_key, key, e=e, ) if saved: logger.debug("Saved %s for '%s' from response", saved, key) return saved tavern-3.6.0/tests/000077500000000000000000000000001520710011500141515ustar00rootroot00000000000000tavern-3.6.0/tests/conftest.py000066400000000000000000000020331520710011500163460ustar00rootroot00000000000000import logging.config import os import pytest import stevedore import yaml import tavern from tavern._plugins.rest.tavernhook import TavernRestPlugin as rest_plugin @pytest.fixture(scope="function", autouse=True) def run_all(): current_dir = os.path.dirname(os.path.abspath(__file__)) with open(os.path.join(current_dir, "logging.yaml")) as spec_file: settings = yaml.load(spec_file, Loader=yaml.SafeLoader) logging.config.dictConfig(settings) @pytest.fixture(scope="session", autouse=True) def set_plugins(): def extension(name, point): return stevedore.extension.Extension(name, point, point, point) plugins = [ extension( "requests", rest_plugin, ), ] try: import tavern._plugins.mqtt.tavernhook as mqtt_plugin except ImportError: pass else: plugins.append( extension( "paho-mqtt", mqtt_plugin, ) ) tavern._core.plugins.load_plugins.plugins = plugins tavern-3.6.0/tests/integration/000077500000000000000000000000001520710011500164745ustar00rootroot00000000000000tavern-3.6.0/tests/integration/881_1.json000066400000000000000000000000451520710011500201260ustar00rootroot00000000000000{ "{enclosed-in-key}": "sample" }tavern-3.6.0/tests/integration/881_2.yaml000066400000000000000000000000511520710011500201150ustar00rootroot00000000000000{ "sample": !raw "{enclosed-in-value}" } tavern-3.6.0/tests/integration/Dockerfile000066400000000000000000000003751520710011500204730ustar00rootroot00000000000000FROM python:3.11-slim-trixie RUN pip3 install 'pyjwt>=2.4.0,<3' 'flask>=2.2.3' "python-box>=6,<7" 'flask-httpauth>=4.8.1,<6' ENV FLASK_DEBUG=1 ENV PYTHONUNBUFFERED=0 COPY server.py / ENV FLASK_APP=/server.py CMD ["flask", "run", "--host=0.0.0.0"] tavern-3.6.0/tests/integration/OK.json.gz000066400000000000000000000000411520710011500203120ustar00rootroot00000000000000aOK.jsonSV tavern-3.6.0/tests/integration/OK.txt000066400000000000000000000000031520710011500175370ustar00rootroot00000000000000OK tavern-3.6.0/tests/integration/README.md000066400000000000000000000007701520710011500177570ustar00rootroot00000000000000# Random integration tests Though there are full examples for testing MQTT, cookies, etc, this subfolder contains more 'generic' tests such as testing regex functionality and pattern matching that don't nicely slot into the examples. Essentially, tests in this folder will typically consist of one stage (unless multi-stage functionality is being tested), and will not require logging in. For the time being, all the random tests are just being put into the same server.py and will be run with docker. tavern-3.6.0/tests/integration/common.yaml000066400000000000000000000011401520710011500206440ustar00rootroot00000000000000--- name: test includes description: used for testing against local server variables: host: http://localhost:5003 first_part: "nested" second_part: "{tavern.env_vars.SECOND_URL_PART}" # Type conversion tests v_int: 123 v_str: "abc" v_float: 4.56 v_bool: false status_200: 200 verify_false: "False" delay_before_0_1: 0.1 formatted_cookie_name: tavern-cookie-2 file_body_ref: "OK.txt" stages: - id: typetoken-anything-match name: match top level request: url: "{host}/fake_dictionary" method: GET response: status_code: 200 json: !anything tavern-3.6.0/tests/integration/conftest.py000066400000000000000000000035131520710011500206750ustar00rootroot00000000000000import logging import os from collections.abc import Iterable import pytest from box import Box from tavern._core import exceptions @pytest.fixture def str_fixture(): return "abc-fixture-value" @pytest.fixture(name="yield_str_fixture") def sdkofsok(str_fixture): yield str_fixture @pytest.fixture(name="yielder") def bluerhug(request): # This doesn't really do anything at the moment. In future it might yield # the result or something, but it's a bit difficult to do at the moment. yield "hello" @pytest.fixture(scope="session", autouse=True) def autouse_thing(): return "abc" @pytest.fixture(scope="session", autouse=True) def fixture_echo_url(): return "http://localhost:5003/echo" @pytest.fixture(scope="session", autouse=True, name="autouse_thing_named") def second(autouse_thing): return autouse_thing @pytest.fixture(scope="function") def tavern_include_env(tmp_path_factory): """Create a temporary directory with a test file and set TAVERN_INCLUDE so tavern can resolve files in it.""" tmp_dir = tmp_path_factory.mktemp("tavern_include") include_file = tmp_dir / "tavern_include_data.txt" include_file.write_text("OK") old_value = os.environ.get("TAVERN_INCLUDE") os.environ["TAVERN_INCLUDE"] = str(tmp_dir) yield if old_value is not None: os.environ["TAVERN_INCLUDE"] = old_value else: os.environ.pop("TAVERN_INCLUDE", None) def pytest_tavern_beta_before_every_request(request_args: Box): logging.info("Making request: %s", request_args) if not isinstance(request_args.get("json"), Iterable): return if "PLEASE ADD DATA HERE" in request_args["json"]: request_args["json"] = {"value": 123} if "PLEASE FAIL THIS TEST" in request_args["json"]: raise exceptions.TestFailError("I was asked to fail this test") tavern-3.6.0/tests/integration/docker-compose.yaml000066400000000000000000000001761520710011500222760ustar00rootroot00000000000000--- version: "2" services: server: build: context: . dockerfile: Dockerfile ports: - "5003:5000" tavern-3.6.0/tests/integration/expected_table.txt000066400000000000000000000002661520710011500222110ustar00rootroot00000000000000+----+----------+-------+ | id | name | score | +----+----------+-------+ | 1 | Alice | 95 | | 2 | Bob | 87 | | 3 | Charlie | 92 | +----+----------+-------+ tavern-3.6.0/tests/integration/expected_wrong.txt000066400000000000000000000000171520710011500222500ustar00rootroot00000000000000Goodbye, World!tavern-3.6.0/tests/integration/ext_functions.py000066400000000000000000000032051520710011500217360ustar00rootroot00000000000000import dataclasses import time from requests.auth import AuthBase, HTTPDigestAuth class PizzaAuth(AuthBase): """Attaches HTTP Pizza Authentication to the given Request object.""" def __init__(self, username): self.username = username def __call__(self, r): r.headers["X-Pizza"] = self.username return r def return_hello(): return {"hello": "there"} def return_goodbye_string(): return "goodbye" def return_list_vals(): return [{"a_value": "b_value"}, 2] def gen_echo_url(host): return f"{host}/echo" def get_digest_auth(): return HTTPDigestAuth("fakeuser", "fakepass") def get_pizza_auth(): return PizzaAuth("pizza_user") def time_request(_): time.time() yield time.time() def print_response(_, extra_print="affa"): (_, r) = yield @dataclasses.dataclass class _TinctureCounter: count: dict[str, int] = dataclasses.field(default_factory=dict) def increment(self, stage: str): try: self.count[stage] += 1 except KeyError: self.count[stage] = 1 def reset(self): self.count = {} _counter = _TinctureCounter() def global_tincture_marker(stage): """Tincture used to verify global tinctures are applied (issue #969). This is a simple tincture that runs before each stage when configured globally. It tracks the number of times it's called to verify it's invoked for every stage. """ _counter.increment(stage["name"]) def get_global_tincture_call_count(): """Return the number of times global_tincture_marker was called.""" return {"call_count": sum(_counter.count.values())} tavern-3.6.0/tests/integration/extra.yaml000066400000000000000000000003131520710011500205000ustar00rootroot00000000000000--- name: global includes description: Used for testing global config tests variables: global_host: http://localhost:5003 test_string_1: "hello" test_string_2: "{tavern.env_vars.SECOND_URL_PART}" tavern-3.6.0/tests/integration/global_cfg.yaml000066400000000000000000000006461520710011500214450ustar00rootroot00000000000000--- name: test global includes description: used for testing against local server variables: global_host: http://localhost:5003 global_test_string_1: abc global_test_string_2: "{tavern.env_vars.SECOND_URL_PART}" retry_max: 4 negative_int: -2 stages: - id: finally-nothing-check name: finally nothing check request: url: "{global_host}/echo" method: POST json: value: "123" tavern-3.6.0/tests/integration/parametrize_includes.yaml000066400000000000000000000001331520710011500235660ustar00rootroot00000000000000key: - edible - fruit vals: - [rotten, apple] - [fresh, orange] - [unripe, pear] tavern-3.6.0/tests/integration/server.py000066400000000000000000000354171520710011500203660ustar00rootroot00000000000000import base64 import gzip import itertools import json import math import mimetypes import os import time import uuid from datetime import datetime, timedelta from hashlib import sha512 from urllib.parse import unquote_plus, urlencode import jwt from box import Box from flask import Flask, Response, jsonify, make_response, redirect, request, session from flask_httpauth import HTTPDigestAuth from itsdangerous import URLSafeTimedSerializer app = Flask(__name__) app.config.update(SECRET_KEY="secret") digest_auth = HTTPDigestAuth() @digest_auth.get_password def get_digest_password(username): if username == "fakeuser": return "fakepass" return None @app.route("/token", methods=["GET"]) def token(): return ( '', 200, ) @app.route("/headers", methods=["GET"]) def headers(): return "OK", 200, {"X-Integration-Value": "_HelloWorld1", "ATestHEader": "orange"} @app.route("/verify", methods=["GET"]) def verify(): if request.args.get("token") == "c9bb34ba-131b-11e8-b642-0ed5f89f718b": return "", 200 else: return "", 401 @app.route("/get_thing_slow", methods=["GET"]) def get_slow(): time.sleep(0.25) response = {"status": "OK"} return jsonify(response), 200 @app.route("/fake_dictionary", methods=["GET"]) def get_fake_dictionary(): fake = { "top": {"Thing": "value", "nested": {"doubly": {"inner": "value"}}}, "an_integer": 123, "a_float": 1.23, "a_string": "abc", "a_bool": True, } return jsonify(fake), 200 @app.route("/fake_list", methods=["GET"]) def list_response(): list_response = ["a", "b", "c", 1, 2, 3, -1.0, -2.0, -3.0] return jsonify(list_response), 200 @app.route("/complicated_list", methods=["GET"]) def complicated_list(): list_response = ["a", {"b": "c"}] return jsonify(list_response), 200 @app.route("/nested_list", methods=["GET"]) def nested_list_response(): response = {"top": ["a", "b", {"key": "val"}]} return jsonify(response), 200 @app.route("/fake_upload_file", methods=["POST"]) def upload_fake_file(): if not request.files: return "", 401 return _handle_files() def _handle_files(): for item in request.files.values(): if item.filename: filetype = ".{}".format(item.filename.split(".")[-1]) if filetype in mimetypes.suffix_map: if not item.content_type: return "", 400 # Try to download each of the files downloaded to /tmp and # then remove them for key in request.files: file_to_save = request.files[key] path = os.path.join("/tmp", file_to_save.filename) file_to_save.save(path) return "", 200 class BadFileUploadException(Exception): """Something wrong when uploading files""" def _verify_is_file_multipart(): if not mimetypes.inited: mimetypes.init() if not request.content_type.startswith("multipart/form-data"): raise BadFileUploadException("Was not a multipart form upload") if not request.files: raise BadFileUploadException("No files in request") @app.route("/fake_upload_file_data", methods=["POST"]) def upload_fake_file_and_data(): try: _verify_is_file_multipart() except BadFileUploadException as e: return jsonify({"error": str(e)}), 400 if not request.form.to_dict(): return "", 400 return _handle_files() @app.route("/files_expect_in_order", methods=["POST"]) def upload_specific_files_in_order(): """Expects a multipart form upload with files in the correct order See test_files.tavern.yaml for expected list of files here """ try: _verify_is_file_multipart() except BadFileUploadException as e: return jsonify({"error": str(e)}), 400 try: group_1 = request.files.getlist("group_1") if len(group_1) != 2: raise Exception(f"expected 2 files in group 1, got {len(group_1)}") if group_1[0].filename != "OK.txt": raise Exception( f"First file in group 1 should be OK.txt, was {group_1[0].filename}" ) if group_1[1].filename != "OK.json.gz": raise Exception( f"Second file in group 1 should be OK.json.gz, was {group_1[1].filename}" ) group_2 = request.files.getlist("group_2") if len(group_2) != 1: raise Exception(f"expected 1 files in group 2, got {len(group_2)}") if group_2[0].filename != "OK.txt": raise Exception( f"First file in group 2 should be OK.txt, was {group_2[0].filename}" ) except Exception as e: return jsonify({"error": str(e)}), 400 return "", 200 @app.route("/nested/again", methods=["GET"]) def multiple_path_items_response(): response = {"status": "OK"} return jsonify(response), 200 @app.route("/pi", methods=["GET"]) def return_fp_number(): response = {"pi": math.pi} return jsonify(response), 200 @app.route("/expect_dtype", methods=["POST"]) def expect_type(): body = request.get_json() value = body.get("value") dtype = body.get("dtype") dvalue = body.get("dvalue") status = "OK" code = 200 if not value and dtype and dvalue: status = "Missing expected type or value" code = 400 if str(type(value)) != f"": status = f"Unexpected type: '{str(type(value))}'" code = 400 if value != dvalue: status = f"Unexpected value: '{value}'" code = 400 return jsonify({"status": status}), code @app.route("/status_code_return", methods=["POST"]) def status_code_return(): body = request.get_json() response = {} return jsonify(response), int(body["status_code"]) @app.route("/echo", methods=["POST"]) def echo_values(): body = request.get_json(silent=True) response = body return jsonify(response), 200 @app.route("/echo_params", methods=["GET"]) def echo_params(): params = request.args response = {} for k, v in params.items(): unquoted = unquote_plus(v) try: response[k] = json.loads(unquoted) except json.decoder.JSONDecodeError: response[k] = unquoted return jsonify(response), 200 @app.route("/expect_raw_data", methods=["POST"]) def expect_raw_data(): raw_data = request.stream.read().decode("utf8").strip() if raw_data == "OK": response = {"status": "ok"} code = 200 elif raw_data == "DENIED": response = {"status": "denied"} code = 401 else: response = {"status": f"err: '{raw_data}'"} code = 400 return jsonify(response), code @app.route("/expect_compressed_data", methods=["POST"]) def expect_compressed_data(): content_type_header = request.headers.get("content-type") if content_type_header != "application/json": return jsonify("invalid content type " + content_type_header), 400 content_encoding_header = request.headers.get("content-encoding") if content_encoding_header != "gzip": return jsonify("invalid content encoding " + content_encoding_header), 400 compressed_data = request.stream.read() decompressed = gzip.decompress(compressed_data) raw_data = decompressed.decode("utf8").strip() loaded = json.loads(raw_data) if loaded == "OK": response = {"status": "ok"} code = 200 else: response = {"status": f"err: '{raw_data}'"} code = 400 return jsonify(response), code @app.route("/form_data", methods=["POST"]) def echo_form_values(): body = request.get_data() key, _, value = body.decode("utf8").partition("=") response = {key: value} return jsonify(response), 200 @app.route("/stream_file", methods=["GET"]) def stream_file(): def iter(): for data in range(1, 10): yield bytes(data) response = Response(iter(), mimetype="application/octet-stream") response.headers["Content-Disposition"] = "attachment; filename=tmp.txt" return response statuses = itertools.cycle(["processing", "ready"]) @app.route("/poll", methods=["GET"]) def poll(): response = {"status": next(statuses)} return jsonify(response) def _maybe_get_cookie_name(): return (request.get_json(silent=True) or {}).get("cookie_name", "tavern-cookie") @app.route("/get_cookie", methods=["POST"]) def give_cookie(): cookie_name = _maybe_get_cookie_name() response = Response() response.set_cookie(cookie_name, base64.b64encode(os.urandom(16)).decode("utf8")) return response, 200 @app.route("/expect_cookie", methods=["GET"]) def expect_cookie(): cookie_name = _maybe_get_cookie_name() if cookie_name not in request.cookies: return ( jsonify({"error": f"No cookie named {cookie_name} in request"}), 400, ) else: return jsonify({"status": "ok"}), 200 @app.route("/redirect/source", methods=["GET"]) def redirect_to_other_endpoint(): query_params = urlencode( { "test_value": "lorem ipsum?", } ) return redirect(f"/redirect/destination?{query_params}", 302) @app.route("/redirect/loop", methods=["GET"]) def redirect_loop(): try: if redirect_loop.tries > 50: return redirect("/redirect/destination", 302) else: redirect_loop.tries += 1 except AttributeError: redirect_loop.tries = 1 return redirect("/redirect/loop", 302) @app.route("/redirect/destination", methods=["GET"]) def get_redirected_to_here(): return jsonify({"status": "successful redirect"}), 200 @app.route("/get_single_json_item", methods=["GET"]) def return_one_item(): return jsonify("c82bfa63-fd2a-419a-8c06-21cb283fd9f7"), 200 @app.route("/authtest/basic", methods=["GET"]) def expect_basic_auth(): auth = request.authorization if auth is None: return jsonify({"status": "No authorisation"}), 403 if auth.type == "basic": if auth.username == "fakeuser" and auth.password == "fakepass": return ( jsonify( { "auth_type": auth.type, "auth_user": auth.username, "auth_pass": auth.password, } ), 200, ) else: return jsonify({"error": "Wrong username/password"}), 401 else: return jsonify({"error": "unrecognised auth type"}), 403 @app.route("/authtest/digest", methods=["GET"]) @digest_auth.login_required def expect_digest_auth(): return ( jsonify( { "auth_type": "digest", "auth_user": digest_auth.current_user(), "auth_pass": "fakepass", } ), 200, ) @app.route("/authtest/custom_header", methods=["GET"]) def expect_custom_header_auth(): pizza_header = request.headers.get("X-Pizza") if pizza_header is None: return jsonify({"error": "No X-Pizza header"}), 403 return ( jsonify( { "auth_type": "custom_header", "pizza_user": pizza_header, } ), 200, ) @app.route("/jmes/return_empty_paged", methods=["GET"]) def return_empty_paged(): return jsonify({"pages": 0, "data": []}), 200 @app.route("/jmes/with_dot", methods=["GET"]) def return_with_dot(): return jsonify({"data.a": "a", "data.b": "b"}), 200 @app.route("/uuid/v4", methods=["GET"]) def get_uuid_v4(): return jsonify({"uuid": uuid.uuid4()}), 200 @app.route("/707-regression", methods=["GET"]) def get_707(): return jsonify({"a": 1, "b": {"first": 10, "second": 20}, "c": 2}) users = {"mark": {"password": "password", "regular": "foo", "protected": "bar"}} serializer = URLSafeTimedSerializer( secret_key="secret", salt="cookie", signer_kwargs={"key_derivation": "hmac", "digest_method": sha512}, ) @app.route("/withsession/login", methods=["POST"]) def login(): r = request.get_json() username = r["username"] password = r["password"] if password == users[username]["password"]: session["user"] = username response = make_response("", 200) response.set_cookie( "remember", value=serializer.dumps(username), expires=datetime.utcnow() + timedelta(days=30), httponly=True, ) return response return "", 401 @app.route("/withsession/regular", methods=["GET"]) def regular(): username = session.get("user") if not username: remember = request.cookies.get("remember") if remember: username = serializer.loads(remember, max_age=3600) if username: return jsonify(regular=users[username]["regular"]), 200 return "", 401 @app.route("/withsession/protected", methods=["GET"]) def protected(): username = session.get("user") if username: return jsonify(protected=users[username]["protected"]), 200 return "", 401 @app.route("/606-regression-list", methods=["GET"]) def get_606_list(): return jsonify([]) @app.route("/606-regression-dict", methods=["GET"]) def get_606_dict(): return jsonify({}) @app.route("/sub-path-query", methods=["POST"]) def sub_path_query(): r = request.get_json(force=True) sub_path = r["sub_path"] return jsonify({"result": Box(r, box_dots=True)[sub_path]}) @app.route("/magic-multi-method", methods=["GET", "POST", "DELETE"]) def get_any_method(): return jsonify({"method": request.method}) @app.route("/get_jwt", methods=["POST"]) def get_jwt(): secret = "240c8c9c-39b9-426b-9503-3126f96c2eaf" audience = "testserver" r = request.get_json() if r["user"] != "test-user" or r["password"] != "correct-password": return jsonify({"error": "Incorrect username/password"}), 401 payload = { "sub": "test-user", "aud": audience, "exp": datetime.utcnow() + timedelta(hours=1), } token = jwt.encode(payload, secret, algorithm="HS256") return jsonify({"jwt": token}) @app.route("/text_response", methods=["GET"]) def text_response(): """Returns a plain text response (not JSON)""" return Response("Hello, World!", content_type="text/plain") @app.route("/ascii_table", methods=["GET"]) def ascii_table(): """Returns an ASCII table as plain text""" table = """\ +----+----------+-------+ | id | name | score | +----+----------+-------+ | 1 | Alice | 95 | | 2 | Bob | 87 | | 3 | Charlie | 92 | +----+----------+-------+ """ return Response(table, content_type="text/plain") @app.route("/expected_text", methods=["POST"]) def expected_text(): """Echoes back plain text from request body""" return Response(request.get_data(as_text=True), content_type="text/plain") tavern-3.6.0/tests/integration/test_allure.tavern.yaml000066400000000000000000000004511520710011500232010ustar00rootroot00000000000000--- test_name: "Test test name can have formatting in it: {host}" includes: - !include common.yaml stages: - name: "Test stage name can have formatting in it: {host}" request: url: "{host}/echo" method: POST json: hi response: status_code: 200 json: hi tavern-3.6.0/tests/integration/test_auth_key.tavern.yaml000066400000000000000000000026521520710011500235330ustar00rootroot00000000000000--- test_name: Test basic auth header stages: - name: Send with basic auth request: url: "{global_host}/authtest/basic" method: GET auth: - "fakeuser" - "fakepass" response: status_code: 200 json: auth_type: basic auth_user: fakeuser auth_pass: fakepass --- test_name: Test basic auth header with wrong username/pass stages: - name: Send with basic auth request: url: "{global_host}/authtest/basic" method: GET auth: - "fakeuser" - "wrongpass" response: status_code: 401 json: "error": "Wrong username/password" --- test_name: Test auth with $ext using digest auth stages: - name: Send with auth from external function request: url: "{global_host}/authtest/digest" method: GET auth: $ext: function: ext_functions:get_digest_auth response: status_code: 200 json: auth_type: digest auth_user: fakeuser auth_pass: fakepass --- test_name: Test auth with $ext using class-based custom auth (PizzaAuth) stages: - name: Send with custom auth from class request: url: "{global_host}/authtest/custom_header" method: GET auth: $ext: function: ext_functions:get_pizza_auth response: status_code: 200 json: auth_type: custom_header pizza_user: pizza_user tavern-3.6.0/tests/integration/test_certs.tavern.yaml000066400000000000000000000011601520710011500230330ustar00rootroot00000000000000--- test_name: Test cannot pass an invalid value to 'cert' _xfail: verify stages: - name: Use a cert incorrectly request: url: "{host}/echo" method: POST cert: 123 json: value: "abc" response: status_code: 200 json: value: "abc" --- test_name: Test cannot pass too many values to 'cert' _xfail: verify stages: - name: Use a cert incorrectly request: url: "{host}/echo" method: POST cert: - abc - def - ghi json: value: "abc" response: status_code: 200 json: value: "abc" tavern-3.6.0/tests/integration/test_control_flow.tavern.yaml000066400000000000000000000021451520710011500244260ustar00rootroot00000000000000--- test_name: Test finally block doing nothing stages: - name: Simple echo request: url: "{global_host}/echo" method: POST json: value: "123" response: status_code: 200 json: value: "123" finally: - name: nothing request: url: "{global_host}/echo" method: POST json: value: "123" --- test_name: Test finally block being replaced stages: - name: Simple echo request: url: "{global_host}/echo" method: POST json: value: "123" response: status_code: 200 json: value: "123" finally: - type: ref id: finally-nothing-check --- test_name: Test finally block fail _xfail: finally stages: - name: Simple echo request: url: "{global_host}/echo" method: POST json: value: "123" response: status_code: 200 json: value: "123" finally: - name: nothing request: url: "{global_host}/echo" method: DELETE json: value: "123" response: status_code: 200 json: value: "123" tavern-3.6.0/tests/integration/test_cookie_remember.tavern.yaml000066400000000000000000000030411520710011500250420ustar00rootroot00000000000000--- test_name: test after browser close stages: - name: login request: url: "{global_host}/withsession/login" method: POST json: username: mark password: password response: cookies: - session - remember status_code: 200 - name: get regular request: url: "{global_host}/withsession/protected" method: GET clear_session_cookies: False response: json: protected: bar status_code: 200 - name: get regular request: url: "{global_host}/withsession/regular" method: GET clear_session_cookies: True # This flows through to the next stage as well response: json: regular: foo status_code: 200 - name: get protected stale request: url: "{global_host}/withsession/protected" method: GET response: status_code: 401 --- test_name: test without browser close stages: - name: login again request: url: "{global_host}/withsession/login" method: POST json: username: mark password: password response: cookies: - session - remember status_code: 200 - name: get protected fresh request: url: "{global_host}/withsession/protected" method: GET response: json: protected: bar status_code: 200 --- test_name: test without login stages: - name: get regular request: url: "{global_host}/withsession/regular" method: GET response: status_code: 401 tavern-3.6.0/tests/integration/test_cookies.tavern.yaml000066400000000000000000000137321520710011500233570ustar00rootroot00000000000000--- test_name: Test receiving and sending cookie includes: - !include common.yaml stages: - &no-cookie-error name: No cookie - error request: url: "{host}/expect_cookie" method: GET response: status_code: 400 json: error: "No cookie named tavern-cookie in request" - name: Expect a cookie returned request: url: "{host}/get_cookie" method: POST response: status_code: 200 cookies: - tavern-cookie - name: Now we have the cookie - success request: url: "{host}/expect_cookie" method: GET response: status_code: 200 json: status: ok --- test_name: Test sending the wrong cookie when multiple are present includes: - !include common.yaml stages: - *no-cookie-error - &get-cookie-1 name: Get tavern-cookie-1 request: url: "{host}/get_cookie" method: POST json: cookie_name: tavern-cookie-1 response: status_code: 200 cookies: - tavern-cookie-1 - &get-cookie-2 name: Get tavern-cookie-2 request: url: "{host}/get_cookie" method: POST json: cookie_name: tavern-cookie-2 response: status_code: 200 cookies: - tavern-cookie-2 - name: Send the wrong cookie - error request: url: "{host}/expect_cookie" method: GET json: cookie_name: tavern-cookie-1 cookies: - tavern-cookie-2 response: status_code: 400 json: error: "No cookie named tavern-cookie-1 in request" - name: Send the wrong cookie again - error request: url: "{host}/expect_cookie" method: GET json: cookie_name: tavern-cookie-2 cookies: - tavern-cookie-1 response: status_code: 400 json: error: "No cookie named tavern-cookie-2 in request" --- test_name: Test sending the right cookie when multiple are present includes: - !include common.yaml stages: - *no-cookie-error - *get-cookie-1 - *get-cookie-2 - name: Send the right cookie - success request: url: "{host}/expect_cookie" method: GET json: cookie_name: tavern-cookie-1 cookies: - tavern-cookie-1 response: status_code: 200 json: status: ok - name: Send the right cookie - success request: url: "{host}/expect_cookie" method: GET json: cookie_name: tavern-cookie-2 cookies: - tavern-cookie-2 response: status_code: 200 json: status: ok --- test_name: Test sending no cookie fails even if we have a cookie includes: - !include common.yaml stages: - *no-cookie-error - *get-cookie-1 - name: Send the right cookie - success request: url: "{host}/expect_cookie" method: GET json: cookie_name: tavern-cookie-1 cookies: - tavern-cookie-1 response: status_code: 200 json: status: ok - name: No cookie - error request: url: "{host}/expect_cookie" method: GET # Explicitly send no cookie cookies: [] json: cookie_name: tavern-cookie-1 response: status_code: 400 json: error: "No cookie named tavern-cookie-1 in request" - name: but it is still available request: url: "{host}/expect_cookie" method: GET json: cookie_name: tavern-cookie-1 cookies: - tavern-cookie-1 response: status_code: 200 json: status: ok --- test_name: Test trying to send a cookie that we don't have fails _xfail: run includes: - !include common.yaml stages: - name: Send a cookie which doesn't exist request: url: "{host}/expect_cookie" method: GET json: cookie_name: tavern-cookie-1 cookies: - tavern-cookie-1 response: status_code: 200 json: status: ok --- test_name: Test trying to send a cookie that we don't have fails, even if we do have a cookie _xfail: run includes: - !include common.yaml stages: - *get-cookie-1 - name: Send a cookie which doesn't exist request: url: "{host}/expect_cookie" method: GET json: cookie_name: tavern-cookie-2 cookies: - tavern-cookie-2 response: status_code: 200 json: status: ok --- test_name: Trying to override the value of a cookie multiple times causes an error # TODO: Make this fail in verify instead? _xfail: run includes: - !include common.yaml stages: - *get-cookie-1 - name: Send a cookie which doesn't exist request: url: "{host}/expect_cookie" method: GET json: cookie_name: tavern-cookie-2 cookies: - cookie-1: abc - cookie-1: abc response: status_code: 200 json: status: ok --- test_name: Override a cookie on the first stage includes: - !include common.yaml stages: - *get-cookie-1 - name: Send a cookie which doesn't exist request: url: "{host}/expect_cookie" method: GET json: cookie_name: tavern-cookie-2 cookies: - tavern-cookie-2: abc response: status_code: 200 json: status: ok --- test_name: Override a cookie on the first stage includes: - !include common.yaml stages: - *get-cookie-1 - name: Override cookie value request: url: "{host}/expect_cookie" method: GET json: cookie_name: tavern-cookie-2 cookies: - tavern-cookie-2: abc response: status_code: 200 json: status: ok --- test_name: Override a cookie on the first stage, with formatting includes: - !include common.yaml stages: - *get-cookie-1 - name: Override cookie value request: url: "{host}/expect_cookie" method: GET json: cookie_name: "{formatted_cookie_name}" cookies: - tavern-cookie-2: abc response: status_code: 200 json: status: ok tavern-3.6.0/tests/integration/test_data_key.tavern.yaml000066400000000000000000000046201520710011500235000ustar00rootroot00000000000000--- test_name: Test sending form encoded data works includes: - !include common.yaml stages: - name: Send a uuid and expect it to be returned request: url: "{host}/form_data" method: POST data: id: !uuid &sent_uuid response: status_code: 200 json: id: *sent_uuid --- test_name: Test sending raw data includes: - !include common.yaml stages: - name: Send 'ok' string and expect it back request: url: "{host}/expect_raw_data" method: POST data: OK response: status_code: 200 json: status: ok - name: Send 'denied' string and expect it back request: url: "{host}/expect_raw_data" method: POST data: DENIED response: status_code: 401 json: status: denied --- test_name: Test sending base64 data includes: - !include common.yaml stages: - name: Send 'ok' string and expect it back request: url: "{host}/expect_raw_data" method: POST data: !!binary T0s= response: status_code: 200 json: status: ok - name: Send 'denied' string and expect it back request: url: "{host}/expect_raw_data" method: POST data: !!binary REVOSUVE response: status_code: 401 json: status: denied - name: Send invalid string and expect it in error message request: url: "{host}/expect_raw_data" method: POST data: !!binary RVJS response: status_code: 400 json: status: "err: 'ERR'" --- test_name: Test sending a list in 'data' raises an error _xfail: verify stages: - name: Try to send a list in 'data' request: url: "{host}/expect_raw_data" method: POST data: - a - b response: status_code: 200 json: status: ok --- test_name: Test sending a float in 'data' raises an error _xfail: verify stages: - name: Try to send a float in 'data' request: url: "{host}/expect_raw_data" method: POST data: 1.2 response: status_code: 200 json: status: ok --- test_name: Test sending JSON and data at the same time fails _xfail: verify stages: - name: Try to send both request: url: "{host}/expect_raw_data" method: POST data: a: 123 json: b: 456 response: status_code: 200 json: status: ok tavern-3.6.0/tests/integration/test_dummy.py000066400000000000000000000000351520710011500212360ustar00rootroot00000000000000def test_nothing(): pass tavern-3.6.0/tests/integration/test_error.tavern.yaml000066400000000000000000000004251520710011500230470ustar00rootroot00000000000000--- test_name: Test yielding fixture includes: - !include common.yaml stages: - name: do something request: method: DELETE url: "{host}/echo" json: { "id": 0 } response: status_code: 200 json: value: { "id": 0 } _xfail: run tavern-3.6.0/tests/integration/test_external_functions.tavern.yaml000066400000000000000000000070361520710011500256350ustar00rootroot00000000000000--- test_name: Use one function includes: - !include common.yaml stages: - name: simple match request: url: "{host}/token" method: GET response: status_code: 200 verify_response_with: function: tavern.helpers:validate_regex extra_kwargs: expression: '' --- test_name: Use one function in a list includes: - !include common.yaml stages: - name: simple match request: url: "{host}/token" method: GET response: status_code: 200 verify_response_with: - function: tavern.helpers:validate_regex extra_kwargs: expression: '' --- test_name: Use two functions includes: - !include common.yaml stages: - name: simple match request: url: "{host}/token" method: GET response: status_code: 200 verify_response_with: - function: tavern.helpers:validate_regex extra_kwargs: expression: '' - function: tavern.helpers:validate_regex extra_kwargs: expression: '' --- test_name: Test first function failing will cause test to fail includes: - !include common.yaml _xfail: run stages: - name: simple match request: url: "{host}/token" method: GET response: status_code: 200 verify_response_with: - function: tavern.helpers:validate_regex extra_kwargs: expression: "bkllelkkkkkkkkkkfff" - function: tavern.helpers:validate_regex extra_kwargs: expression: '' --- test_name: Test second function failing will cause test to fail includes: - !include common.yaml _xfail: run stages: - name: simple match request: url: "{host}/token" method: GET response: status_code: 200 verify_response_with: - function: tavern.helpers:validate_regex extra_kwargs: expression: '' - function: tavern.helpers:validate_regex extra_kwargs: expression: "bkllelkkkkkkkkkkfff" --- test_name: Test merging in input (depends on option being enabled) includes: - !include common.yaml stages: - name: simple match request: url: "{host}/echo" method: POST json: value1: "hi" $ext: function: ext_functions:return_hello response: status_code: 200 json: value1: "hi" hello: "there" --- test_name: Test generating query params from ext functions stages: - name: simple match request: params: $ext: function: ext_functions:return_hello url: "{global_host}/echo_params" method: GET response: status_code: 200 json: hello: "there" --- test_name: Test can still pass json in a query param stages: - name: simple match request: params: top_level: nested: value url: "{global_host}/echo_params" method: GET response: status_code: 200 json: top_level: nested: value --- test_name: Test external function url includes: - !include common.yaml stages: - name: external function url request: url: $ext: function: ext_functions:gen_echo_url extra_kwargs: host: "{host}" method: POST json: value1: "hi" $ext: function: ext_functions:return_hello response: status_code: 200 json: value1: "hi" hello: "there" tavern-3.6.0/tests/integration/test_files.tavern.yaml000066400000000000000000000124731520710011500230260ustar00rootroot00000000000000--- test_name: Test files can be uploaded with tavern includes: - !include common.yaml stages: - name: Upload multiple files request: url: "{host}/fake_upload_file" method: POST files: test_files: "test_files.tavern.yaml" common: "common.yaml" response: status_code: 200 --- test_name: Test files can be uploaded with a formatted file name includes: - !include common.yaml stages: - name: Upload multiple files request: url: "{host}/fake_upload_file" method: POST files: test_files: "{file_body_ref}" common: "common.yaml" response: status_code: 200 --- test_name: Test files can be uploaded alongside data includes: - !include common.yaml stages: - name: Upload file and data request: url: "{host}/fake_upload_file_data" method: POST files: test_files: "test_files.tavern.yaml" data: key: value response: status_code: 200 --- test_name: Test extra headers don't break content-type includes: - !include common.yaml stages: - name: Upload multiple files request: url: "{host}/fake_upload_file" method: POST headers: x-test-header: chorp files: test_files: "test_files.tavern.yaml" common: "common.yaml" response: status_code: 200 --- test_name: Test sending a text file will send the correct content type includes: - !include common.yaml stages: - name: Upload multiple files request: url: "{host}/fake_upload_file" method: POST files: test: testfile.txt response: status_code: 200 --- test_name: Test long form file upload includes: - !include common.yaml stages: - name: Upload with long spec request: url: "{host}/fake_upload_file" method: POST files: test: file_path: testfile.txt content_type: application/txt response: status_code: 200 --- test_name: Test sending file body includes: - !include common.yaml stages: - name: Upload file body request: url: "{host}/expect_raw_data" method: POST file_body: OK.txt response: status_code: 200 json: status: ok --- test_name: Test sending file body with appropriate encoding includes: - !include common.yaml stages: - name: Upload gzipped json file body request: url: "{host}/expect_compressed_data" method: POST file_body: OK.json.gz response: status_code: 200 json: status: ok --- test_name: Test sending file body from variable ref includes: - !include common.yaml stages: - name: Upload file body request: url: "{host}/expect_raw_data" method: POST file_body: "{file_body_ref}" response: status_code: 200 json: status: ok --- test_name: Test sending bad file body includes: - !include common.yaml stages: - name: Upload file body request: url: "{host}/expect_raw_data" method: POST file_body: testfile.txt response: status_code: 400 --- test_name: Test mutually exclusive with files _xfail: verify includes: - !include common.yaml stages: - name: Upload file body request: url: "{host}/expect_raw_data" method: POST file_body: testfile.txt files: test_files: "test_files.tavern.yaml" response: status_code: 200 --- test_name: Test mutually exclusive with data _xfail: verify includes: - !include common.yaml stages: - name: Upload file body request: url: "{host}/expect_raw_data" method: POST file_body: testfile.txt data: OK response: status_code: 200 --- test_name: Test mutually exclusive with json _xfail: verify includes: - !include common.yaml stages: - name: Upload file body request: url: "{host}/expect_raw_data" method: POST file_body: testfile.txt json: test_files: "test_files.tavern.yaml" response: status_code: 200 --- test_name: Test uploading multi part files in a common group stages: - name: Upload files request: url: "{global_host}/files_expect_in_order" method: POST files: - form_field_name: group_2 file_path: OK.txt - form_field_name: group_1 file_path: OK.txt - form_field_name: group_1 file_path: OK.json.gz response: status_code: 200 --- test_name: Test uploading multi part files in a common group in a different order stages: - name: Upload files request: url: "{global_host}/files_expect_in_order" method: POST files: - form_field_name: group_1 file_path: OK.txt - form_field_name: group_2 file_path: OK.txt - form_field_name: group_1 file_path: OK.json.gz response: status_code: 200 --- test_name: Test uploading multi part files with wrong groups stages: - name: Upload files request: url: "{global_host}/files_expect_in_order" method: POST files: - form_field_name: group_1 file_path: OK.txt - form_field_name: group_2 file_path: OK.txt - form_field_name: group_3 file_path: OK.json.gz response: status_code: 400 json: error: expected 2 files in group 1, got 1 tavern-3.6.0/tests/integration/test_fixtures.tavern.yaml000066400000000000000000000052201520710011500235650ustar00rootroot00000000000000--- test_name: Test empty usefixtures errors includes: - !include common.yaml _xfail: verify marks: # Breaks validation somehow...? - usefixtures: {} - usefixtures: - str_fixture - yield_str_fixture stages: - name: Echo back a unicode value and make sure it matches request: url: "{host}/echo" method: POST json: value: "{str_fixture}" response: status_code: 200 json: value: "{yield_str_fixture}" --- test_name: Test usefixtures being a mapping errors includes: - !include common.yaml _xfail: verify marks: - usefixtures: str_fixture stages: - name: Echo back a unicode value and make sure it matches request: url: "{host}/echo" method: POST json: value: "{str_fixture}" response: status_code: 200 json: value: "{yield_str_fixture}" --- test_name: Test usefixtures includes: - !include common.yaml marks: - usefixtures: - str_fixture - yield_str_fixture stages: - name: Echo back a unicode value and make sure it matches request: url: "{host}/echo" method: POST json: value: "{str_fixture}" response: status_code: 200 json: value: "{yield_str_fixture}" --- test_name: Test yielding fixture includes: - !include common.yaml marks: - usefixtures: - yielder - str_fixture - yield_str_fixture stages: - name: Echo back a unicode value and make sure it matches request: url: "{host}/echo" method: POST json: value: "{str_fixture}" response: status_code: 200 json: value: "{yield_str_fixture}" --- test_name: Test yielding fixture includes: - !include common.yaml _xfail: verify marks: usefixtures: thing stages: - name: Echo back a unicode value and make sure it matches request: url: "{host}/echo" method: POST json: value: "{str_fixture}" response: status_code: 200 json: value: "{yield_str_fixture}" --- test_name: Test autouse fixture includes: - !include common.yaml stages: - name: Echo back a unicode value and make sure it matches request: url: "{host}/echo" method: POST json: value: "{autouse_thing}" response: status_code: 200 json: value: "{autouse_thing_named}" --- test_name: Test autouse fixture host includes: - !include common.yaml stages: - name: post json and expect it to be echoed request: url: "{fixture_echo_url}" method: POST json: value: "hello" response: status_code: 200 json: value: "hello" tavern-3.6.0/tests/integration/test_follow_redirects.tavern.yaml000066400000000000000000000020631520710011500252640ustar00rootroot00000000000000--- includes: - !include common.yaml test_name: Test redirecting without setting flag works stages: - name: Expect a 302 without setting the flag request: url: "{host}/redirect/source" response: status_code: 302 --- includes: - !include common.yaml test_name: Test redirecting loops stages: - name: Expect a 302 without setting the flag max_retries: 2 request: follow_redirects: true url: "{host}/redirect/loop" response: status_code: 200 --- includes: - !include common.yaml test_name: Expect a redirect when setting the flag stages: - name: Expect to be redirected request: url: "{host}/redirect/source" follow_redirects: true response: status_code: 200 json: status: successful redirect --- test_name: Checking for redirect_query_params stages: - name: Check for a complex value in redirect query params request: url: "{global_host}/redirect/source" response: status_code: 302 redirect_query_params: test_value: lorem ipsum? tavern-3.6.0/tests/integration/test_format.tavern.yaml000066400000000000000000000013041520710011500232030ustar00rootroot00000000000000--- test_name: Test getting format vars from environment variables includes: - !include common.yaml stages: - name: Make requests using environment variables request: url: "{tavern.env_vars.TEST_HOST}/{first_part}/{second_part}" method: GET response: status_code: 200 json: status: OK --- test_name: Test slicing request vars stages: - name: Make request and expect part of list in it to be returned request: url: "{global_host}/sub-path-query" method: POST json: sub_path: "a.b[0].c" "a": { "b": [{ "c": 3 }] } response: status_code: 200 json: result: !int "{tavern.request_vars.json.a.b[0].c}" tavern-3.6.0/tests/integration/test_header_comparisons.tavern.yaml000066400000000000000000000020041520710011500255560ustar00rootroot00000000000000--- test_name: Test matching both headers includes: - !include common.yaml stages: - name: Match case sensitive request: url: "{host}/headers" method: GET response: status_code: 200 headers: X-Integration-Value: _HelloWorld1 ATestHEader: orange - name: Match case insensitive, upper request: url: "{host}/headers" method: GET response: status_code: 200 headers: X-INTEGRATION-VALUE: _HelloWorld1 ATESTHEADER: orange - name: Match case insensitive, lower request: url: "{host}/headers" method: GET response: status_code: 200 headers: x-integration-value: _HelloWorld1 atestheader: orange --- test_name: Test mismatch in header value _xfail: run includes: - !include common.yaml stages: - name: Match case sensitive request: url: "{host}/headers" method: GET response: status_code: 200 headers: X-Integration-Value: incorrect tavern-3.6.0/tests/integration/test_helpers.tavern.yaml000066400000000000000000000023651520710011500233650ustar00rootroot00000000000000--- test_name: Make sure JWT verification works includes: - !include common.yaml stages: - name: login request: url: "{host}/get_jwt" json: user: test-user password: correct-password method: POST response: status_code: 200 verify_response_with: function: tavern.helpers:validate_jwt extra_kwargs: jwt_key: "jwt" key: 240c8c9c-39b9-426b-9503-3126f96c2eaf algorithms: [HS256] options: verify_signature: true verify_aud: true verify_exp: true audience: testserver --- test_name: Make sure JWT rejects the wrong algorithm includes: - !include common.yaml stages: - name: login request: url: "{host}/get_jwt" json: user: test-user password: correct-password method: POST response: status_code: 200 verify_response_with: function: tavern.helpers:validate_jwt extra_kwargs: jwt_key: "jwt" key: 240c8c9c-39b9-426b-9503-3126f96c2eaf algorithms: [RS256] options: verify_signature: true verify_aud: true verify_exp: true audience: testserver _xfail: run tavern-3.6.0/tests/integration/test_hooks.tavern.yaml000066400000000000000000000010511520710011500230350ustar00rootroot00000000000000--- test_name: Test hook adding data includes: - !include common.yaml stages: - name: do something request: method: POST url: "{host}/echo" json: "PLEASE ADD DATA HERE" response: status_code: 200 json: value: 123 --- test_name: Test hook causing a failure _xfail: run: I was asked to fail this test includes: - !include common.yaml stages: - name: do something request: method: POST url: "{host}/echo" json: "PLEASE FAIL THIS TEST" response: status_code: 500 tavern-3.6.0/tests/integration/test_include.tavern.yaml000066400000000000000000000025541520710011500233460ustar00rootroot00000000000000--- test_name: Test including json includes: - !include common.yaml stages: - name: Send included json request: url: "{host}/echo" method: POST json: !include 881_1.json response: status_code: 200 json: !include 881_1.json --- test_name: Test using variables directly includes: - !include common.yaml - variables: full_path: "echo" stages: - name: Send included json request: url: "{host}/{full_path}" method: POST json: hell: o response: status_code: 200 json: hell: o --- test_name: Test including json with key includes: - !include common.yaml stages: - name: Send included json request: url: "{host}/echo" method: POST json: !include 881_2.yaml response: status_code: 200 json: !include 881_2.yaml --- test_name: Test including json with error includes: - !include common.yaml stages: - name: Send included json request: url: "{host}/echo" method: POST json: !include 881_1.json response: status_code: 201 _xfail: run --- test_name: Test including json with error and key includes: - !include common.yaml stages: - name: Send included json request: url: "{host}/echo" method: POST json: !include 881_2.yaml response: status_code: 201 _xfail: run tavern-3.6.0/tests/integration/test_jmes.tavern.yaml000066400000000000000000000116401520710011500226550ustar00rootroot00000000000000--- test_name: test dict comparisons includes: - !include common.yaml stages: - name: test_comparisons request: url: "{host}/fake_dictionary" method: GET response: status_code: 200 verify_response_with: function: tavern.helpers:validate_content extra_kwargs: comparisons: - jmespath: "an_integer" operator: "eq" expected: 123 - jmespath: "an_integer" operator: "type" expected: int - jmespath: "a_float" operator: "eq" expected: 1.23 - jmespath: "a_float" operator: "type" expected: float - jmespath: "a_string" operator: "eq" expected: abc - jmespath: "top.Thing" operator: "type" expected: str - jmespath: "top.nested.doubly.inner" operator: "eq" expected: value --- test_name: test list comparisons includes: - !include common.yaml stages: - name: test_comparisons request: url: "{host}/fake_list" method: GET response: status_code: 200 verify_response_with: function: tavern.helpers:validate_content extra_kwargs: comparisons: - jmespath: "[1]" operator: "type" expected: str - jmespath: "[1]" operator: "eq" expected: b - jmespath: "[5]" operator: "type" expected: int - jmespath: "[5]" operator: "eq" expected: 3 - jmespath: "[8]" operator: "type" expected: float - jmespath: "[8]" operator: "eq" expected: -3.0 --- test_name: Test we can save a single value with jmespath includes: - !include common.yaml stages: - name: get item request: url: "{host}/get_single_json_item" method: GET response: status_code: 200 json: c82bfa63-fd2a-419a-8c06-21cb283fd9f7 save: json: saved_with_at: "@" - name: Echo back the value and it should be as expected request: url: "{host}/echo" method: POST json: value: "{saved_with_at}" response: status_code: 200 json: value: c82bfa63-fd2a-419a-8c06-21cb283fd9f7 --- test_name: Test saving an item from a list includes: - !include common.yaml stages: - name: get item request: url: "{host}/fake_list" method: GET response: status_code: 200 save: json: first_item: "[0]" second_item: "[1]" - name: Echo back the first value and it should be as expected request: url: "{host}/echo" method: POST json: value: "{first_item}" response: status_code: 200 json: value: "a" - name: Echo back the second value and it should be as expected request: url: "{host}/echo" method: POST json: value: "{second_item}" response: status_code: 200 json: value: "b" --- test_name: Test saving an item from a dict inside a list includes: - !include common.yaml stages: - name: get item request: url: "{host}/complicated_list" method: GET response: status_code: 200 save: json: first_item: "[0]" second_item: "[1]" - name: Echo back the first value and it should be as expected request: url: "{host}/echo" method: POST json: value: "{first_item}" response: status_code: 200 json: value: "a" - name: Echo back the second value and it should be as expected request: url: "{host}/echo" method: POST json: value: "{second_item.b}" response: status_code: 200 json: value: "c" --- test_name: Test we can save a single value with jmespath if it has a dot in includes: - !include common.yaml stages: - name: get item with dot in a name request: url: "{host}/jmes/with_dot" method: GET response: status_code: 200 save: json: saved_with_dot_a: '"data.a"' saved_with_dot_b: '"data.b"' --- test_name: Test 'negative' jmespath tests includes: - !include common.yaml stages: - name: Check can match empty/zero jmespath checks request: url: "{host}/jmes/return_empty_paged" method: GET response: status_code: 200 json: pages: 0 data: [] verify_response_with: - function: tavern.helpers:validate_content extra_kwargs: comparisons: - jmespath: pages operator: "equals" expected: 0 - jmespath: "data[?id=='bllp']" operator: "equals" expected: [] tavern-3.6.0/tests/integration/test_markers.tavern.yaml000066400000000000000000000036721520710011500233710ustar00rootroot00000000000000--- test_name: Test xdist mark includes: - !include common.yaml marks: - xdist_group('test') stages: - name: Echo back a unicode value and make sure it matches request: url: "{host}/echo" method: POST json: value: test-1 response: status_code: 200 json: value: test-1 #--- #test_name: Test xdist mark # #includes: # - !include common.yaml # #_xfail: verify # # This fails at verification time because it's done before the tests run, so can't be tested directly. #marks: # - xdist_group(bad mark') # #stages: # - name: Echo back a unicode value and make sure it matches # request: # url: "{host}/echo" # method: POST # json: # value: test-1 # response: # status_code: 200 # json: # value: test-1 --- test_name: Test should not be run if it has a special marker includes: - !include common.yaml marks: - do_not_run stages: - name: Echo back a unicode value and make sure it matches request: url: "{host}/echo" method: POST json: value: test-1 response: status_code: 555 json: value: good --- test_name: Test mark with keyword arguments includes: - !include common.yaml marks: - skipif(True, reason='Testing keyword arguments in marks') stages: - name: Echo back a unicode value and make sure it matches request: url: "{host}/echo" method: POST json: value: test-1 response: status_code: 200 json: value: test-failure --- test_name: Test mark with keyword arguments expected failure includes: - !include common.yaml marks: - skipif(False, reason='Testing keyword arguments in marks') _xfail: run stages: - name: Echo back a unicode value and make sure it matches request: url: "{host}/echo" method: POST json: value: test-1 response: status_code: 200 json: value: test-failure tavern-3.6.0/tests/integration/test_merge_down.tavern.yaml000066400000000000000000000006121520710011500240420ustar00rootroot00000000000000--- is_defaults: true includes: - !include common.yaml --- test_name: Test redirecting loops stages: - name: Expect a 302 without setting the flag max_retries: 2 request: follow_redirects: true url: "{host}/redirect/loop" response: status_code: 200 --- test_name: Using a shared stage from common.yaml stages: - type: ref id: typetoken-anything-match tavern-3.6.0/tests/integration/test_parametrize.tavern.yaml000066400000000000000000000356271520710011500242550ustar00rootroot00000000000000--- test_name: Test parametrizing using 'vals' directly and not in the list marks: - parametrize: key: mycoolvalue vals: $ext: function: ext_functions:return_list_vals stages: - name: Echo back parametrized value request: url: "{global_host}/echo" method: POST json: !force_format_include "{mycoolvalue}" response: status_code: 200 json: !force_format_include "{tavern.request_vars.json}" --- test_name: Test parametrizing query parameters marks: - parametrize: key: query_param vals: - example_value stages: - name: Echo back parametrized value from query parameter request: url: "{global_host}/echo_params" method: GET params: example_param: "{query_param}" response: status_code: 200 json: example_param: example_value --- test_name: Test parametrizing using 'vals' directly and not in the list, list key marks: - parametrize: key: - mycoolvalue vals: $ext: function: ext_functions:return_list_vals stages: - name: Echo back parametrized value request: url: "{global_host}/echo" method: POST json: !force_format_include "{mycoolvalue}" response: status_code: 200 json: !force_format_include "{tavern.request_vars.json}" --- test_name: Test echo parametrized includes: - !include common.yaml marks: - parametrize: key: to_send vals: - abc - def - "123" stages: - name: Echo back a unicode value and make sure it matches request: url: "{host}/echo" method: POST json: value: "{to_send}" response: status_code: 200 json: value: "{to_send}" --- test_name: Test multiple parametrized values includes: - !include common.yaml marks: - parametrize: key: fruit vals: - apple - orange - pear - parametrize: key: edible vals: - rotten - fresh - unripe stages: - name: Echo back a unicode value and make sure it matches request: url: "{host}/echo" method: POST json: value: "{fruit}-{edible}" response: status_code: 200 json: value: "{fruit}-{edible}" --- test_name: Test multiple parametrized values, mismatched amounts includes: - !include common.yaml marks: - parametrize: key: fruit vals: - apple - orange - pear - parametrize: key: edible vals: - rotten stages: - name: Echo back a unicode value and make sure it matches request: url: "{host}/echo" method: POST json: value: "{fruit}-{edible}" response: status_code: 200 json: value: "{fruit}-{edible}" --- test_name: Test skip parametrized includes: - !include common.yaml marks: - skip - parametrize: key: to_send vals: - abc - def - "123" stages: - name: Echo back a unicode value and make sure it matches request: url: "{host}/echo" method: POST json: value: "{to_send}" response: status_code: 503 json: value: "klskdfiogj4iji34o" --- test_name: Test skipif parametrized includes: - !include common.yaml marks: - skipif: "2 > 1" - parametrize: key: to_send vals: - abc - def - "123" stages: - name: Echo back a unicode value and make sure it matches request: url: "{host}/echo" method: POST json: value: "{to_send}" response: status_code: 503 json: value: "klskdfiogj4iji34o" #This is now a validation error #--- # #test_name: Test empty vals raises error # #includes: # - !include common.yaml # #marks: # - parametrize: # key: to_send # vals: # #_xfail: verify # #stages: # - name: Echo back a unicode value and make sure it matches # request: # url: "{host}/echo" # method: POST # json: # value: "{to_send}" # response: # status_code: 200 # json: # value: "{to_send}" --- test_name: Test invalid parametrize vals raises an error includes: - !include common.yaml marks: - parametrize: key: to_send vals: a: b _xfail: verify stages: - name: Echo back a unicode value and make sure it matches request: url: "{host}/echo" method: POST json: value: "{to_send}" response: status_code: 200 json: value: "{to_send}" --- test_name: Test parametrize without include marks: - parametrize: key: to_send vals: - abc stages: - name: Echo back a unicode value and make sure it matches request: url: "http://localhost:5003/echo" method: POST json: value: "{to_send}" response: status_code: 200 json: value: "{to_send}" --- test_name: Test combined parametrizing includes: - !include common.yaml marks: - parametrize: key: - edible - fruit vals: - [rotten, apple] - [fresh, orange] - [unripe, pear] stages: - name: Echo back a unicode value and make sure it matches request: url: "{host}/echo" method: POST json: value: "{fruit}-{edible}" response: status_code: 200 json: value: "{fruit}-{edible}" --- test_name: Test combined parametrizing with normal parametrizing includes: - !include common.yaml marks: - parametrize: key: - edible - fruit vals: - [rotten, apple] - [fresh, orange] - [unripe, pear] - parametrize: key: to_send vals: - abc - def stages: - name: Echo back a unicode value and make sure it matches request: url: "{host}/echo" method: POST json: value: "{fruit}-{edible}_{to_send}" response: status_code: 200 json: value: "{fruit}-{edible}_{to_send}" --- test_name: Test double combined parametrizing includes: - !include common.yaml marks: - parametrize: key: - edible - fruit vals: - [rotten, apple] - [fresh, orange] - [unripe, pear] - parametrize: key: - first_half - second_half vals: - [spear, mint] - [jack, fruit] stages: - name: Echo back a unicode value and make sure it matches request: url: "{host}/echo" method: POST json: value: "{fruit}-{edible}_{first_half}-{second_half}" response: status_code: 200 json: value: "{fruit}-{edible}_{first_half}-{second_half}" --- test_name: Test include marks from a file includes: - !include common.yaml marks: - parametrize: !include parametrize_includes.yaml - parametrize: key: - first_half - second_half vals: - [spear, mint] - [jack, fruit] stages: - name: Echo back a unicode value and make sure it matches request: url: "{host}/echo" method: POST json: value: "{fruit}-{edible}_{first_half}-{second_half}" response: status_code: 200 json: value: "{fruit}-{edible}_{first_half}-{second_half}" # Now fails at collection time #--- # #test_name: Test failing when key is a list and vals isn't # #_xfail: verify # #includes: # - !include common.yaml # #marks: # - parametrize: # key: # - edible # - fruit # vals: # - fresh # - orange # #stages: # - name: Echo back a unicode value and make sure it matches # request: # url: "{host}/echo" # method: POST # json: # value: "{fruit}-{edible}" # response: # status_code: 200 # json: # value: "{fruit}-{edible}-nope" # Now fails at collection time #--- # #test_name: Test failing when keys and values list lengths do not match # #_xfail: verify # #includes: # - !include common.yaml # #marks: # - parametrize: # key: # - edible # - fruit # vals: # - [fresh] # #stages: # - name: Echo back a unicode value and make sure it matches # request: # url: "{host}/echo" # method: POST # json: # value: "{fruit}-{edible}" # response: # status_code: 200 # json: # value: "{fruit}-{edible}-nope" --- test_name: Test parametrize from thing in common.yaml includes: - !include common.yaml marks: - parametrize: key: generic_str vals: # normal string - "{v_str}" # from env var - "{second_part}" - parametrize: key: edible vals: - rotten - fresh - unripe stages: - name: Echo back a unicode value and make sure it matches request: url: "{host}/echo" method: POST json: value: "{generic_str}-{edible}" response: status_code: 200 json: value: "{generic_str}-{edible}" --- test_name: Test parametrize from thing in global config marks: - parametrize: key: generic_str vals: # normal string - "{global_test_string_1}" # from env var - "{global_test_string_2}" - parametrize: key: edible vals: - rotten - fresh - unripe stages: - name: Echo back a unicode value and make sure it matches request: url: "{global_host}/echo" method: POST json: value: "{generic_str}-{edible}" response: status_code: 200 json: value: "{generic_str}-{edible}" --- test_name: Test that double formatting something in marks: - parametrize: key: - line - text vals: # NOTE: "\" requires doubling, !raw will take care of "{" and "}" - [1, "XYZ[\\]^_`abcdefghijk"] - [2, !raw "lmnopqrstuvwxyz{|}~*"] stages: - name: Echo back parametrized text request: url: "{global_host}/echo" method: POST json: value: "{line}-{text}" response: status_code: 200 json: value: "{line}-{text}" --- test_name: Test parametrizing http method marks: - parametrize: key: method vals: - POST - GET - DELETE stages: - name: Make a request to the magic endpoint and expect method back request: url: "{global_host}/magic-multi-method" method: "{method}" response: status_code: 200 json: method: "{method}" --- test_name: Test parametrizing http method badly marks: - parametrize: key: method vals: - Brean _xfail: verify stages: - name: Make a request to the magic endpoint and expect method back request: url: "{global_host}/magic-multi-method" method: "{method}" response: status_code: 200 json: method: "{method}" --- test_name: Test sending a list of keys includes: - !include common.yaml marks: - parametrize: key: edible vals: - [not, edible, at, all] stages: - name: make sure list is sent and returned request: url: "{host}/echo" method: POST json: value: !force_format_include "{edible}" response: status_code: 200 json: value: - not - edible - at - all --- test_name: Test sending a list of list of keys includes: - !include common.yaml marks: - parametrize: key: - edible - fruit vals: - [rotten, apple] - [poisonous, pear] stages: - name: make sure list is sent and returned request: url: "{host}/echo" method: POST json: edibility: "{edible}" fruit: "{fruit}" response: status_code: 200 json: edibility: "{edible}" fruit: "{fruit}" --- test_name: Test sending a list of list of keys where one is not a string includes: - !include common.yaml marks: - parametrize: key: - fruit - colours vals: - [apple, [red, green, pink]] - [pear, [yellow, green]] stages: - name: make sure list and sublist is sent and returned request: url: "{host}/echo" method: POST json: fruit: "{fruit}" colours: !force_format_include "{colours}" response: status_code: 200 json: fruit: "{fruit}" colours: !force_format_include "{tavern.request_vars.json.colours}" --- test_name: Test parametrizing with an ext function marks: - parametrize: key: value_to_get vals: - goodbye - $ext: function: ext_functions:return_goodbye_string stages: - name: Echo back parametrized value request: url: "{global_host}/echo" method: POST json: value: "{value_to_get}" response: status_code: 200 json: value: "goodbye" --- test_name: Test parametrizing with an ext function that returns a dict marks: - parametrize: key: value_to_get vals: - hello: there - $ext: function: ext_functions:return_hello stages: - name: Echo back parametrized value request: url: "{global_host}/echo" method: POST json: !force_format_include "{value_to_get}" response: status_code: 200 json: hello: "there" --- test_name: Test parametrizing with an ext function that returns a dict with supplemental data marks: - parametrize: key: value_to_get vals: - and: this hello: there - and: this $ext: function: ext_functions:return_hello stages: - name: Echo back parametrized value request: url: "{global_host}/echo" method: POST json: !force_format_include "{value_to_get}" response: status_code: 200 json: hello: "there" and: this #--- # # NOTE: This fails immediately because it's impossible to resolve at the test level # #test_name: Test parametrizing with an ext function that returns a dict with supplemental data, but wrong function type # #_xfail: verify # #marks: #- parametrize: # key: value_to_get # vals: # - and: this # $ext: # function: ext_functions:return_goodbye_string # #stages: #- name: Echo back parametrized value # request: # url: "{global_host}/echo" # method: POST # json: !force_format_include "{value_to_get}" # response: # status_code: 200 # json: {} # --- test_name: Test parametrizing random different data types in the same test marks: - parametrize: key: value_to_send vals: - a - [b, c] - more: stuff - yet: [more, stuff] - $ext: function: ext_functions:return_goodbye_string - and: this $ext: function: ext_functions:return_hello stages: - name: Echo back parametrized value request: url: "{global_host}/echo" method: POST json: !force_format_include "{value_to_send}" response: status_code: 200 json: !force_format_include "{tavern.request_vars.json}" tavern-3.6.0/tests/integration/test_regex.tavern.yaml000066400000000000000000000041671520710011500230370ustar00rootroot00000000000000--- test_name: Make sure server response matches regex includes: - !include common.yaml stages: - name: simple match request: url: "{host}/token" method: GET response: status_code: 200 verify_response_with: function: tavern.helpers:validate_regex extra_kwargs: expression: '' --- test_name: Use saved value includes: - !include common.yaml stages: - name: simple match request: url: "{host}/token" method: GET response: status_code: 200 verify_response_with: function: tavern.helpers:validate_regex extra_kwargs: expression: '' - name: save groups request: url: "{host}/token" method: GET response: status_code: 200 save: $ext: function: tavern.helpers:validate_regex extra_kwargs: expression: '.*)\?token=(?P.*)\">' - name: send saved request: url: "{regex.url}" method: GET params: token: "{regex.token}" response: status_code: 200 - name: simple header match request: url: "{host}/headers" method: GET response: status_code: 200 verify_response_with: function: tavern.helpers:validate_regex extra_kwargs: expression: '(?<=Hello)[wW]orld\d+$' header: X-Integration-Value --- test_name: Match something in part of the request stages: - name: simple match request: url: "{global_host}/echo" method: POST json: fake: code=abc123&state=f fake2: code=abc123&state=f fake3: code=abc124&state=f response: status_code: 200 save: $ext: function: tavern.helpers:validate_regex extra_kwargs: expression: "code=(?P.*)&state" in_jmespath: "fake3" - name: Reuse thing specified in first request request: url: "{global_host}/echo" method: POST json: fake: "{regex.code_token}" response: status_code: 200 json: fake: abc124 tavern-3.6.0/tests/integration/test_response_types.tavern.yaml000066400000000000000000000037571520710011500250130ustar00rootroot00000000000000--- test_name: Make sure it can handle list responses includes: - !include common.yaml stages: - name: Match line notation request: url: "{host}/fake_list" method: GET response: status_code: 200 json: - a - b - c - 1 - 2 - 3 - -1.0 - -2.0 - -3.0 - name: match json notation request: url: "{host}/fake_list" method: GET response: status_code: 200 json: [a, b, c, 1, 2, 3, -1.0, -2.0, -3.0] --- test_name: Test unicode responses includes: - !include common.yaml stages: - name: Echo back a unicode value and make sure it matches request: url: "{host}/echo" method: POST json: value: 手机号格式不正确 response: status_code: 200 json: value: 手机号格式不正确 --- test_name: Test string as top-level JSON type includes: - !include common.yaml stages: - name: Echo back a string value and make sure it matches request: url: "{host}/echo" method: POST json: "json_string" response: status_code: 200 json: "json_string" --- test_name: Test boolean as top-level JSON type includes: - !include common.yaml stages: - name: Echo back a boolean value and make sure it matches request: url: "{host}/echo" method: POST json: False response: status_code: 200 json: False --- test_name: Test number as top-level JSON type includes: - !include common.yaml stages: - name: Echo back a number value and make sure it matches request: url: "{host}/echo" method: POST json: 1337 response: status_code: 200 json: 1337 --- test_name: Test null as top-level JSON type includes: - !include common.yaml stages: - name: Echo back a null value and make sure it matches request: url: "{host}/echo" method: POST json: null response: status_code: 200 json: null tavern-3.6.0/tests/integration/test_retry.tavern.yaml000066400000000000000000000045041520710011500230650ustar00rootroot00000000000000--- test_name: Make sure tavern repeats request includes: - !include common.yaml stages: - name: polling max_retries: 1 request: url: "{host}/poll" method: GET response: status_code: 200 json: status: ready --- test_name: Setting max_retries to a float should fail - doesn't make sense includes: - !include common.yaml _xfail: verify stages: - name: polling max_retries: 1.5 request: url: "{host}/poll" method: GET response: status_code: 200 json: status: ready --- test_name: Format max retry variable correctly includes: - !include common.yaml stages: - name: polling max_retries: !int "{retry_max}" request: url: "{host}/poll" method: GET response: status_code: 200 json: status: ready --- test_name: Format max retry variable fails if not using type token includes: - !include common.yaml _xfail: verify stages: - name: polling max_retries: "{retry_max}" request: url: "{host}/poll" method: GET response: status_code: 200 json: status: ready --- test_name: Format max retry variable fails if invalid value includes: - !include common.yaml _xfail: run stages: - name: polling max_retries: !int "{negative_int}" request: url: "{host}/poll" method: GET response: status_code: 200 json: status: ready --- test_name: Format max retry variable fails if using wrong type token includes: - !include common.yaml _xfail: verify stages: - name: polling max_retries: !float "{retry_max}" request: url: "{host}/poll" method: GET response: status_code: 200 json: status: ready --- test_name: Setting max_retries to less than 0 should fail includes: - !include common.yaml _xfail: verify stages: - name: polling max_retries: -1 request: url: "{host}/poll" method: GET response: status_code: 200 json: status: ready --- test_name: Setting max_retries to something other than an int should fail includes: - !include common.yaml _xfail: verify stages: - name: polling max_retries: five request: url: "{host}/poll" method: GET response: status_code: 200 json: status: ready tavern-3.6.0/tests/integration/test_save_dict_value.tavern.yaml000066400000000000000000000011171520710011500250520ustar00rootroot00000000000000# This is what we expect: # top: # Thing: value # nested: # doubly: # inner: value # an_integer: 123 # a_float: 1.23 # a_string: abc # a_bool: true --- test_name: Test saving a dict stages: - name: Save whole dict request: url: "{global_host}/fake_dictionary" response: status_code: 200 save: json: top_value: top - name: Use a saved dict value request: url: "{global_host}/echo" method: POST json: value: "{top_value.Thing}" response: status_code: 200 json: value: value tavern-3.6.0/tests/integration/test_selective_tests.tavern.yaml000066400000000000000000000017121520710011500251230ustar00rootroot00000000000000--- test_name: Test 'only' keyword for test isolation includes: - !include common.yaml stages: - name: This is the only test that should run only: yes request: url: "{host}/fake_list" method: GET response: status_code: 200 - name: This should be ignored request: url: "{host}/fake_list" method: GET response: status_code: 999 - name: This should also be ignored because it's not the first 'only' test only: yes request: url: "{host}/fake_list" method: GET response: status_code: 999 --- test_name: Test 'only' keyword for test isolation includes: - !include common.yaml stages: - name: This should be ignored request: url: "{host}/fake_list" method: GET response: status_code: 999 - name: This is the only test that should run only: yes request: url: "{host}/fake_list" method: GET response: status_code: 200 tavern-3.6.0/tests/integration/test_skipped_tests.tavern.yaml000066400000000000000000000112221520710011500245740ustar00rootroot00000000000000--- test_name: Test 'skip' keyword for selectively ignoring tests includes: - !include common.yaml stages: - name: This test should not run skip: yes request: url: "{host}/fake_list" method: GET response: status_code: 999 - name: This test should still run request: url: "{host}/fake_list" method: GET response: status_code: 200 --- test_name: Test unconditional skip with pytest marker includes: - !include common.yaml marks: - skip stages: - name: This test should not run request: url: "{host}/fake_list" method: GET response: status_code: 999 --- test_name: Test skipif with pytest marker includes: - !include common.yaml marks: - skipif: "100 > 50" stages: - name: This test should not run request: url: "{host}/fake_list" method: GET response: status_code: 999 --- test_name: Test skipif with pytest marker with a formatted integer includes: - !include common.yaml marks: - skipif: "{v_int} > 50" stages: - name: This test should not run request: url: "{host}/fake_list" method: GET response: status_code: 999 --- test_name: Test skipif with pytest marker with a formatted string includes: - !include common.yaml marks: - skipif: "'https' not in '{host}'" stages: - name: This test should not run request: url: "{host}/fake_list" method: GET response: status_code: 999 --- test_name: Test skipif failure goes on to test failure includes: - !include common.yaml marks: # ie, only run this test against insecure server. incorrect logic. - skipif: "'https' in '{host}'" _xfail: run stages: - name: This test should not run request: url: "{host}/fake_list" method: GET response: status_code: 999 --- test_name: Test skipif with env var includes: - !include common.yaml marks: # ie only run against https - skipif: "'https' in '{tavern.env_vars.TEST_HOST}'" stages: - name: Fake get request: url: "{host}/fake_list" method: GET response: status_code: 200 --- test_name: Test skipif with env var, negative includes: - !include common.yaml marks: - skipif: "'http' in '{tavern.env_vars.TEST_HOST}'" stages: - name: This test should not run request: url: "{host}/fake_list" method: GET response: status_code: 999 --- test_name: Test simpleeval integer comparison includes: - !include common.yaml stages: - name: Do not skip on integer equality skip: "{v_int} == 456" request: url: "{host}/fake_list" method: GET response: status_code: 200 - name: Skip on integer inequality skip: "{v_int} == 123" request: url: "{host}/fake_list" method: GET response: status_code: 234 --- test_name: Test simpleeval float comparison includes: - !include common.yaml stages: - name: Do not skip on float comparison skip: "{v_float} <= 100.5" request: url: "{host}/fake_list" method: GET response: status_code: 200 - name: Skip on float comparison skip: "{v_float} >= 100.5" request: url: "{host}/fake_list" method: GET response: status_code: 200 --- test_name: Test simpleeval string operations includes: - !include common.yaml stages: - name: Skip on string contains skip: "'http' in '{host}'" request: url: "{host}/fake_list" method: GET response: status_code: 234 - name: Skip on string length skip: "len('{host}') > 10" request: url: "{host}/fake_list" method: GET response: status_code: 234 --- test_name: Test simpleeval boolean operations includes: - !include common.yaml stages: - name: Skip on boolean true skip: "{v_bool}" request: url: "{host}/fake_list" method: GET response: status_code: 200 - name: Skip on boolean false skip: "not {v_bool}" request: url: "{host}/fake_list" method: GET response: status_code: 200 --- test_name: Test simpleeval complex expressions includes: - !include common.yaml stages: - name: Do not skip on compound expression skip: "({v_int} > 50) and ({v_float} > 50.0)" request: url: "{host}/fake_list" method: GET response: status_code: 200 - name: Skip on compound expression skip: "({v_int} > 50) or ({v_float} > 50.0)" request: url: "{host}/fake_list" method: GET response: status_code: 234 - name: Skip on parentheticals skip: "({v_int} + {v_int}) == 200" request: url: "{host}/fake_list" method: GET response: status_code: 200 tavern-3.6.0/tests/integration/test_status_codes.tavern.yaml000066400000000000000000000027421520710011500244220ustar00rootroot00000000000000--- test_name: Test matching one of multiple status codes includes: - !include common.yaml stages: - name: Match one status code request: url: "{host}/status_code_return" method: POST json: status_code: 200 response: status_code: 200 - name: Match one of many request: url: "{host}/status_code_return" method: POST json: status_code: 200 response: status_code: - 100 - 200 --- test_name: Test missing from status code list fails _xfail: run includes: - !include common.yaml stages: - name: Missing from status code list request: url: "{host}/status_code_return" method: POST json: status_code: 400 response: status_code: - 100 - 200 --- test_name: Test using invalid status code format fails at verification _xfail: verify includes: - !include common.yaml stages: - name: Missing from status code list request: url: "{host}/status_code_return" method: POST json: status_code: 400 response: status_code: first: 100 second: 200 --- test_name: Test using invalid status code value fails at verification _xfail: verify includes: - !include common.yaml stages: - name: Missing from status code list request: url: "{host}/status_code_return" method: POST json: status_code: 400 response: status_code: - "200" - 300 tavern-3.6.0/tests/integration/test_stream.tavern.yaml000066400000000000000000000003531520710011500232110ustar00rootroot00000000000000--- test_name: Test streaming (downloading) file includes: - !include common.yaml stages: - name: Stream file request: url: "{host}/stream_file" method: GET stream: True response: status_code: 200 tavern-3.6.0/tests/integration/test_strict_key_checks.tavern.yaml000066400000000000000000000256041520710011500254240ustar00rootroot00000000000000# This is what we expect: # top: # Thing: value # nested: # doubly: # inner: value # an_integer: 123 # a_float: 1.23 # a_string: abc # a_bool: true --- test_name: Test setting 'strict' to a string fails _xfail: verify strict: fkd stages: - name: match top level request: url: "{host}/fake_dictionary" response: status_code: 200 --- test_name: Test setting 'strict' to a dict fails _xfail: verify strict: json: true stages: - name: match top level request: url: "{host}/fake_dictionary" response: status_code: 200 --- test_name: Test setting 'strict' to a list with invalid values fails _xfail: verify strict: - blokergh stages: - name: match top level request: url: "{host}/fake_dictionary" response: status_code: 200 --- test_name: Test key matching matches headers case insensitive includes: - !include common.yaml stages: - name: Get with header request: url: "{host}/headers" response: status_code: 200 headers: x-InTeGrAtIoN-vALUe: _HelloWorld1 --- test_name: Test strict key matching against body fails _xfail: run strict: - json includes: - !include common.yaml stages: - name: match top level request: url: "{host}/fake_dictionary" response: status_code: 200 json: top: Thing: value nested: doubly: inner: value an_integer: 123 a_float: 1.23 a_string: abc # missing # a_bool: true --- test_name: Test strict key matching against body fails with dict missing when specified in test _xfail: run strict: - json includes: - !include common.yaml stages: - name: match top level request: url: "{host}/fake_dictionary" response: status_code: 200 json: # top: # Thing: value # nested: # doubly: # inner: value an_integer: 123 a_float: 1.23 a_string: abc a_bool: true --- test_name: Test strict key matching against body fails with dict missing when specified in stage _xfail: run includes: - !include common.yaml stages: - name: match top level request: url: "{host}/fake_dictionary" response: strict: - json status_code: 200 json: # top: # Thing: value # nested: # doubly: # inner: value an_integer: 123 a_float: 1.23 a_string: abc a_bool: true --- test_name: Test strict key matching against headers with mismatch body passes # same as above, but changing 'strict' to only check headers should work strict: - headers - json:off includes: - !include common.yaml stages: - name: match top level request: url: "{host}/fake_dictionary" response: status_code: 200 json: top: Thing: value nested: doubly: inner: value an_integer: 123 a_float: 1.23 a_string: abc # missing # a_bool: true --- test_name: Test strict key matching against exact body is fine when strict is specified in the test strict: - json includes: - !include common.yaml stages: - name: match top level request: url: "{host}/fake_dictionary" response: status_code: 200 json: top: Thing: value nested: doubly: inner: value an_integer: 123 a_float: 1.23 a_string: abc a_bool: true --- test_name: Test strict key matching against exact body is fine when strict is specified in the stage strict: - json includes: - !include common.yaml stages: - name: match top level request: url: "{host}/fake_dictionary" response: status_code: 200 json: top: Thing: value nested: doubly: inner: value an_integer: 123 a_float: 1.23 a_string: abc a_bool: true --- test_name: Test strict key matching works for specific test stages includes: - !include common.yaml strict: - json:off stages: - name: match with one key missing request: url: "{host}/fake_dictionary" response: status_code: 200 json: top: Thing: value nested: doubly: inner: value an_integer: 123 a_float: 1.23 a_string: abc # a_bool: true - name: match strictly for this stage request: url: "{host}/fake_dictionary" response: strict: - json status_code: 200 json: top: Thing: value nested: doubly: inner: value an_integer: 123 a_float: 1.23 a_string: abc a_bool: true - name: match with one key missing again, without 'strict' cascading through stages request: url: "{host}/fake_dictionary" response: status_code: 200 json: top: Thing: value nested: doubly: inner: value # an_integer: 123 a_float: 1.23 a_string: abc a_bool: true --- test_name: Test non-strict key matching one list item strict: - json includes: - !include common.yaml stages: - name: match first item in list request: url: "{host}/fake_list" response: status_code: 200 strict: false json: - a - name: match a middle item in the list request: url: "{host}/fake_list" response: status_code: 200 # Use new syntax strict: - json:off json: - c - name: match a number in the middle of the list request: url: "{host}/fake_list" response: status_code: 200 strict: false json: - 2 --- test_name: Test strict key matching one list item fails _xfail: run strict: - json includes: - !include common.yaml stages: - name: match 'e' in list request: url: "{host}/fake_list" response: status_code: 200 strict: true json: - c --- test_name: Test strict key matching works for specific test stages with false includes: - !include common.yaml stages: - name: match with one key missing request: url: "{host}/fake_dictionary" response: status_code: 200 strict: False json: top: Thing: value an_integer: 123 a_string: abc a_bool: true --- test_name: Test strict key matching works for specific test stages with true _xfail: run includes: - !include common.yaml stages: - name: match with one key missing request: url: "{host}/fake_dictionary" response: status_code: 200 strict: True json: top: Thing: value an_integer: 123 a_string: abc a_bool: true --- test_name: Test non-strict key matching one list item strict: - json includes: - !include common.yaml stages: - name: match 'e' in list request: url: "{host}/fake_list" response: status_code: 200 strict: false json: - c --- test_name: Test matching any order on json strict: - json:list_any_order includes: - !include common.yaml stages: - name: match some things in list, in any order request: url: "{host}/fake_list" response: status_code: 200 json: - 2 - c - a - -3.0 - 1 --- test_name: Test matching any order on json includes: - !include common.yaml stages: - name: match some things in list, in any order request: url: "{host}/fake_list" response: strict: - json:list_any_order status_code: 200 json: - 2 - c - a - -3.0 - 1 --- test_name: Test matching any order on json nested strict: - json:list_any_order includes: - !include common.yaml stages: - name: match some things in list, in any order request: url: "{host}/nested_list" response: status_code: 200 json: top: - b - key: val - a --- test_name: Test matching any order on json nested includes: - !include common.yaml stages: - name: match some things in list, in any order request: url: "{host}/nested_list" response: strict: - json:list_any_order status_code: 200 json: top: - b - key: val --- test_name: Test non-strict key matching in one stage does not leak over to the next _xfail: run includes: - !include common.yaml stages: - name: match 'c' in list, strict false request: url: "{host}/fake_list" response: status_code: 200 strict: false json: - c - name: match 'c' in list, no strict request: url: "{host}/fake_list" response: status_code: 200 json: - c --- test_name: Test strict key matching one list item fails _xfail: run strict: - json includes: - !include common.yaml stages: - name: match 'e' in list request: url: "{host}/fake_list" response: status_code: 200 strict: true json: - c --- test_name: strict test _xfail: run stages: - name: Simple get request: url: "{global_host}/707-regression" method: GET response: status_code: 200 json: a: 1 b: first: 10 # second: 20 c: 2 --- test_name: test empty list matches with strict json on strict: - json:on stages: - name: Simple get request: url: "{global_host}/606-regression-list" method: GET response: status_code: 200 json: [] --- test_name: test full list does not match with strict json on _xfail: run strict: - json:on stages: - name: Simple get request: url: "{global_host}/fake_list" method: GET response: status_code: 200 json: [] --- test_name: test full list matches with strict json off strict: - json:off stages: - name: Simple get request: url: "{global_host}/fake_list" method: GET response: status_code: 200 json: [] --- test_name: test empty dict matches with strict json on strict: - json:on stages: - name: Simple get request: url: "{global_host}/606-regression-dict" method: GET response: status_code: 200 json: {} --- test_name: test full dict does not match with strict json on _xfail: run strict: - json:on stages: - name: Simple get request: url: "{global_host}/fake_dictionary" method: GET response: status_code: 200 json: {} --- test_name: test full dict matches with strict json off strict: - json:off stages: - name: Simple get request: url: "{global_host}/fake_dictionary" method: GET response: status_code: 200 json: {} tavern-3.6.0/tests/integration/test_tavern_include.tavern.yaml000066400000000000000000000015321520710011500247200ustar00rootroot00000000000000--- test_name: Test file_body resolved via TAVERN_INCLUDE environment variable includes: - !include common.yaml marks: - usefixtures: - tavern_include_env stages: - name: Send file body found via TAVERN_INCLUDE include path request: url: "{host}/expect_raw_data" method: POST file_body: tavern_include_data.txt response: status_code: 200 json: status: ok --- test_name: Test file_body not resolved without fixture (also file does not exist) includes: - !include common.yaml _xfail: run: File 'tavern_include_data.txt' not found in include path stages: - name: "Send file body (xfail: file not in include path)" request: url: "{host}/expect_raw_data" method: POST file_body: tavern_include_data.txt response: status_code: 500 json: status: ok tavern-3.6.0/tests/integration/test_text_responses.tavern.yaml000066400000000000000000000043431520710011500250060ustar00rootroot00000000000000--- test_name: Test plain text response matching includes: - !include common.yaml stages: - name: Match simple text response request: url: "{host}/text_response" method: GET response: status_code: 200 text: "Hello, World!" --- test_name: Test ASCII table response matching includes: - !include common.yaml stages: - name: Match ASCII table response request: url: "{host}/ascii_table" method: GET response: status_code: 200 text: | +----+----------+-------+ | id | name | score | +----+----------+-------+ | 1 | Alice | 95 | | 2 | Bob | 87 | | 3 | Charlie | 92 | +----+----------+-------+ --- test_name: Test text response with strict mode includes: - !include common.yaml stages: - name: Match text response with strict text on request: url: "{host}/text_response" method: GET response: status_code: 200 strict: - text:on text: "Hello, World!" --- test_name: Test text response mismatch fails _xfail: run includes: - !include common.yaml stages: - name: Mismatched text should fail request: url: "{host}/text_response" method: GET response: status_code: 200 text: "Goodbye, World!" --- test_name: Test text response matching from file includes: - !include common.yaml stages: - name: Match response against file content request: url: "{host}/ascii_table" method: GET response: status_code: 200 text: !include_raw expected_table.txt --- test_name: Test text response mismatch from file fails _xfail: run includes: - !include common.yaml stages: - name: Mismatched file content should fail request: url: "{host}/text_response" method: GET response: status_code: 200 text: !include_raw expected_wrong.txt --- test_name: Test echo plain text with POST includes: - !include common.yaml stages: - name: POST text and verify echo request: url: "{host}/expected_text" method: POST headers: Content-Type: text/plain data: "test data to echo" response: status_code: 200 text: "test data to echo" tavern-3.6.0/tests/integration/test_timeout.tavern.yaml000066400000000000000000000036371520710011500234140ustar00rootroot00000000000000--- test_name: Test timeout to server includes: - !include common.yaml stages: - name: Test single timeout parameter request: url: "{host}/get_thing_slow" method: GET timeout: 0.4 response: status_code: 200 --- test_name: Test timeout to server tuple includes: - !include common.yaml stages: - name: Test tuple timeout parameter request: url: "{host}/get_thing_slow" method: GET timeout: - 0.1 - 0.4 response: status_code: 200 --- test_name: Test timeout to server actually times out _xfail: run includes: - !include common.yaml stages: - name: Test single timeout parameter request: url: "{host}/get_thing_slow" method: GET timeout: 0.1 response: status_code: 200 --- test_name: Test timeout to server tuple actually times out _xfail: run includes: - !include common.yaml stages: - name: Test tuple timeout parameter request: url: "{host}/get_thing_slow" method: GET timeout: - 0.1 - 0.1 response: status_code: 200 --- test_name: Test timeout tuple too short _xfail: verify includes: - !include common.yaml stages: - name: Test tuple timeout parameter request: url: "{host}/get_thing_slow" method: GET timeout: - 0.1 response: status_code: 200 --- test_name: Test timeout tuple too long _xfail: verify includes: - !include common.yaml stages: - name: Test tuple timeout parameter request: url: "{host}/get_thing_slow" method: GET timeout: - 0.1 - 0.4 - 1 response: status_code: 200 --- test_name: Test timeout wrong type _xfail: verify includes: - !include common.yaml stages: - name: Test incorrect timeout parameter request: url: "{host}/get_thing_slow" method: GET timeout: hello response: status_code: 200 tavern-3.6.0/tests/integration/test_tincture.tavern.yaml000066400000000000000000000067711520710011500235650ustar00rootroot00000000000000--- is_defaults: true # Global tinctures applied to all test stages (issue #969) tinctures: - function: ext_functions:global_tincture_marker --- test_name: Test tincture + fixtures includes: - !include common.yaml tinctures: - function: ext_functions:time_request - function: ext_functions:print_response extra_kwargs: extra_print: "blooble" marks: - usefixtures: - yielder - str_fixture - yield_str_fixture stages: - name: do something tinctures: - function: ext_functions:time_request - function: ext_functions:print_response extra_kwargs: extra_print: "blooble" request: url: "{host}/echo" method: POST json: value: "{str_fixture}" response: status_code: 200 json: value: "{yield_str_fixture}" --- test_name: Test tincture extra kwargs fails includes: - !include common.yaml tinctures: - function: ext_functions:print_response extra_kwargs: extra_print: "blooble" something: else _xfail: run stages: - name: do something request: url: "{host}/echo" method: POST json: value: "{str_fixture}" response: status_code: 200 json: value: "{yield_str_fixture}" --- # Test for global tinctures feature (issue #969) # This test verifies that tinctures defined in the global config file # (global_cfg.yaml) are applied to all test stages. # The global config includes a tincture: ext_functions:global_tincture_marker # which should run for every stage in this test. test_name: Test global tinctures applied includes: - !include common.yaml # No test-level tinctures defined - only global tinctures from global_cfg.yaml # should be applied stages: - name: stage with only global tincture # No stage-level tinctures - only global tinctures should run request: url: "{host}/echo" method: POST json: value: "test_global_tincture" response: status_code: 200 json: value: "test_global_tincture" - name: another stage with only global tincture # Verify global tinctures run for each stage request: url: "{host}/echo" method: POST json: value: "second_stage" response: status_code: 200 json: value: "second_stage" - name: verify global tincture call count # Verify that global tincture was called for every stage # The global tincture runs once per stage # So for 2 stages, it should be called 2 times total request: url: "{host}/echo" method: POST json: $ext: function: ext_functions:get_global_tincture_call_count response: status_code: 200 json: # The tincture should have been called 2 times total: # 1. Once in first stage # 2. Once in second stage call_count: 2 --- # Test that global tinctures combine with test/stage tinctures test_name: Test global tinctures combined with test tinctures includes: - !include common.yaml # Test-level tincture combined with global tincture from global_cfg.yaml tinctures: - function: ext_functions:time_request stages: - name: test and global tinctures combined # Stage-level tincture also combined tinctures: - function: ext_functions:print_response extra_kwargs: extra_print: "combined_test" request: url: "{host}/echo" method: POST json: value: "combined" response: status_code: 200 json: value: "combined" tavern-3.6.0/tests/integration/test_typetokens.tavern.yaml000066400000000000000000000327711520710011500241340ustar00rootroot00000000000000# This is what we expect: # top: # Thing: value # nested: # doubly: # inner: value # an_integer: 123 # a_string: abc --- test_name: Test 'anything' token will match any response includes: - !include common.yaml stages: - name: match top level request: url: "{host}/fake_dictionary" method: GET response: status_code: 200 json: !anything --- test_name: Test 'anything' token will match any response, from included stage includes: - !include common.yaml stages: - type: ref id: typetoken-anything-match --- test_name: Test bool type match strict: - json:off includes: - !include common.yaml stages: - name: match explicitly request: url: "{host}/fake_dictionary" method: GET response: status_code: 200 json: a_bool: true - name: match top level request: url: "{host}/fake_dictionary" method: GET response: status_code: 200 json: a_bool: !anybool --- test_name: Test integer type match strict: - json:off includes: - !include common.yaml stages: - name: match explicitly request: url: "{host}/fake_dictionary" method: GET response: status_code: 200 json: an_integer: 123 - name: match top level request: url: "{host}/fake_dictionary" method: GET response: status_code: 200 json: an_integer: !anyint --- test_name: Test list type match includes: - !include common.yaml stages: - name: match any list request: url: "{host}/fake_list" method: GET response: status_code: 200 json: !anylist --- test_name: Test dict type match includes: - !include common.yaml stages: - name: match any dict request: url: "{host}/fake_dictionary" method: GET response: status_code: 200 json: !anydict --- test_name: Test string type match strict: - json:off includes: - !include common.yaml stages: - name: match explicitly request: url: "{host}/fake_dictionary" method: GET response: status_code: 200 json: a_string: "abc" - name: match top level request: url: "{host}/fake_dictionary" method: GET response: status_code: 200 json: a_string: !anystr --- test_name: Test all at once includes: - !include common.yaml stages: - name: match top level request: url: "{host}/fake_dictionary" method: GET response: status_code: 200 json: top: Thing: !anystr nested: !anything an_integer: !anyint a_float: !anyfloat a_string: !anystr a_bool: !anybool --- test_name: Match list item responses includes: - !include common.yaml stages: - name: Match generic types request: url: "{host}/fake_list" method: GET response: status_code: 200 json: - a - b - !anystr - 1 - 2 - !anyint - -1.0 - -2.0 - !anyfloat --- test_name: Match whole list 'anything' includes: - !include common.yaml stages: - name: Match with anything request: url: "{host}/nested_list" method: GET response: status_code: 200 json: top: !anything --- test_name: Match list items anything includes: - !include common.yaml stages: - name: Match with anything request: url: "{host}/nested_list" method: GET response: status_code: 200 json: top: - a - !anystr - !anything --- test_name: Test converting to a bool from a formatted string includes: - !include common.yaml stages: - name: Convert bool from a formatted string request: url: "{host}/expect_dtype" method: POST json: value: !bool "{v_bool}" dtype: bool dvalue: False response: status_code: 200 # We could use strtobool to make this pass, but it's a bit # of magic # - name: Convert bool from a string # request: # url: "{host}/expect_dtype" # method: POST # json: # value: !bool "true" # dtype: bool # dvalue: True # response: # status_code: 200 --- test_name: Test using a converted bool as part of the validated schema includes: - !include common.yaml stages: - name: Validate converted bool request: url: "{host}/pi" method: GET verify: !bool "{verify_false}" response: status_code: 200 --- test_name: Test can't use approx numbers in a request includes: - !include common.yaml _xfail: verify stages: - name: Match pi approximately request: url: "{host}/pi" method: GET json: pi: !approx 2.4 bkd: a: b: 342 response: status_code: 200 --- # This actually raises an error when first loading the file, so it's not easy to # test like this # test_name: Test approximate numbers must be a float # # includes: # - !include common.yaml # # _xfail: run # # stages: # - name: Match pi approximately # request: # url: "{host}/pi" # method: GET # response: # status_code: 200 # json: # pi: !approx about three # # --- test_name: Test approximate numbers includes: - !include common.yaml stages: - name: Match pi approximately request: url: "{host}/pi" method: GET response: status_code: 200 json: pi: !approx 3.1415926 --- test_name: Test converting to an integer includes: - !include common.yaml stages: - name: Convert integer from a formatted string request: url: "{host}/expect_dtype" method: POST json: value: !int "{v_int}" dtype: int dvalue: 123 response: status_code: 200 - name: Convert integer from a string request: url: "{host}/expect_dtype" method: POST json: value: !int "1" dtype: int dvalue: 1 response: status_code: 200 # This will actually not work because it tries to convert from a string, which # doesn't work in python (eg int("4.56")) # - name: Convert float # request: # url: "{host}/expect_dtype" # method: POST # json: # value: !int "{v_float}" # dtype: int # dvalue: 123 # response: # status_code: 200 --- test_name: Test using a converted int as part of the validated schema includes: - !include common.yaml stages: - name: Validate converted int request: url: "{host}/pi" method: GET response: status_code: !int "{status_200}" --- test_name: Test conversion to an float from included files includes: - !include common.yaml stages: - name: Convert integer from a formatted string request: url: "{host}/expect_dtype" method: POST json: value: !float "{v_int}" dtype: float dvalue: 123.0 response: status_code: 200 - name: Convert integer from a string request: url: "{host}/expect_dtype" method: POST json: value: !float "1" dtype: float dvalue: 1 response: status_code: 200 - name: Convert float from a formatted string request: url: "{host}/expect_dtype" method: POST json: value: !float "{v_float}" dtype: float dvalue: 4.56 response: status_code: 200 - name: Convert float from a string request: url: "{host}/expect_dtype" method: POST json: value: !float "5.67" dtype: float dvalue: 5.67 response: status_code: 200 --- test_name: Test using a converted float as part of the validated schema includes: - !include common.yaml stages: - name: Validate converted float delay_before: !float "{delay_before_0_1}" request: url: "{host}/pi" method: GET response: status_code: 200 --- test_name: Test saving specific types between stages includes: - !include common.yaml stages: - name: Convert and post a float, then save the echoed value request: url: "{host}/echo" method: POST json: value: !float "{v_int}" response: status_code: 200 json: value: !float "{tavern.request_vars.json.value}" save: json: saved_float_value: value - name: Try to use it again and make sure it can be converted to the correct type request: url: "{host}/expect_dtype" method: POST json: value: !float "{saved_float_value}" dtype: float dvalue: 123.0 response: status_code: 200 --- test_name: Ignore variable syntax with double braces includes: - !include common.yaml stages: - name: Do not convert double braces request: url: "{host}/expect_dtype" method: POST json: value: '{{"query": "{{ val1 {{ val2 {{ val3 {{ val4, val5 }} }} }} }}"}}' dtype: str dvalue: '{{"query": "{{ val1 {{ val2 {{ val3 {{ val4, val5 }} }} }} }}"}}' response: status_code: 200 --- test_name: Test not converting a raw string (ignore variable like syntax) includes: - !include common.yaml stages: - name: Do not convert raw string request: url: "{host}/expect_dtype" method: POST json: value: !raw '{"query": "{ val1 { val2 { val3 { val4, val5 } } } }"}' dtype: str dvalue: '{{"query": "{{ val1 {{ val2 {{ val3 {{ val4, val5 }} }} }} }}"}}' response: status_code: 200 --- test_name: Test raw token works in response as well includes: - !include common.yaml stages: - name: Post raw, expect raw request: url: "{host}/echo" method: POST json: value: !raw '{"query": "{ val1 { val2 { val3 { val4, val5 } } } }"}' response: status_code: 200 json: value: !raw '{"query": "{ val1 { val2 { val3 { val4, val5 } } } }"}' --- test_name: Test magic format token includes: - !include common.yaml stages: - name: get dictionary request: url: "{host}/fake_dictionary" response: status_code: 200 save: json: whole_body: "@" - name: reuse dictionary request: url: "{host}/echo" method: POST json: !force_original_structure "{whole_body}" response: status_code: 200 json: !force_original_structure "{tavern.request_vars.json}" --- test_name: Test magic format token with list includes: - !include common.yaml stages: - name: get dictionary request: url: "{host}/fake_list" response: status_code: 200 json: !anylist save: json: whole_list_body: "@" - name: reuse dictionary request: url: "{host}/echo" method: POST json: !force_original_structure "{whole_list_body}" response: status_code: 200 json: - a - b - c - 1 - 2 - 3 - -1.0 - -2.0 - -3.0 --- test_name: Test old tag still works includes: - !include common.yaml stages: - name: get dictionary request: url: "{host}/fake_list" response: status_code: 200 json: !anylist save: json: whole_list_body: "@" - name: reuse dictionary request: url: "{host}/echo" method: POST json: !force_format_include "{whole_list_body}" response: status_code: 200 json: - a - b - c - 1 - 2 - 3 - -1.0 - -2.0 - -3.0 --- test_name: Match a regex at top level includes: - !include common.yaml stages: - name: match token request: url: "{host}/get_single_json_item" method: GET response: status_code: 200 json: !re_match "c82bfa63-.*" --- test_name: Match a regex in a nested thing includes: - !include common.yaml strict: - json:off stages: - name: match token request: url: "{host}/fake_dictionary" method: GET response: status_code: 200 json: top: nested: doubly: inner: !re_match "value" --- test_name: Match a regex number doesnt work because its the wrong type _xfail: run includes: - !include common.yaml stages: - name: try to match number request: url: "{host}/pi" method: GET response: strict: false status_code: 200 json: pi: !re_match "3.14.*" --- test_name: Match a uuid v4 includes: - !include common.yaml stages: - name: Match a uuid v4 request: url: "{host}/uuid/v4" method: GET response: status_code: 200 json: uuid: !re_fullmatch "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" --- test_name: Test number type match strict: - json:off includes: - !include common.yaml stages: - name: match integer value request: url: "{host}/fake_dictionary" method: GET response: status_code: 200 json: an_integer: !anynumber - name: match float value request: url: "{host}/fake_dictionary" method: GET response: status_code: 200 json: a_float: !anynumber --- test_name: Test number type match in list includes: - !include common.yaml stages: - name: Match generic number types request: url: "{host}/fake_list" method: GET response: status_code: 200 json: - a - b - c - !anynumber - !anynumber - !anynumber - !anynumber - !anynumber - !anynumber tavern-3.6.0/tests/integration/test_validate_pykwalify.tavern.yaml000066400000000000000000000026071520710011500256120ustar00rootroot00000000000000--- test_name: Test validating with extension function includes: - !include common.yaml stages: - name: Correctly validate request: url: "{host}/nested_list" method: GET response: status_code: 200 verify_response_with: function: tavern.helpers:validate_pykwalify extra_kwargs: schema: type: map required: true mapping: top: type: seq required: true sequence: - type: str required: true - type: str required: true - type: map mapping: key: type: str required: true --- test_name: Test validating with extension function mismatch _xfail: run includes: - !include common.yaml stages: - name: Incorrectly validate request: url: "{host}/nested_list" method: GET response: status_code: 200 verify_response_with: function: tavern.helpers:validate_pykwalify extra_kwargs: schema: type: mapping required: true map: top: type: mapping map: invalid: type: str require: true tavern-3.6.0/tests/integration/testfile.txt000066400000000000000000000000001520710011500210420ustar00rootroot00000000000000tavern-3.6.0/tests/logging.yaml000066400000000000000000000012121520710011500164570ustar00rootroot00000000000000--- version: 1 formatters: default: # colorlog is really useful (): colorlog.ColoredFormatter format: "%(asctime)s [%(bold)s%(log_color)s%(levelname)s%(reset)s]: (%(bold)s%(name)s:%(lineno)d%(reset)s) %(message)s" style: "%" datefmt: "%X" log_colors: DEBUG: cyan INFO: green WARNING: yellow ERROR: red CRITICAL: red,bg_white handlers: # print to stderr in tests. This will only show up if the test fails stderr: class: colorlog.StreamHandler formatter: default loggers: paho: handlers: - stderr level: DEBUG tavern: handlers: - stderr level: DEBUG tavern-3.6.0/tests/unit/000077500000000000000000000000001520710011500151305ustar00rootroot00000000000000tavern-3.6.0/tests/unit/conftest.py000066400000000000000000000016431520710011500173330ustar00rootroot00000000000000import copy from unittest.mock import Mock import pytest from tavern._core.plugins import load_plugins from tavern._core.pytest.config import TavernInternalConfig, TestConfig from tavern._core.strict_util import StrictLevel _includes = TestConfig( variables={ "request": {"prefix": "www.", "url": "google.com"}, "test_auth_token": "abc123", "code": "def456", "callback_url": "www.yahoo.co.uk", "request_topic": "/abc", }, strict=StrictLevel.all_on(), tavern_internal=TavernInternalConfig( pytest_hook_caller=Mock(), backends={"mqtt": "paho-mqtt", "http": "requests", "grpc": "grpc"}, ), follow_redirects=False, stages=[], ) @pytest.fixture(scope="function", name="includes") def fix_example_includes(): return copy.deepcopy(_includes) @pytest.fixture(scope="session", autouse=True) def initialise_plugins(): load_plugins(_includes) tavern-3.6.0/tests/unit/plugins/000077500000000000000000000000001520710011500166115ustar00rootroot00000000000000tavern-3.6.0/tests/unit/plugins/graphql/000077500000000000000000000000001520710011500202475ustar00rootroot00000000000000tavern-3.6.0/tests/unit/plugins/graphql/conftest.py000066400000000000000000000012401520710011500224430ustar00rootroot00000000000000import copy from unittest.mock import Mock import pytest from tavern._core.pytest.config import TavernInternalConfig, TestConfig from tavern._core.strict_util import StrictLevel # GraphQL-specific configuration _graphql_includes = TestConfig( variables={}, strict=StrictLevel.all_on(), tavern_internal=TavernInternalConfig( pytest_hook_caller=Mock(), backends={"graphql": "gql"}, ), follow_redirects=False, stages=[], ) @pytest.fixture(scope="function", name="graphql_test_block_config") def graphql_test_block_config_fixture(): """Test block configuration for GraphQL tests.""" return copy.deepcopy(_graphql_includes) tavern-3.6.0/tests/unit/plugins/graphql/test_graphql_client.py000066400000000000000000000433031520710011500246570ustar00rootroot00000000000000import asyncio from unittest.mock import AsyncMock, Mock, patch import gql import pytest from gql.transport.aiohttp import AIOHTTPTransport from tavern._plugins.graphql.client import ClientCacheKey, GraphQLClient class TestGraphQLClient: def test_init_with_kwargs(self): kwargs = { "headers": {"Authorization": "Bearer token"}, "timeout": 60, } client = GraphQLClient(**kwargs) assert client.default_headers == {"Authorization": "Bearer token"} assert client.timeout == 60 def test_start_subscription_success(self): """Test starting a subscription successfully""" client = GraphQLClient() # Mock the necessary components with ( patch( "tavern._plugins.graphql.client.WebsocketsTransport" ) as mock_ws_transport, patch("tavern._plugins.graphql.client.gql") as mock_gql, ): # Set up the mock session and subscription generator mock_session = AsyncMock() mock_generator = AsyncMock() mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=None) mock_session.subscribe = Mock(return_value=mock_generator) mock_gql_obj = Mock() mock_gql.return_value = mock_gql_obj # Start a subscription with valid parameters with client: # Use context manager to set up the event loop client.start_subscription( url="https://example.com/graphql", query="subscription Test { test }", variables={"param": "value"}, operation_name="TestSubscription", ) # Verify the subscription was created assert "TestSubscription" in client._subscriptions assert mock_ws_transport.called def test_start_subscription_requires_operation_name(self): """Test that starting a subscription requires operation_name""" client = GraphQLClient() with ( patch("tavern._plugins.graphql.client.WebsocketsTransport"), patch("tavern._plugins.graphql.client.Client"), patch("tavern._plugins.graphql.client.gql"), ): # Should raise ValueError when operation_name is None with pytest.raises(ValueError) as exc_info: client.start_subscription( "ws://example.com", "subscription Test { test }", {}, None ) assert "operation_name required for subscriptions" in str(exc_info.value) @pytest.mark.asyncio async def test_get_next_message_not_found(self): """Test getting next message from non-existent subscription""" client = GraphQLClient() with pytest.raises(ValueError) as exc_info: await client.get_next_message("non_existent") assert "Subscription with name 'non_existent' not found" in str(exc_info.value) @pytest.mark.asyncio async def test_get_next_message_success(self): """Test getting next message from subscription successfully""" client = GraphQLClient() # Create a mock async generator for the subscription async def mock_async_gen(): yield {"data": {"test": "value"}} client._subscriptions["test_op"] = mock_async_gen() # Test getting the next message with client: # Use context manager to set up the event loop message = await client.get_next_message("test_op") assert message == {"data": {"test": "value"}} @pytest.mark.asyncio async def test_get_next_message_timeout(self): """Test getting next message times out""" client = GraphQLClient() # Create a mock async generator that will timeout async def slow_async_gen(): await asyncio.sleep(10) # This will cause timeout yield {"data": {"test": "value"}} # Add the mock generator to subscriptions client._subscriptions["slow_op"] = slow_async_gen() # Test that timeout is raised with client: # Use context manager to set up the event loop with pytest.raises(TimeoutError): await client.get_next_message("slow_op", timeout=0.1) @pytest.mark.asyncio async def test_get_next_message_exception(self): """Test getting next message when an exception occurs""" client = GraphQLClient() # Create a mock async generator that raises an exception async def error_async_gen(): raise Exception("Test error") yield client._subscriptions["error_op"] = error_async_gen() # Test that the exception is propagated with client: # Use context manager to set up the event loop with pytest.raises(Exception) as exc_info: await client.get_next_message("error_op") assert "Test error" in str(exc_info.value) class TestClientCacheKey: """Tests for the ClientCacheKey class""" def test_client_cache_key_creation(self): """Test creating a ClientCacheKey from components""" headers = {"Authorization": "Bearer token", "Content-Type": "application/json"} key = ClientCacheKey( url="https://example.com/graphql", headers=headers, timeout=30, ) assert key.url == "https://example.com/graphql" assert key.timeout == 30 # Headers should be converted to sorted tuple assert isinstance(key.headers, tuple) assert ("Authorization", "Bearer token") in key.headers assert ("Content-Type", "application/json") in key.headers def test_client_cache_key_hashable(self): """Test that ClientCacheKey instances are hashable""" headers1 = {"Authorization": "Bearer token"} headers2 = {"Authorization": "Bearer token"} headers3 = {"Authorization": "Different token"} key1 = ClientCacheKey( url="https://example.com/graphql", headers=headers1, timeout=30 ) key2 = ClientCacheKey( url="https://example.com/graphql", headers=headers2, timeout=30 ) key3 = ClientCacheKey( url="https://example.com/graphql", headers=headers3, timeout=30 ) # Keys with same values should have same hash assert hash(key1) == hash(key2) # Keys with different values should have different hashes assert hash(key1) != hash(key3) def test_client_cache_key_equality(self): """Test ClientCacheKey equality comparison""" headers1 = {"Authorization": "Bearer token"} headers2 = {"Authorization": "Bearer token"} headers3 = {"Authorization": "Different token"} key1 = ClientCacheKey( url="https://example.com/graphql", headers=headers1, timeout=30 ) key2 = ClientCacheKey( url="https://example.com/graphql", headers=headers2, timeout=30 ) key3 = ClientCacheKey( url="https://example.com/graphql", headers=headers3, timeout=30 ) # Equal keys assert key1 == key2 # Different keys assert key1 != key3 assert key1 != "not a client cache key" def test_client_cache_key_headers_order_independence(self): """Test that header order doesn't matter for ClientCacheKey""" headers1 = {"Authorization": "Bearer token", "Content-Type": "application/json"} headers2 = {"Content-Type": "application/json", "Authorization": "Bearer token"} key1 = ClientCacheKey( url="https://example.com/graphql", headers=headers1, timeout=30 ) key2 = ClientCacheKey( url="https://example.com/graphql", headers=headers2, timeout=30 ) # Should be equal despite different ordering assert key1 == key2 assert hash(key1) == hash(key2) def test_client_cache_key_repr(self): """Test ClientCacheKey string representation""" headers = {"Authorization": "Bearer token"} key = ClientCacheKey( url="https://example.com/graphql", headers=headers, timeout=30 ) repr_str = repr(key) assert "ClientCacheKey" in repr_str assert "https://example.com/graphql" in repr_str class TestClientCaching: """Tests for GraphQL client caching functionality""" def test_make_request_creates_new_client(self): """Test that first request creates a new GraphQL client""" client = GraphQLClient() query = """ query TestQuery { user { id } } """ with patch("tavern._plugins.graphql.client.AIOHTTPTransport") as mock_transport: mock_transport_instance = Mock(spec=AIOHTTPTransport) mock_transport.return_value = mock_transport_instance with patch("tavern._plugins.graphql.client.Client") as mock_client: mock_execution_result = Mock() mock_execution_result.data = {"user": {"id": "1"}} mock_client_instance = Mock(spec=gql.Client) mock_client_instance.execute.return_value = mock_execution_result mock_client.return_value = mock_client_instance # Make a request response = client.make_request( url="https://example.com/graphql", query=query, ) # Verify client and transport were created assert mock_transport.called assert mock_client.called assert len(client._gql_client_cache) == 1 assert len(client._transport_cache) == 1 # Verify the response assert response.json() == {"user": {"id": "1"}} def test_make_request_reuses_cached_client(self): """Test that subsequent requests to same URL reuse GraphQL client""" client = GraphQLClient() query1 = """ query Query1 { user { id } } """ query2 = """ query Query2 { posts { id } } """ with patch("tavern._plugins.graphql.client.AIOHTTPTransport") as mock_transport: mock_transport_instance = Mock(spec=AIOHTTPTransport) mock_transport.return_value = mock_transport_instance with patch("tavern._plugins.graphql.client.Client") as mock_client: mock_execution_result = Mock() mock_execution_result.data = {"data": "result"} mock_client_instance = Mock(spec=gql.Client) mock_client_instance.execute.return_value = mock_execution_result mock_client.return_value = mock_client_instance # Make first request client.make_request( url="https://example.com/graphql", query=query1, ) # Make second request to same URL client.make_request( url="https://example.com/graphql", query=query2, ) # Verify client was created only once assert mock_transport.call_count == 1 assert mock_client.call_count == 1 assert len(client._gql_client_cache) == 1 assert len(client._transport_cache) == 1 def test_make_request_different_urls_create_different_clients(self): """Test that requests to different URLs create different GraphQL clients""" client = GraphQLClient() query = """ query TestQuery { user { id } } """ with patch("tavern._plugins.graphql.client.AIOHTTPTransport") as mock_transport: mock_transport_instance = Mock(spec=AIOHTTPTransport) mock_transport.return_value = mock_transport_instance with patch("tavern._plugins.graphql.client.Client") as mock_client: mock_execution_result = Mock() mock_execution_result.data = {"data": "result"} mock_client_instance = Mock(spec=gql.Client) mock_client_instance.execute.return_value = mock_execution_result mock_client.return_value = mock_client_instance # Make requests to different URLs client.make_request( url="https://example.com/graphql", query=query, ) client.make_request( url="https://another-example.com/graphql", query=query, ) # Verify two clients were created assert mock_transport.call_count == 2 assert mock_client.call_count == 2 assert len(client._gql_client_cache) == 2 assert len(client._transport_cache) == 2 def test_make_request_different_headers_create_different_clients(self): """Test that requests with different headers create different GraphQL clients""" client = GraphQLClient() query = """ query TestQuery { user { id } } """ with patch("tavern._plugins.graphql.client.AIOHTTPTransport") as mock_transport: mock_transport_instance = Mock(spec=AIOHTTPTransport) mock_transport.return_value = mock_transport_instance with patch("tavern._plugins.graphql.client.Client") as mock_client: mock_execution_result = Mock() mock_execution_result.data = {"data": "result"} mock_client_instance = Mock(spec=gql.Client) mock_client_instance.execute.return_value = mock_execution_result mock_client.return_value = mock_client_instance # Make first request with Authorization header client.make_request( url="https://example.com/graphql", query=query, headers={"Authorization": "Bearer token1"}, ) # Make second request with different Authorization header client.make_request( url="https://example.com/graphql", query=query, headers={"Authorization": "Bearer token2"}, ) # Verify two clients were created (different headers) assert mock_transport.call_count == 2 assert mock_client.call_count == 2 assert len(client._gql_client_cache) == 2 assert len(client._transport_cache) == 2 def test_make_request_same_headers_reuse_client(self): """Test that requests with same headers reuse GraphQL client""" client = GraphQLClient() query = """ query TestQuery { user { id } } """ with patch("tavern._plugins.graphql.client.AIOHTTPTransport") as mock_transport: mock_transport_instance = Mock(spec=AIOHTTPTransport) mock_transport.return_value = mock_transport_instance with patch("tavern._plugins.graphql.client.Client") as mock_client: mock_execution_result = Mock() mock_execution_result.data = {"data": "result"} mock_client_instance = Mock(spec=gql.Client) mock_client_instance.execute.return_value = mock_execution_result mock_client.return_value = mock_client_instance # Make two requests with same headers client.make_request( url="https://example.com/graphql", query=query, headers={"Authorization": "Bearer token"}, ) client.make_request( url="https://example.com/graphql", query=query, headers={"Authorization": "Bearer token"}, ) # Verify only one client was created assert mock_transport.call_count == 1 assert mock_client.call_count == 1 assert len(client._gql_client_cache) == 1 assert len(client._transport_cache) == 1 def test_client_context_manager_closes_cached_clients(self): """Test that context manager exit closes cached clients and transports""" client = GraphQLClient() with patch("tavern._plugins.graphql.client.AIOHTTPTransport") as mock_transport: mock_transport_instance = Mock(spec=AIOHTTPTransport) mock_transport.return_value = mock_transport_instance with patch("tavern._plugins.graphql.client.Client") as mock_client: mock_execution_result = Mock() mock_execution_result.data = {"data": "result"} mock_client_instance = Mock(spec=gql.Client) mock_client_instance.execute.return_value = mock_execution_result mock_client.return_value = mock_client_instance # Use context manager and make a request with client: client.make_request( url="https://example.com/graphql", query="query { test }", ) # Verify transport.close() was called on exit assert mock_transport_instance.close.called # Verify client cache was populated assert len(client._gql_client_cache) == 1 assert len(client._transport_cache) == 1 tavern-3.6.0/tests/unit/plugins/graphql/test_graphql_request.py000066400000000000000000000261311520710011500250710ustar00rootroot00000000000000from unittest.mock import Mock, patch import pytest from gql import FileVar from tavern._core import exceptions from tavern._plugins.graphql.client import GraphQLClient, GraphQLResponseLike from tavern._plugins.graphql.request import GraphQLRequest, get_file_arguments class TestGraphQLRequest: def test_init_valid_request(self, graphql_test_block_config): session = GraphQLClient() rspec = { "url": "http://example.com/graphql", "query": "query { hello }", "variables": {"name": "world"}, } request = GraphQLRequest(session, rspec, graphql_test_block_config) assert request.request_vars.url == "http://example.com/graphql" assert request.request_vars.query == "query { hello }" assert request.request_vars.variables == {"name": "world"} def test_init_missing_query(self, graphql_test_block_config): session = GraphQLClient() rspec = {"url": "http://example.com/graphql", "variables": {"name": "world"}} with pytest.raises( exceptions.MissingKeysError, match="GraphQL request must contain 'query' field", ): GraphQLRequest(session, rspec, graphql_test_block_config) def test_init_missing_url(self, graphql_test_block_config): session = GraphQLClient() rspec = {"query": "query { hello }", "variables": {"name": "world"}} with pytest.raises( exceptions.MissingKeysError, match="GraphQL request must contain 'url' field", ): GraphQLRequest(session, rspec, graphql_test_block_config) def test_request_vars_defaults(self, graphql_test_block_config): session = GraphQLClient() rspec = {"url": "http://example.com/graphql", "query": "query { hello }"} request = GraphQLRequest(session, rspec, graphql_test_block_config) assert request.request_vars.variables == {} assert request.request_vars.operation_name is None assert request.request_vars.headers == {} def test_run_success(self, graphql_test_block_config): with ( patch.object(GraphQLClient, "make_request") as mock_make_request, ): mock_response = Mock(spec=GraphQLResponseLike) mock_response.text = '{"data": {"hello": "world"}}' mock_make_request.return_value = mock_response session = GraphQLClient() rspec = { "url": "http://example.com/graphql", "query": "query { hello }", "headers": {"Authorization": "Bearer token"}, } request = GraphQLRequest(session, rspec, graphql_test_block_config) response = request.run() mock_make_request.assert_called_once_with( url="http://example.com/graphql", query="query { hello }", variables={}, operation_name=None, headers={ "Content-Type": "application/json", "Authorization": "Bearer token", }, has_files=False, ) assert response.text == '{"data": {"hello": "world"}}' def test_run_failure(self, graphql_test_block_config): with patch.object(GraphQLClient, "make_request") as mock_make_request: mock_make_request.side_effect = Exception("Connection error") session = GraphQLClient() rspec = {"url": "http://example.com/graphql", "query": "query { hello }"} request = GraphQLRequest(session, rspec, graphql_test_block_config) with pytest.raises( exceptions.TavernException, match="GraphQL request failed: Connection error", ): request.run() class TestGraphQLFileUploads: """Tests for GraphQL file upload functionality""" def test_get_file_arguments_single_file(self, graphql_test_block_config, tmp_path): """Test get_file_arguments with a single file""" # Create a test file test_file = tmp_path / "test.txt" test_file.write_text("test content") file_args = {"files": str(test_file)} result = get_file_arguments(file_args, graphql_test_block_config) # Should return a dict mapping 'files' to a FileVar assert "files" in result assert isinstance(result["files"], FileVar) def test_get_file_arguments_multiple_files( self, graphql_test_block_config, tmp_path ): """Test get_file_arguments with multiple files""" # Create test files test_file1 = tmp_path / "test1.txt" test_file1.write_text("test content 1") test_file2 = tmp_path / "test2.txt" test_file2.write_text("test content 2") file_args = { "file1": str(test_file1), "file2": str(test_file2), } result = get_file_arguments(file_args, graphql_test_block_config) # Should return a dict mapping variable names to FileVar objects assert "file1" in result assert "file2" in result assert isinstance(result["file1"], FileVar) assert isinstance(result["file2"], FileVar) def test_get_file_arguments_with_content_type( self, graphql_test_block_config, tmp_path ): """Test get_file_arguments with content type specified""" # Create a test file test_file = tmp_path / "test.json" test_file.write_text('{"key": "value"}') file_args = { "files": { "file_path": str(test_file), "content_type": "application/json", } } result = get_file_arguments(file_args, graphql_test_block_config) # Should return a dict mapping 'files' to a FileVar assert "files" in result assert isinstance(result["files"], FileVar) def test_get_file_arguments_empty_dict(self, graphql_test_block_config): """Test get_file_arguments with empty dict""" file_args = {} result = get_file_arguments(file_args, graphql_test_block_config) # Should return empty dict when no files assert result == {} def test_run_with_files_parameter(self, graphql_test_block_config, tmp_path): """Test running a GraphQL request with files parameter""" # Create a test file test_file = tmp_path / "test.txt" test_file.write_text("test content") with ( patch.object(GraphQLClient, "make_request") as mock_make_request, ): mock_response = Mock(spec=GraphQLResponseLike) mock_response.text = '{"data": {"upload": "success"}}' mock_make_request.return_value = mock_response session = GraphQLClient() rspec = { "url": "http://example.com/graphql", "query": "mutation($files: Upload!) { singleUpload(file: $files) { id } }", "files": {"files": str(test_file)}, } request = GraphQLRequest(session, rspec, graphql_test_block_config) response = request.run() # Verify that make_request was called mock_make_request.assert_called_once() # Get the call arguments call_args = mock_make_request.call_args # Check that variables include the files assert "variables" in call_args.kwargs variables = call_args.kwargs["variables"] assert "files" in variables assert isinstance(variables["files"], FileVar) assert response.text == '{"data": {"upload": "success"}}' def test_run_with_files_and_variables(self, graphql_test_block_config, tmp_path): """Test running a GraphQL request with both files and variables""" # Create a test file test_file = tmp_path / "test.txt" test_file.write_text("test content") with ( patch.object(GraphQLClient, "make_request") as mock_make_request, ): mock_response = Mock(spec=GraphQLResponseLike) mock_response.text = '{"data": {"upload": "success"}}' mock_make_request.return_value = mock_response session = GraphQLClient() rspec = { "url": "http://example.com/graphql", "query": "mutation($file: Upload!, $title: String!) { uploadFile(file: $file, title: $title) { id } }", "files": {"file": str(test_file)}, "variables": {"title": "My Upload"}, } request = GraphQLRequest(session, rspec, graphql_test_block_config) response = request.run() # Verify that make_request was called mock_make_request.assert_called_once() # Get the call arguments call_args = mock_make_request.call_args # Check that variables include both the files and the regular variables assert "variables" in call_args.kwargs variables = call_args.kwargs["variables"] assert "file" in variables assert "title" in variables assert variables["title"] == "My Upload" assert isinstance(variables["file"], FileVar) assert response.text == '{"data": {"upload": "success"}}' def test_run_with_multiple_files_and_metadata( self, graphql_test_block_config, tmp_path ): """Test running a GraphQL request with multiple files including metadata""" # Create test files test_file1 = tmp_path / "test1.txt" test_file1.write_text("test content 1") test_file2 = tmp_path / "test2.json" test_file2.write_text('{"key": "value"}') with ( patch.object(GraphQLClient, "make_request") as mock_make_request, ): mock_response = Mock(spec=GraphQLResponseLike) mock_response.text = '{"data": {"upload": "success"}}' mock_make_request.return_value = mock_response session = GraphQLClient() rspec = { "url": "http://example.com/graphql", "query": "mutation($file1: Upload!, $file2: Upload!) { multipleUpload(file1: $file1, file2: $file2) { id } }", "files": { "file1": str(test_file1), "file2": { "file_path": str(test_file2), "content_type": "application/json", }, }, } request = GraphQLRequest(session, rspec, graphql_test_block_config) response = request.run() # Verify that make_request was called mock_make_request.assert_called_once() # Get the call arguments call_args = mock_make_request.call_args # Check that variables include the files assert "variables" in call_args.kwargs variables = call_args.kwargs["variables"] assert "file1" in variables assert "file2" in variables assert isinstance(variables["file1"], FileVar) assert isinstance(variables["file2"], FileVar) assert response.text == '{"data": {"upload": "success"}}' tavern-3.6.0/tests/unit/plugins/graphql/test_graphql_response.py000066400000000000000000000064261520710011500252440ustar00rootroot00000000000000from unittest.mock import Mock import requests from tavern._plugins.graphql.client import GraphQLClient from tavern._plugins.graphql.response import GraphQLResponse class TestGraphQLResponse: def test_init_defaults(self, graphql_test_block_config): session = Mock(spec=GraphQLClient) expected = {} response = GraphQLResponse(session, "test", expected, graphql_test_block_config) assert response.expected["status_code"] == 200 def test_init_custom_status_code(self, graphql_test_block_config): session = Mock(spec=GraphQLClient) expected = {"status_code": 201} response = GraphQLResponse(session, "test", expected, graphql_test_block_config) assert response.expected["status_code"] == 201 def test_init_list_status_code(self, graphql_test_block_config): session = Mock(spec=GraphQLClient) expected = {"status_code": [200, 201]} response = GraphQLResponse(session, "test", expected, graphql_test_block_config) assert response.expected["status_code"] == [200, 201] def test_str_with_response(self, graphql_test_block_config): session = Mock(spec=GraphQLClient) expected = {} mock_response = Mock(spec=requests.Response) mock_response.text = '{"data": {"hello": "world"}}' response = GraphQLResponse(session, "test", expected, graphql_test_block_config) response.response = mock_response assert str(response) == '{"data": {"hello": "world"}}' def test_str_without_response(self, graphql_test_block_config): session = Mock(spec=GraphQLClient) expected = {} response = GraphQLResponse(session, "test", expected, graphql_test_block_config) assert str(response) == "" def test_validate_response_format_valid_data(self, graphql_test_block_config): session = Mock(spec=GraphQLClient) expected = {} response = GraphQLResponse(session, "test", expected, graphql_test_block_config) # Should not raise any exception response._validate_graphql_response_structure({"data": {"hello": "world"}}) assert len(response.errors) == 0 def test_validate_response_format_valid_errors(self, graphql_test_block_config): session = Mock(spec=GraphQLClient) expected = {} response = GraphQLResponse(session, "test", expected, graphql_test_block_config) # Should not raise any exception, just log warning response._validate_graphql_response_structure( {"errors": [{"message": "Something went wrong"}]} ) assert len(response.errors) == 0 def test_validate_response_format_invalid_no_data_or_errors( self, graphql_test_block_config ): session = Mock(spec=GraphQLClient) expected = {} response = GraphQLResponse(session, "test", expected, graphql_test_block_config) response._validate_graphql_response_structure({"other": "field"}) assert len(response.errors) == 2 assert ( "Response must contain either 'data' or 'errors' at the top level" in response.errors[0] ) assert ( "Invalid GraphQL top-level keys: {'other'}. Only 'data' and 'errors' are allowed" in response.errors[1] ) tavern-3.6.0/tests/unit/plugins/graphql/test_graphql_tavernhook.py000066400000000000000000000031721520710011500255610ustar00rootroot00000000000000import dataclasses from tavern._plugins.graphql import tavernhook from tavern._plugins.graphql.client import GraphQLClient class TestTavernGraphQLPlugin: def test_plugin_properties(self): assert tavernhook.session_type == GraphQLClient assert tavernhook.request_block_name == "graphql_request" assert tavernhook.response_block_name == "graphql_response" def test_get_expected_from_request_with_response(self, graphql_test_block_config): response_block = {"status_code": 200} session = GraphQLClient() result = tavernhook.get_expected_from_request( response_block, graphql_test_block_config, session ) assert result == {"graphql_responses": [{"status_code": 200}]} def test_get_expected_from_request_without_response( self, graphql_test_block_config ): response_block = None session = GraphQLClient() result = tavernhook.get_expected_from_request( response_block, graphql_test_block_config, session ) assert result is None def test_get_expected_from_request_with_variables(self, graphql_test_block_config): response_block = {"status_code": 200, "data": {"user_id": "{user_id}"}} test_block_config = dataclasses.replace( graphql_test_block_config, variables={"user_id": "123"} ) session = GraphQLClient() result = tavernhook.get_expected_from_request( response_block, test_block_config, session ) assert result == { "graphql_responses": [{"status_code": 200, "data": {"user_id": "123"}}] } tavern-3.6.0/tests/unit/response/000077500000000000000000000000001520710011500167665ustar00rootroot00000000000000tavern-3.6.0/tests/unit/response/test_mqtt_response.py000066400000000000000000000312261520710011500233060ustar00rootroot00000000000000import random import re import threading from unittest.mock import Mock, patch import hypothesis import pytest from hypothesis import given, settings from hypothesis import strategies as st from tavern._core import exceptions from tavern._core.loader import ANYTHING from tavern._core.strict_util import StrictLevel from tavern._plugins.mqtt.client import MQTTClient from tavern._plugins.mqtt.response import MQTTResponse def test_nothing_returned_fails(includes): """Raises an error if no message was received""" fake_client = Mock(spec=MQTTClient, message_received=Mock(return_value=None)) expected = {"mqtt_responses": [{"topic": "/a/b/c", "payload": "hello"}]} verifier = MQTTResponse( fake_client, "Test stage", expected, includes.with_strictness(StrictLevel.all_on()), ) with pytest.raises(exceptions.TestFailError): verifier.verify(expected) assert not verifier.received_messages class FakeMessage: def __init__(self, returned): self.topic = returned["topic"] self.payload = returned["payload"].encode("utf8") self.timestamp = 0 class TestResponse: @staticmethod def _get_fake_verifier(expected, fake_messages, includes): """Given a list of messages, return a mocked version of the MQTT response verifier which will take messages off the front of this list as if they were published This mocks it as if all messages were returned in order, which they might not have been...? """ if not isinstance(fake_messages, list): pytest.fail("Need to pass a list of messages") msg_lock = threading.RLock() responses: dict[str, list[FakeMessage]] = { message.topic: [] for message in fake_messages } for message in fake_messages: responses[message.topic].append(message) def yield_all_messages(): def inner(topic, timeout): try: msg_lock.acquire() r = responses[topic] if len(r) == 0: return None return r.pop(random.randint(0, len(r) - 1)) finally: msg_lock.release() return inner fake_client = Mock( spec=MQTTClient, message_received=yield_all_messages(), ) if not isinstance(expected, list): expected = [expected] return MQTTResponse( fake_client, "Test stage", {"mqtt_responses": expected}, includes ) def test_message_on_same_topic_fails(self, includes): """Correct topic, wrong message""" expected = {"topic": "/a/b/c", "payload": "hello"} fake_message = FakeMessage({"topic": "/a/b/c", "payload": "goodbye"}) verifier = self._get_fake_verifier(expected, [fake_message], includes) with pytest.raises(exceptions.TestFailError): verifier.verify(expected) assert len(verifier.received_messages) == 1 assert verifier.received_messages[0].topic == fake_message.topic def test_correct_message(self, includes): """Both correct matches""" expected = {"topic": "/a/b/c", "payload": "hello"} fake_message = FakeMessage(expected) verifier = self._get_fake_verifier(expected, [fake_message], includes) verifier.verify(expected) assert len(verifier.received_messages) == 1 assert verifier.received_messages[0].topic == fake_message.topic @pytest.mark.parametrize("n_messages", (1, 2)) def test_ext_function_called_save(self, includes, n_messages: int): """Make sure that it calls ext functions appropriately on individual MQTT responses and saved the response""" expecteds = [] fake_messages = [] for i in range(n_messages): expected = { "topic": f"/a/b/c/{i + 1}", "payload": "hello", "save": { "$ext": {"function": f"function_name_{i + 1}"}, }, } fake_message = FakeMessage(expected) expecteds += [expected] fake_messages += [fake_message] verifier = self._get_fake_verifier(expecteds, fake_messages, includes) def fake_get_wrapped_response(): def wrap(ext): def actual(response, *args, **kwargs): match = re.match(r"function_name_(?P\d+)", ext["function"]) assert match message_number = match.group("idx") return {f"saved_topic_{message_number}": response.topic} return actual return wrap with patch( "tavern.response.get_wrapped_response_function", new_callable=fake_get_wrapped_response, ): saved = verifier.verify(None) assert len(verifier.received_messages) == n_messages for i in range(n_messages): assert verifier.received_messages[i].topic == fake_messages[i].topic assert len(saved) == n_messages assert saved[f"saved_topic_{i + 1}"] == expecteds[i]["topic"] def test_correct_message_eventually(self, includes): """One wrong messge, then the correct one""" expected = {"topic": "/a/b/c", "payload": "hello"} fake_message_good = FakeMessage(expected) fake_message_bad = FakeMessage({"topic": "/a/b/c", "payload": "goodbye"}) verifier = self._get_fake_verifier( expected, [fake_message_bad, fake_message_good], includes ) verifier.verify(expected) assert len(verifier.received_messages) >= 1 received_topics = [m.topic for m in verifier.received_messages] assert fake_message_good.topic in received_topics def test_unexpected_fail(self, includes): """Messages marked unexpected fail test""" expected = {"topic": "/a/b/c", "payload": "hello", "unexpected": True} fake_message = FakeMessage(expected) verifier = self._get_fake_verifier(expected, [fake_message], includes) with pytest.raises(exceptions.TestFailError): verifier.verify(expected) assert len(verifier.received_messages) == 1 assert verifier.received_messages[0].topic == fake_message.topic @given(st.permutations([0, 1, 2])) @settings( max_examples=10, suppress_health_check=[hypothesis.HealthCheck.function_scoped_fixture], ) def test_multiple_messages(self, includes, perm: list[int]): """One wrong message, two correct ones""" expected = [ {"topic": "/a/b/c", "payload": "hello"}, {"topic": "/d/e/f", "payload": "hellog"}, ] fake_message_good_1 = FakeMessage(expected[0]) fake_message_good_2 = FakeMessage(expected[1]) fake_message_bad = FakeMessage({"topic": "/a/b/c", "payload": "goodbye"}) base_messages = [fake_message_bad, fake_message_good_1, fake_message_good_2] messages = [base_messages[i] for i in perm] verifier = self._get_fake_verifier( expected, messages, includes, ) verifier.verify(expected) assert len(verifier.received_messages) >= 2 received_topics = [m.topic for m in verifier.received_messages] assert fake_message_good_1.topic in received_topics assert fake_message_good_2.topic in received_topics @given(st.permutations([0, 1])) @settings( max_examples=10, suppress_health_check=[hypothesis.HealthCheck.function_scoped_fixture], ) def test_different_order(self, includes, perm): """Messages coming in a different order""" expected = [ {"topic": "/a/b/c", "payload": "hello"}, {"topic": "/d/e/f", "payload": "hellog"}, ] fake_message_good_1 = FakeMessage(expected[0]) fake_message_good_2 = FakeMessage(expected[1]) base_messages = [fake_message_good_1, fake_message_good_2] messages = [base_messages[i] for i in perm] verifier = self._get_fake_verifier(expected, messages, includes) verifier.verify(expected) assert len(verifier.received_messages) == 2 received_topics = [m.topic for m in verifier.received_messages] assert fake_message_good_1.topic in received_topics assert fake_message_good_2.topic in received_topics @pytest.mark.parametrize( "payload", ( ( "!anything", ANYTHING, ), ( "null", None, ), ( "goog", "goog", ), ), ) @given(st.permutations([0, 1])) @settings( max_examples=10, suppress_health_check=[hypothesis.HealthCheck.function_scoped_fixture], ) def test_same_topic(self, includes, payload, perm): """Messages coming in a different order""" expected = [ {"topic": "/a/b/c", "payload": "hello"}, {"topic": "/a/b/c", "payload": payload[0]}, ] fake_message_good_1 = FakeMessage(expected[0]) fake_message_good_2 = FakeMessage(expected[1]) base_messages = [fake_message_good_1, fake_message_good_2] messages = [base_messages[i] for i in perm] verifier = self._get_fake_verifier(expected, messages, includes) loaded = [ {"topic": "/a/b/c", "payload": "hello"}, {"topic": "/a/b/c", "payload": payload[1]}, ] verifier.verify(loaded) assert len(verifier.received_messages) == 2 received_topics = [m.topic for m in verifier.received_messages] assert fake_message_good_1.topic in received_topics assert fake_message_good_2.topic in received_topics @given(st.permutations([0, 1, 2])) @settings( max_examples=20, suppress_health_check=[hypothesis.HealthCheck.function_scoped_fixture], ) def test_repeated_topic_three_expectations(self, includes, perm): """[a, b, a] sequence – both 'a' messages must be individually verified""" expected = [ {"topic": "/a/b/c", "payload": "first"}, {"topic": "/d/e/f", "payload": "middle"}, {"topic": "/a/b/c", "payload": "second"}, ] fake_message_a1 = FakeMessage({"topic": "/a/b/c", "payload": "first"}) fake_message_b = FakeMessage({"topic": "/d/e/f", "payload": "middle"}) fake_message_a2 = FakeMessage({"topic": "/a/b/c", "payload": "second"}) base_messages = [fake_message_a1, fake_message_b, fake_message_a2] messages = [base_messages[i] for i in perm] verifier = self._get_fake_verifier(expected, messages, includes) verifier.verify(expected) assert len(verifier.received_messages) == 3 received_payloads = [m.payload for m in verifier.received_messages] assert "first" in received_payloads assert "middle" in received_payloads assert "second" in received_payloads def test_repeated_topic_wrong_payload_fails(self, includes): """[a, b, a] – delivering only one 'a' payload must fail""" expected = [ {"topic": "/a/b/c", "payload": "first"}, {"topic": "/d/e/f", "payload": "middle"}, {"topic": "/a/b/c", "payload": "second"}, ] fake_message_a_only = FakeMessage({"topic": "/a/b/c", "payload": "first"}) fake_message_b = FakeMessage({"topic": "/d/e/f", "payload": "middle"}) verifier = self._get_fake_verifier( expected, [fake_message_a_only, fake_message_b], includes ) with pytest.raises(exceptions.TestFailError): verifier.verify(expected) def test_repeated_topic_regression_dict_keying(self, includes): """ Regression: old itertools.groupby+dict approach for by_topic would produce only 1 entry for topic '/a/b/c' instead of 2, silently dropping the first expectation. Verify both expectations are checked. """ expected = [ {"topic": "/a/b/c", "payload": "msg-one"}, {"topic": "/x/y/z", "payload": "unrelated"}, {"topic": "/a/b/c", "payload": "msg-two"}, ] fake_a1 = FakeMessage({"topic": "/a/b/c", "payload": "msg-one"}) fake_x = FakeMessage({"topic": "/x/y/z", "payload": "unrelated"}) fake_a2 = FakeMessage({"topic": "/a/b/c", "payload": "msg-two"}) verifier = self._get_fake_verifier( expected, [fake_a1, fake_x, fake_a2], includes ) verifier.verify(expected) assert len(verifier.received_messages) == 3 tavern-3.6.0/tests/unit/response/test_rest.py000066400000000000000000000353161520710011500213640ustar00rootroot00000000000000from unittest.mock import Mock, patch import pytest from tavern._core import exceptions from tavern._core.dict_util import format_keys from tavern._core.loader import ANYTHING from tavern._plugins.rest.response import RestResponse @pytest.fixture(name="example_response") def fix_example_response(): spec = { "status_code": 302, "headers": { "Content-Type": "application/json", "location": "www.google.com?search=breadsticks", }, "json": {"a_thing": "authorization_code", "code": "abc123"}, } return spec.copy() @pytest.fixture(name="nested_response") def fix_nested_response(): # https://github.com/taverntesting/tavern/issues/45 spec = { "status_code": 200, "headers": {"Content-Type": "application/json"}, "json": {"users": [{"u": {"user_id": "def456"}}]}, } return spec.copy() @pytest.fixture(name="nested_schema") def fix_nested_schema(): # https://github.com/taverntesting/tavern/issues/45 spec = { "status_code": 200, "headers": {"Content-Type": "application/json"}, "json": {"users": [{"u": {"user_id": "{code}"}}]}, } return spec.copy() class TestSave: def test_save_body(self, example_response, includes): """Save a key from the body into the right name""" example_response["save"] = {"json": {"test_code": "code"}} r = RestResponse(Mock(), "Test 1", example_response, includes) saved = r.maybe_get_save_values_from_save_block( "json", example_response["json"] ) assert saved == {"test_code": example_response["json"]["code"]} def test_save_body_nested(self, example_response, includes): """Save a key from the body into the right name""" example_response["json"]["nested"] = {"subthing": "blah"} example_response["save"] = {"json": {"test_nested_thing": "nested.subthing"}} r = RestResponse(Mock(), "Test 1", example_response, includes) saved = r.maybe_get_save_values_from_save_block( "json", example_response["json"] ) assert saved == { "test_nested_thing": example_response["json"]["nested"]["subthing"] } def test_save_body_nested_list(self, example_response, includes): """Save a key from the body into the right name""" example_response["json"]["nested"] = {"subthing": ["abc", "def"]} example_response["save"] = {"json": {"test_nested_thing": "nested.subthing[0]"}} r = RestResponse(Mock(), "Test 1", example_response, includes) saved = r.maybe_get_save_values_from_save_block( "json", example_response["json"] ) assert saved == { "test_nested_thing": example_response["json"]["nested"]["subthing"][0] } def test_save_header(self, example_response, includes): """Save a key from the headers into the right name""" example_response["save"] = {"headers": {"next_location": "location"}} r = RestResponse(Mock(), "Test 1", example_response, includes) saved = r.maybe_get_save_values_from_save_block( "headers", example_response["headers"] ) assert saved == {"next_location": example_response["headers"]["location"]} def test_save_redirect_query_param(self, example_response, includes): """Save a key from the query parameters of the redirect location""" example_response["save"] = {"redirect_query_params": {"test_search": "search"}} r = RestResponse(Mock(), "Test 1", example_response, includes) saved = r.maybe_get_save_values_from_save_block( "redirect_query_params", {"search": "breadsticks"} ) assert saved == {"test_search": "breadsticks"} @pytest.mark.parametrize("save_from", ("json", "headers", "redirect_query_params")) def test_bad_save(self, save_from, example_response, includes): example_response["save"] = {save_from: {"abc": "123"}} r = RestResponse(Mock(), "Test 1", example_response, includes) saved = r.maybe_get_save_values_from_save_block(save_from, {}) assert not saved assert r.errors class TestValidate: def test_simple_validate_body(self, example_response, includes): """Make sure a simple value comparison works""" r = RestResponse(Mock(), "Test 1", example_response, includes) r._validate_block("json", example_response["json"]) assert not r.errors def test_validate_list_body(self, example_response, includes): """Make sure a list response can be validated""" example_response["json"] = ["a", 1, "b"] r = RestResponse(Mock(), "Test 1", example_response, includes) r._validate_block("json", example_response["json"]) assert not r.errors def test_validate_list_body_wrong_order(self, example_response, includes): """Order of list items matters""" example_response["json"] = ["a", 1, "b"] r = RestResponse(Mock(), "Test 1", example_response, includes) r._validate_block("json", example_response["json"][::-1]) assert r.errors def test_validate_nested_body(self, example_response, includes): """Make sure a nested value comparison works""" example_response["json"]["nested"] = {"subthing": "blah"} r = RestResponse(Mock(), "Test 1", example_response, includes) r._validate_block("json", example_response["json"]) assert not r.errors def test_simple_validate_headers(self, example_response, includes): """Make sure a simple value comparison works""" r = RestResponse(Mock(), "Test 1", example_response, includes) r._validate_block("headers", example_response["headers"]) assert not r.errors def test_simple_validate_redirect_query_params(self, example_response, includes): """Make sure a simple value comparison works""" r = RestResponse(Mock(), "Test 1", example_response, includes) r._validate_block("redirect_query_params", {"search": "breadsticks"}) assert not r.errors def test_validate_missing_list_key(self, example_response, includes): """If we expect 4 items and 3 were returned, catch error""" example_response["json"] = ["a", 1, "b", "c"] bad_expected = example_response["json"][:-1] r = RestResponse(Mock(), "Test 1", example_response, includes) r._validate_block("json", bad_expected) assert r.errors def test_validate_wrong_list_dict(self, example_response, includes): """We expected a list, but we got a dict in the response""" example_response["json"] = ["a", 1, "b", "c"] bad_expected = {"a": "b"} r = RestResponse(Mock(), "Test 1", example_response, includes) r._validate_block("json", bad_expected) assert r.errors def test_validate_wrong_dict_list(self, example_response, includes): """We expected a dict, but we got a list in the response""" example_response["json"] = {"a": "b"} bad_expected = ["a", "b", "c"] r = RestResponse(Mock(), "Test 1", example_response, includes) r._validate_block("json", bad_expected) assert r.errors class TestMatchStatusCodes: def test_validate_single_status_code_passes(self, example_response, includes): """single status code match""" example_response["status_code"] = 100 r = RestResponse(Mock(), "Test 1", example_response, includes) r._check_status_code(100, {}) assert not r.errors def test_validate_single_status_code_incorrect(self, example_response, includes): """single status code mismatch""" example_response["status_code"] = 100 r = RestResponse(Mock(), "Test 1", example_response, includes) r._check_status_code(102, {}) assert r.errors def test_validate_multiple_status_codes_passes(self, example_response, includes): """Check it can match mutliple status codes""" example_response["status_code"] = [100, 200, 300] r = RestResponse(Mock(), "Test 1", example_response, includes) r._check_status_code(100, {}) assert not r.errors def test_validate_multiple_status_codes_missing(self, example_response, includes): """Status code was not in list""" example_response["status_code"] = [100, 200, 300] r = RestResponse(Mock(), "Test 1", example_response, includes) r._check_status_code(103, {}) assert r.errors class TestNestedValidate: def test_validate_nested_null(self, example_response, includes): """Check that nested 'null' comparisons do not work""" example_response["json"] = {"nested": {"subthing": None}} expected = {"nested": {"subthing": "blah"}} r = RestResponse(Mock(), "Test 1", example_response, includes) r._validate_block("json", expected) assert r.errors def test_validate_nested_anything(self, example_response, includes): """Check that nested 'anything' comparisons work This is a bit hacky because we're directly checking the ANYTHING comparison - need to add an integration test too """ example_response["json"] = {"nested": {"subthing": ANYTHING}} expected = {"nested": {"subthing": "blah"}} r = RestResponse(Mock(), "Test 1", example_response, includes) r._validate_block("json", expected) assert not r.errors class TestFull: def test_validate_and_save(self, example_response, includes): """Test full verification + return saved values""" example_response["save"] = {"json": {"test_code": "code"}} r = RestResponse(Mock(), "Test 1", example_response, includes) class FakeResponse: headers = example_response["headers"] content = b"test" text = "test" def json(self): return example_response["json"] status_code = example_response["status_code"] saved = r.verify(FakeResponse()) assert saved == {"test_code": example_response["json"]["code"]} def test_incorrect_status_code(self, example_response, includes): """Test full verification + return saved values""" r = RestResponse(Mock(), "Test 1", example_response, includes) class FakeResponse: headers = example_response["headers"] content = b"test" text = "test" def json(self): return example_response["json"] status_code = 400 with pytest.raises(exceptions.TestFailError): r.verify(FakeResponse()) assert r.errors def test_saved_value_in_validate(self, nested_response, nested_schema, includes): r = RestResponse( Mock(), "Test 1", format_keys(nested_schema, includes.variables), includes, ) class FakeResponse: headers = nested_response["headers"] content = b"test" text = "test" def json(self): return nested_response["json"] status_code = nested_response["status_code"] r.verify(FakeResponse()) @pytest.mark.parametrize("value", [1, "some", False, None]) def test_validate_single_value_response(self, example_response, includes, value): """Check validating single value response (string, int, etc).""" del example_response["json"] r = RestResponse(Mock(), "Test 1", example_response, includes) class FakeResponse: headers = example_response["headers"] content = b"test" text = "test" def json(self): return value status_code = example_response["status_code"] r.verify(FakeResponse()) def test_status_code_warns(example_response, includes): """Should continue if the status code is nonexistent""" example_response["status_code"] = 231234 with patch("tavern._plugins.common.response.logger.warning") as wmock: RestResponse(Mock(), "Test 1", example_response, includes) assert wmock.called class TestTextValidation: def test_validate_text_matches(self, example_response, includes): """Text validation passes when response text matches expected""" del example_response["json"] example_response["text"] = "Hello, World!" r = RestResponse(Mock(), "Test 1", example_response, includes) class FakeResponse: headers = example_response["headers"] content = b"Hello, World!" text = "Hello, World!" def json(self): raise ValueError("No JSON") status_code = example_response["status_code"] r.verify(FakeResponse()) assert not r.errors def test_validate_text_mismatch(self, example_response, includes): """Text validation fails when response text doesn't match expected""" del example_response["json"] example_response["text"] = "Hello, World!" r = RestResponse(Mock(), "Test 1", example_response, includes) class FakeResponse: headers = example_response["headers"] content = b"Goodbye!" text = "Goodbye!" def json(self): raise ValueError("No JSON") status_code = example_response["status_code"] with pytest.raises(exceptions.TestFailError): r.verify(FakeResponse()) assert r.errors def test_validate_text_multiline(self, example_response, includes): """Text validation works with multiline strings like ASCII tables""" del example_response["json"] expected_text = """\ +---+---+ | A | B | +---+---+ | 1 | 2 | | 3 | 4 | +---+---+""" example_response["text"] = expected_text r = RestResponse(Mock(), "Test 1", example_response, includes) class FakeResponse: headers = example_response["headers"] content = expected_text.encode() text = expected_text def json(self): raise ValueError("No JSON") status_code = example_response["status_code"] r.verify(FakeResponse()) assert not r.errors def test_no_text_block_no_error(self, example_response, includes): """No error when text block is not specified""" del example_response["json"] r = RestResponse(Mock(), "Test 1", example_response, includes) class FakeResponse: headers = example_response["headers"] content = b"anything" text = "anything" def json(self): raise ValueError("No JSON") status_code = example_response["status_code"] r.verify(FakeResponse()) assert not r.errors tavern-3.6.0/tests/unit/tavern_grpc/000077500000000000000000000000001520710011500174425ustar00rootroot00000000000000tavern-3.6.0/tests/unit/tavern_grpc/__init__.py000066400000000000000000000000001520710011500215410ustar00rootroot00000000000000tavern-3.6.0/tests/unit/tavern_grpc/test_grpc.py000066400000000000000000000140741520710011500220140ustar00rootroot00000000000000import dataclasses import os.path import random import sys from collections.abc import Mapping from concurrent import futures from typing import Any import grpc import pytest from google.protobuf import json_format from google.protobuf.empty_pb2 import Empty from grpc_reflection.v1alpha import reflection from pytest import MarkGenerator from tavern._core.pytest.config import TestConfig from tavern._plugins.grpc.client import GRPCClient from tavern._plugins.grpc.request import GRPCRequest from tavern._plugins.grpc.response import GRPCCode, GRPCResponse sys.path.append(os.path.dirname(__file__)) from . import test_services_pb2, test_services_pb2_grpc class ServiceImpl(test_services_pb2_grpc.DummyServiceServicer): def Empty(self, request: Empty, context) -> Empty: return Empty() def SimpleTest( self, request: test_services_pb2.DummyRequest, context: grpc.ServicerContext ) -> test_services_pb2.DummyResponse: if request.request_id > 1000: context.abort(grpc.StatusCode.FAILED_PRECONDITION, "number too big!") return test_services_pb2.DummyResponse(response_id=request.request_id + 1) @pytest.fixture(scope="session") def service() -> int: server = grpc.server(futures.ThreadPoolExecutor(max_workers=5)) service_impl = ServiceImpl() test_services_pb2_grpc.add_DummyServiceServicer_to_server(service_impl, server) service_names = ( test_services_pb2.DESCRIPTOR.services_by_name["DummyService"].full_name, reflection.SERVICE_NAME, ) reflection.enable_server_reflection(service_names, server) port = random.randint(10000, 40000) server.add_insecure_port(f"127.0.0.1:{port}") server.start() yield port server.stop(1) @pytest.fixture() def grpc_client(service: int) -> GRPCClient: opts = { "connect": {"host": "localhost", "port": service, "secure": False}, "attempt_reflection": False, } return GRPCClient(**opts) @dataclasses.dataclass class GRPCTestSpec: test_name: str method: str req: Any resp: Any | None = None xfail: bool = False code: GRPCCode = grpc.StatusCode.OK.value[0] service: str = "tavern.tests.v1.DummyService" def service_method(self): return f"{self.service}/{self.method}" def request(self) -> Mapping: return json_format.MessageToDict( self.req, always_print_fields_with_no_presence=True, preserving_proto_field_name=True, ) def body(self) -> Mapping: return json_format.MessageToDict( self.resp, always_print_fields_with_no_presence=True, preserving_proto_field_name=True, ) def test_grpc(grpc_client: GRPCClient, includes: TestConfig, test_spec: GRPCTestSpec): request = GRPCRequest( grpc_client, {"service": test_spec.service_method(), "body": test_spec.request()}, includes, ) expected = {"status": test_spec.code} if test_spec.resp: expected["body"] = test_spec.body() resp = GRPCResponse(grpc_client, "test", expected, includes) if test_spec.xfail: pytest.xfail() future = request.run() resp.verify(future) def pytest_generate_tests(metafunc: MarkGenerator): if "test_spec" in metafunc.fixturenames: tests = [ GRPCTestSpec( test_name="basic empty", method="Empty", req=Empty(), resp=Empty() ), GRPCTestSpec( test_name="nonexistent method", method="Wek", req=Empty(), resp=Empty(), xfail=True, ), GRPCTestSpec( test_name="empty with numeric status code", method="Empty", req=Empty(), resp=Empty(), code=0, ), GRPCTestSpec( test_name="empty with wrong status code", method="Empty", req=Empty(), resp=Empty(), code="ABORTED", xfail=True, ), GRPCTestSpec( test_name="empty with the wrong request type", method="Empty", req=test_services_pb2.DummyRequest(), resp=Empty(), code=0, xfail=True, ), GRPCTestSpec( test_name="empty with the wrong response type", method="Empty", req=Empty(), resp=test_services_pb2.DummyResponse(), code=0, xfail=True, ), GRPCTestSpec( test_name="Simple service", method="SimpleTest", req=test_services_pb2.DummyRequest(request_id=2), resp=test_services_pb2.DummyResponse(response_id=3), ), GRPCTestSpec( test_name="Simple service with error", method="SimpleTest", req=test_services_pb2.DummyRequest(request_id=10000), code="FAILED_PRECONDITION", ), GRPCTestSpec( test_name="Simple service with error code but also a response", method="SimpleTest", req=test_services_pb2.DummyRequest(request_id=10000), resp=test_services_pb2.DummyResponse(response_id=3), code="FAILED_PRECONDITION", xfail=True, ), GRPCTestSpec( test_name="Simple service with wrong request type", method="SimpleTest", req=Empty(), resp=test_services_pb2.DummyResponse(response_id=3), xfail=True, ), GRPCTestSpec( test_name="Simple service with wrong response type", method="SimpleTest", req=test_services_pb2.DummyRequest(request_id=2), resp=Empty(), xfail=True, ), ] metafunc.parametrize("test_spec", tests, ids=[g.test_name for g in tests]) tavern-3.6.0/tests/unit/tavern_grpc/test_services.proto000066400000000000000000000005201520710011500234060ustar00rootroot00000000000000syntax = "proto3"; package tavern.tests.v1; import "google/protobuf/empty.proto"; service DummyService { rpc Empty(google.protobuf.Empty) returns (google.protobuf.Empty); rpc SimpleTest(DummyRequest) returns (DummyResponse); } message DummyRequest { int32 request_id = 1; } message DummyResponse { int32 response_id = 1; } tavern-3.6.0/tests/unit/tavern_grpc/test_services_pb2.py000066400000000000000000000031161520710011500234420ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: test_services.proto """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database from google.protobuf.internal import builder as _builder # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13test_services.proto\x12\x0ftavern.tests.v1\x1a\x1bgoogle/protobuf/empty.proto\"\"\n\x0c\x44ummyRequest\x12\x12\n\nrequest_id\x18\x01 \x01(\x05\"$\n\rDummyResponse\x12\x13\n\x0bresponse_id\x18\x01 \x01(\x05\x32\x94\x01\n\x0c\x44ummyService\x12\x37\n\x05\x45mpty\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\x12K\n\nSimpleTest\x12\x1d.tavern.tests.v1.DummyRequest\x1a\x1e.tavern.tests.v1.DummyResponseb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'test_services_pb2', _globals) if _descriptor._USE_C_DESCRIPTORS == False: DESCRIPTOR._options = None _globals['_DUMMYREQUEST']._serialized_start=69 _globals['_DUMMYREQUEST']._serialized_end=103 _globals['_DUMMYRESPONSE']._serialized_start=105 _globals['_DUMMYRESPONSE']._serialized_end=141 _globals['_DUMMYSERVICE']._serialized_start=144 _globals['_DUMMYSERVICE']._serialized_end=292 # @@protoc_insertion_point(module_scope) tavern-3.6.0/tests/unit/tavern_grpc/test_services_pb2.pyi000066400000000000000000000012371520710011500236150ustar00rootroot00000000000000from google.protobuf import empty_pb2 as _empty_pb2 from google.protobuf import descriptor as _descriptor from google.protobuf import message as _message from typing import ClassVar as _ClassVar, Optional as _Optional DESCRIPTOR: _descriptor.FileDescriptor class DummyRequest(_message.Message): __slots__ = ["request_id"] REQUEST_ID_FIELD_NUMBER: _ClassVar[int] request_id: int def __init__(self, request_id: _Optional[int] = ...) -> None: ... class DummyResponse(_message.Message): __slots__ = ["response_id"] RESPONSE_ID_FIELD_NUMBER: _ClassVar[int] response_id: int def __init__(self, response_id: _Optional[int] = ...) -> None: ... tavern-3.6.0/tests/unit/tavern_grpc/test_services_pb2_grpc.py000066400000000000000000000100511520710011500244510ustar00rootroot00000000000000# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! """Client and server classes corresponding to protobuf-defined services.""" import grpc from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 import test_services_pb2 as test__services__pb2 class DummyServiceStub(object): """Missing associated documentation comment in .proto file.""" def __init__(self, channel): """Constructor. Args: channel: A grpc.Channel. """ self.Empty = channel.unary_unary( '/tavern.tests.v1.DummyService/Empty', request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, ) self.SimpleTest = channel.unary_unary( '/tavern.tests.v1.DummyService/SimpleTest', request_serializer=test__services__pb2.DummyRequest.SerializeToString, response_deserializer=test__services__pb2.DummyResponse.FromString, ) class DummyServiceServicer(object): """Missing associated documentation comment in .proto file.""" def Empty(self, request, context): """Missing associated documentation comment in .proto file.""" context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') def SimpleTest(self, request, context): """Missing associated documentation comment in .proto file.""" context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') def add_DummyServiceServicer_to_server(servicer, server): rpc_method_handlers = { 'Empty': grpc.unary_unary_rpc_method_handler( servicer.Empty, request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, ), 'SimpleTest': grpc.unary_unary_rpc_method_handler( servicer.SimpleTest, request_deserializer=test__services__pb2.DummyRequest.FromString, response_serializer=test__services__pb2.DummyResponse.SerializeToString, ), } generic_handler = grpc.method_handlers_generic_handler( 'tavern.tests.v1.DummyService', rpc_method_handlers) server.add_generic_rpc_handlers((generic_handler,)) # This class is part of an EXPERIMENTAL API. class DummyService(object): """Missing associated documentation comment in .proto file.""" @staticmethod def Empty(request, target, options=(), channel_credentials=None, call_credentials=None, insecure=False, compression=None, wait_for_ready=None, timeout=None, metadata=None): return grpc.experimental.unary_unary(request, target, '/tavern.tests.v1.DummyService/Empty', google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, google_dot_protobuf_dot_empty__pb2.Empty.FromString, options, channel_credentials, insecure, call_credentials, compression, wait_for_ready, timeout, metadata) @staticmethod def SimpleTest(request, target, options=(), channel_credentials=None, call_credentials=None, insecure=False, compression=None, wait_for_ready=None, timeout=None, metadata=None): return grpc.experimental.unary_unary(request, target, '/tavern.tests.v1.DummyService/SimpleTest', test__services__pb2.DummyRequest.SerializeToString, test__services__pb2.DummyResponse.FromString, options, channel_credentials, insecure, call_credentials, compression, wait_for_ready, timeout, metadata) tavern-3.6.0/tests/unit/test_call_run.py000066400000000000000000000030771520710011500203470ustar00rootroot00000000000000import warnings from unittest.mock import patch import pytest from tavern._core import exceptions from tavern.core import run @pytest.fixture(autouse=True) def patch_pytest(): with patch("tavern.core.pytest.main") as fake_main: yield assert fake_main.called class TestBasicRun: def test_run(self): run("") def test_run_with_empty_cfg(self): run("", {}) def test_run_with_cfg(self): run("", {"a": 2}) @pytest.mark.parametrize( "expected_kwarg", ( "tavern_mqtt_backend", "tavern_http_backend", "tavern_grpc_backend", "tavern_strict", ), ) def test_doesnt_warn_about_expected_kwargs(self, expected_kwarg): kw = {expected_kwarg: 123} with warnings.catch_warnings(record=True) as warn_rec: run("", **kw) assert not len(warn_rec) class TestParseGlobalCfg: def test_path_correct(self): run("", tavern_global_cfg=__file__) def test_pass_dict(self): run("", tavern_global_cfg={"variables": {"a": 1}}) class TestParseFailures: @pytest.fixture(autouse=True) def patch_pytest(self): with patch("tavern.core.pytest.main") as fake_main: yield assert not fake_main.called def test_path_nonexistent(self): with pytest.raises(exceptions.InvalidSettingsError): run("", tavern_global_cfg="sdfsdd") def test_bad_type(self): with pytest.raises(exceptions.InvalidSettingsError): run("", tavern_global_cfg=["a", "b", "c"]) tavern-3.6.0/tests/unit/test_core.py000066400000000000000000000531631520710011500175010ustar00rootroot00000000000000import copy import dataclasses import json import os import uuid from copy import deepcopy from unittest.mock import MagicMock, Mock, patch import paho.mqtt.client as paho import pytest import requests from tavern._core import exceptions from tavern._core.pytest.util import load_global_cfg from tavern._core.run import run_test from tavern._plugins.mqtt.client import MQTTClient @pytest.fixture(name="fulltest") def fix_example_test(): spec = { "test_name": "A test with a single stage", "stages": [ { "name": "step 1", "request": {"url": "http://www.google.com", "method": "GET"}, "response": { "status_code": 200, "json": {"key": "value"}, "headers": {"content-type": "application/json"}, }, } ], } return spec @pytest.fixture(name="mockargs") def fix_mock_response_args(fulltest): response = copy.deepcopy(fulltest["stages"][0]["response"]) content = response["json"] args = { "spec": requests.Response, "content": json.dumps(content).encode("utf8"), "status_code": response["status_code"], "json": lambda: content, "headers": response["headers"], } return args class TestRunStages: def test_success(self, fulltest, mockargs, includes): """Successful test""" mock_response = Mock(**mockargs) with patch( "tavern._plugins.rest.request.requests.Session.request", return_value=mock_response, ) as pmock: run_test("heif", fulltest, includes) assert pmock.called def test_invalid_code(self, fulltest, mockargs, includes): """Wrong status code""" mockargs["status_code"] = 400 mock_response = Mock(**mockargs) with patch( "tavern._plugins.rest.request.requests.Session.request", return_value=mock_response, ) as pmock: with pytest.raises(exceptions.TestFailError): run_test("heif", fulltest, includes) assert pmock.called def test_invalid_body(self, fulltest, mockargs, includes): """Wrong body returned""" mockargs["json"] = lambda: {"wrong": "thing"} mock_response = Mock(**mockargs) with patch( "tavern._plugins.rest.request.requests.Session.request", return_value=mock_response, ) as pmock: with pytest.raises(exceptions.TestFailError): run_test("heif", fulltest, includes) assert pmock.called def test_invalid_headers(self, fulltest, mockargs, includes): """Wrong headers""" mockargs["headers"] = {"content-type": "application/x-www-url-formencoded"} mock_response = Mock(**mockargs) with patch( "tavern._plugins.rest.request.requests.Session.request", return_value=mock_response, ) as pmock: with pytest.raises(exceptions.TestFailError): run_test("heif", fulltest, includes) assert pmock.called class TestIncludeStages: @pytest.fixture def fake_stages(self): stages = [ { "id": "my_external_stage", "name": "My external stage", "request": {"url": "http://www.bing.com", "method": "GET"}, "response": { "status_code": 200, "json": {"key": "value"}, "headers": {"content-type": "application/json"}, }, } ] return stages def check_mocks_called(self, pmock): assert pmock.called # We expect 2 calls, first to bing (external stage), # then google (part of fulltest) assert len(pmock.call_args_list) == 2 args, kwargs = pmock.call_args_list[0] assert kwargs["url"] == "http://www.bing.com" args, kwargs = pmock.call_args_list[1] assert kwargs["url"] == "http://www.google.com" def test_included_stage(self, fulltest, mockargs, includes, fake_stages): """Load stage from includes""" mock_response = Mock(**mockargs) stage_includes = [{"stages": fake_stages}] newtest = deepcopy(fulltest) newtest["includes"] = stage_includes newtest["stages"].insert(0, {"type": "ref", "id": "my_external_stage"}) with patch( "tavern._plugins.rest.request.requests.Session.request", return_value=mock_response, ) as pmock: run_test("heif", newtest, includes) self.check_mocks_called(pmock) def test_included_finally_stage(self, fulltest, mockargs, includes, fake_stages): """Load stage from includes""" mock_response = Mock(**mockargs) stage_includes = [{"stages": fake_stages}] newtest = deepcopy(fulltest) newtest["includes"] = stage_includes newtest["finally"] = [{"type": "ref", "id": "my_external_stage"}] with patch( "tavern._plugins.rest.request.requests.Session.request", return_value=mock_response, ) as pmock: run_test("bloo", newtest, includes) pmock.call_args_list = list(reversed(pmock.call_args_list)) self.check_mocks_called(pmock) def test_global_stage(self, fulltest, mockargs, includes, fake_stages): """Load stage from global config""" mock_response = Mock(**mockargs) stage_includes = [] newtest = deepcopy(fulltest) newtest["includes"] = stage_includes newtest["stages"].insert(0, {"type": "ref", "id": "my_external_stage"}) includes = dataclasses.replace(includes, stages=fake_stages) with patch( "tavern._plugins.rest.request.requests.Session.request", return_value=mock_response, ) as pmock: run_test("heif", newtest, includes) self.check_mocks_called(pmock) def test_both_stages(self, fulltest, mockargs, includes, fake_stages): """Load stage defined in both - raise a warning for now""" mock_response = Mock(**mockargs) stage_includes = [{"stages": fake_stages}] newtest = deepcopy(fulltest) newtest["includes"] = stage_includes newtest["stages"].insert(0, {"type": "ref", "id": "my_external_stage"}) includes = dataclasses.replace(includes, stages=fake_stages) with pytest.raises(exceptions.DuplicateStageDefinitionError): with patch( "tavern._plugins.rest.request.requests.Session.request", return_value=mock_response, ) as pmock: run_test("heif", newtest, includes) assert not pmock.called def test_neither(self, fulltest, mockargs, includes, fake_stages): """Raises error if not defined""" mock_response = Mock(**mockargs) stage_includes = [] newtest = deepcopy(fulltest) newtest["includes"] = stage_includes newtest["stages"].insert(0, {"type": "ref", "id": "my_external_stage"}) with pytest.raises(exceptions.InvalidStageReferenceError): with patch( "tavern._plugins.rest.request.requests.Session.request", return_value=mock_response, ): run_test("heif", newtest, includes) class TestRetry: def test_repeats_twice_and_succeeds(self, fulltest, mockargs, includes): fulltest["stages"][0]["max_retries"] = 1 failed_mockargs = deepcopy(mockargs) failed_mockargs["status_code"] = 400 mock_responses = [Mock(**failed_mockargs), Mock(**mockargs)] with patch( "tavern._plugins.rest.request.requests.Session.request", side_effect=mock_responses, ) as pmock: run_test("heif", fulltest, includes) assert pmock.call_count == 2 def test_repeats_twice_and_fails(self, fulltest, mockargs, includes): fulltest["stages"][0]["max_retries"] = 1 mockargs["status_code"] = 400 mock_response = Mock(**mockargs) with patch( "tavern._plugins.rest.request.requests.Session.request", return_value=mock_response, ) as pmock: with pytest.raises(exceptions.TestFailError): run_test("heif", fulltest, includes) assert pmock.call_count == 2 def test_run_once(self, fulltest, mockargs, includes): mock_responses = Mock(**mockargs) with patch( "tavern._plugins.rest.request.requests.Session.request", return_value=mock_responses, ) as pmock: run_test("heif", fulltest, includes) assert pmock.call_count == 1 class TestDelay: def test_sleep_before(self, fulltest, mockargs, includes): """Should sleep with delay_before in stage spec""" fulltest["stages"][0]["delay_before"] = 2 mock_response = Mock(**mockargs) with patch( "tavern._plugins.rest.request.requests.Session.request", return_value=mock_response, ) as pmock: with patch("tavern._core.testhelpers.time.sleep") as smock: run_test("heif", fulltest, includes) assert pmock.called smock.assert_called_with(2) def test_sleep_after(self, fulltest, mockargs, includes): """Should sleep with delay_after in stage spec""" fulltest["stages"][0]["delay_after"] = 2 mock_response = Mock(**mockargs) with patch( "tavern._plugins.rest.request.requests.Session.request", return_value=mock_response, ) as pmock: with patch("tavern._core.testhelpers.time.sleep") as smock: run_test("heif", fulltest, includes) assert pmock.called smock.assert_called_with(2) class TestTavernMetaFormat: def test_format_env_keys(self, fulltest, mockargs, includes): """Should be able to get variables from the environment and use them in test responses""" env_key = "SPECIAL_CI_MAGIC_COMMIT_TAG" fulltest["stages"][0]["request"]["params"] = { "a_format_key": f"{{tavern.env_vars.{env_key}}}" } mock_response = Mock(**mockargs) with patch( "tavern._plugins.rest.request.requests.Session.request", return_value=mock_response, ) as pmock: with patch.dict(os.environ, {env_key: "bleuihg"}): run_test("heif", fulltest, includes) assert pmock.called def test_format_env_keys_missing_failure(self, fulltest, mockargs, includes): """Fails if key is not present""" env_key = "SPECIAL_CI_MAGIC_COMMIT_TAG" fulltest["stages"][0]["request"]["params"] = { "a_format_key": f"{{tavern.env_vars.{env_key}}}" } with pytest.raises(exceptions.MissingFormatError): run_test("heif", fulltest, includes) class TestFormatRequestVars: @pytest.mark.parametrize("request_key", ("params", "json", "headers")) def test_format_request_var_dict(self, fulltest, mockargs, includes, request_key): """Variables from request should be available to format in response""" sent_value = str(uuid.uuid4()) fulltest["stages"][0]["request"]["method"] = "POST" fulltest["stages"][0]["request"][request_key] = {"a_format_key": sent_value} if request_key == "json": resp_key = "json" mockargs[request_key] = lambda: {"returned": sent_value} else: resp_key = request_key mockargs[request_key] = {"returned": sent_value} fulltest["stages"][0]["response"][resp_key] = { "returned": f"{{tavern.request_vars.{request_key}.a_format_key:s}}" } mock_response = Mock(**mockargs) with patch( "tavern._plugins.rest.request.requests.Session.request", return_value=mock_response, ) as pmock: run_test("heif", fulltest, includes) assert pmock.called @pytest.mark.parametrize("request_key", ("url", "method")) def test_format_request_var_value(self, fulltest, mockargs, includes, request_key): """Variables from request should be available to format in response""" sent_value = str(uuid.uuid4()) fulltest["stages"][0]["request"]["method"] = "POST" fulltest["stages"][0]["request"][request_key] = sent_value resp_key = request_key mockargs[request_key] = {"returned": sent_value} fulltest["stages"][0]["response"][resp_key] = { "returned": f"{{tavern.request_vars.{request_key}:s}}" } mock_response = Mock(**mockargs) with ( patch( "tavern._plugins.rest.request.requests.Session.request", return_value=mock_response, ) as pmock, patch( "tavern._plugins.rest.request.valid_http_methods", ["POST", sent_value] ), ): run_test("heif", fulltest, includes) assert pmock.called class TestFormatMQTTVarsJson: """Test that formatting request vars from mqtt works as well, with json payload""" @pytest.fixture(name="fulltest") def fix_mqtt_publish_test(self): spec = { "test_name": "An mqtt test with a single stage", "paho-mqtt": { "connect": {"host": "localhost"}, }, "stages": [ { "name": "step 1", "mqtt_publish": { "topic": "/abc/123", "json": {"message": str(uuid.uuid4())}, }, "mqtt_response": { "topic": "{tavern.request_vars.topic}", "json": {"echo": "{tavern.request_vars.json.message}"}, }, } ], } return spec def test_format_request_var_dict(self, fulltest, includes): """Variables from request should be available to format in response - this is the original keys in the input file, NOT the formatted ones where 'json' is converted to 'payload' in the actual MQTT publish""" stage = fulltest["stages"][0] sent = stage["mqtt_publish"]["json"] mockargs = { "spec": paho.MQTTMessage, "payload": json.dumps({"echo": sent["message"]}).encode("utf8"), "topic": stage["mqtt_publish"]["topic"], "timestamp": 0, } mock_response = Mock(**mockargs) fake_client = MagicMock( spec=MQTTClient, message_received=Mock(return_value=mock_response), ) with patch( "tavern._core.run.get_extra_sessions", return_value={"paho-mqtt": fake_client}, ) as pmock: run_test("heif", fulltest, includes) assert pmock.called class TestFormatMQTTVarsPlain: """Test that formatting request vars from mqtt works as well, with normal payload""" @pytest.fixture(name="fulltest") def fix_mqtt_publish_test(self): spec = { "test_name": "An mqtt test with a single stage", "paho-mqtt": { "connect": {"host": "localhost"}, }, "stages": [ { "name": "step 1", "mqtt_publish": {"topic": "/abc/123", "payload": "hello"}, "mqtt_response": { "topic": "{tavern.request_vars.topic}", "payload": "{tavern.request_vars.payload}", }, } ], } return spec def test_format_request_var_value(self, fulltest, includes): """Same as above but with plain keys""" stage = fulltest["stages"][0] sent = stage["mqtt_publish"]["payload"] mockargs = { "spec": paho.MQTTMessage, "payload": sent.encode("utf8"), "topic": stage["mqtt_publish"]["topic"], "timestamp": 0, } mock_response = Mock(**mockargs) fake_client = MagicMock( spec=MQTTClient, message_received=Mock(return_value=mock_response) ) with patch( "tavern._core.run.get_extra_sessions", return_value={"paho-mqtt": fake_client}, ) as pmock: run_test("heif", fulltest, includes) assert pmock.called class TestFinally: @staticmethod def run_test(fulltest, mockargs, includes): mock_response = Mock(**mockargs) with patch( "tavern._plugins.rest.request.requests.Session.request", return_value=mock_response, ) as pmock: run_test("heif", fulltest, includes) assert pmock.called return pmock @pytest.mark.parametrize("finally_block", ([],)) def test_nop(self, fulltest, mockargs, includes, finally_block): """ignore empty finally blocks""" fulltest["finally"] = finally_block self.run_test(fulltest, mockargs, includes) @pytest.mark.parametrize( "finally_block", ( {}, "hi", 3, ), ) def test_wrong_type(self, fulltest, mockargs, includes, finally_block): """final stages need to be dicts too""" fulltest["finally"] = finally_block with pytest.raises(exceptions.BadSchemaError): self.run_test(fulltest, mockargs, includes) @pytest.fixture def finally_request(self): return { "name": "step 1", "request": {"url": "http://www.myfinal.com", "method": "POST"}, "response": { "status_code": 200, "json": {"key": "value"}, "headers": {"content-type": "application/json"}, }, } def test_finally_run(self, fulltest, mockargs, includes, finally_request): fulltest["finally"] = [finally_request] pmock = self.run_test(fulltest, mockargs, includes) assert pmock.call_count == 2 assert pmock.mock_calls[1].kwargs.items() >= finally_request["request"].items() def test_finally_run_twice(self, fulltest, mockargs, includes, finally_request): fulltest["finally"] = [finally_request, finally_request] pmock = self.run_test(fulltest, mockargs, includes) assert pmock.call_count == 3 assert pmock.mock_calls[1].kwargs.items() >= finally_request["request"].items() assert pmock.mock_calls[2].kwargs.items() >= finally_request["request"].items() def test_finally_run_on_main_failure( self, fulltest, mockargs, includes, finally_request ): fulltest["finally"] = [finally_request] mockargs["status_code"] = 503 mock_response = Mock(**mockargs) with patch( "tavern._plugins.rest.request.requests.Session.request", return_value=mock_response, ) as pmock: with pytest.raises(exceptions.TestFailError): run_test("heif", fulltest, includes) assert pmock.call_count == 2 assert pmock.mock_calls[1].kwargs.items() >= finally_request["request"].items() class TestTinctures: @pytest.mark.parametrize( "tinctures", ( {"function": "abc"}, [{"function": "abc"}], [{"function": "abc"}, {"function": "def"}], ), ) @pytest.mark.parametrize( "at_stage_level", ( True, False, ), ) def test_tinctures( self, fulltest, mockargs, includes, tinctures, at_stage_level, ): if at_stage_level: fulltest["tinctures"] = tinctures else: fulltest["stages"][0]["tinctures"] = tinctures mock_response = Mock(**mockargs) tincture_func_mock = Mock() with ( patch( "tavern._plugins.rest.request.requests.Session.request", return_value=mock_response, ) as pmock, patch( "tavern._core.tincture.get_wrapped_response_function", return_value=tincture_func_mock, ), ): run_test("heif", fulltest, includes) assert pmock.call_count == 1 assert tincture_func_mock.call_count == len(tinctures) def test_copy_config(pytestconfig): cfg_1 = load_global_cfg(pytestconfig) cfg_1.variables["test1"] = "abc" cfg_2 = load_global_cfg(pytestconfig) assert cfg_2.variables.get("test1") is None class TestHooks: def test_before_every_request_hook_called(self, fulltest, mockargs, includes): """Verify that the before_every_request hook is called""" mock_response = Mock(**mockargs) def call_func(request_args): request_args["headers"] = {"foo": "myzclqkptpk"} # Mock the hook caller hook_mock = Mock(side_effect=call_func) includes.tavern_internal.pytest_hook_caller.pytest_tavern_beta_before_every_request = hook_mock with patch( "tavern._plugins.rest.request.requests.Session.request", return_value=mock_response, ): run_test("test_file_name", fulltest, includes) # Verify the hook was called with the request arguments hook_mock.assert_called_once() # Verify the request args passed to hook contain the expected values request_args = hook_mock.call_args[1]["request_args"] assert "url" in request_args assert "method" in request_args assert request_args["method"] == "GET" assert "http://www.google.com" in request_args["url"] assert request_args["headers"] == {"foo": "myzclqkptpk"} tavern-3.6.0/tests/unit/test_extensions.py000066400000000000000000000014361520710011500207440ustar00rootroot00000000000000import pytest from tavern._core import exceptions from tavern._core.schema.extensions import ( validate_grpc_status_is_valid_or_list_of_names as validate_grpc, ) class TestGrpcCodes: @pytest.mark.parametrize("code", ("UNAVAILABLE", "unavailable", "ok", 14, 0)) def test_validate_grpc_valid_status(self, code): assert True is validate_grpc(code, None, None) assert True is validate_grpc([code], None, None) @pytest.mark.parametrize("code", (-1, "fo", "J", {"status": "OK"})) def test_validate_grpc_invalid_status(self, code): with pytest.raises(exceptions.BadSchemaError): assert False is validate_grpc(code, None, None) with pytest.raises(exceptions.BadSchemaError): assert False is validate_grpc([code], None, None) tavern-3.6.0/tests/unit/test_files.py000066400000000000000000000163751520710011500176570ustar00rootroot00000000000000import contextlib import dataclasses import os import pathlib import tempfile from collections.abc import Callable, Generator from typing import Any from unittest.mock import Mock import pytest import yaml from tavern._core import exceptions from tavern._core.files import _find_file_in_include_path, _get_include_dirs from tavern._core.pytest.file import YamlFile from tavern._core.pytest.item import YamlItem @pytest.fixture(scope="function") def tavern_test_content(): """return some example tests""" test_docs = [ {"test_name": "First test", "stages": [{"name": "stage 1"}]}, {"test_name": "Second test", "stages": [{"name": "stage 2"}]}, {"test_name": "Third test", "stages": [{"name": "stage 3"}]}, ] return test_docs @contextlib.contextmanager def tavern_test_file(test_content: list[Any]) -> Generator[pathlib.Path, Any, None]: """Create a temporary YAML file with multiple documents""" with tempfile.TemporaryDirectory() as tmpdir: file_path = pathlib.Path(tmpdir) / "test.yaml" # Write the documents to the file with file_path.open("w", encoding="utf-8") as f: for doc in test_content: yaml.dump(doc, f) f.write("---\n") yield file_path @dataclasses.dataclass class Opener: """Simple mock for generating items because pytest makes it hard to wrap their internal functionality""" path: pathlib.Path _generate_items: Callable[[dict], Any] class TestGenerateFiles: @pytest.mark.parametrize("with_merge_down_test", (True, False)) def test_multiple_documents(self, tavern_test_content, with_merge_down_test): """Verify that multiple documents in a YAML file result in multiple tests""" # Collect all tests if with_merge_down_test: tavern_test_content.insert(0, {"includes": [], "is_defaults": True}) def generate_yamlitem(test_spec): mock = Mock(spec=YamlItem) mock.name = test_spec["test_name"] yield mock with tavern_test_file(tavern_test_content) as filename: tests = list( YamlFile.collect( Opener( path=filename, _generate_items=generate_yamlitem, ) ) ) assert len(tests) == 3 # Verify each test has the correct name expected_names = ["First test", "Second test", "Third test"] for test, expected_name in zip(tests, expected_names): assert test.name == expected_name @pytest.mark.parametrize( "content, exception", ( ({"kookdff": "?A?A??"}, exceptions.BadSchemaError), ({"test_name": "name", "stages": [{"name": "lflfl"}]}, TypeError), ), ) def test_reraise_exception( self, tavern_test_content, content: dict, exception: BaseException ): """Verify that exceptions are properly reraised when loading YAML test files. Test that when an exception occurs during test generation, it is properly reraised as a schema error if the schema is bad.""" def raise_error(test_spec): raise TypeError tavern_test_content.insert(0, content) with tavern_test_file(tavern_test_content) as filename: with pytest.raises(exception): list( YamlFile.collect( Opener( path=filename, _generate_items=raise_error, ) ) ) class TestGetIncludeDirs: def test_default_dirs_no_test_file(self, monkeypatch): monkeypatch.delenv("TAVERN_INCLUDE", raising=False) dirs = _get_include_dirs() assert dirs == [os.path.curdir] def test_test_file_directory_first(self, tmp_path, monkeypatch): monkeypatch.delenv("TAVERN_INCLUDE", raising=False) test_file = str(tmp_path / "test.tavern.yaml") dirs = _get_include_dirs(test_file) assert dirs == [str(tmp_path), os.path.curdir] def test_env_var_paths(self, tmp_path, monkeypatch): monkeypatch.delenv("TAVERN_INCLUDE", raising=False) include_dir = str(tmp_path / "includes") monkeypatch.setenv("TAVERN_INCLUDE", include_dir) dirs = _get_include_dirs() assert dirs == [os.path.curdir, include_dir] def test_multiple_env_paths(self, tmp_path, monkeypatch): monkeypatch.delenv("TAVERN_INCLUDE", raising=False) path1 = str(tmp_path / "inc1") path2 = str(tmp_path / "inc2") monkeypatch.setenv("TAVERN_INCLUDE", f"{path1}:{path2}") dirs = _get_include_dirs() assert dirs == [os.path.curdir, path1, path2] def test_env_var_expansion(self, tmp_path, monkeypatch): monkeypatch.delenv("TAVERN_INCLUDE", raising=False) include_dir = str(tmp_path / "includes") monkeypatch.setenv("SOME_DIR", include_dir) monkeypatch.setenv("TAVERN_INCLUDE", "$SOME_DIR") dirs = _get_include_dirs() assert dirs == [os.path.curdir, include_dir] class TestFindFileInIncludePath: def test_absolute_path(self, tmp_path): f = tmp_path / "test.txt" f.write_text("hello") result = _find_file_in_include_path(str(f)) assert result == str(f) def test_relative_from_test_file_dir(self, tmp_path, monkeypatch): monkeypatch.delenv("TAVERN_INCLUDE", raising=False) test_dir = tmp_path / "tests" test_dir.mkdir() test_file = str(test_dir / "test.tavern.yaml") f = test_dir / "data.txt" f.write_text("hello") result = _find_file_in_include_path("data.txt", test_file) assert result == str(f) def test_relative_from_cwd(self, tmp_path, monkeypatch): monkeypatch.delenv("TAVERN_INCLUDE", raising=False) f = tmp_path / "data.txt" f.write_text("hello") monkeypatch.chdir(tmp_path) result = _find_file_in_include_path("data.txt") assert result == str(f) def test_relative_from_env_var(self, tmp_path, monkeypatch): include_dir = tmp_path / "includes" include_dir.mkdir() f = include_dir / "data.txt" f.write_text("hello") monkeypatch.setenv("TAVERN_INCLUDE", str(include_dir)) result = _find_file_in_include_path("data.txt") assert result == str(f) def test_not_found(self, monkeypatch, tmp_path): monkeypatch.delenv("TAVERN_INCLUDE", raising=False) monkeypatch.chdir(tmp_path) with pytest.raises(exceptions.IncludedFileNotFoundError): _find_file_in_include_path("nonexistent.txt") def test_test_file_dir_priority(self, tmp_path, monkeypatch): monkeypatch.delenv("TAVERN_INCLUDE", raising=False) test_dir = tmp_path / "tests" test_dir.mkdir() test_file = str(test_dir / "test.tavern.yaml") # Create file in cwd cwd_file = tmp_path / "data.txt" cwd_file.write_text("cwd") # Create file in test dir test_file_data = test_dir / "data.txt" test_file_data.write_text("test") monkeypatch.chdir(tmp_path) result = _find_file_in_include_path("data.txt", test_file) assert result == str(test_file_data) tavern-3.6.0/tests/unit/test_helpers.py000066400000000000000000000441011520710011500202030ustar00rootroot00000000000000import contextlib import json import sys import tempfile from textwrap import dedent from unittest.mock import Mock, patch import _pytest import pytest import yaml from box import Box from pydantic import BaseModel from tavern._core import exceptions from tavern._core.dict_util import _check_and_format_values, format_keys from tavern._core.loader import ForceIncludeToken from tavern._core.pytest.item import YamlItem from tavern._core.schema.extensions import validate_file_spec from tavern._core.strict_util import ( StrictLevel, StrictSetting, validate_and_parse_option, ) from tavern.core import run from tavern.helpers import ( validate_content, validate_pydantic, validate_pykwalify, validate_regex, ) # Pydantic models for testing validate_pydantic # Defined at module level so they can be imported dynamically class PydanticTestModel(BaseModel): name: str value: int class PydanticTestModelWithRequired(BaseModel): name: str required_field: int class PydanticInnerModel(BaseModel): inner_value: str class PydanticOuterModel(BaseModel): outer_name: str nested: "PydanticInnerModel" class FakeResponse: def __init__(self, text): self.text = text self.headers = {"test_header": text} class TestRegex: def test_regex_match(self): response = FakeResponse("abchelloabc") matched = validate_regex(response, "(?Phello)") assert "greeting" in matched["regex"] def test_regex_no_match(self): response = FakeResponse("abchelloabc") with pytest.raises(exceptions.RegexAccessError): validate_regex(response, "(?Phola)") def test_regex_match_header(self): response = FakeResponse("abchelloabc") matched = validate_regex(response, "(?Phello)", header="test_header") assert "greeting" in matched["regex"] def test_regex_no_match_header(self): response = FakeResponse("abchelloabc") with pytest.raises(exceptions.RegexAccessError): validate_regex(response, "(?Phola)", header="test_header") @pytest.mark.parametrize( "match", ( (r"val(?P\d)", "path1", "val1"), (r"val(?P\d)", "path2[0]", "val2"), (r"val(?P\d)", "path3.sub", "val3"), ), ) def test_in_jmespath(self, match): response = FakeResponse( json.dumps({"path1": "val1", "path2": ["val2"], "path3": {"sub": "val3"}}) ) expression, path, expected = match result = validate_regex(response, expression, in_jmespath=path) assert result["regex"]["num"] == expected[-1] class TestRunAlone: def test_run_calls_pytest(self): """This should just return from pytest.main()""" with patch("tavern.core.pytest.main") as pmock: run("abc") assert pmock.called def test_normal_args(self): with patch("tavern.core.pytest.main") as pmock: run( **{ "tavern_global_cfg": None, "in_file": "kfdoskdof", "tavern_http_backend": "requests", "tavern_mqtt_backend": "paho-mqtt", "tavern_strict": True, } ) assert pmock.called def test_extra_args(self): with pytest.raises(TypeError): with patch("tavern.core.pytest.main") as pmock: run( **{ "tavern_global_cfg": None, "in_file": "kfdoskdof", "tavern_http_backend": "requests", "tavern_mqtt_backend": "paho-mqtt", "tavern_strict": True, "gfg": "2efsf", } ) assert not pmock.called class TestOptionParsing: valid = [ f"{section:s}:{setting:s}" for section in ("json", "headers", "redirect_params") for setting in ("on", "off") ] @pytest.mark.parametrize("optval", valid) def test_strictness_parsing_good(self, pytestconfig, optval): args = pytestconfig._parser.parse_known_args([f"--tavern-strict={optval}"]) assert "tavern_strict" in args assert args.tavern_strict == [optval] class TestTavernRepr: @pytest.fixture(name="fake_item") def fix_fake_item(self, request): item = YamlItem.from_parent( name="Fake Test Item", parent=request.node, spec={}, path="/tmp/hello" ) return item @pytest.fixture(autouse=True, scope="session") def add_opts(self, pytestconfig): from tavern._core.pytest.hooks import pytest_addoption # noqa: PLC0415 with contextlib.suppress(ValueError): pytest_addoption(pytestconfig._parser) def _make_fake_exc_info(self, exc_type): # Copied from pytest tests class FakeExcinfo(_pytest._code.ExceptionInfo): # type:ignore pass try: raise exc_type except exc_type: excinfo = FakeExcinfo(sys.exc_info()) return excinfo def test_not_called_for_normal_exception(self, fake_item): """Does not call tavern repr for non tavern errors""" fake_info = self._make_fake_exc_info(RuntimeError) with patch("tavern._core.pytest.item.ReprdError") as rmock: fake_item.repr_failure(fake_info) assert not rmock.called @pytest.mark.parametrize("ini_flag", [True, False]) def test_not_called_for_badschema_tavern_exception_(self, fake_item, ini_flag): """Does not call taven repr for badschemerror - tavern repr gives no useful information in this case""" fake_info = self._make_fake_exc_info(exceptions.BadSchemaError) with patch.object(fake_item.config, "getini", return_value=ini_flag): with patch("tavern._core.pytest.item.ReprdError") as rmock: fake_item.repr_failure(fake_info) assert not rmock.called def test_not_called_ini(self, fake_item): """Enable ini flag, should call old style""" fake_info = self._make_fake_exc_info(exceptions.InvalidSettingsError) with patch.object(fake_item.config, "getini", return_value=True): with patch("tavern._core.pytest.item.ReprdError") as rmock: fake_item.repr_failure(fake_info) assert not rmock.called def test_not_called_cli(self, fake_item): """Enable cli flag, should call old style""" fake_info = self._make_fake_exc_info(exceptions.InvalidSettingsError) with patch.object(fake_item.config, "getoption", return_value=True): with patch("tavern._core.pytest.item.ReprdError") as rmock: fake_item.repr_failure(fake_info) assert not rmock.called @pytest.fixture(name="nested_response") def fix_nested_response(): class response_content: content = { "top": { "Thing": "value", "float": 0.1, "nested": {"doubly": {"inner_value": "value", "inner_list": [1, 2, 3]}}, }, "an_integer": 123, "a_string": "abc", "a_bool": True, } def json(self): return self.content return response_content() class TestContent: def test_correct_jmes_path(self, nested_response): comparisons = [ {"jmespath": "top.Thing", "operator": "eq", "expected": "value"}, {"jmespath": "an_integer", "operator": "eq", "expected": 123}, { "jmespath": "top.nested.doubly.inner_list", "operator": "type", "expected": "list", }, ] validate_content(nested_response, comparisons) assert True def test_incorrect_jmes_path(self, nested_response): comparisons = [{"jmespath": "userId", "operator": "eq", "expected": 1}] with pytest.raises(exceptions.JMESError): validate_content(nested_response, comparisons) def test_incorrect_value(self, nested_response): comparisons = [{"jmespath": "a_bool", "operator": "eq", "expected": False}] with pytest.raises(exceptions.JMESError): validate_content(nested_response, comparisons) class TestPykwalifyExtension: def test_validate_schema_correct(self, nested_response): correct_schema = dedent( """ type: map required: true mapping: top: type: map required: true mapping: Thing: type: str float: type: float nested: type: any an_integer: type: int a_string: type: str a_bool: type: bool """ ) validate_pykwalify( nested_response, yaml.load(correct_schema, Loader=yaml.SafeLoader) ) def test_validate_schema_incorrect(self, nested_response): correct_schema = dedent( """ type: seq required: true sequence: - type: str """ ) with pytest.raises(exceptions.BadSchemaError): validate_pykwalify( nested_response, yaml.load(correct_schema, Loader=yaml.SafeLoader) ) class TestValidatePykwalify: def test_non_json_response_raises_bad_schema_error(self): schema = yaml.load("type: map", Loader=yaml.SafeLoader) class NonJsonResponse: def json(self): raise ValueError("not json") with pytest.raises(exceptions.BadSchemaError) as exc_info: validate_pykwalify(NonJsonResponse(), schema) assert "non-json response" in str(exc_info.value) class TestCheckParseValues: @pytest.mark.parametrize("item", [yaml, yaml.load, yaml.SafeLoader]) def test_warns_bad_type(self, item): with patch("tavern._core.dict_util.logger.warning") as wmock: _check_and_format_values("{fd}", Box({"fd": item})) wmock.assert_called_with( "Formatting '%s' will result in it being coerced to a string (it is a %s)", "fd", type(item), ) @pytest.mark.parametrize( "item", [ [134], {"a": 2}, ], ) def test_warns_bad_type_box(self, item): box = Box({"fd": item}) with patch("tavern._core.dict_util.logger.warning") as wmock: _check_and_format_values("{fd}", box) wmock.assert_called_with( "Formatting '%s' will result in it being coerced to a string (it is a %s)", "fd", type(box["fd"]), ) @pytest.mark.parametrize("item", [1, "a", 1.3, format_keys("{s}", {"s": 2})]) def test_no_warn_good_type(self, item): with patch("tavern._core.dict_util.logger.warning") as wmock: _check_and_format_values("{fd}", Box({"fd": item})) assert not wmock.called def test_format_with_array_access(self): """Test accessing using array indexing format""" assert "hello" == _check_and_format_values( "{a.b.c[0].d}", Box({"a": {"b": {"c": [{"d": "hello"}]}}}) ) def test_format_with_dict_access(self): """Test accessing using dict indexing format""" assert "hello" == _check_and_format_values( "{a[b].c[0].d}", Box({"a": {"b": {"c": [{"d": "hello"}]}}}) ) class TestFormatWithJson: @pytest.mark.parametrize( "item", [[134], {"a": 2}, yaml, yaml.load, yaml.SafeLoader] ) def test_custom_format(self, item): """Can format everything""" val = format_keys(ForceIncludeToken("{fd}"), {"fd": item}) assert val == item def test_bad_format_string_extra(self): """Extra things in format string""" with pytest.raises(exceptions.InvalidFormattedJsonError): format_keys(ForceIncludeToken("{fd}gg"), {"fd": "123"}) def test_bad_format_string_conversion(self): """No format string""" with pytest.raises(exceptions.InvalidFormattedJsonError): format_keys(ForceIncludeToken(""), {"fd": "123"}) def test_bad_format_string_multiple(self): """Multple format spec in string is disallowed""" with pytest.raises(exceptions.InvalidFormattedJsonError): format_keys(ForceIncludeToken("{a}{b}"), {"fd": "123"}) class TestCheckFileSpec: def _wrap_test_block(self, dowith): validate_file_spec({"files": dowith}, Mock(), Mock()) def test_string_valid(self): with tempfile.NamedTemporaryFile() as tfile: self._wrap_test_block(tfile.name) def test_dict_valid(self): with tempfile.NamedTemporaryFile() as tfile: self._wrap_test_block({"file_path": tfile.name}) def test_nonexistsnt_string(self): with pytest.raises(exceptions.BadSchemaError): self._wrap_test_block("kdsfofs") def nonexistent_dict(self): with pytest.raises(exceptions.BadSchemaError): self._wrap_test_block({"file_path": "gogfgl"}) def extra_keys_dict(self): with pytest.raises(exceptions.BadSchemaError): self._wrap_test_block({"file_path": "gogfgl", "blop": 123}) class TestStrictUtils: @pytest.mark.parametrize("section", ["json", "headers", "redirect_query_params"]) @pytest.mark.parametrize("setting", ["on", "off"]) def test_parse_option(self, section, setting): option = f"{section}:{setting}" match = validate_and_parse_option(option) assert match.section == section if setting == "on": assert match.is_on() else: assert not match.is_on() @pytest.mark.parametrize("section", ["json", "headers", "redirect_query_params"]) def test_unset_defaults(self, section): match = validate_and_parse_option(section) if section == "json": assert match.is_on() else: assert not match.is_on() @pytest.mark.parametrize("setting", ["true", "1", "hi", ""]) def test_fails_bad_setting(self, setting): with pytest.raises(exceptions.InvalidConfigurationException): validate_and_parse_option(f"json:{setting}") @pytest.mark.parametrize("section", ["json", "headers", "redirect_query_params"]) def test_defaults_good(self, section): level = StrictLevel() if section == "json": assert level.option_for(section).is_on() else: assert not level.option_for(section).is_on() @pytest.mark.parametrize("section", ["true", "1", "hi", ""]) def test_defaults_bad(self, section): level = StrictLevel() with pytest.raises(exceptions.InvalidConfigurationException): level.option_for(section) # These tests could be removed, they are testing implementation details... @pytest.mark.parametrize("section", ["json", "headers", "redirect_query_params"]) def test_set_on(self, section): level = StrictLevel.from_options([section + ":on"]) assert level.option_for(section).setting == StrictSetting.ON assert level.option_for(section).is_on() @pytest.mark.parametrize("section", ["json", "headers", "redirect_query_params"]) def test_set_off(self, section): level = StrictLevel.from_options([section + ":off"]) assert level.option_for(section).setting == StrictSetting.OFF assert not level.option_for(section).is_on() @pytest.mark.parametrize("section", ["json", "headers", "redirect_query_params"]) def test_unset(self, section): level = StrictLevel.from_options([section]) assert level.option_for(section).setting == StrictSetting.UNSET class TestPydanticValidation: """Tests for validate_pydantic function""" @pytest.fixture(autouse=True) def pydantic_available(self): """Skip tests if pydantic is not installed""" pytest.importorskip("pydantic") def test_valid_model_validation(self): """Test successful validation against a pydantic model""" class ValidResponse: def json(self): return {"name": "test", "value": 42} # Should not raise any exception validate_pydantic(ValidResponse(), "tests.unit.test_helpers:PydanticTestModel") def test_invalid_model_validation(self): """Test validation failure against a pydantic model""" class InvalidResponse: def json(self): return {"name": "test", "value": "not_an_int"} with pytest.raises(exceptions.BadSchemaError) as exc_info: validate_pydantic( InvalidResponse(), "tests.unit.test_helpers:PydanticTestModel" ) assert "pydantic validation" in str(exc_info.value).lower() def test_non_json_response(self): """Test validation fails when response is not JSON""" class NonJsonResponse: def json(self): raise TypeError("Not JSON") with pytest.raises(exceptions.BadSchemaError) as exc_info: validate_pydantic( NonJsonResponse(), "tests.unit.test_helpers:PydanticTestModel" ) assert "not JSON" in str(exc_info.value) def test_missing_required_field(self): """Test validation fails when required field is missing""" class MissingFieldResponse: def json(self): return {"name": "test"} with pytest.raises(exceptions.BadSchemaError): validate_pydantic( MissingFieldResponse(), "tests.unit.test_helpers:PydanticTestModelWithRequired", ) def test_nested_model_validation(self): """Test validation with nested pydantic models""" class NestedResponse: def json(self): return {"outer_name": "test", "nested": {"inner_value": "hello"}} # Should not raise any exception validate_pydantic( NestedResponse(), "tests.unit.test_helpers:PydanticOuterModel" ) tavern-3.6.0/tests/unit/test_marks.py000066400000000000000000000203121520710011500176540ustar00rootroot00000000000000import ast import pytest from tavern._core import exceptions from tavern._core.pytest.file import _ast_node_to_literal, _format_test_marks @pytest.mark.parametrize( "marks,expected", [ (["skip"], ([pytest.mark.skip], [])), (["xfail"], ([pytest.mark.xfail], [])), (["slow"], ([pytest.mark.slow], [])), (["skip", "xfail"], ([pytest.mark.skip, pytest.mark.xfail], [])), (["xdist_group('group1')"], ([pytest.mark.xdist_group("group1")], [])), ( ["xdist_group(group='group1')"], ([pytest.mark.xdist_group(group="group1")], []), ), ( ["xdist_group('group1', 'group2')"], ([pytest.mark.xdist_group("group1", "group2")], []), ), ( ["xdist_group(('group1', 'group2'),)"], ( [ pytest.mark.xdist_group( ("group1", "group2"), ) ], [], ), ), ( ["skip", "xfail", 'xdist_group("group1")'], ( [ pytest.mark.skip, pytest.mark.xfail, pytest.mark.xdist_group("group1"), ], [], ), ), # Additional test cases (["usefixtures('fixture1')"], ([pytest.mark.usefixtures("fixture1")], [])), ( ["usefixtures('fixture1', 'fixture2')"], ([pytest.mark.usefixtures("fixture1", "fixture2")], []), ), ( ["skipif(True, reason='test')"], ([pytest.mark.skipif(True, reason="test")], []), ), ( ["xfail(reason='flaky')"], ([pytest.mark.xfail(reason="flaky")], []), ), ( ["parametrize('a,b', [(1,2), (3,4)])"], ([pytest.mark.parametrize("a,b", [(1, 2), (3, 4)])], []), ), ( ["skip", "slow", 'usefixtures("db")'], ( [ pytest.mark.skip, pytest.mark.slow, pytest.mark.usefixtures("db"), ], [], ), ), ], ) def test_format_test_marks(marks, expected): # Dummy values for fmt_vars and test_name, as required by the function signature result = _format_test_marks(marks, fmt_vars={}, test_name="dummy") assert result == expected @pytest.mark.parametrize( "invalid_marks", [ ["invalid(mark)"], # nonexistent mark name ["xdist_group('unclosed)"], # Invalid string literal ["xdist_group(missing_quote)"], # Invalid arg format ], ) def test_failing_format_marks(invalid_marks): with pytest.raises(exceptions.BadSchemaError): _format_test_marks(invalid_marks, fmt_vars={}, test_name="dummy") class TestAstNodeToLiteral: """Tests for _ast_node_to_literal function""" def test_constant_node(self): """Test converting ast.Constant nodes""" # Test string constant node = ast.Constant(value="test_string") result = _ast_node_to_literal(node) assert result == "test_string" # Test numeric constant node = ast.Constant(value=42) result = _ast_node_to_literal(node) assert result == 42 # Test boolean constant node = ast.Constant(value=True) result = _ast_node_to_literal(node) assert result is True # Test None constant node = ast.Constant(value=None) result = _ast_node_to_literal(node) assert result is None def test_list_node(self): """Test converting ast.List nodes""" # Empty list node = ast.List(elts=[], ctx=ast.Load()) result = _ast_node_to_literal(node) assert result == [] # List with constants node = ast.List( elts=[ ast.Constant(value="a"), ast.Constant(value=1), ast.Constant(value=True), ], ctx=ast.Load(), ) result = _ast_node_to_literal(node) assert result == ["a", 1, True] # Nested list inner_list = ast.List(elts=[ast.Constant(value="nested")], ctx=ast.Load()) node = ast.List( elts=[ast.Constant(value="outer"), inner_list], ctx=ast.Load(), ) result = _ast_node_to_literal(node) assert result == ["outer", ["nested"]] def test_dict_node(self): """Test converting ast.Dict nodes""" # Empty dict node = ast.Dict(keys=[], values=[]) result = _ast_node_to_literal(node) assert result == {} # Dict with constants node = ast.Dict( keys=[ ast.Constant(value="key1"), ast.Constant(value="key2"), ], values=[ ast.Constant(value="value1"), ast.Constant(value=2), ], ) result = _ast_node_to_literal(node) assert result == {"key1": "value1", "key2": 2} # Nested dict inner_dict = ast.Dict( keys=[ast.Constant(value="inner_key")], values=[ast.Constant(value="inner_value")], ) node = ast.Dict( keys=[ ast.Constant(value="outer_key"), ast.Constant(value="nested_dict"), ], values=[ ast.Constant(value="outer_value"), inner_dict, ], ) result = _ast_node_to_literal(node) assert result == { "outer_key": "outer_value", "nested_dict": {"inner_key": "inner_value"}, } def test_tuple_node(self): """Test converting ast.Tuple nodes""" # Empty tuple node = ast.Tuple(elts=[], ctx=ast.Load()) result = _ast_node_to_literal(node) assert result == () # Tuple with constants node = ast.Tuple( elts=[ ast.Constant(value="a"), ast.Constant(value=1), ast.Constant(value=True), ], ctx=ast.Load(), ) result = _ast_node_to_literal(node) assert result == ("a", 1, True) # Nested tuple inner_tuple = ast.Tuple(elts=[ast.Constant(value="nested")], ctx=ast.Load()) node = ast.Tuple( elts=[ast.Constant(value="outer"), inner_tuple], ctx=ast.Load(), ) result = _ast_node_to_literal(node) assert result == ("outer", ("nested",)) def test_name_node_constants(self): """Test converting ast.Name nodes for constants""" # Test True node = ast.Name(id="True", ctx=ast.Load()) result = _ast_node_to_literal(node) assert result is True # Test False node = ast.Name(id="False", ctx=ast.Load()) result = _ast_node_to_literal(node) assert result is False # Test None node = ast.Name(id="None", ctx=ast.Load()) result = _ast_node_to_literal(node) assert result is None def test_name_node_variable_reference_error(self): """Test that ast.Name nodes for variables raise ValueError""" node = ast.Name(id="some_variable", ctx=ast.Load()) with pytest.raises( ValueError, match="Unsupported variable reference: some_variable" ): _ast_node_to_literal(node) def test_unsupported_node_type_error(self): """Test that unsupported node types raise ValueError""" # Use ast.Expr as an example of an unsupported node type node = ast.Expr(value=ast.Constant(value="test")) with pytest.raises( ValueError, match="Unsupported AST node type: " ): _ast_node_to_literal(node) def test_format_test_marks_with_fmt_vars(): """Test that _format_test_marks correctly substitutes formatted variables in mark arguments""" marks = ["skipif('{condition}', reason='{reason}')"] fmt_vars = {"condition": "True", "reason": "test_skip"} expected = ([pytest.mark.skipif("True", reason="test_skip")], []) result = _format_test_marks(marks, fmt_vars=fmt_vars, test_name="dummy") assert result == expected tavern-3.6.0/tests/unit/test_mqtt.py000066400000000000000000000222741520710011500175350ustar00rootroot00000000000000import time from unittest.mock import MagicMock, Mock, patch import paho.mqtt.client as paho import pytest from tavern._core import exceptions from tavern._plugins.mqtt.client import MQTTClient, _handle_tls_args, _Subscription from tavern._plugins.mqtt.request import MQTTRequest def test_host_required(): """Always needs a host, but it's the only required key""" with pytest.raises(exceptions.MissingKeysError): MQTTClient() args = {"connect": {"host": "localhost"}} MQTTClient(**args) @pytest.fixture(name="fake_client") def fix_fake_client(): args = {"connect": {"host": "localhost", "timeout": 0.6}} mqtt_client = MQTTClient(**args) mqtt_client._subscribed[2] = _Subscription("abc") mqtt_client._subscription_mappings["abc"] = 2 return mqtt_client class TestClient: def test_no_queue(self, fake_client): """Trying to fetch from a nonexistent queue raised exception""" with pytest.raises(exceptions.MQTTTopicException): fake_client.message_received("", 0) def test_no_message(self, fake_client): """No message in queue returns None""" assert fake_client.message_received("abc", 0) is None def test_message_queued(self, fake_client): """Returns message in queue""" message = "abc123" fake_client._userdata["_subscribed"][2].queue.put(message) assert fake_client.message_received("abc", 0) == message def test_context_connection_failure(self, fake_client): """Unable to connect on __enter__ raises MQTTError""" fake_client._connect_timeout = 0.3 with patch.object(fake_client._client, "loop_start"): with pytest.raises(exceptions.MQTTError): with fake_client: pass def test_context_connection_success(self, fake_client): """returns self on success""" with ( patch.object(fake_client._client, "loop_start"), patch.object(fake_client._client, "connect_async"), ): fake_client._client._state = paho.mqtt_cs_connected with fake_client as x: assert fake_client == x def test_assert_message_published_error(self, fake_client): """Error waiting for it to publish""" class FakeMessage(paho.MQTTMessageInfo): def wait_for_publish(self, timeout=None): raise RuntimeError rc = 1 with ( patch.object(fake_client._client, "subscribe"), patch.object(fake_client._client, "publish", return_value=FakeMessage(10)), ): with pytest.raises(exceptions.MQTTError): fake_client.publish("abc", "123") def test_assert_message_published_failure(self, fake_client: MQTTClient): """If it couldn't publish the message, error out""" class FakeMessage(paho.MQTTMessageInfo): def wait_for_publish(self, timeout=None): return def is_published(self): return False rc = 1 with ( patch.object(fake_client._client, "subscribe"), patch.object(fake_client._client, "publish", return_value=FakeMessage(10)), ): with pytest.raises(exceptions.MQTTError): fake_client.publish("abc", "123") def test_assert_message_published_delay(self, fake_client): """Published but only after a small delay""" class FakeMessage(paho.MQTTMessageInfo): def wait_for_publish(self, timeout=None): time.sleep(0.5) def is_published(self): return True rc = 1 with ( patch.object(fake_client._client, "subscribe"), patch.object(fake_client._client, "publish", return_value=FakeMessage(10)), ): fake_client.publish("abc", "123") def test_assert_message_published_unknown_err(self, fake_client): """Same, but with an unknown error code""" class FakeMessage(paho.MQTTMessageInfo): def is_published(self): return False rc = 2342423 with ( patch.object(fake_client._client, "subscribe"), patch.object(fake_client._client, "publish", return_value=FakeMessage(10)), ): with pytest.raises(exceptions.MQTTError): fake_client.publish("abc", "123") class TestTLS: def test_missing_cert_gives_error(self): """Missing TLS cert gives an error""" args = {"certfile": "/lcliueurhug/ropko3kork32"} with pytest.raises(exceptions.MQTTTLSError): _handle_tls_args(args) def test_disabled_tls(self): """Even if there are other invalid options, disable tls and early exit without checking other args """ args = {"certfile": "/lcliueurhug/ropko3kork32", "enable": False} parsed_args = _handle_tls_args(args) assert not parsed_args def test_invalid_tls_ver(self): """Bad tls versions raise exception""" args = {"tls_version": "custom_tls"} with pytest.raises(exceptions.MQTTTLSError): _handle_tls_args(args) @pytest.fixture(name="req") def fix_example_request(): spec = {"topic": "{request_topic:s}", "payload": "abc123"} return spec.copy() class TestRequests: def test_unknown_fields(self, req, includes): """Unkown args should raise an error""" req["fodokfowe"] = "Hello" with pytest.raises(exceptions.UnexpectedKeysError): MQTTRequest(Mock(), req, includes) def test_missing_format(self, req, includes): """All format variables should be present""" del includes.variables["request_topic"] with pytest.raises(exceptions.MissingFormatError): MQTTRequest(Mock(), req, includes) def test_correct_format(self, req, includes): """All format variables should be present""" MQTTRequest(Mock(), req, includes) class TestSubscription: @staticmethod def get_mock_client_with(subcribe_action): mock_paho = Mock(spec=paho.Client, subscribe=subcribe_action) mock_client = Mock( spec=MQTTClient, _client=mock_paho, _subscribed={}, _subscription_mappings={}, _subscribe_lock=MagicMock(), ) return mock_client def test_handles_subscriptions(self): def subscribe_success(topic, *args, **kwargs): return (0, 123) mock_client = TestSubscription.get_mock_client_with(subscribe_success) MQTTClient.subscribe(mock_client, "abc") assert mock_client._subscribed[123].topic == "abc" assert mock_client._subscribed[123].subscribed is False def test_no_subscribe_on_err(self): def subscribe_err(topic, *args, **kwargs): return (1, 123) mock_client = TestSubscription.get_mock_client_with(subscribe_err) with pytest.raises(exceptions.MQTTError): MQTTClient.subscribe(mock_client, "abc") assert mock_client._subscribed == {} def test_no_subscribe_on_unrecognised_suback(self): def subscribe_success(topic, *args, **kwargs): return (0, 123) mock_client = TestSubscription.get_mock_client_with(subscribe_success) MQTTClient._on_subscribe(mock_client, "abc", {}, 123, 0) assert mock_client._subscribed == {} class TestExtFunctions: @pytest.fixture() def basic_mqtt_request_args(self) -> dict: return { "topic": "/a/b/c", } def test_basic(self, fake_client, basic_mqtt_request_args, includes): MQTTRequest(fake_client, basic_mqtt_request_args, includes) def test_ext_function_bad(self, fake_client, basic_mqtt_request_args, includes): basic_mqtt_request_args["json"] = {"$ext": "kk"} with pytest.raises(exceptions.InvalidExtFunctionError): MQTTRequest(fake_client, basic_mqtt_request_args, includes) def test_ext_function_good(self, fake_client, basic_mqtt_request_args, includes): basic_mqtt_request_args["json"] = { "$ext": { "function": "operator:add", "extra_args": (1, 2), } } m = MQTTRequest(fake_client, basic_mqtt_request_args, includes) assert "payload" in m._publish_args assert m._publish_args["payload"] == "3" class TestSSLContext: def test_ciphers_set_correctly(self): """Test that ciphers are set correctly in SSL context""" args = { "connect": {"host": "localhost"}, "ssl_context": { "certfile": "/path/to/certfile", "keyfile": "/path/to/keyfile", "ciphers": "ECDHE-RSA-AES256-GCM-SHA384", }, } with ( patch( "tavern._plugins.mqtt.client.ssl.create_default_context" ) as mock_create_context, patch("tavern._plugins.mqtt.client.check_file_exists"), ): mock_context = MagicMock() mock_create_context.return_value = mock_context _ = MQTTClient(**args) mock_context.set_ciphers.assert_called_once_with( "ECDHE-RSA-AES256-GCM-SHA384" ) tavern-3.6.0/tests/unit/test_pytest_hooks.py000066400000000000000000000132371520710011500213020ustar00rootroot00000000000000import os import pathlib from dataclasses import dataclass from unittest.mock import Mock, patch import pytest from faker import Faker from tavern._core import exceptions from tavern._core.pytest.file import YamlFile, _get_parametrized_items @dataclass class MockArgs: session: pytest.Session parent: pytest.File path: pathlib.Path def mock_args(): """Get a basic test config to initialise a YamlFile object with""" path = pathlib.Path("abc") cargs = {"rootdir": "abc", "path": path} config = Mock(**cargs, rootpath="abc") session = Mock(_initialpaths=[], config=config) parent = Mock( spec=os.PathLike, config=config, parent=None, nodeid="sdlfs", **cargs, session=session, ) return MockArgs(session, parent, path) def get_basic_parametrize_mark(faker): """Get a random 'normal' parametrize mark""" return {"parametrize": {"key": faker.name(), "vals": [faker.name(), 2, 3]}} def get_joined_parametrize_mark(faker): """Get a random 'combined' parametrize mark""" return { "parametrize": { "key": [faker.name(), faker.name()], "vals": [["w", "x"], ["y", "z"]], } } def get_parametrised_tests(marks): args = mock_args() y = YamlFile.from_parent(args.parent, path=args.path) y.session = args.session spec = {"test_name": "a test", "stages": []} gen = _get_parametrized_items(y, spec, marks, []) return list(gen) def test_none(): marks = [] tests = get_parametrised_tests(marks) # Only 1 assert len(tests) == 1 @pytest.mark.parametrize("faker", [Faker(), Faker("zh_CN")]) class TestMakeFile: def test_only_single(self, faker): marks = [get_basic_parametrize_mark(faker)] tests = get_parametrised_tests(marks) # [1] # [2] # [3] assert len(tests) == 3 def test_only_basic(self, faker): marks = [ get_basic_parametrize_mark(faker), get_basic_parametrize_mark(faker), get_basic_parametrize_mark(faker), ] tests = get_parametrised_tests(marks) # [1, 1, 1] # [1, 1, 2] # [1, 1, 3] # [1, 2, 1] # [1, 2, 2] # [1, 2, 3] # etc. assert len(tests) == 27 def test_double(self, faker): marks = [get_joined_parametrize_mark(faker), get_basic_parametrize_mark(faker)] tests = get_parametrised_tests(marks) # [w, x, 1] # [w, x, 2] # [w, x, 3] # [y, z, 1] # [y, z, 2] # [y, z, 3] assert len(tests) == 6 def test_double_double(self, faker): marks = [ get_joined_parametrize_mark(faker), get_joined_parametrize_mark(faker), get_basic_parametrize_mark(faker), ] tests = get_parametrised_tests(marks) # [w, x, w, x, 1] # [w, x, w, x, 2] # [w, x, w, x, 3] # [w, x, y, z, 1] # [w, x, y, z, 2] # [w, x, y, z, 3] # etc. assert len(tests) == 12 def test_double_double_single(self, faker): marks = [ get_joined_parametrize_mark(faker), get_joined_parametrize_mark(faker), get_basic_parametrize_mark(faker), get_basic_parametrize_mark(faker), ] tests = get_parametrised_tests(marks) # [w, x, w, x, 1, 1] # [w, x, w, x, 1, 2] # [w, x, w, x, 2, 1] # [w, x, w, x, 2, 2] # [w, x, y, z, 1, 1] # [w, x, y, z, 1, 2] # etc. assert len(tests) == 36 @pytest.mark.parametrize( ("keys", "values"), ( ("a", ["b", "c", "d"]), (["a"], ["b", "c", "d"]), ("a", {"k": "v"}), (["a"], {"k": "v"}), (["a", "b"], [["b", "c"]]), (["a", "b"], [["b", "c"], [{"a": "b"}, {"a": "b"}]]), (["a", "b"], [["b", "c"], ["b", "c"], ["d", "e"]]), ), ) def test_ext_function_top_level(self, faker, keys, values): with patch( "tavern._core.pytest.file.get_wrapped_create_function", lambda _: lambda: values, ): marks = [ {"parametrize": {"key": keys, "vals": {"$ext": {"function": "a:v"}}}} ] tests = get_parametrised_tests(marks) assert len(tests) == len(values) @pytest.mark.parametrize( ("keys", "values"), ( # must return a list of lists (["a", "b"], {"a": "b"}), # must return a list of lists (["a", "b"], [{"a": "b"}]), # must return a list of lists (["a", "b"], [{"a": "b"}, {"a": "b"}]), # must return a list of lists (["a", "b"], "b"), # must return a list of lists (["a", "b"], ["b", "c"]), # must return a list of lists, where each element is also 3 long (["a", "b"], [["b", "c", "e"]]), # must return a list of lists, where each element is also 3 long (["a", "b"], [["b"]]), ), ) def test_ext_function_top_level_invalid(self, faker, keys, values): with patch( "tavern._core.pytest.file.get_wrapped_create_function", lambda _: lambda: values, ): marks = [ {"parametrize": {"key": keys, "vals": {"$ext": {"function": "a:v"}}}} ] with pytest.raises(exceptions.BadSchemaError): get_parametrised_tests(marks) def test_doc_string(): args = mock_args() y = YamlFile.from_parent(args.parent, path=args.path) assert isinstance(y.obj.__doc__, str) tavern-3.6.0/tests/unit/test_request.py000066400000000000000000000466601520710011500202450ustar00rootroot00000000000000import dataclasses import os import tempfile from contextlib import ExitStack from textwrap import dedent from unittest.mock import Mock import pytest import requests import yaml from requests.cookies import RequestsCookieJar from tavern._core import exceptions from tavern._core.extfunctions import update_from_ext from tavern._core.files import FileSendSpec from tavern._plugins.rest.request import ( RestRequest, _check_allow_redirects, _read_expected_cookies, get_file_arguments, get_request_args, ) @pytest.fixture(name="req") def fix_example_request(): spec = { "url": "{request.prefix:s}{request.url:s}", "method": "POST", "headers": { "Content-Type": "application/x-www-form-urlencoded", "Authorization": "Basic {test_auth_token:s}", }, "data": { "a_thing": "authorization_code", "code": "{code:s}", "url": "{callback_url:s}", "array": ["{code:s}", "{code:s}"], }, } return spec.copy() class TestRequests: def test_unknown_fields(self, req, includes): """Unkown args should raise an error""" req["fodokfowe"] = "Hello" with pytest.raises(exceptions.UnexpectedKeysError): RestRequest(Mock(), req, includes) def test_missing_format(self, req, includes): """All format variables should be present""" del includes.variables["code"] with pytest.raises(exceptions.MissingFormatError): RestRequest(Mock(), req, includes) def test_bad_get_body(self, req, includes): """Can't add a body with a GET request""" req["method"] = "GET" with pytest.warns(RuntimeWarning): RestRequest( Mock(spec=requests.Session, cookies=RequestsCookieJar()), req, includes ) class TestHttpRedirects: def test_session_called_no_redirects(self, req, includes): """Always disable redirects by defauly""" assert _check_allow_redirects(req, includes) is False @pytest.mark.parametrize("do_follow", [True, False]) def test_session_do_follow_redirects_based_on_test(self, req, includes, do_follow): """Locally enable following redirects in test""" req["follow_redirects"] = do_follow assert _check_allow_redirects(req, includes) == do_follow @pytest.mark.parametrize("do_follow", [True, False]) def test_session_do_follow_redirects_based_on_global_flag( self, req, includes, do_follow ): """Globally enable following redirects in test""" includes = dataclasses.replace(includes, follow_redirects=do_follow) assert _check_allow_redirects(req, includes) == do_follow class TestCookies: @pytest.fixture def mock_session(self): return Mock(spec=requests.Session, cookies=RequestsCookieJar()) def test_no_expected_none_available(self, mock_session, req, includes): """No cookies expected and none available = OK""" req["cookies"] = [] assert _read_expected_cookies(mock_session, req, includes) == {} def test_available_not_waited(self, req, includes): """some available but not set""" cookiejar = RequestsCookieJar() cookiejar.set("a", "2") mock_session = Mock(spec=requests.Session, cookies=cookiejar) assert _read_expected_cookies(mock_session, req, includes) is None def test_ask_for_nothing(self, req, includes): """explicitly ask fo rno cookies""" cookiejar = RequestsCookieJar() cookiejar.set("a", "2") mock_session = Mock(spec=requests.Session, cookies=cookiejar) req["cookies"] = [] assert _read_expected_cookies(mock_session, req, includes) == {} def test_not_available_but_wanted(self, mock_session, req, includes): """Some wanted but not available""" req["cookies"] = ["a"] with pytest.raises(exceptions.MissingCookieError): _read_expected_cookies(mock_session, req, includes) def test_available_and_waited(self, req, includes): """some available and wanted""" cookiejar = RequestsCookieJar() cookiejar.set("a", "2") req["cookies"] = ["a"] mock_session = Mock(spec=requests.Session, cookies=cookiejar) assert _read_expected_cookies(mock_session, req, includes) == {"a": "2"} def test_format_cookies(self, req, includes): """cookies in request should be formatted""" cookiejar = RequestsCookieJar() cookiejar.set("a", "2") req["cookies"] = ["{cookiename}"] includes.variables["cookiename"] = "a" mock_session = Mock(spec=requests.Session, cookies=cookiejar) assert _read_expected_cookies(mock_session, req, includes) == {"a": "2"} def test_no_overwrite_cookie(self, req, includes): """cant redefine a cookie from previous request""" cookiejar = RequestsCookieJar() cookiejar.set("a", "2") req["cookies"] = ["a", {"a": "sjidfsd"}] mock_session = Mock(spec=requests.Session, cookies=cookiejar) with pytest.raises(exceptions.DuplicateCookieError): _read_expected_cookies(mock_session, req, includes) def test_no_duplicate_cookie(self, req, includes): """Can't override a cookiev alue twice""" cookiejar = RequestsCookieJar() req["cookies"] = [{"a": "sjidfsd"}, {"a": "fjhj"}] mock_session = Mock(spec=requests.Session, cookies=cookiejar) with pytest.raises(exceptions.DuplicateCookieError): _read_expected_cookies(mock_session, req, includes) class TestRequestArgs: def test_default_method(self, req, includes): del req["method"] del req["data"] args = get_request_args(req, includes) assert args["method"] == "GET" @pytest.mark.parametrize("body_key", ("json", "data")) def test_default_method_raises_with_body(self, req, includes, body_key): del req["method"] del req["data"] req[body_key] = {"a": "b"} with pytest.warns(RuntimeWarning): get_request_args(req, includes) def test_no_override_method(self, req, includes): req["method"] = "POST" args = get_request_args(req, includes) assert args["method"] == "POST" @pytest.mark.parametrize("extra", [{}, {"json": [1, 2, 3]}, {"data": {"a": 2}}]) def test_no_default_content_type(self, req, includes, extra): del req["headers"]["Content-Type"] req.pop("json", {}) req.pop("data", {}) req.update(**extra) args = get_request_args(req, includes) # Requests will automatically set content type headers for json/form encoded data so we don't need to with pytest.raises(KeyError): assert args["headers"]["content-type"] def test_no_set_content_type(self, req, includes): del req["headers"]["Content-Type"] args = get_request_args(req, includes) with pytest.raises(KeyError): assert args["headers"]["content-type"] def test_cannot_send_data_and_json(self, req, includes): req["json"] = [1, 2, 3] req["data"] = [1, 2, 3] with pytest.raises(exceptions.BadSchemaError): get_request_args(req, includes) def test_no_override_content_type(self, req, includes): req["headers"]["Content-Type"] = "application/x-www-form-urlencoded" args = get_request_args(req, includes) assert args["headers"]["Content-Type"] == "application/x-www-form-urlencoded" def test_no_override_content_type_case_insensitive(self, req, includes): del req["headers"]["Content-Type"] req["headers"]["content-type"] = "application/x-www-form-urlencoded" args = get_request_args(req, includes) assert args["headers"]["content-type"] == "application/x-www-form-urlencoded" def test_nested_params_encoded(self, req, includes): req["params"] = {"a": {"b": {"c": "d"}}} args = get_request_args(req, includes) assert args["params"]["a"] == "%7B%22b%22%3A+%7B%22c%22%3A+%22d%22%7D%7D" def test_array_substitution(self, req, includes): args = get_request_args(req, includes) assert args["data"]["array"] == ["def456", "def456"] def test_file_and_json_fails(self, req, includes): """Can't send json and files at once""" req["files"] = ["abc"] req["json"] = {"key": "value"} with pytest.raises(exceptions.BadSchemaError): get_request_args(req, includes) def test_file_and_data_succeeds(self, req, includes): """Can send form data and files at once""" req["files"] = ["abc"] get_request_args(req, includes) @pytest.mark.parametrize("extra_headers", ({}, {"x-cool-header": "plark"})) def test_headers_no_content_type_change(self, req, includes, extra_headers): """Sending a file doesn't set the content type as json""" del req["data"] req["files"] = ["abc"] args = get_request_args(req, includes) assert "content-type" not in [i.lower() for i in args["headers"].keys()] @pytest.mark.parametrize("cert_value", ("a", ("a", "b"), ["a", "b"])) def test_cert_with_valid_values(self, req, includes, cert_value): req["cert"] = cert_value args = get_request_args(req, includes) if isinstance(cert_value, list): assert args["cert"] == (cert_value[0], cert_value[1]) else: assert args["cert"] == cert_value @pytest.mark.parametrize("verify_values", (True, False, "a")) def test_verity_with_valid_values(self, req, includes, verify_values): req["verify"] = verify_values args = get_request_args(req, includes) assert args["verify"] == verify_values class TestExtFunctions: def test_get_from_function(self, req, includes): """Make sure ext functions work in request This is a bit of a silly example because we're passing a dictionary instead of a string like it would be from the test, but it saves us having to define another external function just for this test """ to_copy = {"thing": "value"} original_json = {"test": "test"} req["json"] = { "$ext": {"function": "copy:copy", "extra_args": [to_copy]}, **original_json, } update_from_ext(req, ["json"]) assert req["json"] == dict(**to_copy, **original_json) class TestOptionalDefaults: @pytest.mark.parametrize("verify", (True, False)) def test_passthrough_verify(self, req, includes, verify): """Should be able to pass 'verify' through to requests.request""" req["verify"] = verify args = get_request_args(req, includes) assert args["verify"] == verify class TestFileBody: def test_file_body_format(self, req, includes): """Test getting file body""" req.pop("data") with tempfile.NamedTemporaryFile(encoding="utf8", mode="w") as tmpin: tmpin.write("OK") includes.variables["tmpfile_loc"] = tmpin.name req["file_body"] = "{tmpfile_loc}" args = get_request_args(req, includes) assert args["file_body"] == tmpin.name def test_file_body_content_type(self, req, includes): """Test inferring content type etc. works""" req.pop("data") req.pop("headers") with tempfile.NamedTemporaryFile( encoding="utf8", mode="w", suffix=".json" ) as tmpin: tmpin.write("OK") req["file_body"] = tmpin.name args = get_request_args(req, includes) assert args["file_body"] == tmpin.name assert args["headers"]["content-type"] == "application/json" def test_file_body_content_encoding(self, req, includes): """Test inferring content type etc. works""" req.pop("data") req.pop("headers") with tempfile.NamedTemporaryFile( encoding="utf8", mode="w", suffix=".tar.gz" ) as tmpin: tmpin.write("OK") req["file_body"] = tmpin.name args = get_request_args(req, includes) assert args["file_body"] == tmpin.name assert args["headers"]["content-type"] == "application/x-tar" assert args["headers"]["Content-Encoding"] == "gzip" def test_file_body_relative_path(self, req, includes, tmp_path): """Test resolving a relative file_body path from the test file directory""" req.pop("data") req.pop("headers") test_dir = tmp_path / "tests" test_dir.mkdir() test_file = test_dir / "test.yaml" test_file.write_text("test") f = test_dir / "body.json" f.write_text('{"ok": true}') includes = dataclasses.replace(includes, test_file_path=str(test_file)) req["file_body"] = "body.json" args = get_request_args(req, includes) assert args["file_body"] == str(f) def test_file_body_relative_path_from_env( self, req, includes, tmp_path, monkeypatch ): """Test resolving a relative file_body path from TAVERN_INCLUDE""" req.pop("data") req.pop("headers") include_dir = tmp_path / "includes" include_dir.mkdir() f = include_dir / "body.json" f.write_text('{"ok": true}') # Change to an empty dir so the file isn't found in cwd monkeypatch.chdir(tmp_path) monkeypatch.setenv("TAVERN_INCLUDE", str(include_dir)) req["file_body"] = "body.json" args = get_request_args(req, includes) assert args["file_body"] == str(f) def test_file_body_not_found(self, req, includes, tmp_path, monkeypatch): """Test that a missing relative file_body raises an error""" req.pop("data") req.pop("headers") monkeypatch.chdir(tmp_path) req["file_body"] = "nonexistent_file.txt" with pytest.raises(exceptions.IncludedFileNotFoundError): get_request_args(req, includes) def test_file_body_absolute_path(self, req, includes, tmp_path): """Test that an absolute file_body path passes through unchanged""" req.pop("data") req.pop("headers") f = tmp_path / "body.json" f.write_text('{"ok": true}') req["file_body"] = str(f) args = get_request_args(req, includes) assert args["file_body"] == str(f) class TestGetFiles: @pytest.fixture def mock_stack(self): return Mock(spec=ExitStack) def test_get_no_files(self, mock_stack, includes): """No files in request -> no files""" request_args = {} assert get_file_arguments(request_args, mock_stack, includes) == {} def test_get_empty_files_list(self, mock_stack, includes): """No specific files specified -> no files""" request_args = {"files": {}} assert get_file_arguments(request_args, mock_stack, includes) == {} def test_a_file(self, mock_stack, includes): """Json file should have the correct mimetype etc.""" with tempfile.NamedTemporaryFile(suffix=".json") as tfile: request_args = {"files": {"file1": tfile.name}} file_spec = get_file_arguments(request_args, mock_stack, includes) file = file_spec["files"]["file1"] assert file.filename == os.path.basename(tfile.name) assert file.content_type == "application/json" def test_use_long_form_content_type(self, mock_stack, includes): """Use custom content type""" with tempfile.NamedTemporaryFile(suffix=".json") as tfile: request_args = { "files": { "file1": { "file_path": tfile.name, "content_type": "abc123", "content_encoding": "def456", } } } file_spec = get_file_arguments(request_args, mock_stack, includes) file = file_spec["files"]["file1"] assert file.filename == os.path.basename(tfile.name) assert file.content_type == "abc123" assert file.content_encoding == {"Content-Encoding": "def456"} @pytest.mark.parametrize( "file_args", [ { "file1": { "file_path": "{tmpname}", "content_type": "abc123", "content_encoding": "def456", } }, {"file1": "{tmpname}"}, ], ) def test_format_filename(self, mock_stack, includes, file_args): """Filenames should be formatted in short and long styles""" with tempfile.NamedTemporaryFile(suffix=".json") as tfile: includes.variables["tmpname"] = tfile.name request_args = {"files": {"file1": tfile.name}} file_spec = get_file_arguments(request_args, mock_stack, includes) file = file_spec["files"]["file1"] assert file[0] == os.path.basename(tfile.name) def test_grouped_file_names(self, mock_stack, includes): """Parse grouped names appropriately""" with tempfile.NamedTemporaryFile() as tfile: raw_yaml_args = """ # Send file_1.txt and file_2.txt, both with name="input_files", in the multipart data. - form_field_name: "input_files" file_path: "%FILENAME%" content_type: "application/customtype" content_encoding: "UTF16" - form_field_name: "input_files" file_path: "%FILENAME%" content_type: "application/json" """ raw_yaml_args = raw_yaml_args.replace("%FILENAME%", tfile.name) file_args = yaml.safe_load(dedent(raw_yaml_args)) request_args = {"files": file_args} parsed = get_file_arguments(request_args, mock_stack, includes) parsed_into = { "files": [ ( "input_files", FileSendSpec( os.path.basename(tfile.name), mock_stack.enter_context.return_value, "application/customtype", {"Content-Encoding": "UTF16"}, ), ), ( "input_files", FileSendSpec( os.path.basename(tfile.name), mock_stack.enter_context.return_value, "application/json", ), ), ], } assert parsed_into == parsed class TestPreparedRequest: def test_headers_passed_to_session_are_strings(self, req, includes): """All header keys and values should be strings when passed to session.request""" req["headers"] = {"Content-Type": "application/json", "X-Int-Val": 123} mock_session = Mock(spec=requests.Session, cookies=RequestsCookieJar()) rr = RestRequest(mock_session, req, includes) # Inject a non-string key to verify key stringification as well rr._request_args["headers"][100] = "non-string-key-value" rr.run() call_kwargs = mock_session.request.call_args.kwargs headers = call_kwargs["headers"] for k, v in headers.items(): assert isinstance(k, str), f"Header key {k!r} is not a string" assert isinstance(v, str), ( f"Header value {v!r} for key {k!r} is not a string" ) tavern-3.6.0/tests/unit/test_schema.py000066400000000000000000000137631520710011500200130ustar00rootroot00000000000000import contextlib import os import tempfile from textwrap import dedent import pytest import yaml from tavern._core.exceptions import BadSchemaError from tavern._core.loader import load_single_document_yaml from tavern._core.schema.files import verify_tests @pytest.fixture(name="test_dict") def fix_test_dict(): text = dedent( """ --- test_name: Make sure server doubles number properly stages: - name: Make sure number is returned correctly request: url: http://localhost:5000/double json: number: 5 method: POST headers: content-type: application/json response: status_code: 200 json: double: 10 """ ) as_dict = yaml.load(text, Loader=yaml.SafeLoader) return as_dict class TestJSON: def test_simple_json_body(self, test_dict): """Simple json dict in request and response""" verify_tests(test_dict) def test_json_list_request(self, test_dict): """Request contains a list""" test_dict["stages"][0]["request"]["json"] = [1, "text", -1] verify_tests(test_dict) def test_json_list_response(self, test_dict): """Response contains a list""" test_dict["stages"][0]["response"]["json"] = [1, "text", -1] verify_tests(test_dict) class TestHeaders: def test_header_request_list(self, test_dict): """Headers must always be a dict""" test_dict["stages"][0]["request"]["headers"] = [1, "text", -1] with pytest.raises(BadSchemaError): verify_tests(test_dict) def test_headers_response_list(self, test_dict): """Headers must always be a dict""" test_dict["stages"][0]["response"]["headers"] = [1, "text", -1] with pytest.raises(BadSchemaError): verify_tests(test_dict) class TestParameters: def test_header_request_list(self, test_dict): """Parameters must always be a dict""" test_dict["stages"][0]["request"]["params"] = [1, "text", -1] with pytest.raises(BadSchemaError): verify_tests(test_dict) class TestTimeout: @pytest.mark.parametrize("incorrect_value", ("abc", True, {"a": 2}, [1, 2, 3])) def test_timeout_single_fail(self, test_dict, incorrect_value): """Timeout must be a list of floats or a float""" test_dict["stages"][0]["request"]["timeout"] = incorrect_value with pytest.raises(BadSchemaError): verify_tests(test_dict) @pytest.mark.parametrize("incorrect_value", ("abc", True, None, {"a": 2})) def test_timeout_tuple_fail(self, test_dict, incorrect_value): """Timeout must be a list of floats or a float""" test_dict["stages"][0]["request"]["timeout"] = [1, incorrect_value] with pytest.raises(BadSchemaError): verify_tests(test_dict) test_dict["stages"][0]["request"]["timeout"] = [incorrect_value, 1] with pytest.raises(BadSchemaError): verify_tests(test_dict) class TestCert: @pytest.mark.parametrize("correct_value", ("a", ["a", "b"])) def test_cert_as_string_tuple_list(self, test_dict, correct_value): test_dict["stages"][0]["request"]["cert"] = correct_value verify_tests(test_dict) @pytest.mark.parametrize( "incorrect_value", (None, True, {}, ("a", "b", "c"), [], ["a"], ["a", "b", "c"]) ) def test_cert_as_tuple(self, test_dict, incorrect_value): test_dict["stages"][0]["request"]["cert"] = incorrect_value with pytest.raises(BadSchemaError): verify_tests(test_dict) class TestVerify: @pytest.mark.parametrize("correct_value", ("a", True, False)) def test_verify_with_string_boolean(self, test_dict, correct_value): test_dict["stages"][0]["request"]["verify"] = correct_value verify_tests(test_dict) @pytest.mark.parametrize("incorrect_value", (None, 1, {}, [], ("a", "b"))) def test_verify_with_incorrect_value(self, test_dict, incorrect_value): test_dict["stages"][0]["request"]["verify"] = incorrect_value with pytest.raises(BadSchemaError): verify_tests(test_dict) class TestBadSchemaAtCollect: """Some errors happen at collection time - harder to test""" @staticmethod @contextlib.contextmanager def wrapfile_nondict(to_wrap): with tempfile.NamedTemporaryFile( suffix=".yaml", prefix="test_", delete=False ) as wrapped_tmp: # put into a file wrapped_tmp.write(to_wrap.encode("utf8")) wrapped_tmp.close() try: yield wrapped_tmp.name finally: os.remove(wrapped_tmp.name) def test_empty_dict_val(self): """Defining an empty mapping value is not allowed""" text = dedent( """ --- test_name: Test cannot send a set stages: - name: match top level request: url: "{host}/fake_dictionary" method: GET json: {a, b} response: status_code: 200 json: top: !anything """ ) with TestBadSchemaAtCollect.wrapfile_nondict(text) as filename: with pytest.raises(BadSchemaError): load_single_document_yaml(filename) def test_empty_list_val(self): """Defining an empty list value is not allowed""" text = dedent( """ --- test_name: Test cannot send a set stages: - name: match top level request: url: "{host}/fake_dictionary" method: GET json: - a - - b response: status_code: 200 json: top: !anything """ ) with TestBadSchemaAtCollect.wrapfile_nondict(text) as filename: with pytest.raises(BadSchemaError): load_single_document_yaml(filename) tavern-3.6.0/tests/unit/test_skip.py000066400000000000000000000102261520710011500175100ustar00rootroot00000000000000import dataclasses import unittest.mock from collections.abc import Mapping from unittest.mock import patch import pytest from tavern._core import exceptions from tavern._core.pytest.config import TestConfig from tavern._core.run import run_test def _run_test( stage: Mapping, test_block_config: TestConfig, run_mock: unittest.mock.Mock ) -> bool: """runs the test and returns whether the stage was run or not""" full_test = { "test_name": "A test with a single stage", "stages": [stage], } run_test("test_file_name", full_test, test_block_config) return run_mock.called class TestSkipStage: @pytest.fixture(autouse=True) def run_mock(self): with patch("tavern._core.run._TestRunner.run_stage") as run_mock: yield run_mock @pytest.fixture(scope="function") def stage(self): return { "name": "test stage", "request": {"url": "https://example.com", "method": "GET"}, "response": {"status_code": 200}, } @pytest.fixture def test_block_config(self, includes): return dataclasses.replace(includes, variables={"env_vars": {}}) def test_skip_true(self, stage, test_block_config, run_mock): """Skip stage when 'skip' is True""" stage["skip"] = True assert _run_test(stage, test_block_config, run_mock) is False def test_skip_false(self, stage, test_block_config, run_mock): """Don't skip stage when 'skip' is False""" stage["skip"] = False assert _run_test(stage, test_block_config, run_mock) is True def test_skip_simpleeval_true(self, stage, test_block_config, run_mock): """Skip stage when simpleeval expression evaluates to True""" stage["skip"] = "True" assert _run_test(stage, test_block_config, run_mock) is False def test_skip_simpleeval_false(self, stage, test_block_config, run_mock): """Don't skip stage when simpleeval expression evaluates to False""" stage["skip"] = "False" assert _run_test(stage, test_block_config, run_mock) is True def test_skip_env_var_true(self, stage, test_block_config, run_mock): """Skip stage when using a variable that evaluates to True""" stage["skip"] = "'{some_var}' == 'value'" test_block_config.variables.update({"some_var": "value"}) assert _run_test(stage, test_block_config, run_mock) is False def test_skip_env_var_false(self, stage, test_block_config, run_mock): """Don't skip stage when using a variable that evaluates to False""" stage["skip"] = "'{some_var}' == 'value'" test_block_config.variables.update({"some_var": "not_value"}) assert _run_test(stage, test_block_config, run_mock) is True def test_skip_invalid_var_types(self, stage, test_block_config, run_mock): """Error when cel types are wrong""" stage["skip"] = "'{some_var}' > 3" test_block_config.variables.update({"some_var": "value"}) with pytest.raises(exceptions.EvalError): _run_test(stage, test_block_config, run_mock) def test_skip_invalid_simpleeval(self, stage, test_block_config, run_mock): """Handle invalid simpleeval expressions gracefully""" stage["skip"] = "hello i am a test <<<" with pytest.raises(exceptions.EvalError): _run_test(stage, test_block_config, run_mock) def test_error_valid_simpleeval_missing_var( self, stage, test_block_config, run_mock ): """Handle missing variable""" stage["skip"] = "invalid_cel_expression" with pytest.raises(exceptions.EvalError): _run_test(stage, test_block_config, run_mock) def test_skip_non_bool_result(self, stage, test_block_config, run_mock): """Raise error when CEL returns non-boolean value""" stage["skip"] = "'not a boolean'" with pytest.raises(exceptions.EvalError): _run_test(stage, test_block_config, run_mock) def test_skip_empty_string(self, stage, test_block_config, run_mock): """Treat empty string as False""" stage["skip"] = "" assert _run_test(stage, test_block_config, run_mock) is True tavern-3.6.0/tests/unit/test_strict_util.py000066400000000000000000000020061520710011500211040ustar00rootroot00000000000000import pytest from tavern._core.strict_util import StrictOption, StrictSetting, extract_strict_setting @pytest.mark.parametrize( "strict", [True, StrictSetting.ON, StrictOption("json", StrictSetting.ON)] ) def test_extract_strict_setting_true(strict): as_bool, as_setting = extract_strict_setting(strict) assert as_bool is True if isinstance(strict, StrictSetting): assert as_setting == strict if isinstance(strict, StrictOption): assert as_setting == strict.setting @pytest.mark.parametrize( "strict", [ False, StrictSetting.OFF, StrictSetting.LIST_ANY_ORDER, StrictSetting.UNSET, StrictOption("json", StrictSetting.OFF), None, ], ) def test_extract_strict_setting_false(strict): as_bool, as_setting = extract_strict_setting(strict) assert as_bool is False if isinstance(strict, StrictSetting): assert as_setting == strict if isinstance(strict, StrictOption): assert as_setting == strict.setting tavern-3.6.0/tests/unit/test_tinctures.py000066400000000000000000000237371520710011500205750ustar00rootroot00000000000000from unittest.mock import patch import pytest from tavern._core.pytest.config import TavernInternalConfig, TestConfig from tavern._core.strict_util import StrictLevel from tavern._core.tincture import Tinctures, get_stage_tinctures @pytest.fixture(name="example") def example(): spec = { "test_name": "A test with a single stage", "stages": [ { "name": "step 1", "request": {"url": "http://www.google.com", "method": "GET"}, "response": { "status_code": 200, "json": {"key": "value"}, "headers": {"content-type": "application/json"}, }, } ], } return spec @pytest.fixture(name="mock_internal_config") def mock_internal_config(): """Create a mock TavernInternalConfig for testing""" from unittest.mock import Mock return TavernInternalConfig(pytest_hook_caller=Mock(), backends={}) def make_test_config(tinctures=None, mock_internal_config=None): """Helper to create TestConfig objects for testing""" if mock_internal_config is None: from unittest.mock import Mock mock_internal_config = TavernInternalConfig( pytest_hook_caller=Mock(), backends={} ) return TestConfig( variables={}, strict=StrictLevel.all_off(), follow_redirects=False, stages=[], tavern_internal=mock_internal_config, tinctures=tinctures, ) def test_empty(): t = Tinctures([]) t.start_tinctures({}) t.end_tinctures({}, None) @pytest.mark.parametrize( "tinctures", ( {"function": "abc"}, [{"function": "abc"}], [{"function": "abc"}, {"function": "def"}], ), ) class TestTinctures: class TestTinctureBasic: def test_stage_tinctures_normal(self, example, tinctures): stage = example["stages"][0] stage["tinctures"] = tinctures with patch( "tavern._core.tincture.get_wrapped_response_function", return_value=lambda _: None, ) as call_mock: t = get_stage_tinctures(stage, example) t.start_tinctures(stage) t.end_tinctures(stage, None) assert call_mock.call_count == len(tinctures) def test_test_tinctures_normal(self, example, tinctures): stage = example["stages"][0] example["tinctures"] = tinctures with patch( "tavern._core.tincture.get_wrapped_response_function", return_value=lambda _: None, ) as call_mock: t = get_stage_tinctures(stage, example) t.start_tinctures(stage) t.end_tinctures(stage, None) assert call_mock.call_count == len(tinctures) class TestTinctureYields: @staticmethod def does_yield(stage): assert stage["name"] == "step 1" (expected, response) = yield assert expected == stage["response"] assert response is None def test_stage_tinctures_normal(self, example, tinctures): stage = example["stages"][0] stage["tinctures"] = tinctures with patch( "tavern._core.tincture.get_wrapped_response_function", return_value=self.does_yield, ) as call_mock: t = get_stage_tinctures(stage, example) t.start_tinctures(stage) t.end_tinctures(stage["response"], None) assert call_mock.call_count == len(tinctures) def test_test_tinctures_normal(self, example, tinctures): stage = example["stages"][0] example["tinctures"] = tinctures with patch( "tavern._core.tincture.get_wrapped_response_function", return_value=self.does_yield, ) as call_mock: t = get_stage_tinctures(stage, example) t.start_tinctures(stage) t.end_tinctures(stage["response"], None) assert call_mock.call_count == len(tinctures) class TestGlobalTinctures: """Tests for global tinctures feature (issue #969)""" @pytest.mark.parametrize( "global_tinctures", ( {"function": "global_func"}, [{"function": "global_func"}], [{"function": "global_func1"}, {"function": "global_func2"}], ), ) def test_global_tinctures_only( self, example, mock_internal_config, global_tinctures ): """Test that global tinctures are applied when no test/stage tinctures exist""" stage = example["stages"][0] global_cfg = make_test_config( tinctures=global_tinctures, mock_internal_config=mock_internal_config ) with patch( "tavern._core.tincture.get_wrapped_response_function", return_value=lambda _: None, ) as call_mock: t = get_stage_tinctures(stage, example, global_cfg) t.start_tinctures(stage) t.end_tinctures(stage, None) expected_count = ( 1 if isinstance(global_tinctures, dict) else len(global_tinctures) ) assert call_mock.call_count == expected_count def test_global_tinctures_combined_with_test_tinctures( self, example, mock_internal_config ): """Test that global tinctures are combined with test-level tinctures""" stage = example["stages"][0] example["tinctures"] = [{"function": "test_func"}] global_cfg = make_test_config( tinctures=[{"function": "global_func"}], mock_internal_config=mock_internal_config, ) with patch( "tavern._core.tincture.get_wrapped_response_function", return_value=lambda _: None, ) as call_mock: t = get_stage_tinctures(stage, example, global_cfg) t.start_tinctures(stage) t.end_tinctures(stage, None) # 1 test tincture + 1 global tincture = 2 total assert call_mock.call_count == 2 def test_global_tinctures_combined_with_stage_tinctures( self, example, mock_internal_config ): """Test that global tinctures are combined with stage-level tinctures""" stage = example["stages"][0] stage["tinctures"] = [{"function": "stage_func"}] global_cfg = make_test_config( tinctures=[{"function": "global_func"}], mock_internal_config=mock_internal_config, ) with patch( "tavern._core.tincture.get_wrapped_response_function", return_value=lambda _: None, ) as call_mock: t = get_stage_tinctures(stage, example, global_cfg) t.start_tinctures(stage) t.end_tinctures(stage, None) # 1 stage tincture + 1 global tincture = 2 total assert call_mock.call_count == 2 def test_global_tinctures_combined_with_all_levels( self, example, mock_internal_config ): """Test that global tinctures are combined with both test and stage tinctures""" stage = example["stages"][0] example["tinctures"] = [{"function": "test_func"}] stage["tinctures"] = [{"function": "stage_func"}] global_cfg = make_test_config( tinctures=[{"function": "global_func"}], mock_internal_config=mock_internal_config, ) with patch( "tavern._core.tincture.get_wrapped_response_function", return_value=lambda _: None, ) as call_mock: t = get_stage_tinctures(stage, example, global_cfg) t.start_tinctures(stage) t.end_tinctures(stage, None) # 1 test + 1 stage + 1 global = 3 total assert call_mock.call_count == 3 def test_global_tinctures_none(self, example): """Test that passing None for global_cfg works (no global tinctures)""" stage = example["stages"][0] example["tinctures"] = [{"function": "test_func"}] with patch( "tavern._core.tincture.get_wrapped_response_function", return_value=lambda _: None, ) as call_mock: t = get_stage_tinctures(stage, example, None) t.start_tinctures(stage) t.end_tinctures(stage, None) assert call_mock.call_count == 1 def test_global_tinctures_none_value(self, example, mock_internal_config): """Test that global_cfg with tinctures=None works (no global tinctures)""" stage = example["stages"][0] example["tinctures"] = [{"function": "test_func"}] global_cfg = make_test_config( tinctures=None, mock_internal_config=mock_internal_config ) with patch( "tavern._core.tincture.get_wrapped_response_function", return_value=lambda _: None, ) as call_mock: t = get_stage_tinctures(stage, example, global_cfg) t.start_tinctures(stage) t.end_tinctures(stage, None) assert call_mock.call_count == 1 def test_tincture_execution_order(self, example, mock_internal_config): """Test that tinctures are executed in order: test → stage → global""" stage = example["stages"][0] example["tinctures"] = [{"function": "test_func"}] stage["tinctures"] = [{"function": "stage_func"}] global_cfg = make_test_config( tinctures=[{"function": "global_func"}], mock_internal_config=mock_internal_config, ) call_order = [] def mock_wrapper(func_spec): name = func_spec["function"] def wrapper(stage): call_order.append(name) return wrapper with patch( "tavern._core.tincture.get_wrapped_response_function", side_effect=mock_wrapper, ): t = get_stage_tinctures(stage, example, global_cfg) t.start_tinctures(stage) t.end_tinctures(stage, None) # Verify order: test, stage, global assert call_order == ["test_func", "stage_func", "global_func"] tavern-3.6.0/tests/unit/test_utilities.py000066400000000000000000000354521520710011500205650ustar00rootroot00000000000000import contextlib import copy import os import tempfile from collections import OrderedDict from textwrap import dedent from unittest.mock import Mock, patch import pytest import yaml from tavern._core import exceptions from tavern._core.dict_util import ( check_keys_match_recursive, deep_dict_merge, format_keys, recurse_access_key, ) from tavern._core.loader import ( ANYTHING, DictSentinel, FloatSentinel, IncludeLoader, IntSentinel, ListSentinel, NumberSentinel, StrSentinel, construct_include, load_single_document_yaml, ) from tavern._core.schema.extensions import validate_extensions from tavern._core.schema.files import wrapfile class TestValidateFunctions: def test_get_extension(self): """Loads a validation function correctly This doesn't check the signature at the time of writing """ spec = {"function": "operator:add"} validate_extensions(spec, None, None) def test_get_extension_list(self): """Loads a validation function correctly This doesn't check the signature at the time of writing """ spec = [{"function": "operator:add"}] validate_extensions(spec, None, None) def test_get_extension_list_empty(self): """Loads a validation function correctly This doesn't check the signature at the time of writing """ spec = [] validate_extensions(spec, None, None) def test_get_invalid_module(self): """Nonexistent module""" spec = {"function": "bleuuerhug:add"} with pytest.raises(exceptions.BadSchemaError): validate_extensions(spec, None, None) def test_get_nonexistent_function(self): """No name in module""" spec = {"function": "os:aaueurhg"} with pytest.raises(exceptions.BadSchemaError): validate_extensions(spec, None, None) class TestDictMerge: def test_single_level(self): """Merge two depth-one dicts with no conflicts""" dict_1 = {"key_1": "original_value_1", "key_2": "original_value_2"} dict_2 = {"key_2": "new_value_2", "key_3": "new_value_3"} result = deep_dict_merge(dict_1, dict_2) assert dict_1 == {"key_1": "original_value_1", "key_2": "original_value_2"} assert dict_2 == {"key_2": "new_value_2", "key_3": "new_value_3"} assert result == { "key_1": "original_value_1", "key_2": "new_value_2", "key_3": "new_value_3", } def test_recursive_merge(self): """Merge two depth-one dicts with no conflicts""" dict_1 = { "key": {"deep_key_1": "original_value_1", "deep_key_2": "original_value_2"} } dict_2 = {"key": {"deep_key_2": "new_value_2", "deep_key_3": "new_value_3"}} result = deep_dict_merge(dict_1, dict_2) assert dict_1 == { "key": {"deep_key_1": "original_value_1", "deep_key_2": "original_value_2"} } assert dict_2 == { "key": {"deep_key_2": "new_value_2", "deep_key_3": "new_value_3"} } assert result == { "key": { "deep_key_1": "original_value_1", "deep_key_2": "new_value_2", "deep_key_3": "new_value_3", } } class TestMatchRecursive: def test_match_dict(self): a = {"a": [{"b": "val"}]} b = copy.deepcopy(a) check_keys_match_recursive(a, b, []) def test_match_dict_mismatch(self): a = {"a": [{"b": "val"}]} b = copy.deepcopy(a) b["a"][0]["b"] = "wrong" with pytest.raises(exceptions.KeyMismatchError): check_keys_match_recursive(a, b, []) def test_match_nested_list(self): a = {"a": ["val"]} b = copy.deepcopy(a) b["a"][0] = "wrong" with pytest.raises(exceptions.KeyMismatchError): check_keys_match_recursive(a, b, []) def test_match_nested_list_length(self): a = {"a": ["val"]} b = copy.deepcopy(a) b["a"].append("wrong") with pytest.raises(exceptions.KeyMismatchError): check_keys_match_recursive(a, b, []) # These ones are testing 'internal' behaviour, might break in future def test_match_nested_anything_dict(self): a = {"a": [{"b": ANYTHING}]} b = copy.deepcopy(a) b["a"][0]["b"] = "wrong" check_keys_match_recursive(a, b, []) def test_match_nested_anything_list(self): a = {"a": [ANYTHING]} b = copy.deepcopy(a) b["a"][0] = "wrong" check_keys_match_recursive(a, b, []) def test_match_ordered(self): """Should be able to match an ordereddict""" first = {"a": 1, "b": 2} second = OrderedDict(b=2, a=1) check_keys_match_recursive(first, second, []) def test_key_case_matters(self): """Make sure case of keys matters""" a = {"a": [{"b": "val"}]} b = copy.deepcopy(a) b["a"][0] = {"B": "val"} with pytest.raises(exceptions.KeyMismatchError): check_keys_match_recursive(a, b, []) def test_value_case_matters(self): """Make sure case of values matters""" a = {"a": [{"b": "val"}]} b = copy.deepcopy(a) b["a"][0]["b"] = "VAL" with pytest.raises(exceptions.KeyMismatchError): check_keys_match_recursive(a, b, []) @pytest.mark.parametrize( "token, response", [ (IntSentinel(), 2), (ListSentinel(), [1, 2, 3]), (DictSentinel(), {2: 2}), (FloatSentinel(), 4.5), (StrSentinel(), "dood"), (NumberSentinel(), 2), (NumberSentinel(), 2.5), ], ) def test_type_token_matches(self, token, response): """Make sure type tokens match with generic types""" check_keys_match_recursive(token, response, []) @pytest.mark.parametrize( "token, response", [ (IntSentinel(), 2.3), (ListSentinel(), 1), (DictSentinel(), [4, 5, 6]), (FloatSentinel(), "4"), (StrSentinel(), {"a": 2}), (NumberSentinel(), "2"), (NumberSentinel(), [1]), (NumberSentinel(), {"val": 1}), (NumberSentinel(), True), ], ) def test_type_token_no_match_errors(self, token, response): """Make sure type tokens do not match if the type is wrong""" with pytest.raises(exceptions.KeyMismatchError): check_keys_match_recursive(token, response, []) class TestNonStrictListMatching: def test_match_list_items(self): """Should match any 2 list items if strict is False, not if it's True""" a = ["b"] b = ["a", "b", "c"] with pytest.raises(exceptions.KeyMismatchError): check_keys_match_recursive(a, b, []) check_keys_match_recursive(a, b, [], strict=False) def test_match_multiple(self): """As long as they are in the right order, it can match multiple items""" a = ["a", "c"] b = ["a", "b", "c"] with pytest.raises(exceptions.KeyMismatchError): check_keys_match_recursive(a, b, []) check_keys_match_recursive(a, b, [], strict=False) def test_match_multiple_wrong_order(self): """Raises an error if the expected items are in the wrong order""" a = ["c", "a"] b = ["a", "b", "c"] with pytest.raises(exceptions.KeyMismatchError): check_keys_match_recursive(a, b, []) with pytest.raises(exceptions.KeyMismatchError): check_keys_match_recursive(a, b, [], strict=False) def test_match_wrong_type(self): """Can't match incorrect type""" a = [1] b = ["1", "2", "3"] with pytest.raises(exceptions.KeyMismatchError): check_keys_match_recursive(a, b, []) with pytest.raises(exceptions.KeyMismatchError): check_keys_match_recursive(a, b, [], strict=False) def test_match_list_items_more_as(self): """One of them is present, the others aren't""" a = ["a", "b", "c"] b = ["a"] with pytest.raises(exceptions.KeyMismatchError): check_keys_match_recursive(a, b, []) with pytest.raises(exceptions.KeyMismatchError): check_keys_match_recursive(a, b, [], strict=False) @pytest.fixture(name="test_yaml") def fix_test_yaml(): text = dedent( """ --- test_name: Make sure server doubles number properly stages: - name: Make sure number is returned correctly request: url: http://localhost:5000/double json: is_sensitive: !bool "False" raw_str: !raw '{"query": "{ val1 { val2 { val3 { val4, val5 } } } }"}' number: !int '5' return_float: !bool "True" method: POST headers: content-type: application/json response: status_code: 200 json: double: !float 10 """ ) return text class TestCustomTokens: def assert_type_value(self, test_value, expected_type, expected_value): assert isinstance(test_value, expected_type) assert test_value == expected_value def test_conversion(self, test_yaml): stages = yaml.load(test_yaml, Loader=IncludeLoader)["stages"][0] self.assert_type_value(stages["request"]["json"]["number"], int, 5) self.assert_type_value(stages["response"]["json"]["double"], float, 10.0) self.assert_type_value(stages["request"]["json"]["return_float"], bool, True) self.assert_type_value(stages["request"]["json"]["is_sensitive"], bool, False) self.assert_type_value( stages["request"]["json"]["raw_str"], str, '{{"query": "{{ val1 {{ val2 {{ val3 {{ val4, val5 }} }} }} }}"}}', ) class TestFormatKeys: def test_format_missing_raises(self): to_format = {"a": "{b}"} with pytest.raises(exceptions.MissingFormatError): format_keys(to_format, {}) def test_format_success(self): to_format = {"a": "{b}"} final_value = "formatted" format_variables = {"b": final_value} assert format_keys(to_format, format_variables)["a"] == final_value def test_no_double_format_failure(self): to_format = "{{b}}" final_value = "{b}" format_variables = {"b": final_value} formatted = format_keys(to_format, format_variables) assert formatted == final_value formatted_2 = format_keys(formatted, {}) assert formatted_2 == final_value class TestRecurseAccess: @pytest.fixture def nested_data(self): data = {"a": ["b", {"c": "d"}]} return data @pytest.mark.parametrize( "old_query, new_query, expected_data", (("a.0", "a[0]", "b"), ("a.1.c", "a[1].c", "d")), ) def test_search_old_style(self, nested_data, old_query, new_query, expected_data): """Make sure old style searches perform the same as jmes queries""" with pytest.raises(exceptions.JMESError): recurse_access_key(nested_data, old_query) new_val = recurse_access_key(nested_data, new_query) assert new_val == expected_data @pytest.mark.parametrize("new_query", ("f", "a[3]", "a[1].x")) def test_missing_search(self, nested_data, new_query): """Searching for data not in given data returns None, because of the way the jmespath library works...""" assert recurse_access_key(nested_data, new_query) is None class TestLoadCfg: def test_load_one(self): example = {"a": "b"} with wrapfile(example) as f: assert example == load_single_document_yaml(f) def test_load_multiple_fails(self): example = [{"a": "b"}, {"c": "d"}] with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as wrapped_tmp: # put into a file dumped = yaml.dump_all(example) wrapped_tmp.write(dumped.encode("utf8")) wrapped_tmp.close() try: with pytest.raises(exceptions.UnexpectedDocumentsError): load_single_document_yaml(wrapped_tmp.name) finally: os.remove(wrapped_tmp.name) @pytest.mark.parametrize("value", ("b", "三木")) def test_load_utf8(self, value): """if yaml has utf8 char , may load error""" content = f"""a: {value}""" "" with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as f: f.write(content.encode("utf8")) try: load_single_document_yaml(f.name) finally: os.remove(f.name) class TestLoadFile: @staticmethod @contextlib.contextmanager def magic_wrap(to_wrap, suffix): with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as wrapped_tmp: dumped = yaml.dump(to_wrap, default_flow_style=False) wrapped_tmp.write(dumped.encode("utf8")) wrapped_tmp.close() try: yield wrapped_tmp.name finally: os.remove(wrapped_tmp.name) @pytest.mark.parametrize("suffix", (".yaml", ".yml", ".json")) def test_load_extensions(self, suffix): example = {"a": "b"} with TestLoadFile.magic_wrap(example, suffix) as tmpfile: with patch("tavern._core.loader.os.path.join", return_value=tmpfile): assert example == construct_include(Mock(), Mock()) def test_load_bad_extension(self): example = {"a": "b"} with TestLoadFile.magic_wrap(example, ".bllakjf") as tmpfile: with patch("tavern._core.loader.os.path.join", return_value=tmpfile): with pytest.raises(exceptions.BadSchemaError): construct_include(Mock(), Mock()) def test_include_path(self): example = {"a": "b"} with TestLoadFile.magic_wrap(example, ".yaml") as tmpfile: tmppath, tmpfilename = os.path.split(tmpfile) with pytest.raises(exceptions.BadSchemaError): construct_include( Mock( _root="/does-not-exist", construct_scalar=lambda x: tmpfilename ), Mock(), ) with patch("tavern._core.loader.IncludeLoader.env_path_list", None): assert example == construct_include( Mock(_root=tmppath, construct_scalar=lambda x: tmpfilename), Mock() ) os.environ[IncludeLoader.env_var_name] = tmppath with patch("tavern._core.loader.IncludeLoader.env_path_list", None): assert example == construct_include( Mock( _root="/does-not-exist", construct_scalar=lambda x: tmpfilename ), Mock(), ) tavern-3.6.0/tox-integration.ini000066400000000000000000000027511520710011500166500ustar00rootroot00000000000000[tox] envlist = py3-{generic,mqtt,grpc,http,graphql,noextra} [testenv] dependency_groups = dev allowlist_externals = docker passenv = DOCKER_TLS_VERIFY,DOCKER_HOST,DOCKER_CERT_PATH,DOCKER_BUILDKIT setenv = TEST_HOST = http://localhost:5003 SECOND_URL_PART = again PYTHONPATH = . changedir = grpc: example/grpc mqtt: example/mqtt http: example/http graphql: example/graphql generic: tests/integration noextra: tests/integration deps = flask allure-pytest pyjwt pytest-xdist pytest-cov colorlog mqtt: fluent-logger extras = mqtt: mqtt grpc: grpc ; regression test for https://github.com/taverntesting/tavern/issues/1016 http: mqtt graphql: graphql commands = ; docker compose stop ; docker compose build docker compose up --build -d python -m pytest --collect-only python -m pytest --tavern-global-cfg={toxinidir}/tests/integration/global_cfg.yaml --cov tavern {posargs} --tavern-setup-init-logging generic: py.test --tavern-global-cfg={toxinidir}/tests/integration/global_cfg.yaml -n 3 generic: tavern-ci --stdout . --tavern-global-cfg={toxinidir}/tests/integration/global_cfg.yaml generic: python -c "from tavern.core import run; exit(run('.', '{toxinidir}/tests/integration/global_cfg.yaml', pytest_args=[ ]))" generic: python -c "from tavern.core import run; exit(run('.', pytest_args=['--tavern-global-cfg={toxinidir}/tests/integration/global_cfg.yaml']))" docker compose stop tavern-3.6.0/tox.ini000066400000000000000000000005041520710011500143210ustar00rootroot00000000000000[tox] envlist = py3,py3check [testenv] passenv = XDG_CACHE_HOME extras = grpc mqtt graphql dependency_groups = dev commands = {envbindir}/python -m pytest --cov-report term-missing --cov tavern {posargs} [testenv:py3check] allowlist_externals = pre-commit commands = pre-commit run --all-files tavern-3.6.0/uv.lock000066400000000000000000023212661520710011500143270ustar00rootroot00000000000000version = 1 revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.13'", "python_full_version == '3.12.*'", "python_full_version < '3.12'", ] [manifest] members = [ "tavern", "tavern-graphql-example", "tavern-grpc-example", "tavern-http-example", "tavern-mqtt-example", ] constraints = [{ name = "ruamel-yaml", specifier = "<0.19.0" }] [[package]] name = "aiohappyeyeballs" version = "2.6.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, ] [[package]] name = "aiohttp" version = "3.13.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, { name = "aiosignal" }, { name = "attrs" }, { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, { name = "yarl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" }, { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" }, { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" }, { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" }, { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" }, { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" }, { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" }, { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" }, { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" }, { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" }, { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" }, { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" }, { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" }, { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" }, { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" }, { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" }, { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" }, { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, ] [[package]] name = "aiosignal" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] [[package]] name = "allure-pytest" version = "2.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "allure-python-commons" }, { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ea/31/7bde416fb98264e3e9a3025b176529c6c071d120a61be17fca4b100a4d7d/allure_pytest-2.16.0.tar.gz", hash = "sha256:3cc883595b1ce4280b0b9a5fdaa0ce3bb5cdbaa1b43c7269d8b82e825e6e107c", size = 17697, upload-time = "2026-04-27T08:34:40.254Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/19/41/7e37edaf081c01055afedb8236b6b6232e515745b7959342f801d9464834/allure_pytest-2.16.0-py3-none-any.whl", hash = "sha256:e5035a3b1f541b0c2ee566822df6fec20e0628b8536780e5bdfe39060ac0c71b", size = 12493, upload-time = "2026-04-27T08:34:39.37Z" }, ] [[package]] name = "allure-python-commons" version = "2.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "pluggy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d6/ad/9cd9020d194bb0fff783538096cb5cfae9d714d1f63a7c542c0e92fd822f/allure_python_commons-2.16.0.tar.gz", hash = "sha256:ecdc92bafea074bab96b5f2c4eb3100825340188f5aece608ae80eced709b36f", size = 15681, upload-time = "2026-04-27T08:34:31.138Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/53/78/ddd0affa881b1e09bc59f11ab829a7904f4bca29d25c34cbb9395e8e0061/allure_python_commons-2.16.0-py3-none-any.whl", hash = "sha256:6d42a500078aca8a2e68823075c1ffc2396987bb268d62b19af82390b205ce88", size = 16871, upload-time = "2026-04-27T08:34:29.828Z" }, ] [[package]] name = "annotated-doc" version = "0.0.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] [[package]] name = "annotated-types" version = "0.7.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] [[package]] name = "anyio" version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] name = "attrs" version = "26.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] name = "backoff" version = "2.2.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] [[package]] name = "blinker" version = "1.9.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, ] [[package]] name = "cachetools" version = "7.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/87/53/984d70974279207f676fbd525cbe7533b95da34d829f2adc0797a6860718/cachetools-7.1.2.tar.gz", hash = "sha256:c1373e3cad0933dfb46bb04d04ef67b5204f8220eb906096dd89a76196053d57", size = 39828, upload-time = "2026-05-16T19:59:03.565Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/70/9b/56cf24737a6756d8751659c8809a67c23b7b256a587bcb147a6d24fddea3/cachetools-7.1.2-py3-none-any.whl", hash = "sha256:89386be5bece29963e0f22bb7e1aba91c8395c7ad107780e2ce7af3ab315ae40", size = 16805, upload-time = "2026-05-16T19:59:01.927Z" }, ] [[package]] name = "certifi" version = "2026.4.22" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] [[package]] name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] [[package]] name = "cfgv" version = "3.5.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] name = "cli-ui" version = "0.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama" }, { name = "tabulate" }, { name = "unidecode" }, ] sdist = { url = "https://files.pythonhosted.org/packages/21/63/70d8fefa7b4140367c45287b94fb5df535b6ba6f77464087b18fdae2bb47/cli_ui-0.19.0.tar.gz", hash = "sha256:59cdab0c6a2a6703c61b31cb75a1943076888907f015fffe15c5a8eb41a933aa", size = 12808, upload-time = "2025-03-29T20:24:37.486Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c6/a9/09591cff626f71bd11f88d8eedb81619c17422b4a5e778614a91e83c4b6a/cli_ui-0.19.0-py3-none-any.whl", hash = "sha256:1cf1b93328f7377730db29507e10bcb29ccc1427ceef45714b522d1f2055e7cd", size = 13492, upload-time = "2025-03-29T20:24:35.841Z" }, ] [[package]] name = "click" version = "8.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "colorlog" version = "6.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a2/61/f083b5ac52e505dfc1c624eafbf8c7589a0d7f32daa398d2e7590efa5fda/colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321", size = 17162, upload-time = "2025-10-16T16:14:11.978Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6d/c1/e419ef3723a074172b68aaa89c9f3de486ed4c2399e2dbd8113a4fdcaf9e/colorlog-6.10.1-py3-none-any.whl", hash = "sha256:2d7e8348291948af66122cff006c9f8da6255d224e7cf8e37d8de2df3bad8c9c", size = 11743, upload-time = "2025-10-16T16:14:10.512Z" }, ] [[package]] name = "coverage" version = "7.14.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fc/e4/649c8d4f7f1709b6dbfc474358aa1bba02f67bcd52e2fec291a5014006cd/coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480", size = 219795, upload-time = "2026-05-10T17:59:48.198Z" }, { url = "https://files.pythonhosted.org/packages/7f/8d/46692d24b3f395d4cbf17bfcc57136b4f2f9c0c0df864b0bddfc1d71a014/coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4", size = 220299, upload-time = "2026-05-10T17:59:49.683Z" }, { url = "https://files.pythonhosted.org/packages/12/c2/a40f5cb295bbcbb697a76947a56081c494c61950366294ee426ffe261099/coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7", size = 250721, upload-time = "2026-05-10T17:59:51.494Z" }, { url = "https://files.pythonhosted.org/packages/fd/35/202235eb5c3c14c212462cd91d61b7386bf8fc44bc7a77f4742d2a69174b/coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed", size = 252633, upload-time = "2026-05-10T17:59:53.244Z" }, { url = "https://files.pythonhosted.org/packages/bb/80/5f596e8995785124ee191c42535664c5e62c65995b66f4ca21e28ae04c81/coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980", size = 254743, upload-time = "2026-05-10T17:59:55.021Z" }, { url = "https://files.pythonhosted.org/packages/1e/6d/0d178825be2350f0adb27984d0aa7cf84bbdab201f6fb926b535d23a8f5f/coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0", size = 256700, upload-time = "2026-05-10T17:59:56.511Z" }, { url = "https://files.pythonhosted.org/packages/19/5b/9e549c2f6e9dfea472adadba06c294e64735dabc2dd19015fac082095013/coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742", size = 250854, upload-time = "2026-05-10T17:59:57.94Z" }, { url = "https://files.pythonhosted.org/packages/3d/1c/b94f9f5f36396021ee2f62c5834b12e6a3d31f0bed5d6fc6d1c3caec087c/coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5", size = 252433, upload-time = "2026-05-10T17:59:59.688Z" }, { url = "https://files.pythonhosted.org/packages/b5/cb/d192cd8e1345eccabc32016f2d39072ecd10cb4f4b983ed8d0ebdeaf00dc/coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327", size = 250494, upload-time = "2026-05-10T18:00:01.953Z" }, { url = "https://files.pythonhosted.org/packages/53/c5/aac9f460a41d835dbddef1d377f105f6ac2311d0f3c1588e9f51046d8813/coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d", size = 254261, upload-time = "2026-05-10T18:00:03.779Z" }, { url = "https://files.pythonhosted.org/packages/23/aa/7af7c0081980a9cb3d289c5a435a4b7657dcecbd128e25c580e6a50389b5/coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20", size = 250216, upload-time = "2026-05-10T18:00:05.262Z" }, { url = "https://files.pythonhosted.org/packages/35/60/a4257538ce2f6b978aeb51870d6c4208c510928a03db7e0339bb625dccb7/coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c", size = 251125, upload-time = "2026-05-10T18:00:06.858Z" }, { url = "https://files.pythonhosted.org/packages/a1/ab/f91af47642ec1aa53490e835a95847168d9c77fc39aa58527604c051e145/coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3", size = 222300, upload-time = "2026-05-10T18:00:08.608Z" }, { url = "https://files.pythonhosted.org/packages/f0/f0/a71ddbd874431e7a7cd96071f0c331cfbbad07704833c765d24ffbab8a67/coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1", size = 223241, upload-time = "2026-05-10T18:00:10.746Z" }, { url = "https://files.pythonhosted.org/packages/d8/6e/d9d312a5151a96cd110efee32efc3fc97b01ebd86203fe618ccb29cf4c92/coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627", size = 221908, upload-time = "2026-05-10T18:00:12.242Z" }, { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" }, { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" }, { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" }, { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" }, { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" }, { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" }, { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" }, { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" }, { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" }, { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" }, { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" }, { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" }, { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" }, { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" }, { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" }, { url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" }, { url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" }, { url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" }, { url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" }, { url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" }, { url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" }, { url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" }, { url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" }, { url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" }, { url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" }, { url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" }, { url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" }, { url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" }, { url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" }, { url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" }, { url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" }, { url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" }, { url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" }, { url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" }, { url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" }, { url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" }, { url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" }, { url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" }, { url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" }, { url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" }, { url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" }, { url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" }, { url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" }, { url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" }, { url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" }, { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, ] [package.optional-dependencies] toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] name = "cross-web" version = "0.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ad/83/b5ef04565acc065387dda3a4fbf0c4cfb6bab805c81b66b2bc5b5ac9a282/cross_web-0.6.0.tar.gz", hash = "sha256:ae90570802615365ca1a781117b43bfd0d6cd3bf611649d24c3a206a82a693c9", size = 331315, upload-time = "2026-04-13T14:29:12.718Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/35/a2/dab06d9b80cb76c700883186a9a2e6fd103342c9b4def4d88f5787796e17/cross_web-0.6.0-py3-none-any.whl", hash = "sha256:bdebf0c08d02f3a48cf67b6904d3a6d8fd8cab2cd905592ab96ab00b259cd582", size = 24820, upload-time = "2026-04-13T14:29:11.198Z" }, ] [[package]] name = "cryptography" version = "48.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, { url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" }, { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, ] [[package]] name = "distlib" version = "0.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] [[package]] name = "docopt" version = "0.6.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a2/55/8f8cab2afd404cf578136ef2cc5dfb50baa1761b68c9da1fb1e4eed343c9/docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491", size = 25901, upload-time = "2014-06-16T11:18:57.406Z" } [[package]] name = "docutils" version = "0.22.4" source = { registry = "https://pypi.org/simple" } 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.13'" }, ] 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 = "execnet" version = "2.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] [[package]] name = "faker" version = "40.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/06/70886e82d8f1d2b73454f3a7c1b7405300128df22e70d85a828951366932/faker-40.18.0.tar.gz", hash = "sha256:2207575c0e8f90e6ccd6dbef764de875c614d16d3db4eee9712d9a00087f2e70", size = 1968243, upload-time = "2026-05-14T16:43:04.834Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/84/0b/5c0b2d3a4b7a715f1835dd3f963bfbe841a02ae5cad1df8ee0325dfad235/faker-40.18.0-py3-none-any.whl", hash = "sha256:61a6b94b74605ddb090a065deb197a1c585ae7a874c094cf6693671d271e6083", size = 2006355, upload-time = "2026-05-14T16:43:02.489Z" }, ] [[package]] name = "fastapi" version = "0.136.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, ] [[package]] name = "filelock" version = "3.29.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, ] [[package]] name = "flask" version = "3.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "blinker" }, { name = "click" }, { name = "itsdangerous" }, { name = "jinja2" }, { name = "markupsafe" }, { name = "werkzeug" }, ] sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, ] [[package]] name = "flask-httpauth" version = "4.8.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "flask" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ec/f4/6957215e827021eeb7d8de9f59b1864d73933b04851e59272708cb6e5d2b/flask_httpauth-4.8.1.tar.gz", hash = "sha256:88499b22f1353893743c3cd68f2ca561c4ad9ef75cd6bcc7f621161cd0e80744", size = 38993, upload-time = "2026-03-28T19:45:24.254Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/72/da/624c87bf6c13107ceab8ee23815d9468e47d89c7480c1dc9af39b08eb290/flask_httpauth-4.8.1-py3-none-any.whl", hash = "sha256:0080393d70e12327781f7509115175ec5e47209816489a620d4fd39e20cea2e8", size = 9651, upload-time = "2026-03-28T19:45:23.155Z" }, ] [[package]] name = "flit" version = "3.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils" }, { name = "flit-core" }, { name = "pip" }, { name = "requests" }, { name = "tomli-w" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/9c/0608c91a5b6c013c63548515ae31cff6399cd9ce891bd9daee8c103da09b/flit-3.12.0.tar.gz", hash = "sha256:1c80f34dd96992e7758b40423d2809f48f640ca285d0b7821825e50745ec3740", size = 155038, upload-time = "2025-03-25T08:03:22.505Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f5/82/ce1d3bb380b227e26e517655d1de7b32a72aad61fa21ff9bd91a2e2db6ee/flit-3.12.0-py3-none-any.whl", hash = "sha256:2b4e7171dc22881fa6adc2dbf083e5ecc72520be3cd7587d2a803da94d6ef431", size = 50657, upload-time = "2025-03-25T08:03:19.031Z" }, ] [[package]] name = "flit-core" version = "3.12.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/69/59/b6fc2188dfc7ea4f936cd12b49d707f66a1cb7a1d2c16172963534db741b/flit_core-3.12.0.tar.gz", hash = "sha256:18f63100d6f94385c6ed57a72073443e1a71a4acb4339491615d0f16d6ff01b2", size = 53690, upload-time = "2025-03-25T08:03:23.969Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f2/65/b6ba90634c984a4fcc02c7e3afe523fef500c4980fec67cc27536ee50acf/flit_core-3.12.0-py3-none-any.whl", hash = "sha256:e7a0304069ea895172e3c7bb703292e992c5d1555dd1233ab7b5621b5b69e62c", size = 45594, upload-time = "2025-03-25T08:03:20.772Z" }, ] [[package]] name = "fluent-logger" version = "0.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "msgpack" }, ] sdist = { url = "https://files.pythonhosted.org/packages/80/c3/28b1522cc6b17a8b6bd9551e4a81c111004cf48eaf4fd6a7c644cf3dc3e8/fluent_logger-0.11.1.tar.gz", hash = "sha256:6727525ba08671b758e3ac222f36fa3345bc1a77b81e7ddbc045ced68f44cd77", size = 58650, upload-time = "2024-06-06T15:17:37.199Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b0/82/4faf651e942897790b1bbba156e61ab4d84a7378e8f64d0e6e2d8538b0d5/fluent_logger-0.11.1-py3-none-any.whl", hash = "sha256:1601c4b929a93b4aef03310573387ac808cf4463cdeaf619c39b257da34c0c0c", size = 12640, upload-time = "2024-06-06T15:17:11.7Z" }, ] [[package]] name = "frozenlist" version = "1.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] [[package]] name = "google-api-core" version = "2.30.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-auth" }, { name = "googleapis-common-protos" }, { name = "proto-plus" }, { name = "protobuf" }, { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/16/ce/502a57fb0ec752026d24df1280b162294b22a0afb98a326084f9a979138b/google_api_core-2.30.3.tar.gz", hash = "sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b", size = 177001, upload-time = "2026-04-10T00:41:28.035Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/03/15/e56f351cf6ef1cfea58e6ac226a7318ed1deb2218c4b3cc9bd9e4b786c5a/google_api_core-2.30.3-py3-none-any.whl", hash = "sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8", size = 173274, upload-time = "2026-04-09T22:57:16.198Z" }, ] [[package]] name = "google-api-python-client" version = "2.196.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, { name = "google-auth" }, { name = "google-auth-httplib2" }, { name = "httplib2" }, { name = "uritemplate" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6d/f3/34ef8aca7909675fe327f96c1ed927f0520e7acf68af19157e96acc05e76/google_api_python_client-2.196.0.tar.gz", hash = "sha256:9f335d38f6caaa2747bcf64335ed1a9a19047d53e86538eda6a1b17d37f1743d", size = 14628129, upload-time = "2026-05-06T23:47:35.655Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/99/c7/1817b4edf966d5afcac1c0781ca36d621bc0cb58104c4e7c2a475ab185f7/google_api_python_client-2.196.0-py3-none-any.whl", hash = "sha256:2591e9b47dcb17e4e62a09370aaee3bcf323af8f28ccecdabcd0a42a23ca4db5", size = 15206663, upload-time = "2026-05-06T23:47:32.886Z" }, ] [[package]] name = "google-auth" version = "2.53.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c6/ad/ff781329bbbdc0974a098d996e89c9e1f7024262f9e3eec442fbb9ad1ac6/google_auth-2.53.0.tar.gz", hash = "sha256:e7e6aa16f6bee7b2b264830fd04f08087a1d5a836df516251a5d15327b246c9c", size = 335844, upload-time = "2026-05-15T20:53:07.928Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4a/c9/db44165ba7c581268c6d46017ef63339110378305062830104fc7fa144cb/google_auth-2.53.0-py3-none-any.whl", hash = "sha256:6e7449917c599b35126a99ec268ec6880301f2fea41dce198fe8fd83ff642b68", size = 246071, upload-time = "2026-05-15T20:53:05.609Z" }, ] [[package]] name = "google-auth-httplib2" version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-auth" }, { name = "httplib2" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/b3/f192c8bc7e41e0ebdbd95afcae4783417a34b6a6af62d22daf22c3fd38fc/google_auth_httplib2-0.4.0.tar.gz", hash = "sha256:d5b030a204b7a4b4d553ba9ca701b62481ee2b74419325580be70f7d85ffed35", size = 11161, upload-time = "2026-05-07T08:03:46.878Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/97/be/954c35a62b9e31de66b0a43c225c9b6bb9e0f98d6b1dc110a2308e3644f5/google_auth_httplib2-0.4.0-py3-none-any.whl", hash = "sha256:8e55cfafa3358cba85f6cad4a886138e88e158d71e7e5c9ee5936a5c1507fb91", size = 9529, upload-time = "2026-05-07T08:02:12.375Z" }, ] [[package]] name = "googleapis-common-protos" version = "1.75.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, ] [[package]] name = "gql" version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "backoff" }, { name = "graphql-core" }, { name = "yarl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/9f/cf224a88ed71eb223b7aa0b9ff0aa10d7ecc9a4acdca2279eb046c26d5dc/gql-4.0.0.tar.gz", hash = "sha256:f22980844eb6a7c0266ffc70f111b9c7e7c7c13da38c3b439afc7eab3d7c9c8e", size = 215644, upload-time = "2025-08-17T14:32:35.397Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ac/94/30bbd09e8d45339fa77a48f5778d74d47e9242c11b3cd1093b3d994770a5/gql-4.0.0-py3-none-any.whl", hash = "sha256:f3beed7c531218eb24d97cb7df031b4a84fdb462f4a2beb86e2633d395937479", size = 89900, upload-time = "2025-08-17T14:32:34.029Z" }, ] [[package]] name = "graphql-core" version = "3.2.8" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/68/c5/36aa96205c3ecbb3d34c7c24189e4553c7ca2ebc7e1dd07432339b980272/graphql_core-3.2.8.tar.gz", hash = "sha256:015457da5d996c924ddf57a43f4e959b0b94fb695b85ed4c29446e508ed65cf3", size = 513181, upload-time = "2026-03-05T19:55:37.332Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/86/41/cb887d9afc5dabd78feefe6ccbaf83ff423c206a7a1b7aeeac05120b2125/graphql_core-3.2.8-py3-none-any.whl", hash = "sha256:cbee07bee1b3ed5e531723685369039f32ff815ef60166686e0162f540f1520c", size = 207349, upload-time = "2026-03-05T19:55:35.911Z" }, ] [[package]] name = "greenlet" version = "3.5.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8b/0f/a91f143f356523ff682309732b175765a9bc2836fd7c081c2c67fedc1ad4/greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082", size = 284726, upload-time = "2026-04-27T12:20:51.402Z" }, { url = "https://files.pythonhosted.org/packages/95/82/800646c7ffc5dbabd75ddd2f6b519bb898c0c9c969e5d0473bfe5d20bcce/greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3", size = 604264, upload-time = "2026-04-27T12:52:39.494Z" }, { url = "https://files.pythonhosted.org/packages/ca/ac/354867c0bba812fc33b15bc55aedafedd0aee3c7dd91dfca22444157dc0c/greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c", size = 616099, upload-time = "2026-04-27T12:59:39.623Z" }, { url = "https://files.pythonhosted.org/packages/c9/ab/192090c4a5b30df148c22bf4b8895457d739a7c7c5a7b9c41e5dd7f537f2/greenlet-3.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564", size = 623976, upload-time = "2026-04-27T13:02:37.363Z" }, { url = "https://files.pythonhosted.org/packages/ff/b0/815bece7399e01cadb69014219eebd0042339875c59a59b0820a46ece356/greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662", size = 615198, upload-time = "2026-04-27T12:25:25.928Z" }, { url = "https://files.pythonhosted.org/packages/24/11/05eb2b9b188c6df7d68a89c99134d644a7af616a40b9808e8e6ced315d5d/greenlet-3.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc", size = 418379, upload-time = "2026-04-27T13:05:12.755Z" }, { url = "https://files.pythonhosted.org/packages/10/80/3b2c0a895d6698f6ddb31b07942ebfa982f3e30888bc5546a5b5990de8b2/greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b", size = 1574927, upload-time = "2026-04-27T12:53:25.81Z" }, { url = "https://files.pythonhosted.org/packages/44/0e/f354af514a4c61454dbc68e44d47544a5a4d6317e30b77ddfa3a09f4c5f3/greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4", size = 1642683, upload-time = "2026-04-27T12:25:23.9Z" }, { url = "https://files.pythonhosted.org/packages/fa/6a/87f38255201e993a1915265ebb80cd7c2c78b04a45744995abbf6b259fd8/greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8", size = 238115, upload-time = "2026-04-27T12:21:48.845Z" }, { url = "https://files.pythonhosted.org/packages/e3/f8/450fe3c5938fa737ea4d22699772e6e34e8e24431a47bf4e8a1ceed4a98e/greenlet-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339", size = 235017, upload-time = "2026-04-27T12:22:26.768Z" }, { url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" }, { url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" }, { url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" }, { url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" }, { url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" }, { url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" }, { url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" }, { url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" }, { url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" }, { url = "https://files.pythonhosted.org/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615, upload-time = "2026-04-27T12:21:38.57Z" }, { url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" }, { url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" }, { url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" }, { url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" }, { url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" }, { url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" }, { url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" }, { url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" }, { url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" }, { url = "https://files.pythonhosted.org/packages/b6/b7/9c5c3d653bd4ff614277c049ac676422e2c557db47b4fe43e6313fc005dc/greenlet-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b", size = 235525, upload-time = "2026-04-27T12:23:12.308Z" }, { url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" }, { url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" }, { url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" }, { url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" }, { url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" }, { url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" }, { url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" }, { url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" }, { url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" }, { url = "https://files.pythonhosted.org/packages/4e/62/1c498375cee177b55d980c1db319f26470e5309e54698c8f8fc06c0fd539/greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988", size = 236862, upload-time = "2026-04-27T12:23:24.957Z" }, { url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" }, { url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" }, { url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" }, { url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" }, { url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" }, { url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" }, { url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" }, { url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" }, { url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" }, ] [[package]] name = "grpc-interceptor" version = "0.15.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "grpcio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/28/57449d5567adf4c1d3e216aaca545913fbc21a915f2da6790d6734aac76e/grpc-interceptor-0.15.4.tar.gz", hash = "sha256:1f45c0bcb58b6f332f37c637632247c9b02bc6af0fdceb7ba7ce8d2ebbfb0926", size = 19322, upload-time = "2023-11-16T02:05:42.459Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/15/ac/8d53f230a7443401ce81791ec50a3b0e54924bf615ad287654fa4a2f5cdc/grpc_interceptor-0.15.4-py3-none-any.whl", hash = "sha256:0035f33228693ed3767ee49d937bac424318db173fef4d2d0170b3215f254d9d", size = 20848, upload-time = "2023-11-16T02:05:40.913Z" }, ] [[package]] name = "grpcio" version = "1.80.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5d/db/1d56e5f5823257b291962d6c0ce106146c6447f405b60b234c4f222a7cde/grpcio-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a", size = 6055009, upload-time = "2026-03-30T08:46:46.265Z" }, { url = "https://files.pythonhosted.org/packages/6e/18/c83f3cad64c5ca63bca7e91e5e46b0d026afc5af9d0a9972472ceba294b3/grpcio-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060", size = 12035295, upload-time = "2026-03-30T08:46:49.099Z" }, { url = "https://files.pythonhosted.org/packages/0f/8e/e14966b435be2dda99fbe89db9525ea436edc79780431a1c2875a3582644/grpcio-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2", size = 6610297, upload-time = "2026-03-30T08:46:52.123Z" }, { url = "https://files.pythonhosted.org/packages/cc/26/d5eb38f42ce0e3fdc8174ea4d52036ef8d58cc4426cb800f2610f625dd75/grpcio-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21", size = 7300208, upload-time = "2026-03-30T08:46:54.859Z" }, { url = "https://files.pythonhosted.org/packages/25/51/bd267c989f85a17a5b3eea65a6feb4ff672af41ca614e5a0279cc0ea381c/grpcio-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab", size = 6813442, upload-time = "2026-03-30T08:46:57.056Z" }, { url = "https://files.pythonhosted.org/packages/9e/d9/d80eef735b19e9169e30164bbf889b46f9df9127598a83d174eb13a48b26/grpcio-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", size = 7414743, upload-time = "2026-03-30T08:46:59.682Z" }, { url = "https://files.pythonhosted.org/packages/de/f2/567f5bd5054398ed6b0509b9a30900376dcf2786bd936812098808b49d8d/grpcio-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106", size = 8426046, upload-time = "2026-03-30T08:47:02.474Z" }, { url = "https://files.pythonhosted.org/packages/62/29/73ef0141b4732ff5eacd68430ff2512a65c004696997f70476a83e548e7e/grpcio-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6", size = 7851641, upload-time = "2026-03-30T08:47:05.462Z" }, { url = "https://files.pythonhosted.org/packages/46/69/abbfa360eb229a8623bab5f5a4f8105e445bd38ce81a89514ba55d281ad0/grpcio-1.80.0-cp311-cp311-win32.whl", hash = "sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440", size = 4154368, upload-time = "2026-03-30T08:47:08.027Z" }, { url = "https://files.pythonhosted.org/packages/6f/d4/ae92206d01183b08613e846076115f5ac5991bae358d2a749fa864da5699/grpcio-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9", size = 4894235, upload-time = "2026-03-30T08:47:10.839Z" }, { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" }, { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" }, { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" }, { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" }, { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" }, { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" }, { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" }, { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" }, { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" }, { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" }, { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, ] [[package]] name = "grpcio-reflection" version = "1.71.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "grpcio" }, { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/41/14/4e5f8e902fa9461abae292773b921a578f68333c7c3e731bcff7514f78cd/grpcio_reflection-1.71.2.tar.gz", hash = "sha256:bedfac3d2095d6c066b16b66bfce85b4be3e92dc9f3b7121e6f019d24a9c09c0", size = 18798, upload-time = "2025-06-28T04:24:06.019Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a3/89/c99ff79b90315cf47dbcdd86babb637764e5f14f523d622020bfee57dc4d/grpcio_reflection-1.71.2-py3-none-any.whl", hash = "sha256:c4f1a0959acb94ec9e1369bb7dab827cc9a6efcc448bdb10436246c8e52e2f57", size = 22684, upload-time = "2025-06-28T04:23:44.759Z" }, ] [[package]] name = "grpcio-status" version = "1.71.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, { name = "grpcio" }, { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fd/d1/b6e9877fedae3add1afdeae1f89d1927d296da9cf977eca0eb08fb8a460e/grpcio_status-1.71.2.tar.gz", hash = "sha256:c7a97e176df71cdc2c179cd1847d7fc86cca5832ad12e9798d7fed6b7a1aab50", size = 13677, upload-time = "2025-06-28T04:24:05.426Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/67/58/317b0134129b556a93a3b0afe00ee675b5657f0155509e22fcb853bafe2d/grpcio_status-1.71.2-py3-none-any.whl", hash = "sha256:803c98cb6a8b7dc6dbb785b1111aed739f241ab5e9da0bba96888aa74704cfd3", size = 14424, upload-time = "2025-06-28T04:23:42.136Z" }, ] [[package]] name = "grpcio-tools" version = "1.71.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "grpcio" }, { name = "protobuf" }, { name = "setuptools" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ad/9a/edfefb47f11ef6b0f39eea4d8f022c5bb05ac1d14fcc7058e84a51305b73/grpcio_tools-1.71.2.tar.gz", hash = "sha256:b5304d65c7569b21270b568e404a5a843cf027c66552a6a0978b23f137679c09", size = 5330655, upload-time = "2025-06-28T04:22:00.308Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/17/e4/0568d38b8da6237ea8ea15abb960fb7ab83eb7bb51e0ea5926dab3d865b1/grpcio_tools-1.71.2-cp311-cp311-linux_armv7l.whl", hash = "sha256:0acb8151ea866be5b35233877fbee6445c36644c0aa77e230c9d1b46bf34b18b", size = 2385557, upload-time = "2025-06-28T04:20:54.323Z" }, { url = "https://files.pythonhosted.org/packages/76/fb/700d46f72b0f636cf0e625f3c18a4f74543ff127471377e49a071f64f1e7/grpcio_tools-1.71.2-cp311-cp311-macosx_10_14_universal2.whl", hash = "sha256:b28f8606f4123edb4e6da281547465d6e449e89f0c943c376d1732dc65e6d8b3", size = 5447590, upload-time = "2025-06-28T04:20:55.836Z" }, { url = "https://files.pythonhosted.org/packages/12/69/d9bb2aec3de305162b23c5c884b9f79b1a195d42b1e6dabcc084cc9d0804/grpcio_tools-1.71.2-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:cbae6f849ad2d1f5e26cd55448b9828e678cb947fa32c8729d01998238266a6a", size = 2348495, upload-time = "2025-06-28T04:20:57.33Z" }, { url = "https://files.pythonhosted.org/packages/d5/83/f840aba1690461b65330efbca96170893ee02fae66651bcc75f28b33a46c/grpcio_tools-1.71.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4d1027615cfb1e9b1f31f2f384251c847d68c2f3e025697e5f5c72e26ed1316", size = 2742333, upload-time = "2025-06-28T04:20:59.051Z" }, { url = "https://files.pythonhosted.org/packages/30/34/c02cd9b37de26045190ba665ee6ab8597d47f033d098968f812d253bbf8c/grpcio_tools-1.71.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9bac95662dc69338edb9eb727cc3dd92342131b84b12b3e8ec6abe973d4cbf1b", size = 2473490, upload-time = "2025-06-28T04:21:00.614Z" }, { url = "https://files.pythonhosted.org/packages/4d/c7/375718ae091c8f5776828ce97bdcb014ca26244296f8b7f70af1a803ed2f/grpcio_tools-1.71.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c50250c7248055040f89eb29ecad39d3a260a4b6d3696af1575945f7a8d5dcdc", size = 2850333, upload-time = "2025-06-28T04:21:01.95Z" }, { url = "https://files.pythonhosted.org/packages/19/37/efc69345bd92a73b2bc80f4f9e53d42dfdc234b2491ae58c87da20ca0ea5/grpcio_tools-1.71.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6ab1ad955e69027ef12ace4d700c5fc36341bdc2f420e87881e9d6d02af3d7b8", size = 3300748, upload-time = "2025-06-28T04:21:03.451Z" }, { url = "https://files.pythonhosted.org/packages/d2/1f/15f787eb25ae42086f55ed3e4260e85f385921c788debf0f7583b34446e3/grpcio_tools-1.71.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dd75dde575781262b6b96cc6d0b2ac6002b2f50882bf5e06713f1bf364ee6e09", size = 2913178, upload-time = "2025-06-28T04:21:04.879Z" }, { url = "https://files.pythonhosted.org/packages/12/aa/69cb3a9dff7d143a05e4021c3c9b5cde07aacb8eb1c892b7c5b9fb4973e3/grpcio_tools-1.71.2-cp311-cp311-win32.whl", hash = "sha256:9a3cb244d2bfe0d187f858c5408d17cb0e76ca60ec9a274c8fd94cc81457c7fc", size = 946256, upload-time = "2025-06-28T04:21:06.518Z" }, { url = "https://files.pythonhosted.org/packages/1e/df/fb951c5c87eadb507a832243942e56e67d50d7667b0e5324616ffd51b845/grpcio_tools-1.71.2-cp311-cp311-win_amd64.whl", hash = "sha256:00eb909997fd359a39b789342b476cbe291f4dd9c01ae9887a474f35972a257e", size = 1117661, upload-time = "2025-06-28T04:21:08.18Z" }, { url = "https://files.pythonhosted.org/packages/9c/d3/3ed30a9c5b2424627b4b8411e2cd6a1a3f997d3812dbc6a8630a78bcfe26/grpcio_tools-1.71.2-cp312-cp312-linux_armv7l.whl", hash = "sha256:bfc0b5d289e383bc7d317f0e64c9dfb59dc4bef078ecd23afa1a816358fb1473", size = 2385479, upload-time = "2025-06-28T04:21:10.413Z" }, { url = "https://files.pythonhosted.org/packages/54/61/e0b7295456c7e21ef777eae60403c06835160c8d0e1e58ebfc7d024c51d3/grpcio_tools-1.71.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b4669827716355fa913b1376b1b985855d5cfdb63443f8d18faf210180199006", size = 5431521, upload-time = "2025-06-28T04:21:12.261Z" }, { url = "https://files.pythonhosted.org/packages/75/d7/7bcad6bcc5f5b7fab53e6bce5db87041f38ef3e740b1ec2d8c49534fa286/grpcio_tools-1.71.2-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:d4071f9b44564e3f75cdf0f05b10b3e8c7ea0ca5220acbf4dc50b148552eef2f", size = 2350289, upload-time = "2025-06-28T04:21:13.625Z" }, { url = "https://files.pythonhosted.org/packages/b2/8a/e4c1c4cb8c9ff7f50b7b2bba94abe8d1e98ea05f52a5db476e7f1c1a3c70/grpcio_tools-1.71.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a28eda8137d587eb30081384c256f5e5de7feda34776f89848b846da64e4be35", size = 2743321, upload-time = "2025-06-28T04:21:15.007Z" }, { url = "https://files.pythonhosted.org/packages/fd/aa/95bc77fda5c2d56fb4a318c1b22bdba8914d5d84602525c99047114de531/grpcio_tools-1.71.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b19c083198f5eb15cc69c0a2f2c415540cbc636bfe76cea268e5894f34023b40", size = 2474005, upload-time = "2025-06-28T04:21:16.443Z" }, { url = "https://files.pythonhosted.org/packages/c9/ff/ca11f930fe1daa799ee0ce1ac9630d58a3a3deed3dd2f465edb9a32f299d/grpcio_tools-1.71.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:784c284acda0d925052be19053d35afbf78300f4d025836d424cf632404f676a", size = 2851559, upload-time = "2025-06-28T04:21:18.139Z" }, { url = "https://files.pythonhosted.org/packages/64/10/c6fc97914c7e19c9bb061722e55052fa3f575165da9f6510e2038d6e8643/grpcio_tools-1.71.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:381e684d29a5d052194e095546eef067201f5af30fd99b07b5d94766f44bf1ae", size = 3300622, upload-time = "2025-06-28T04:21:20.291Z" }, { url = "https://files.pythonhosted.org/packages/e5/d6/965f36cfc367c276799b730d5dd1311b90a54a33726e561393b808339b04/grpcio_tools-1.71.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3e4b4801fabd0427fc61d50d09588a01b1cfab0ec5e8a5f5d515fbdd0891fd11", size = 2913863, upload-time = "2025-06-28T04:21:22.196Z" }, { url = "https://files.pythonhosted.org/packages/8d/f0/c05d5c3d0c1d79ac87df964e9d36f1e3a77b60d948af65bec35d3e5c75a3/grpcio_tools-1.71.2-cp312-cp312-win32.whl", hash = "sha256:84ad86332c44572305138eafa4cc30040c9a5e81826993eae8227863b700b490", size = 945744, upload-time = "2025-06-28T04:21:23.463Z" }, { url = "https://files.pythonhosted.org/packages/e2/e9/c84c1078f0b7af7d8a40f5214a9bdd8d2a567ad6c09975e6e2613a08d29d/grpcio_tools-1.71.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e1108d37eecc73b1c4a27350a6ed921b5dda25091700c1da17cfe30761cd462", size = 1117695, upload-time = "2025-06-28T04:21:25.22Z" }, { url = "https://files.pythonhosted.org/packages/60/9c/bdf9c5055a1ad0a09123402d73ecad3629f75b9cf97828d547173b328891/grpcio_tools-1.71.2-cp313-cp313-linux_armv7l.whl", hash = "sha256:b0f0a8611614949c906e25c225e3360551b488d10a366c96d89856bcef09f729", size = 2384758, upload-time = "2025-06-28T04:21:26.712Z" }, { url = "https://files.pythonhosted.org/packages/49/d0/6aaee4940a8fb8269c13719f56d69c8d39569bee272924086aef81616d4a/grpcio_tools-1.71.2-cp313-cp313-macosx_10_14_universal2.whl", hash = "sha256:7931783ea7ac42ac57f94c5047d00a504f72fbd96118bf7df911bb0e0435fc0f", size = 5443127, upload-time = "2025-06-28T04:21:28.383Z" }, { url = "https://files.pythonhosted.org/packages/d9/11/50a471dcf301b89c0ed5ab92c533baced5bd8f796abfd133bbfadf6b60e5/grpcio_tools-1.71.2-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:d188dc28e069aa96bb48cb11b1338e47ebdf2e2306afa58a8162cc210172d7a8", size = 2349627, upload-time = "2025-06-28T04:21:30.254Z" }, { url = "https://files.pythonhosted.org/packages/bb/66/e3dc58362a9c4c2fbe98a7ceb7e252385777ebb2bbc7f42d5ab138d07ace/grpcio_tools-1.71.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f36c4b3cc42ad6ef67430639174aaf4a862d236c03c4552c4521501422bfaa26", size = 2742932, upload-time = "2025-06-28T04:21:32.325Z" }, { url = "https://files.pythonhosted.org/packages/b7/1e/1e07a07ed8651a2aa9f56095411198385a04a628beba796f36d98a5a03ec/grpcio_tools-1.71.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4bd9ed12ce93b310f0cef304176049d0bc3b9f825e9c8c6a23e35867fed6affd", size = 2473627, upload-time = "2025-06-28T04:21:33.752Z" }, { url = "https://files.pythonhosted.org/packages/d3/f9/3b7b32e4acb419f3a0b4d381bc114fe6cd48e3b778e81273fc9e4748caad/grpcio_tools-1.71.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7ce27e76dd61011182d39abca38bae55d8a277e9b7fe30f6d5466255baccb579", size = 2850879, upload-time = "2025-06-28T04:21:35.241Z" }, { url = "https://files.pythonhosted.org/packages/1e/99/cd9e1acd84315ce05ad1fcdfabf73b7df43807cf00c3b781db372d92b899/grpcio_tools-1.71.2-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:dcc17bf59b85c3676818f2219deacac0156492f32ca165e048427d2d3e6e1157", size = 3300216, upload-time = "2025-06-28T04:21:36.826Z" }, { url = "https://files.pythonhosted.org/packages/9f/c0/66eab57b14550c5b22404dbf60635c9e33efa003bd747211981a9859b94b/grpcio_tools-1.71.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:706360c71bdd722682927a1fb517c276ccb816f1e30cb71f33553e5817dc4031", size = 2913521, upload-time = "2025-06-28T04:21:38.347Z" }, { url = "https://files.pythonhosted.org/packages/05/9b/7c90af8f937d77005625d705ab1160bc42a7e7b021ee5c788192763bccd6/grpcio_tools-1.71.2-cp313-cp313-win32.whl", hash = "sha256:bcf751d5a81c918c26adb2d6abcef71035c77d6eb9dd16afaf176ee096e22c1d", size = 945322, upload-time = "2025-06-28T04:21:39.864Z" }, { url = "https://files.pythonhosted.org/packages/5f/80/6db6247f767c94fe551761772f89ceea355ff295fd4574cb8efc8b2d1199/grpcio_tools-1.71.2-cp313-cp313-win_amd64.whl", hash = "sha256:b1581a1133552aba96a730178bc44f6f1a071f0eb81c5b6bc4c0f89f5314e2b8", size = 1117234, upload-time = "2025-06-28T04:21:41.893Z" }, ] [[package]] name = "gunicorn" version = "26.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, ] [[package]] name = "h11" version = "0.16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] name = "httplib2" version = "0.31.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyparsing" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800, upload-time = "2026-01-23T11:04:44.165Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" }, ] [[package]] name = "httptools" version = "0.7.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, ] [[package]] name = "hypothesis" version = "6.152.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sortedcontainers" }, ] sdist = { url = "https://files.pythonhosted.org/packages/91/dd/19d273652eb20dac15f32bbc484f2f6d51ccd8fe51fdb27da3f85f9017e8/hypothesis-6.152.7.tar.gz", hash = "sha256:741dedcede2ae0f32c32929a5992804b61f2b0400403b6a51a881a2b58482782", size = 468147, upload-time = "2026-05-13T04:19:34.124Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0a/1e/8222edaee03c37350eaa726213614e343a62f1e56396dd000ad9277bfa3d/hypothesis-6.152.7-py3-none-any.whl", hash = "sha256:c0b17dd428fcb6e962f60315f6f4a77816c72fbb281ce9ba73699dabead5ec82", size = 533802, upload-time = "2026-05-13T04:19:30.635Z" }, ] [[package]] name = "identify" version = "2.6.19" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] [[package]] name = "idna" version = "3.15" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "itsdangerous" version = "2.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, ] [[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 = "jmespath" version = "1.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] [[package]] name = "jsonschema" version = "4.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "jsonschema-specifications" }, { name = "referencing" }, { name = "rpds-py" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] [[package]] name = "jsonschema-specifications" version = "2025.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "referencing" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] [[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/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" }, ] [[package]] name = "msgpack" version = "1.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" }, { url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" }, { url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" }, { url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" }, { url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" }, { url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" }, { url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" }, { url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" }, { url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" }, { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, ] [[package]] name = "multidict" version = "6.7.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] [[package]] name = "nodeenv" version = "1.10.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] [[package]] name = "packaging" version = "26.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] name = "paho-mqtt" version = "1.6.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f8/dd/4b75dcba025f8647bc9862ac17299e0d7d12d3beadbf026d8c8d74215c12/paho-mqtt-1.6.1.tar.gz", hash = "sha256:2a8291c81623aec00372b5a85558a372c747cbca8e9934dfe218638b8eefc26f", size = 99373, upload-time = "2021-10-21T10:33:59.864Z" } [[package]] name = "pbr" version = "7.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "setuptools" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/ab/1de9a4f730edde1bdbbc2b8d19f8fa326f036b4f18b2f72cfbea7dc53c26/pbr-7.0.3.tar.gz", hash = "sha256:b46004ec30a5324672683ec848aed9e8fc500b0d261d40a3229c2d2bbfcedc29", size = 135625, upload-time = "2025-11-03T17:04:56.274Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c0/db/61efa0d08a99f897ef98256b03e563092d36cc38dc4ebe4a85020fe40b31/pbr-7.0.3-py2.py3-none-any.whl", hash = "sha256:ff223894eb1cd271a98076b13d3badff3bb36c424074d26334cd25aebeecea6b", size = 131898, upload-time = "2025-11-03T17:04:54.875Z" }, ] [[package]] name = "pip" version = "26.1.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b6/48/cb9b7a682f6fe01a4221e1728941dd4ac3cd9090a17db3779d6ff490b602/pip-26.1.1.tar.gz", hash = "sha256:d36762751d156a4ee895de8af39aa0abeeeb577f93a2eca6ab62467bbf0f8a78", size = 1840400, upload-time = "2026-05-04T19:02:21.248Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3a/eb/fea4d1d51c49832120f7f285d07306db3960f423a2612c6057caf3e8196f/pip-26.1.1-py3-none-any.whl", hash = "sha256:99cb1c2899893b075ff56e4ed0af55669a955b49ad7fb8d8603ecdaf4ed653fb", size = 1812777, upload-time = "2026-05-04T19:02:18.9Z" }, ] [[package]] name = "platformdirs" version = "4.9.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "pre-commit" version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, { name = "identify" }, { name = "nodeenv" }, { name = "pyyaml" }, { name = "virtualenv" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] [[package]] name = "propcache" version = "0.5.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] [[package]] name = "proto-plus" version = "1.28.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c9/56/e647b0c675392d2da368da7b6f158f7368b18542fd6f7d7400a2f39de000/proto_plus-1.28.0.tar.gz", hash = "sha256:38e5696342835b08fc116f30a25665b29531cda9d5d5643e9b81fc312385abd9", size = 57221, upload-time = "2026-05-07T08:04:50.811Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7c/20/b122d4626976acb81132036d2ad1bb35a1a8775fceb837ec30964622516a/proto_plus-1.28.0-py3-none-any.whl", hash = "sha256:a630604310899e73c59ec302e5765c058d412b2f090b9c79c8822589f14955b8", size = 50410, upload-time = "2026-05-07T08:03:31.962Z" }, ] [[package]] name = "protobuf" version = "5.29.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/57/394a763c103e0edf87f0938dafcd918d53b4c011dfc5c8ae80f3b0452dbb/protobuf-5.29.6.tar.gz", hash = "sha256:da9ee6a5424b6b30fd5e45c5ea663aef540ca95f9ad99d1e887e819cdf9b8723", size = 425623, upload-time = "2026-02-04T22:54:40.584Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d4/88/9ee58ff7863c479d6f8346686d4636dd4c415b0cbeed7a6a7d0617639c2a/protobuf-5.29.6-cp310-abi3-win32.whl", hash = "sha256:62e8a3114992c7c647bce37dcc93647575fc52d50e48de30c6fcb28a6a291eb1", size = 423357, upload-time = "2026-02-04T22:54:25.805Z" }, { url = "https://files.pythonhosted.org/packages/1c/66/2dc736a4d576847134fb6d80bd995c569b13cdc7b815d669050bf0ce2d2c/protobuf-5.29.6-cp310-abi3-win_amd64.whl", hash = "sha256:7e6ad413275be172f67fdee0f43484b6de5a904cc1c3ea9804cb6fe2ff366eda", size = 435175, upload-time = "2026-02-04T22:54:28.592Z" }, { url = "https://files.pythonhosted.org/packages/06/db/49b05966fd208ae3f44dcd33837b6243b4915c57561d730a43f881f24dea/protobuf-5.29.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:b5a169e664b4057183a34bdc424540e86eea47560f3c123a0d64de4e137f9269", size = 418619, upload-time = "2026-02-04T22:54:30.266Z" }, { url = "https://files.pythonhosted.org/packages/b7/d7/48cbf6b0c3c39761e47a99cb483405f0fde2be22cf00d71ef316ce52b458/protobuf-5.29.6-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:a8866b2cff111f0f863c1b3b9e7572dc7eaea23a7fae27f6fc613304046483e6", size = 320284, upload-time = "2026-02-04T22:54:31.782Z" }, { url = "https://files.pythonhosted.org/packages/e3/dd/cadd6ec43069247d91f6345fa7a0d2858bef6af366dbd7ba8f05d2c77d3b/protobuf-5.29.6-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:e3387f44798ac1106af0233c04fb8abf543772ff241169946f698b3a9a3d3ab9", size = 320478, upload-time = "2026-02-04T22:54:32.909Z" }, { url = "https://files.pythonhosted.org/packages/5a/cb/e3065b447186cb70aa65acc70c86baf482d82bf75625bf5a2c4f6919c6a3/protobuf-5.29.6-py3-none-any.whl", hash = "sha256:6b9edb641441b2da9fa8f428760fc136a49cf97a52076010cf22a2ff73438a86", size = 173126, upload-time = "2026-02-04T22:54:39.462Z" }, ] [[package]] name = "protobuf-protoc-bin" version = "29.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/25/95/3564e08e56ed0eb8827c06a9b1e98574e692c0c0707bbdcde450db85cf81/protobuf_protoc_bin-29.5.tar.gz", hash = "sha256:7d3f6b1e2af58dcb9c1bfbb47c89c9d3d9ecc02262b8f0f165d274b2306ba4bf", size = 8510, upload-time = "2025-05-29T05:55:26.096Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/80/02/d10e7b4344a80c3d391c893cb2df27c4f93198b7797b0b067e195e9c71e1/protobuf_protoc_bin-29.5-py2.py3-none-macosx_10_13_universal2.whl", hash = "sha256:013c006e7f54988916f57497a18f7af8dd8bb4587d69884e92e739c96212a982", size = 2380190, upload-time = "2025-05-29T05:55:21.069Z" }, { url = "https://files.pythonhosted.org/packages/4d/d6/d21a9a91630f023095de21449bb0520ec6a8304cf965e7bf069929f3a7e1/protobuf_protoc_bin-29.5-py2.py3-none-manylinux_2_24_x86_64.whl", hash = "sha256:4066fbb0f7aac868ecd4aedd9c2a8e7bf5643c18e886797f28e7616a19114e84", size = 3292846, upload-time = "2025-05-29T05:55:22.933Z" }, { url = "https://files.pythonhosted.org/packages/81/1d/ccd670cdf55a4d49ae97c20035fb19e5c359a535998373e5c69a1c964f10/protobuf_protoc_bin-29.5-py2.py3-none-win_amd64.whl", hash = "sha256:7f8f26e63dc1cf56fe2ab339f0c7533b07b6f010b822c45135b543785562ca78", size = 3193605, upload-time = "2025-05-29T05:55:24.511Z" }, ] [[package]] name = "py" version = "1.11.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/98/ff/fec109ceb715d2a6b4c4a85a61af3b40c723a961e8828319fbcb15b868dc/py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", size = 207796, upload-time = "2021-11-04T17:17:01.377Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378", size = 98708, upload-time = "2021-11-04T17:17:00.152Z" }, ] [[package]] name = "pyasn1" version = "0.6.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, ] [[package]] name = "pyasn1-modules" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyasn1" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, ] [[package]] name = "pycparser" version = "3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[package]] name = "pydantic" version = "2.13.4" 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/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] name = "pydantic-core" version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] [[package]] name = "pygments" version = "2.20.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pyjwt" version = "2.12.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, ] [[package]] name = "pykwalify" version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docopt" }, { name = "python-dateutil" }, { name = "ruamel-yaml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d5/77/2d6849510dbfce5f74f1f69768763630ad0385ad7bb0a4f39b55de3920c7/pykwalify-1.8.0.tar.gz", hash = "sha256:796b2ad3ed4cb99b88308b533fb2f559c30fa6efb4fa9fda11347f483d245884", size = 62462, upload-time = "2020-12-30T22:31:10.745Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1f/fd/ac2161cce19fd67a18c269073f8e86292b5511acec6f8ef6eab88615d032/pykwalify-1.8.0-py2.py3-none-any.whl", hash = "sha256:731dfa87338cca9f559d1fca2bdea37299116e3139b73f78ca90a543722d6651", size = 24860, upload-time = "2020-12-30T22:31:09.09Z" }, ] [[package]] name = "pyparsing" version = "3.3.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] [[package]] name = "pyproject-api" version = "1.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] sdist = { url = "https://files.pythonhosted.org/packages/45/7b/c0e1333b61d41c69e59e5366e727b18c4992688caf0de1be10b3e5265f6b/pyproject_api-1.10.0.tar.gz", hash = "sha256:40c6f2d82eebdc4afee61c773ed208c04c19db4c4a60d97f8d7be3ebc0bbb330", size = 22785, upload-time = "2025-10-09T19:12:27.21Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/cc/cecf97be298bee2b2a37dd360618c819a2a7fd95251d8e480c1f0eb88f3b/pyproject_api-1.10.0-py3-none-any.whl", hash = "sha256:8757c41a79c0f4ab71b99abed52b97ecf66bd20b04fa59da43b5840bac105a09", size = 13218, upload-time = "2025-10-09T19:12:24.428Z" }, ] [[package]] name = "pytest" version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] name = "pytest-asyncio" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] [[package]] name = "pytest-cov" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, { name = "pluggy" }, { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] name = "pytest-xdist" version = "3.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "execnet" }, { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] [[package]] name = "python-box" version = "6.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9a/85/b02b80d74bdb95bfe491d49ad1627e9833c73d331edbe6eed0bdfe170361/python-box-6.1.0.tar.gz", hash = "sha256:6e7c243b356cb36e2c0f0e5ed7850969fede6aa812a7f501de7768996c7744d7", size = 41443, upload-time = "2022-10-29T22:30:45.515Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d4/16/48bcaacf750fa2cc78882a53eef953c28a42e4a84f5e0b27e05d7188a92a/python_box-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ac44b3b85714a4575cc273b5dbd39ef739f938ef6c522d6757704a29e7797d16", size = 1571634, upload-time = "2022-10-29T22:32:40.118Z" }, { url = "https://files.pythonhosted.org/packages/8b/b4/ae3736cfc3970fe6ee348620780811c016fe4c01d2d0ff4a3a19f4eff5f7/python_box-6.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f0036f91e13958d2b37d2bc74c1197aa36ffd66755342eb64910f63d8a2990f", size = 3546030, upload-time = "2022-10-29T22:35:05.688Z" }, { url = "https://files.pythonhosted.org/packages/f3/7d/5cc1f3145792b803ee6debc82d1faf791659baa15c2de7b1d9318adbcd68/python_box-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:af6bcee7e1abe9251e9a41ca9ab677e1f679f6059321cfbae7e78a3831e0b736", size = 957417, upload-time = "2022-10-29T22:33:41.542Z" }, { url = "https://files.pythonhosted.org/packages/88/c6/6d1e368710cb6c458ed692d179d7e101ebce80a3e640b2e74cc7ae886d6f/python_box-6.1.0-py3-none-any.whl", hash = "sha256:bdec0a5f5a17b01fc538d292602a077aa8c641fb121e1900dff0591791af80e8", size = 27277, upload-time = "2022-10-29T22:30:43.645Z" }, ] [[package]] name = "python-dateutil" version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] name = "python-discovery" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, ] [[package]] name = "python-dotenv" version = "1.2.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] name = "python-multipart" version = "0.0.28" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/82/54/a85eb421fbdd5007bc5af39d0f4ed9fa609e0fedbfdc2adcf0b34526870e/python_multipart-0.0.28.tar.gz", hash = "sha256:8550da197eac0f7ab748961fc9509b999fa2662ea25cef857f05249f6893c0f8", size = 45314, upload-time = "2026-05-10T11:05:16.596Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f3/a2/43bbc5860b5034e2af4ef99a0e04d726ff329c43e192ef3abaa8d7ecfce5/python_multipart-0.0.28-py3-none-any.whl", hash = "sha256:10faac07eb966c3f48dc415f9dee46c04cb10d58d30a35677db8027c825ed9b6", size = 29438, upload-time = "2026-05-10T11:05:15.052Z" }, ] [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] name = "referencing" version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "rpds-py" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] [[package]] name = "requests" version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, { name = "idna" }, { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] name = "rpds-py" version = "0.30.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] [[package]] name = "ruamel-yaml" version = "0.18.17" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ruamel-yaml-clib", marker = "python_full_version < '3.15' and platform_python_implementation == 'CPython'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3a/2b/7a1f1ebcd6b3f14febdc003e658778d81e76b40df2267904ee6b13f0c5c6/ruamel_yaml-0.18.17.tar.gz", hash = "sha256:9091cd6e2d93a3a4b157ddb8fabf348c3de7f1fb1381346d985b6b247dcd8d3c", size = 149602, upload-time = "2025-12-17T20:02:55.757Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/af/fe/b6045c782f1fd1ae317d2a6ca1884857ce5c20f59befe6ab25a8603c43a7/ruamel_yaml-0.18.17-py3-none-any.whl", hash = "sha256:9c8ba9eb3e793efdf924b60d521820869d5bf0cb9c6f1b82d82de8295e290b9d", size = 121594, upload-time = "2025-12-17T20:02:07.657Z" }, ] [[package]] name = "ruamel-yaml-clib" version = "0.2.15" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ea/97/60fda20e2fb54b83a61ae14648b0817c8f5d84a3821e40bfbdae1437026a/ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600", size = 225794, upload-time = "2025-11-16T16:12:59.761Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2c/80/8ce7b9af532aa94dd83360f01ce4716264db73de6bc8efd22c32341f6658/ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c583229f336682b7212a43d2fa32c30e643d3076178fb9f7a6a14dde85a2d8bd", size = 147998, upload-time = "2025-11-16T16:13:13.241Z" }, { url = "https://files.pythonhosted.org/packages/53/09/de9d3f6b6701ced5f276d082ad0f980edf08ca67114523d1b9264cd5e2e0/ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56ea19c157ed8c74b6be51b5fa1c3aff6e289a041575f0556f66e5fb848bb137", size = 132743, upload-time = "2025-11-16T16:13:14.265Z" }, { url = "https://files.pythonhosted.org/packages/0e/f7/73a9b517571e214fe5c246698ff3ed232f1ef863c8ae1667486625ec688a/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5fea0932358e18293407feb921d4f4457db837b67ec1837f87074667449f9401", size = 731459, upload-time = "2025-11-16T20:22:44.338Z" }, { url = "https://files.pythonhosted.org/packages/9b/a2/0dc0013169800f1c331a6f55b1282c1f4492a6d32660a0cf7b89e6684919/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71831bd61fbdb7aa0399d5c4da06bea37107ab5c79ff884cc07f2450910262", size = 749289, upload-time = "2025-11-16T16:13:15.633Z" }, { url = "https://files.pythonhosted.org/packages/aa/ed/3fb20a1a96b8dc645d88c4072df481fe06e0289e4d528ebbdcc044ebc8b3/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:617d35dc765715fa86f8c3ccdae1e4229055832c452d4ec20856136acc75053f", size = 777630, upload-time = "2025-11-16T16:13:16.898Z" }, { url = "https://files.pythonhosted.org/packages/60/50/6842f4628bc98b7aa4733ab2378346e1441e150935ad3b9f3c3c429d9408/ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b45498cc81a4724a2d42273d6cfc243c0547ad7c6b87b4f774cb7bcc131c98d", size = 744368, upload-time = "2025-11-16T16:13:18.117Z" }, { url = "https://files.pythonhosted.org/packages/d3/b0/128ae8e19a7d794c2e36130a72b3bb650ce1dd13fb7def6cf10656437dcf/ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:def5663361f6771b18646620fca12968aae730132e104688766cf8a3b1d65922", size = 745233, upload-time = "2025-11-16T20:22:45.833Z" }, { url = "https://files.pythonhosted.org/packages/75/05/91130633602d6ba7ce3e07f8fc865b40d2a09efd4751c740df89eed5caf9/ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:014181cdec565c8745b7cbc4de3bf2cc8ced05183d986e6d1200168e5bb59490", size = 770963, upload-time = "2025-11-16T16:13:19.344Z" }, { url = "https://files.pythonhosted.org/packages/fd/4b/fd4542e7f33d7d1bc64cc9ac9ba574ce8cf145569d21f5f20133336cdc8c/ruamel_yaml_clib-0.2.15-cp311-cp311-win32.whl", hash = "sha256:d290eda8f6ada19e1771b54e5706b8f9807e6bb08e873900d5ba114ced13e02c", size = 102640, upload-time = "2025-11-16T16:13:20.498Z" }, { url = "https://files.pythonhosted.org/packages/bb/eb/00ff6032c19c7537371e3119287999570867a0eafb0154fccc80e74bf57a/ruamel_yaml_clib-0.2.15-cp311-cp311-win_amd64.whl", hash = "sha256:bdc06ad71173b915167702f55d0f3f027fc61abd975bd308a0968c02db4a4c3e", size = 121996, upload-time = "2025-11-16T16:13:21.855Z" }, { url = "https://files.pythonhosted.org/packages/72/4b/5fde11a0722d676e469d3d6f78c6a17591b9c7e0072ca359801c4bd17eee/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb15a2e2a90c8475df45c0949793af1ff413acfb0a716b8b94e488ea95ce7cff", size = 149088, upload-time = "2025-11-16T16:13:22.836Z" }, { url = "https://files.pythonhosted.org/packages/85/82/4d08ac65ecf0ef3b046421985e66301a242804eb9a62c93ca3437dc94ee0/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64da03cbe93c1e91af133f5bec37fd24d0d4ba2418eaf970d7166b0a26a148a2", size = 134553, upload-time = "2025-11-16T16:13:24.151Z" }, { url = "https://files.pythonhosted.org/packages/b9/cb/22366d68b280e281a932403b76da7a988108287adff2bfa5ce881200107a/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f6d3655e95a80325b84c4e14c080b2470fe4f33b6846f288379ce36154993fb1", size = 737468, upload-time = "2025-11-16T20:22:47.335Z" }, { url = "https://files.pythonhosted.org/packages/71/73/81230babf8c9e33770d43ed9056f603f6f5f9665aea4177a2c30ae48e3f3/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71845d377c7a47afc6592aacfea738cc8a7e876d586dfba814501d8c53c1ba60", size = 753349, upload-time = "2025-11-16T16:13:26.269Z" }, { url = "https://files.pythonhosted.org/packages/61/62/150c841f24cda9e30f588ef396ed83f64cfdc13b92d2f925bb96df337ba9/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9", size = 788211, upload-time = "2025-11-16T16:13:27.441Z" }, { url = "https://files.pythonhosted.org/packages/30/93/e79bd9cbecc3267499d9ead919bd61f7ddf55d793fb5ef2b1d7d92444f35/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4b293a37dc97e2b1e8a1aec62792d1e52027087c8eea4fc7b5abd2bdafdd6642", size = 743203, upload-time = "2025-11-16T16:13:28.671Z" }, { url = "https://files.pythonhosted.org/packages/8d/06/1eb640065c3a27ce92d76157f8efddb184bd484ed2639b712396a20d6dce/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512571ad41bba04eac7268fe33f7f4742210ca26a81fe0c75357fa682636c690", size = 747292, upload-time = "2025-11-16T20:22:48.584Z" }, { url = "https://files.pythonhosted.org/packages/a5/21/ee353e882350beab65fcc47a91b6bdc512cace4358ee327af2962892ff16/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5e9f630c73a490b758bf14d859a39f375e6999aea5ddd2e2e9da89b9953486a", size = 771624, upload-time = "2025-11-16T16:13:29.853Z" }, { url = "https://files.pythonhosted.org/packages/57/34/cc1b94057aa867c963ecf9ea92ac59198ec2ee3a8d22a126af0b4d4be712/ruamel_yaml_clib-0.2.15-cp312-cp312-win32.whl", hash = "sha256:f4421ab780c37210a07d138e56dd4b51f8642187cdfb433eb687fe8c11de0144", size = 100342, upload-time = "2025-11-16T16:13:31.067Z" }, { url = "https://files.pythonhosted.org/packages/b3/e5/8925a4208f131b218f9a7e459c0d6fcac8324ae35da269cb437894576366/ruamel_yaml_clib-0.2.15-cp312-cp312-win_amd64.whl", hash = "sha256:2b216904750889133d9222b7b873c199d48ecbb12912aca78970f84a5aa1a4bc", size = 119013, upload-time = "2025-11-16T16:13:32.164Z" }, { url = "https://files.pythonhosted.org/packages/17/5e/2f970ce4c573dc30c2f95825f2691c96d55560268ddc67603dc6ea2dd08e/ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dcec721fddbb62e60c2801ba08c87010bd6b700054a09998c4d09c08147b8fb", size = 147450, upload-time = "2025-11-16T16:13:33.542Z" }, { url = "https://files.pythonhosted.org/packages/d6/03/a1baa5b94f71383913f21b96172fb3a2eb5576a4637729adbf7cd9f797f8/ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:65f48245279f9bb301d1276f9679b82e4c080a1ae25e679f682ac62446fac471", size = 133139, upload-time = "2025-11-16T16:13:34.587Z" }, { url = "https://files.pythonhosted.org/packages/dc/19/40d676802390f85784235a05788fd28940923382e3f8b943d25febbb98b7/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46895c17ead5e22bea5e576f1db7e41cb273e8d062c04a6a49013d9f60996c25", size = 731474, upload-time = "2025-11-16T20:22:49.934Z" }, { url = "https://files.pythonhosted.org/packages/ce/bb/6ef5abfa43b48dd55c30d53e997f8f978722f02add61efba31380d73e42e/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3eb199178b08956e5be6288ee0b05b2fb0b5c1f309725ad25d9c6ea7e27f962a", size = 748047, upload-time = "2025-11-16T16:13:35.633Z" }, { url = "https://files.pythonhosted.org/packages/ff/5d/e4f84c9c448613e12bd62e90b23aa127ea4c46b697f3d760acc32cb94f25/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d1032919280ebc04a80e4fb1e93f7a738129857eaec9448310e638c8bccefcf", size = 782129, upload-time = "2025-11-16T16:13:36.781Z" }, { url = "https://files.pythonhosted.org/packages/de/4b/e98086e88f76c00c88a6bcf15eae27a1454f661a9eb72b111e6bbb69024d/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab0df0648d86a7ecbd9c632e8f8d6b21bb21b5fc9d9e095c796cacf32a728d2d", size = 736848, upload-time = "2025-11-16T16:13:37.952Z" }, { url = "https://files.pythonhosted.org/packages/0c/5c/5964fcd1fd9acc53b7a3a5d9a05ea4f95ead9495d980003a557deb9769c7/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:331fb180858dd8534f0e61aa243b944f25e73a4dae9962bd44c46d1761126bbf", size = 741630, upload-time = "2025-11-16T20:22:51.718Z" }, { url = "https://files.pythonhosted.org/packages/07/1e/99660f5a30fceb58494598e7d15df883a07292346ef5696f0c0ae5dee8c6/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd4c928ddf6bce586285daa6d90680b9c291cfd045fc40aad34e445d57b1bf51", size = 766619, upload-time = "2025-11-16T16:13:39.178Z" }, { url = "https://files.pythonhosted.org/packages/36/2f/fa0344a9327b58b54970e56a27b32416ffbcfe4dcc0700605516708579b2/ruamel_yaml_clib-0.2.15-cp313-cp313-win32.whl", hash = "sha256:bf0846d629e160223805db9fe8cc7aec16aaa11a07310c50c8c7164efa440aec", size = 100171, upload-time = "2025-11-16T16:13:40.456Z" }, { url = "https://files.pythonhosted.org/packages/06/c4/c124fbcef0684fcf3c9b72374c2a8c35c94464d8694c50f37eef27f5a145/ruamel_yaml_clib-0.2.15-cp313-cp313-win_amd64.whl", hash = "sha256:45702dfbea1420ba3450bb3dd9a80b33f0badd57539c6aac09f42584303e0db6", size = 118845, upload-time = "2025-11-16T16:13:41.481Z" }, { url = "https://files.pythonhosted.org/packages/3e/bd/ab8459c8bb759c14a146990bf07f632c1cbec0910d4853feeee4be2ab8bb/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:753faf20b3a5906faf1fc50e4ddb8c074cb9b251e00b14c18b28492f933ac8ef", size = 147248, upload-time = "2025-11-16T16:13:42.872Z" }, { url = "https://files.pythonhosted.org/packages/69/f2/c4cec0a30f1955510fde498aac451d2e52b24afdbcb00204d3a951b772c3/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:480894aee0b29752560a9de46c0e5f84a82602f2bc5c6cde8db9a345319acfdf", size = 133764, upload-time = "2025-11-16T16:13:43.932Z" }, { url = "https://files.pythonhosted.org/packages/82/c7/2480d062281385a2ea4f7cc9476712446e0c548cd74090bff92b4b49e898/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d3b58ab2454b4747442ac76fab66739c72b1e2bb9bd173d7694b9f9dbc9c000", size = 730537, upload-time = "2025-11-16T20:22:52.918Z" }, { url = "https://files.pythonhosted.org/packages/75/08/e365ee305367559f57ba6179d836ecc3d31c7d3fdff2a40ebf6c32823a1f/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bfd309b316228acecfa30670c3887dcedf9b7a44ea39e2101e75d2654522acd4", size = 746944, upload-time = "2025-11-16T16:13:45.338Z" }, { url = "https://files.pythonhosted.org/packages/a1/5c/8b56b08db91e569d0a4fbfa3e492ed2026081bdd7e892f63ba1c88a2f548/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2812ff359ec1f30129b62372e5f22a52936fac13d5d21e70373dbca5d64bb97c", size = 778249, upload-time = "2025-11-16T16:13:46.871Z" }, { url = "https://files.pythonhosted.org/packages/6a/1d/70dbda370bd0e1a92942754c873bd28f513da6198127d1736fa98bb2a16f/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7e74ea87307303ba91073b63e67f2c667e93f05a8c63079ee5b7a5c8d0d7b043", size = 737140, upload-time = "2025-11-16T16:13:48.349Z" }, { url = "https://files.pythonhosted.org/packages/5b/87/822d95874216922e1120afb9d3fafa795a18fdd0c444f5c4c382f6dac761/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:713cd68af9dfbe0bb588e144a61aad8dcc00ef92a82d2e87183ca662d242f524", size = 741070, upload-time = "2025-11-16T20:22:54.151Z" }, { url = "https://files.pythonhosted.org/packages/b9/17/4e01a602693b572149f92c983c1f25bd608df02c3f5cf50fd1f94e124a59/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:542d77b72786a35563f97069b9379ce762944e67055bea293480f7734b2c7e5e", size = 765882, upload-time = "2025-11-16T16:13:49.526Z" }, { url = "https://files.pythonhosted.org/packages/9f/17/7999399081d39ebb79e807314de6b611e1d1374458924eb2a489c01fc5ad/ruamel_yaml_clib-0.2.15-cp314-cp314-win32.whl", hash = "sha256:424ead8cef3939d690c4b5c85ef5b52155a231ff8b252961b6516ed7cf05f6aa", size = 102567, upload-time = "2025-11-16T16:13:50.78Z" }, { url = "https://files.pythonhosted.org/packages/d2/67/be582a7370fdc9e6846c5be4888a530dcadd055eef5b932e0e85c33c7d73/ruamel_yaml_clib-0.2.15-cp314-cp314-win_amd64.whl", hash = "sha256:ac9b8d5fa4bb7fd2917ab5027f60d4234345fd366fe39aa711d5dca090aa1467", size = 122847, upload-time = "2025-11-16T16:13:51.807Z" }, ] [[package]] name = "ruff" version = "0.15.13" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, ] [[package]] name = "schema" version = "0.7.8" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/fb/2e/8da627b65577a8f130fe9dfa88ce94fcb24b1f8b59e0fc763ee61abef8b8/schema-0.7.8.tar.gz", hash = "sha256:e86cc08edd6fe6e2522648f4e47e3a31920a76e82cce8937535422e310862ab5", size = 45540, upload-time = "2025-10-11T13:15:40.281Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c9/75/aad85817266ac5285c93391711d231ca63e9ae7d42cd3ca37549e24ebe52/schema-0.7.8-py2.py3-none-any.whl", hash = "sha256:00bd977fadc7d9521bf289850cd8a8aa5f4948f575476b8daaa5c1b57af2dce1", size = 19108, upload-time = "2025-10-11T17:13:07.323Z" }, ] [[package]] name = "sentinel" version = "1.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/4a/d8/49115169583d02b38e7d93909a474c7ed0863f7d4df27095588344f2e66a/sentinel-1.0.0.tar.gz", hash = "sha256:190928f9951af6e94a1f84eefcaed791c28097dd152b88e988906be300451fd2", size = 6981, upload-time = "2022-07-23T10:22:19.606Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f3/c4/37cd564e7c5ee72afc864e43b872c716ed43604e50ea0adbb510d720f92d/sentinel-1.0.0-py3-none-any.whl", hash = "sha256:24f02a34cc9f0fcba5a666a23b6c7f56aff332fc624632ee442e7237751a9f60", size = 6485, upload-time = "2022-07-23T10:22:16.826Z" }, ] [[package]] name = "setuptools" version = "82.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, ] [[package]] name = "simpleeval" version = "1.0.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b4/9d/e7c9309940794dd3073cba2e5101df5874d84243595ce63b1e1c8f9b9c76/simpleeval-1.0.7.tar.gz", hash = "sha256:1e10e5f9fec597814444e20c0892ed15162fa214c8a88f434b5b077cf2fef85b", size = 30250, upload-time = "2026-03-16T10:53:03.464Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0f/2f/f32aa85591882378bb43caa09363f3ed97df399369a5144c7f19f2275bc0/simpleeval-1.0.7-py3-none-any.whl", hash = "sha256:97ac271bfd8f2af9e7b9a36ceea67617f26fa873f9d5ae1922f64d4c1442534b", size = 18792, upload-time = "2026-03-16T10:53:02.103Z" }, ] [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "sortedcontainers" version = "2.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] [[package]] name = "sqlakeyset" version = "2.0.1775222100" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, { name = "python-dateutil" }, { name = "sqlalchemy" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/75/18/0a06e22a66df13debde3eb799f7f31f17bf02242836f47a20e2c8c45e44c/sqlakeyset-2.0.1775222100.tar.gz", hash = "sha256:c2988c289181fa3615f3c091308a06001d83fa6ebc0591e7d18e08d8b92ee012", size = 114403, upload-time = "2026-04-03T13:15:03.428Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d0/f7/ff211cc8bc2968459b78a63c8b62b3b6819dff0abdfd260aa5ef012a73ec/sqlakeyset-2.0.1775222100-py3-none-any.whl", hash = "sha256:5004e671ddc7d9ecf105bcbd46c079b314cc6d413db3159e63a3567e51e28fa3", size = 27369, upload-time = "2026-04-03T13:15:02.219Z" }, ] [[package]] name = "sqlalchemy" version = "2.0.49" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/60/b5/e3617cc67420f8f403efebd7b043128f94775e57e5b84e7255203390ceae/sqlalchemy-2.0.49-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe", size = 2159126, upload-time = "2026-04-03T16:50:13.242Z" }, { url = "https://files.pythonhosted.org/packages/20/9b/91ca80403b17cd389622a642699e5f6564096b698e7cdcbcbb6409898bc4/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014", size = 3315509, upload-time = "2026-04-03T16:54:49.332Z" }, { url = "https://files.pythonhosted.org/packages/b1/61/0722511d98c54de95acb327824cb759e8653789af2b1944ab1cc69d32565/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536", size = 3315014, upload-time = "2026-04-03T16:56:56.376Z" }, { url = "https://files.pythonhosted.org/packages/46/55/d514a653ffeb4cebf4b54c47bec32ee28ad89d39fafba16eeed1d81dccd5/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88", size = 3267388, upload-time = "2026-04-03T16:54:51.272Z" }, { url = "https://files.pythonhosted.org/packages/2f/16/0dcc56cb6d3335c1671a2258f5d2cb8267c9a2260e27fde53cbfb1b3540a/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700", size = 3289602, upload-time = "2026-04-03T16:56:57.63Z" }, { url = "https://files.pythonhosted.org/packages/51/6c/f8ab6fb04470a133cd80608db40aa292e6bae5f162c3a3d4ab19544a67af/sqlalchemy-2.0.49-cp311-cp311-win32.whl", hash = "sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a", size = 2119044, upload-time = "2026-04-03T17:00:53.455Z" }, { url = "https://files.pythonhosted.org/packages/c4/59/55a6d627d04b6ebb290693681d7683c7da001eddf90b60cfcc41ee907978/sqlalchemy-2.0.49-cp311-cp311-win_amd64.whl", hash = "sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af", size = 2143642, upload-time = "2026-04-03T17:00:54.769Z" }, { url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" }, { url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" }, { url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" }, { url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" }, { url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" }, { url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" }, { url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" }, { url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" }, { url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" }, { url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" }, { url = "https://files.pythonhosted.org/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" }, { url = "https://files.pythonhosted.org/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" }, { url = "https://files.pythonhosted.org/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" }, { url = "https://files.pythonhosted.org/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" }, { url = "https://files.pythonhosted.org/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" }, { url = "https://files.pythonhosted.org/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" }, { url = "https://files.pythonhosted.org/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" }, { url = "https://files.pythonhosted.org/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" }, { url = "https://files.pythonhosted.org/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" }, { url = "https://files.pythonhosted.org/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" }, { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" }, { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" }, { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" }, { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" }, { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" }, { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" }, { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" }, { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" }, { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" }, { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" }, { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" }, { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" }, { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" }, { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, ] [package.optional-dependencies] asyncio = [ { name = "greenlet" }, ] [[package]] name = "starlette" version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] [[package]] name = "stevedore" version = "4.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pbr" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/c0/26afabea111a642f33cfd15f54b3dbe9334679294ad5c0423c556b75eba2/stevedore-4.1.1.tar.gz", hash = "sha256:7f8aeb6e3f90f96832c301bff21a7eb5eefbe894c88c506483d355565d88cc1a", size = 514168, upload-time = "2022-11-10T09:08:25.439Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/74/a3/72792236f981aee8bb1ef15e72a6f65444150834830f6a97178fe1e2cdf4/stevedore-4.1.1-py3-none-any.whl", hash = "sha256:aa6436565c069b2946fe4ebff07f5041e0c8bf18c7376dd29edf80cf7d524e4e", size = 50014, upload-time = "2022-11-10T09:08:22.025Z" }, ] [[package]] name = "strawberry-graphql" version = "0.315.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cross-web" }, { name = "graphql-core" }, { name = "packaging" }, { name = "python-dateutil" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/9b/101940ee899959d4d220cc8beb4f41bce9c58830d1872df06f35cf92c457/strawberry_graphql-0.315.5.tar.gz", hash = "sha256:29a2f04479aba29f9f30ecdfce1ef9ee04acee3c434a4f6019249474cd649492", size = 222724, upload-time = "2026-05-14T10:46:16.222Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7e/19/e389140b3b50faf803b4817ae5a8c3c2d6737f062f4a0fe6778e929a405d/strawberry_graphql-0.315.5-py3-none-any.whl", hash = "sha256:073bc818a5f55951a9a6fbab40bbfa07c418d2c4151cc2aab24399bd14d9a51a", size = 325062, upload-time = "2026-05-14T10:46:18.672Z" }, ] [package.optional-dependencies] fastapi = [ { name = "fastapi" }, { name = "python-multipart" }, ] [[package]] name = "strawberry-sqlalchemy-mapper" version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "python_full_version >= '3.12'" }, { name = "sentinel" }, { name = "sqlakeyset" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "strawberry-graphql" }, ] sdist = { url = "https://files.pythonhosted.org/packages/86/ff/ccdc54830a357ca898aae2368807f6522f5a1bc2cefe24255f2d6c5ad026/strawberry_sqlalchemy_mapper-0.8.0.tar.gz", hash = "sha256:8de01e616d0afe8a325736ea5483a1d9f439b5febb0c43449fe68e157735da70", size = 30132, upload-time = "2025-12-01T19:18:09.624Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f2/1f/cae88ec6f0b45f983972801c0ba093f7332e4b099ff3b48bbc1c6bb937f8/strawberry_sqlalchemy_mapper-0.8.0-py3-none-any.whl", hash = "sha256:7341327c31898547ccbc3e50c01f8c1dc009af0fb90a5c71aeda857016fb870c", size = 29376, upload-time = "2025-12-01T19:18:08.699Z" }, ] [[package]] name = "tabulate" version = "0.9.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, ] [[package]] name = "tavern" version = "3.6.0" source = { editable = "." } dependencies = [ { name = "jmespath" }, { name = "jsonschema" }, { name = "pyjwt" }, { name = "pykwalify" }, { name = "pytest" }, { name = "python-box" }, { name = "pyyaml" }, { name = "requests" }, { name = "simpleeval" }, { name = "stevedore" }, ] [package.optional-dependencies] graphql = [ { name = "aiohttp" }, { name = "gql" }, { name = "websockets" }, ] grpc = [ { name = "google-api-python-client" }, { name = "grpcio" }, { name = "grpcio-reflection" }, { name = "grpcio-status" }, { name = "proto-plus" }, { name = "protobuf" }, ] mqtt = [ { name = "paho-mqtt" }, ] [package.dev-dependencies] dev = [ { name = "allure-pytest" }, { name = "colorlog" }, { name = "coverage", extra = ["toml"] }, { name = "exceptiongroup" }, { name = "faker" }, { name = "flask" }, { name = "flask-httpauth" }, { name = "flit" }, { name = "fluent-logger" }, { name = "grpc-interceptor" }, { name = "grpcio-tools" }, { name = "hypothesis" }, { name = "itsdangerous" }, { name = "pre-commit" }, { name = "protobuf-protoc-bin" }, { name = "py" }, { name = "pydantic" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-xdist" }, { name = "ruff" }, { name = "tbump" }, { name = "tomli" }, { name = "tox" }, { name = "tox-uv" }, { name = "types-jmespath" }, { name = "types-jsonschema" }, { name = "types-paho-mqtt" }, { name = "types-protobuf" }, { name = "types-pyyaml" }, { name = "types-requests" }, { name = "uv" }, { name = "wheel" }, ] [package.metadata] requires-dist = [ { name = "aiohttp", marker = "extra == 'graphql'" }, { name = "google-api-python-client", marker = "extra == 'grpc'" }, { name = "gql", marker = "extra == 'graphql'", specifier = ">=4.0.0" }, { name = "grpcio", marker = "extra == 'grpc'" }, { name = "grpcio-reflection", marker = "extra == 'grpc'" }, { name = "grpcio-status", marker = "extra == 'grpc'" }, { name = "jmespath", specifier = ">=1,<2" }, { name = "jsonschema", specifier = ">=4,<5" }, { name = "paho-mqtt", marker = "extra == 'mqtt'", specifier = ">=1.3.1,<=1.6.1" }, { name = "proto-plus", marker = "extra == 'grpc'" }, { name = "protobuf", marker = "extra == 'grpc'", specifier = ">=5,<6" }, { name = "pyjwt", specifier = ">=2.5.0,<3" }, { name = "pykwalify", specifier = ">=1.8.0,<2" }, { name = "pytest", specifier = ">=8,<10" }, { name = "python-box", specifier = ">=6,<7" }, { name = "pyyaml", specifier = ">=6.0.1,<7" }, { name = "requests", specifier = ">=2.22.0,<3" }, { name = "simpleeval", specifier = ">=1.0.3" }, { name = "stevedore", specifier = ">=4,<5" }, { name = "websockets", marker = "extra == 'graphql'" }, ] provides-extras = ["grpc", "mqtt", "graphql"] [package.metadata.requires-dev] dev = [ { name = "allure-pytest" }, { name = "colorlog" }, { name = "coverage", extras = ["toml"] }, { name = "exceptiongroup" }, { name = "faker" }, { name = "flask", specifier = ">=3,<4" }, { name = "flask-httpauth", specifier = ">=4.8.1,<6" }, { name = "flit", specifier = ">=3.2,<4" }, { name = "fluent-logger" }, { name = "grpc-interceptor" }, { name = "grpcio-tools" }, { name = "hypothesis", specifier = ">=6,<7" }, { name = "itsdangerous" }, { name = "pre-commit" }, { name = "protobuf-protoc-bin", specifier = "==29.5" }, { name = "py" }, { name = "pydantic" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-cov" }, { name = "pytest-xdist" }, { name = "ruff" }, { name = "tbump", specifier = ">=6.10.0" }, { name = "tomli" }, { name = "tox", specifier = ">4.20,<5" }, { name = "tox-uv", specifier = ">=1.28.0" }, { name = "types-jmespath" }, { name = "types-jsonschema" }, { name = "types-paho-mqtt" }, { name = "types-protobuf", specifier = ">=5,<6" }, { name = "types-pyyaml" }, { name = "types-requests" }, { name = "uv", specifier = ">=0.9.2" }, { name = "wheel" }, ] [[package]] name = "tavern-graphql-example" version = "0.1.0" source = { editable = "example/graphql" } dependencies = [ { name = "cross-web" }, { name = "fastapi" }, { name = "sqlalchemy" }, { name = "starlette" }, { name = "strawberry-graphql", extra = ["fastapi"] }, { name = "strawberry-sqlalchemy-mapper" }, { name = "tavern", extra = ["graphql"] }, { name = "uvicorn", extra = ["standard"] }, ] [package.metadata] requires-dist = [ { name = "cross-web" }, { name = "fastapi" }, { name = "sqlalchemy", specifier = ">=2,<3" }, { name = "starlette" }, { name = "strawberry-graphql", extras = ["fastapi"] }, { name = "strawberry-sqlalchemy-mapper" }, { name = "tavern", extras = ["graphql"], editable = "." }, { name = "uvicorn", extras = ["standard"] }, ] [[package]] name = "tavern-grpc-example" version = "0.1.0" source = { editable = "example/grpc" } dependencies = [ { name = "grpc-interceptor" }, { name = "grpcio-tools" }, { name = "tavern", extra = ["grpc"] }, ] [package.metadata] requires-dist = [ { name = "grpc-interceptor" }, { name = "grpcio-tools" }, { name = "tavern", extras = ["grpc"], editable = "." }, ] [[package]] name = "tavern-http-example" version = "0.1.0" source = { editable = "example/http" } dependencies = [ { name = "flask" }, { name = "gunicorn" }, { name = "pydantic" }, { name = "pyjwt" }, { name = "tavern" }, ] [package.metadata] requires-dist = [ { name = "flask" }, { name = "gunicorn" }, { name = "pydantic" }, { name = "pyjwt" }, { name = "tavern", editable = "." }, ] [[package]] name = "tavern-mqtt-example" version = "0.1.0" source = { editable = "example/mqtt" } dependencies = [ { name = "flask" }, { name = "fluent-logger" }, { name = "gunicorn" }, { name = "tavern", extra = ["mqtt"] }, ] [package.metadata] requires-dist = [ { name = "flask" }, { name = "fluent-logger" }, { name = "gunicorn" }, { name = "tavern", extras = ["mqtt"], editable = "." }, ] [[package]] name = "tbump" version = "6.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cli-ui" }, { name = "docopt" }, { name = "schema" }, { name = "tomlkit" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ab/1f/d02379532311192521a20b3597dc0f01bd37596e950a6cb40795ae9acb94/tbump-6.11.0.tar.gz", hash = "sha256:385e710eedf0a8a6ff959cf1e9f3cfd17c873617132fc0ec5f629af0c355c870", size = 28642, upload-time = "2023-09-09T11:22:59.039Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/48/41/c21994a64efe86ed81c1a0935aeec840548839a37a7f3716f74a75e54fc2/tbump-6.11.0-py3-none-any.whl", hash = "sha256:6b181fe6f3ae84ce0b9af8cc2009a8bca41ded34e73f623a7413b9684f1b4526", size = 35607, upload-time = "2023-09-09T11:22:56.581Z" }, ] [[package]] name = "tomli" version = "2.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] name = "tomli-w" version = "1.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] [[package]] name = "tomlkit" version = "0.11.8" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/10/37/dd53019ccb72ef7d73fff0bee9e20b16faff9658b47913a35d79e89978af/tomlkit-0.11.8.tar.gz", hash = "sha256:9330fc7faa1db67b541b28e62018c17d20be733177d290a13b24c62d1614e0c3", size = 188825, upload-time = "2023-04-27T10:39:21.201Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ef/a8/b1c193be753c02e2a94af6e37ee45d3378a74d44fe778c2434a63af92731/tomlkit-0.11.8-py3-none-any.whl", hash = "sha256:8c726c4c202bdb148667835f68d68780b9a003a9ec34167b6c673b38eff2a171", size = 35807, upload-time = "2023-04-27T10:39:19.629Z" }, ] [[package]] name = "tox" version = "4.54.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools" }, { name = "colorama" }, { name = "filelock" }, { name = "packaging" }, { name = "platformdirs" }, { name = "pluggy" }, { name = "pyproject-api" }, { name = "python-discovery" }, { name = "tomli-w" }, { name = "virtualenv" }, ] sdist = { url = "https://files.pythonhosted.org/packages/17/2c/7ca5edb5ecd6bcc5cc926fe87e62a84dcd3cbd03a32f9d0bee98d2bee7cf/tox-4.54.0.tar.gz", hash = "sha256:21e36fd8256590379620848d0b03b52f4d541b65b749de1a17c3e616978dad58", size = 279256, upload-time = "2026-05-12T19:13:05.937Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/26/18/20cf56a76c5d6117547179db9b5d31cc56e3e90507d1b0b748da74aa95c5/tox-4.54.0-py3-none-any.whl", hash = "sha256:a2d7c1177242ae9c3d9e404039e9f945ce16a3e5dfc66972c643e27d7e764f4b", size = 214527, upload-time = "2026-05-12T19:13:04.334Z" }, ] [[package]] name = "tox-uv" version = "1.35.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tox-uv-bare" }, { name = "uv" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ca/dc/6e9994c799bdbb309f829dd6b8d98764dd0757302f3433c380438a3a127b/tox_uv-1.35.2-py3-none-any.whl", hash = "sha256:2d99b0e3c782ba49e7cbe521c8d344758595961b17a3633738d67096641c1bde", size = 6565, upload-time = "2026-05-05T01:34:16.07Z" }, ] [[package]] name = "tox-uv-bare" version = "1.35.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, { name = "tox" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0a/cb/168dc1ccf24e4065a9a0a33df55709ed2b5eb73bd2b13ddd53187e5dffb8/tox_uv_bare-1.35.2.tar.gz", hash = "sha256:49e28a804c97f23ea17e25859960c0fa78f35bccb7e14344cfd840e89a9aade9", size = 32333, upload-time = "2026-05-05T01:34:18.916Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5f/53/4a33dc81da39db7b31e5622333df361e8fe055b7ec636bd5fea762c9182d/tox_uv_bare-1.35.2-py3-none-any.whl", hash = "sha256:c0d590a41d1054a1ad0874e9e5943ff52402786e3d4599d8f8d37a65b566ef53", size = 22307, upload-time = "2026-05-05T01:34:17.681Z" }, ] [[package]] name = "types-jmespath" version = "1.1.0.20260508" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9b/a6/946e666f3830ef81506fad6d453b7331cf78003f7abaaaa52d2f51f16646/types_jmespath-1.1.0.20260508.tar.gz", hash = "sha256:6301cdc0c8f4fd5c48f04bdc89a45d79fe102ef9a89d9c9564d111fbebaaf855", size = 10772, upload-time = "2026-05-08T04:47:41.692Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e5/86/01434f1d59365e8a78bb0ce349e1da1473c3a580c1afc69b70eb8fbaf97d/types_jmespath-1.1.0.20260508-py3-none-any.whl", hash = "sha256:12b644fdea5100e36b391cfdd540d1d11a430bc2492a624d9a72d699f7544762", size = 11516, upload-time = "2026-05-08T04:47:40.634Z" }, ] [[package]] name = "types-jsonschema" version = "4.26.0.20260508" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "referencing" }, ] sdist = { url = "https://files.pythonhosted.org/packages/11/d8/7ba8007c48e0de93d82268e0447312c9c28d90ac7a8f73e034356bb40921/types_jsonschema-4.26.0.20260508.tar.gz", hash = "sha256:ae0be85ac6ec0cb94a98f75f876b0620cf2afa3e37fdf8460203f4d05f745acb", size = 16602, upload-time = "2026-05-08T04:51:45.26Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a3/55/4f25833c410426b1f359dd579f3b3d1ee0f43f5674b23a815f396d3ea63c/types_jsonschema-4.26.0.20260508-py3-none-any.whl", hash = "sha256:4ec1dea0a757c8c2e2aa7bc085612fb54e1ae9562428d5da6f26dd7a0f24dbc2", size = 16062, upload-time = "2026-05-08T04:51:44.437Z" }, ] [[package]] name = "types-paho-mqtt" version = "1.6.0.20240321" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a6/2e/8836d327c52dd8818a6c61e976a627f04e68f11dc2e9980b70e7ce447475/types-paho-mqtt-1.6.0.20240321.tar.gz", hash = "sha256:694eec160340f2a2b151237dcc3f107a63e1c4e5b8f9fcda0ba392049af9cbec", size = 8173, upload-time = "2024-03-21T02:15:17.779Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/92/60/bd4d0cf58c7d7bbeed3c0043647f3b7b6ea0c27fd9cfd54dfdea4927bbc6/types_paho_mqtt-1.6.0.20240321-py3-none-any.whl", hash = "sha256:cd275c14f39363c2a0f8286ead9a46962e5421ebd477547b892ae016699f5a4a", size = 9260, upload-time = "2024-03-21T02:15:16.712Z" }, ] [[package]] name = "types-protobuf" version = "5.29.1.20250403" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/78/6d/62a2e73b966c77609560800004dd49a926920dd4976a9fdd86cf998e7048/types_protobuf-5.29.1.20250403.tar.gz", hash = "sha256:7ff44f15022119c9d7558ce16e78b2d485bf7040b4fadced4dd069bb5faf77a2", size = 59413, upload-time = "2025-04-02T10:07:17.138Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/69/e3/b74dcc2797b21b39d5a4f08a8b08e20369b4ca250d718df7af41a60dd9f0/types_protobuf-5.29.1.20250403-py3-none-any.whl", hash = "sha256:c71de04106a2d54e5b2173d0a422058fae0ef2d058d70cf369fb797bf61ffa59", size = 73874, upload-time = "2025-04-02T10:07:15.755Z" }, ] [[package]] name = "types-pyyaml" version = "6.0.12.20260510" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/36/85/0d9fafce21be112e977a89677f1ce9d1aef921d745b17c758c93e861c11f/types_pyyaml-6.0.12.20260510.tar.gz", hash = "sha256:09c1f1cb65a6eebea1e2e51ccf4918b8288e152909609a35cdb0d805efd125ad", size = 17831, upload-time = "2026-05-10T05:26:28.136Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d3/ad/fd618a218925daada7b8a5e7326e662599fa5fdff4a4c44ab2795bd2d9ca/types_pyyaml-6.0.12.20260510-py3-none-any.whl", hash = "sha256:3492eb9ba4d9d833473214c4d5736cccf5f37d93f5854059721e1c84f785309d", size = 20304, upload-time = "2026-05-10T05:26:26.981Z" }, ] [[package]] name = "types-requests" version = "2.33.0.20260513" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6d/f7/3228dd3794941bcb92ca6ca2045a6671a828ec0b47becbef23310bc45559/types_requests-2.33.0.20260513.tar.gz", hash = "sha256:bd845450e954e751373d5d33526742592f298808a3ee3bda7e858e46b839b57f", size = 24714, upload-time = "2026-05-13T05:39:23.481Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/51/f5/233a78be8367a9888de718f002fb27b1ea4be39471cd88aedeafceed872e/types_requests-2.33.0.20260513-py3-none-any.whl", hash = "sha256:d5a965f9d18b6e06b72039a69565de9027e58f36a7f709857da747fbe7521122", size = 21390, upload-time = "2026-05-13T05:39:22.262Z" }, ] [[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 = "2026.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] [[package]] name = "unidecode" version = "1.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149, upload-time = "2025-04-24T08:45:03.798Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837, upload-time = "2025-04-24T08:45:01.609Z" }, ] [[package]] name = "uritemplate" version = "4.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, ] [[package]] name = "urllib3" version = "2.7.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] name = "uv" version = "0.11.15" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/da/34/609d5d01ba21dc8f0974610ca7802fbb2c946a0c38665cfe5c5aeddbefb5/uv-0.11.15.tar.gz", hash = "sha256:755f959ec6a2fd8ccb6ee76ad90ab759d2eb1f4797444078645dd1ee4bca92d6", size = 4159545, upload-time = "2026-05-18T19:57:48.133Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a6/7c/dcc230c5911884d8848145dabcac8fb95a5ed6f9fe1c57fae8242618f28a/uv-0.11.15-py3-none-linux_armv6l.whl", hash = "sha256:83b04ab49514a0a761ffedb36a748ee81f87746671e72088e5f32c9585e5f1a9", size = 23110183, upload-time = "2026-05-18T19:57:23.051Z" }, { url = "https://files.pythonhosted.org/packages/f4/f3/efd4e044b60eb9c3c12ee386be098d56c335538ccec7caa49349cfba9344/uv-0.11.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6cae61f737be075b90be9e3f07d961072aed7019f4c9b8ed5c5d41c4d6cade3", size = 22637941, upload-time = "2026-05-18T19:57:26.752Z" }, { url = "https://files.pythonhosted.org/packages/a6/b8/48627f895a1569e576822e0a8416aa4797eb4a4551de21a4ad97b9b5819d/uv-0.11.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9accae33619a9166e5c48531deb455d672cfb89f9357a00975e669c76b0bd49f", size = 21258803, upload-time = "2026-05-18T19:57:05.473Z" }, { url = "https://files.pythonhosted.org/packages/af/50/4bc8a148274feabee2d9c9f1fa15009e10c0228dfe57981ee3ea2ef1d481/uv-0.11.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:c0cf52cd6d50bb9e05e2d968f45f80761107e4cbc8d4a26d9758f9d8274aaec1", size = 23066178, upload-time = "2026-05-18T19:57:33.058Z" }, { url = "https://files.pythonhosted.org/packages/a9/56/139fc3bec9a8b0a25bfe2196123adb9f16124da437bf4fbcf0d21cfcafb2/uv-0.11.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:49dc6ed70bff00937384f96cdc4b1a4742d18e5504ec2c4a1214dba2dee5687a", size = 22705332, upload-time = "2026-05-18T19:57:36.714Z" }, { url = "https://files.pythonhosted.org/packages/ca/b0/b18b3dd204f8c213236a1ebd148e009861637129a8cce34df0e9aa22ed40/uv-0.11.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:adb9a89352539fdd8f7cd5f9966cf9f94fc5b98e0ccdf5003a04123dc6423bec", size = 22707534, upload-time = "2026-05-18T19:58:04.117Z" }, { url = "https://files.pythonhosted.org/packages/76/36/3ca09f95572df99d361b49c96b1297149e96e120d8d1ecf074095a4b6da4/uv-0.11.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40ff67e3f8e8a7533781a2e892a534975a93acb83ea35460e64e7b2bf2111774", size = 24096607, upload-time = "2026-05-18T19:58:11.625Z" }, { url = "https://files.pythonhosted.org/packages/64/be/3bdee21a296bbf5336a526e3613d0e7d4538dacc39c62d7fcba55d15f6b0/uv-0.11.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6463a299ed7e6b5a800ed6f108af8e1588352629424133ddef7572b0e1e1118", size = 25082562, upload-time = "2026-05-18T19:57:40.69Z" }, { url = "https://files.pythonhosted.org/packages/cd/73/f371f3689ffe741066468d001d85f739fc4b5574de83b639ef19b5e8a7f4/uv-0.11.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:68c1e62d4b78578b90b833553286b65d6a7e327537716441068583ba652ec4f5", size = 24253391, upload-time = "2026-05-18T19:57:18.47Z" }, { url = "https://files.pythonhosted.org/packages/d3/16/fe392d618af6b00c064b3e718d585dcf791546a77c5123a5bec07ce53a0a/uv-0.11.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98edf1bdaf82447014852051d93e3ee95012509c567bf057fd117e6bdbd9a807", size = 24415871, upload-time = "2026-05-18T19:58:19.651Z" }, { url = "https://files.pythonhosted.org/packages/6e/24/2e92a052fb6334fcd746d1c7cb57847c204b118c84f5da53c0f9e129f7b7/uv-0.11.15-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:be8f76d25bcf4c92bb384240ac1bf9aa7f51063d0bdeca4c9cf0ec3ed8b145e0", size = 23159007, upload-time = "2026-05-18T19:57:10.653Z" }, { url = "https://files.pythonhosted.org/packages/3d/2e/6923d0658d164bb2c435ed1868aa2d49b3074594679917a001ff92dc95bb/uv-0.11.15-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:f9f4fbbf4fe485522054f3c7496c6e8e932d6436e4200ff3daf718db0b7c7bd5", size = 23769385, upload-time = "2026-05-18T19:58:15.856Z" }, { url = "https://files.pythonhosted.org/packages/a4/99/7e34cd949e57360814e8064cc9fb7104df445d0f6a663504e5f7473480aa/uv-0.11.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0ed920e896b2fd13a35031707e307e42fbb2681458b967440a17272d86d49137", size = 23860973, upload-time = "2026-05-18T19:57:55.575Z" }, { url = "https://files.pythonhosted.org/packages/28/98/8fe1f5f9d816e94569a0298dd8e0936801097625fa1952162951f0d628b6/uv-0.11.15-py3-none-musllinux_1_1_i686.whl", hash = "sha256:41d907611f3e6a13262807fd7f0a17849f76285ca80f536f6b3943732bdc6656", size = 23431392, upload-time = "2026-05-18T19:57:59.814Z" }, { url = "https://files.pythonhosted.org/packages/cc/6b/76a1ce2fa860026913a5941700cdc7d715fce9c3277a3fa3489cf2523ca0/uv-0.11.15-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e3b68f8bf1a4568710f77e5bda9182ce7682811d89a8e7468c22460e032b234d", size = 24519478, upload-time = "2026-05-18T19:57:51.165Z" }, { url = "https://files.pythonhosted.org/packages/43/60/1d58e8a05718cb50494763115710b73846cacb651fd735d285233fd72c59/uv-0.11.15-py3-none-win32.whl", hash = "sha256:8e2da3076761086a5b76869c3f38ef0509c836046ef41ddd19485dfd7271dca9", size = 22020178, upload-time = "2026-05-18T19:58:07.64Z" }, { url = "https://files.pythonhosted.org/packages/55/53/40fcefcb348af660488597ed3c01363df7344e60611f8883750dc596f5c6/uv-0.11.15-py3-none-win_amd64.whl", hash = "sha256:cc3915ab291a1ecaf31de05f5d8bd70d09c66fe9911a53f70d9efa62ff0dbd8a", size = 24668779, upload-time = "2026-05-18T19:57:44.894Z" }, { url = "https://files.pythonhosted.org/packages/e5/7d/fa3a9960c95af9bbe2a629048760d0b9b4fead8ccd4f2235af747ec7cdf0/uv-0.11.15-py3-none-win_arm64.whl", hash = "sha256:4f39426a13dee24897aed60c4b98058c66f18bd983885ac5f4a54a04b24fbddf", size = 23198178, upload-time = "2026-05-18T19:57:14.68Z" }, ] [[package]] name = "uvicorn" version = "0.47.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload-time = "2026-05-14T18:16:54.455Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" }, ] [package.optional-dependencies] standard = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "httptools" }, { name = "python-dotenv" }, { name = "pyyaml" }, { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, { name = "watchfiles" }, { name = "websockets" }, ] [[package]] name = "uvloop" version = "0.22.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] [[package]] name = "virtualenv" version = "21.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, { name = "python-discovery" }, ] sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, ] [[package]] name = "watchfiles" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, ] [[package]] name = "websockets" version = "16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] [[package]] name = "werkzeug" version = "3.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, ] [[package]] name = "wheel" version = "0.47.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] sdist = { url = "https://files.pythonhosted.org/packages/39/62/75f18a0f03b4219c456652c7780e4d749b929eb605c098ce3a5b6b6bc081/wheel-0.47.0.tar.gz", hash = "sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3", size = 63854, upload-time = "2026-04-22T15:51:27.727Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/87/1b/9e33c09813d65e248f7f773119148a612516a4bea93e9c6f545f78455b7c/wheel-0.47.0-py3-none-any.whl", hash = "sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced", size = 32218, upload-time = "2026-04-22T15:51:26.296Z" }, ] [[package]] name = "yarl" version = "1.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641, upload-time = "2026-03-01T22:04:42.841Z" }, { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248, upload-time = "2026-03-01T22:04:44.757Z" }, { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988, upload-time = "2026-03-01T22:04:46.365Z" }, { url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566, upload-time = "2026-03-01T22:04:47.639Z" }, { url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079, upload-time = "2026-03-01T22:04:48.925Z" }, { url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741, upload-time = "2026-03-01T22:04:50.838Z" }, { url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099, upload-time = "2026-03-01T22:04:52.499Z" }, { url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678, upload-time = "2026-03-01T22:04:55.176Z" }, { url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803, upload-time = "2026-03-01T22:04:56.588Z" }, { url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163, upload-time = "2026-03-01T22:04:58.492Z" }, { url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859, upload-time = "2026-03-01T22:05:00.268Z" }, { url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202, upload-time = "2026-03-01T22:05:02.273Z" }, { url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866, upload-time = "2026-03-01T22:05:03.597Z" }, { url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852, upload-time = "2026-03-01T22:05:04.986Z" }, { url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919, upload-time = "2026-03-01T22:05:06.397Z" }, { url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602, upload-time = "2026-03-01T22:05:08.444Z" }, { url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461, upload-time = "2026-03-01T22:05:10.145Z" }, { url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336, upload-time = "2026-03-01T22:05:11.554Z" }, { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ]