diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2aec1f5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,104 @@ +name: 🔂 Unit tests + +concurrency: + group: ${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +on: + push: + branches: [main] + pull_request: + branches: [main] + paths: + - "src/**" + - "noxfile.py" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/test.yml" + +jobs: + generate-jobs-tests: + name: 💻 Generate test matrix + runs-on: ubuntu-latest + outputs: + sessions: ${{ steps.set-matrix.outputs.sessions }} + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v3 + - run: uv venv + - run: uv pip install nox + - id: set-matrix + shell: bash + run: | + . .venv/bin/activate + echo sessions=$( + nox --json -t tests -l | + jq 'map( + { + session, + name: "\( .name ) on \( .python )\( if .call_spec != {} then " (\(.call_spec | to_entries | map("\(.key)=\(.value)") | join(", ")))" else "" end )" + } + )' + ) | tee --append $GITHUB_OUTPUT + + unit-tests: + name: 🔬 ${{ matrix.session.name }} + needs: [generate-jobs-tests] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + session: ${{ fromJson(needs.generate-jobs-tests.outputs.sessions) }} + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: | + 3.9 + 3.10 + 3.11 + 3.12 + 3.13 + + - run: pip install nox uv + - run: nox -r -t tests -s "${{ matrix.session.session }}" + - uses: actions/upload-artifact@v4 + if: ${{ always() }} + with: + name: coverage-${{ matrix.session.session }} + path: coverage.xml + + upload-coverage: + name: 🆙 Upload Coverage + needs: [unit-tests] + runs-on: ubuntu-latest + + steps: + - uses: actions/download-artifact@v4 + - uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: true + verbose: true + + # lint: + # name: ✨ Lint + # runs-on: ubuntu-latest + + # steps: + # - uses: actions/checkout@v4 + # - run: pipx install coverage + # - uses: actions/setup-python@v5 + # id: setup-python + # with: + # python-version: "3.12" + + # - run: poetry install --with integrations + # if: steps.setup-python.outputs.cache-hit != 'true' + + # - run: | + # mkdir .mypy_cache + + # uv run mypy --install-types --non-interactive --cache-dir=.mypy_cache/ --config-file mypy.ini diff --git a/.gitignore b/.gitignore index 2452aab..a32a8d4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,209 @@ -*.pyc -*.pyo +# Created by https://www.gitignore.io/api/python,intellij+all,visualstudiocode +# Edit at https://www.gitignore.io/?templates=python,intellij+all,visualstudiocode + +### Intellij+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +.DS_Store + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij+all Patch ### +# Ignores the whole .idea folder and all .iml files +# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 + +.idea/ + +# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 + +*.iml +modules.xml +.idea/misc.xml +*.ipr + +# Sonarlint plugin +.idea/sonarlint + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg *.egg -*.egg-info +MANIFEST -.cache +# 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/ +.nox/ +.venv/ .coverage -.idea -.mypy_cache -.pytest_cache -.tox -.venv +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# pyenv +.python-version + +# Pycharm venv +venv/ + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +### VisualStudioCode ### +.vscode -/build/ -/dist/ +### VisualStudioCode Patch ### +# Ignore all local history of files +.history -docs +# End of https://www.gitignore.io/api/python,intellij+all,visualstudiocode diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 2569cf7..0000000 --- a/.travis.yml +++ /dev/null @@ -1,34 +0,0 @@ -language: python -matrix: - include: - - python: '3.7' - env: TOX_ENV=black,flake8,mypy,py37 - dist: xenial - sudo: true # required workaround for https://github.com/travis-ci/travis-ci/issues/9815 - - python: '3.6' - env: TOX_ENV=py36 - - python: '3.5' - env: TOX_ENV=py35 - - python: '3.4' - env: TOX_ENV=py34 - - python: '2.7' - env: TOX_ENV=py27 - - python: 'pypy3.5' - env: TOX_ENV=pypy3 - - python: 'pypy' - env: TOX_ENV=pypy -cache: - directories: - - $HOME/.cache/pip - - $TRAVIS_BUILD_DIR/.tox -install: -- pip install tox codecov -script: -- tox -e $TOX_ENV -- --cov-report term-missing --cov=graphql_server -after_success: -- codecov -deploy: - provider: pypi - user: syrusakbary - on: - tags: true diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..822a8eb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ +# Agent Instructions + +This repository provides a base library for building GraphQL servers across multiple Python web frameworks. + +## Project Structure +- `src/graphql_server/` contains the library implementation. +- `src/tests/` houses the unit tests. +- `docs/` includes framework-specific documentation. +- `pyproject.toml` defines project metadata and dependencies. +- `noxfile.py` holds automation sessions for linting and testing. + +## Running Tests +Run the full test suite with: + +```bash +uv run pytest +``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..21fda5f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,159 @@ +# Contributing to `graphql-server` + +First off, thanks for taking the time to contribute! + +The following is a set of guidelines for contributing to `graphql-server` on GitHub. +These are mostly guidelines, not rules. Use your best judgment, and feel free +to propose changes to this document in a pull request. + +#### Table of contents + +[How to contribute](#how-to-contribute) + +- [Reporting bugs](#reporting-bugs) +- [Suggesting enhancements](#suggesting-enhancements) +- [Contributing to code](#contributing-to-code) + +## How to contribute + +### Reporting bugs + +This section guides you through submitting a bug report for `graphql-server`. +Following these guidelines helps maintainers and the community understand your report, reproduce the behavior, and find related reports. + +Before creating bug reports, please check +[this list](#before-submitting-a-bug-report) to be sure that you need to create +one. When you are creating a bug report, please include as many details as +possible. Make sure you include the Python and `graphql-server` versions. + +> **Note:** If you find a **Closed** issue that seems like it is the same thing +> that you're experiencing, open a new issue and include a link to the original +> issue in the body of your new one. + +#### Before submitting a bug report + +- Check that your issue does not already exist in the issue tracker on GitHub. + +#### How do I submit a bug report? + +Bugs are tracked on the issue tracker on GitHub where you can create a new one. + +Explain the problem and include additional details to help maintainers reproduce +the problem: + +- **Use a clear and descriptive title** for the issue to identify the problem. +- **Describe the exact steps which reproduce the problem** in as many details as + possible. +- **Provide specific examples to demonstrate the steps to reproduce the issue**. + Include links to files or GitHub projects, or copy-paste-able snippets, which you use in those examples. +- **Describe the behavior you observed after following the steps** and point out + what exactly is the problem with that behavior. +- **Explain which behavior you expected to see instead and why.** + +Provide more context by answering these questions: + +- **Did the problem start happening recently** (e.g. after updating to a new version of `graphql-server`) or was this always a problem? +- If the problem started happening recently, **can you reproduce the problem in + an older version of `graphql-server`?** What's the most recent version in which the problem doesn't happen? +- **Can you reliably reproduce the issue?** If not, provide details about how + often the problem happens and under which conditions it normally happens. + +Include details about your configuration and environment: + +- **Which version of `graphql-server` are you using?** +- **Which Python version `graphql-server` has been installed for?** +- **What's the name and version of the OS you're using?** + +### Suggesting enhancements + +This section guides you through submitting an enhancement suggestion for +`graphql-server`, including completely new features and minor improvements to existing +functionality. Following these guidelines helps maintainers and the community +understand your suggestion and find related suggestions. + +Before creating enhancement suggestions, please check +[this list](#before-submitting-an-enhancement-suggestion) as you might find out +that you don't need to create one. When you are creating an enhancement +suggestion, please +[include as many details as possible](#how-do-i-submit-an-enhancement-suggestion). + +#### Before submitting an enhancement suggestion + +- Check that your issue does not already exist in the issue tracker on GitHub. + +#### How do I submit an enhancement suggestion? + +Enhancement suggestions are tracked on the project's issue tracker on GitHub +where you can create a new one and provide the following information: + +- **Use a clear and descriptive title** for the issue to identify the + suggestion. +- **Provide a step-by-step description of the suggested enhancement** in as many + details as possible. +- **Provide specific examples to demonstrate the steps**. +- **Describe the current behavior** and **explain which behavior you expected to + see instead** and why. + +### Contributing to code + +> This section is about contributing to +[`graphql-server`](https://github.com/graphql-python/`graphql-server`). + +#### Local development + +You will need `uv` to start contributing to `graphql-server`. Refer to the +[documentation](https://docs.astral.sh/uv/) to start using `uv`. + +You will first need to clone the repository using `git` and place yourself in +its directory: + +```shell +$ git clone git@github.com:graphql-python/`graphql-server`.git +$ cd `graphql-server` +``` + +Now, you will need to install the required dependencies for ``graphql-server`` and be sure +that the current tests are passing on your machine: + +```shell +$ uv sync --group integrations +``` + +Some tests are known to be inconsistent. (The fix is in progress.) These tests are marked with the `pytest.mark.flaky` marker. + +`graphql-server` uses the [black](https://github.com/ambv/black) coding style and you +must ensure that your code follows it. If not, the CI will fail and your Pull Request will not be merged. + +To make sure that you don't accidentally commit code that does not follow the +coding style, you can install a pre-commit hook that will check that everything +is in order: + +```shell +$ uv run pre-commit install +``` + +Your code must always be accompanied by corresponding tests. If tests are not +present, your code will not be merged. + +#### Pull requests + +- Be sure that your pull request contains tests that cover the changed or added + code. +- If your changes warrant a documentation change, the pull request must also + update the documentation. + +##### RELEASE.md files + +When you submit a PR, make sure to include a RELEASE.md file. We use that to automatically do releases here on GitHub and, most importantly, to PyPI! + +So as soon as your PR is merged, a release will be made. + +Here's an example of RELEASE.md: + +```text +Release type: patch + +Description of the changes, ideally with some examples, if adding a new feature. +``` + +Release type can be one of patch, minor or major. We use [semver](https://semver.org/), so make sure to pick the appropriate type. If in doubt feel free to ask :) diff --git a/LICENSE b/LICENSE index be33212..b22a8a1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,8 @@ -The MIT License (MIT) +MIT License -Copyright (c) 2017-Present Syrus Akbary +Copyright (c) GraphQL Contributors (GraphQL.js) +Copyright (c) 2018 Patrick Arminio (Strawberry) +Copyright (c) Syrus Akbary (GraphQL Server) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 04f196a..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include README.md -include LICENSE diff --git a/README.md b/README.md index 74ddea6..cfd2540 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,48 @@ -# GraphQL-Server-Core + -[![PyPI version](https://badge.fury.io/py/graphql-server-core.svg)](https://badge.fury.io/py/graphql-server-core) -[![Build Status](https://travis-ci.org/graphql-python/graphql-server-core.svg?branch=master)](https://travis-ci.org/graphql-python/graphql-server-core) -[![Coverage Status](https://codecov.io/gh/graphql-python/graphql-server-core/branch/master/graph/badge.svg)](https://codecov.io/gh/graphql-python/graphql-server-core) +[![PyPI version](https://badge.fury.io/py/graphql-server.svg)](https://badge.fury.io/py/graphql-server) +[![Coverage Status](https://codecov.io/gh/graphql-python/graphql-server/branch/master/graph/badge.svg)](https://codecov.io/gh/graphql-python/graphql-server) -GraphQL-Server-Core is a base library that serves as a helper +GraphQL-Server is a base library that serves as a helper for building GraphQL servers or integrations into existing web frameworks using [GraphQL-Core](https://github.com/graphql-python/graphql-core). -## Existing integrations built with GraphQL-Server-Core +## Integrations built with GraphQL-Server -| Server integration | Package | -|---|---| -| Flask | [flask-graphql](https://github.com/graphql-python/flask-graphql/) | -| Sanic |[sanic-graphql](https://github.com/graphql-python/sanic-graphql/) | -| AIOHTTP | [aiohttp-graphql](https://github.com/graphql-python/aiohttp-graphql) | -| WebOb (Pyramid, TurboGears) | [webob-graphql](https://github.com/graphql-python/webob-graphql/) | -| WSGI | [wsgi-graphql](https://github.com/moritzmhmk/wsgi-graphql) | -| Responder | [responder.ext.graphql](https://github.com/kennethreitz/responder/blob/master/responder/ext/graphql.py) | +| Server integration | Docs | +| --------------------------- | --------------------------------------------------------------------------------------- | +| aiohttp | [aiohttp](https://github.com/graphql-python/graphql-server/blob/master/docs/aiohttp.md) | +| asgi | [asgi](https://github.com/graphql-python/graphql-server/blob/master/docs/asgi.md) | +| Chalice | [chalice](https://github.com/graphql-python/graphql-server/blob/master/docs/chalice.md) | +| Channels (Django) | [channels](https://github.com/graphql-python/graphql-server/blob/master/docs/channels.md) | +| Django | [django](https://github.com/graphql-python/graphql-server/blob/master/docs/django.md) | +| FastAPI | [fastapi](https://github.com/graphql-python/graphql-server/blob/master/docs/fastapi.md) | +| Flask | [flask](https://github.com/graphql-python/graphql-server/blob/master/docs/flask.md) | +| Litestar | [litestar](https://github.com/graphql-python/graphql-server/blob/master/docs/litestar.md) | +| WebOb | [webob](https://github.com/graphql-python/graphql-server/blob/master/docs/webob.md) | +| Quart | [quart](https://github.com/graphql-python/graphql-server/blob/master/docs/quart.md) | +| Sanic | [sanic](https://github.com/graphql-python/graphql-server/blob/master/docs/sanic.md) | + +## Other integrations built with GraphQL-Server + +| Server integration | Package | +| ------------------ | ------------------------------------------------------------------------------------------------------- | +| WSGI | [wsgi-graphql](https://github.com/moritzmhmk/wsgi-graphql) | +| Responder | [responder.ext.graphql](https://github.com/kennethreitz/responder/blob/master/responder/ext/graphql.py) | ## Other integrations using GraphQL-Core or Graphene -| Server integration | Package | -|---|---| -| Django | [graphene-django](https://github.com/graphql-python/graphene-django/) | +| Server integration | Package | +| ------------------ | --------------------------------------------------------------------- | +| Django | [graphene-django](https://github.com/graphql-python/graphene-django/) | ## Documentation The `graphql_server` package provides these public helper functions: - * `run_http_query` - * `encode_execution_results` - * `laod_json_body` - * `json_encode` - * `json_encode_pretty` +- `execute` +- `execute_sync` +- `subscribe` All functions in the package are annotated with type hints and docstrings, and you can build HTML documentation from these using `bin/build_docs`. @@ -42,3 +51,14 @@ You can also use one of the existing integrations listed above as blueprint to build your own integration or GraphQL server implementations. Please let us know when you have built something new, so we can list it here. + +## Contributing + +See [CONTRIBUTING.md](https://github.com/graphql-python/graphql-server/blob/master/CONTRIBUTING.md) + +## Licensing + +The code in this project is licensed under MIT license. See [LICENSE](./LICENSE) +for more information. + +![Recent Activity](https://images.repography.com/0/graphql-python/graphql-server/recent-activity/d751713988987e9331980363e24189ce.svg) diff --git a/README.rst b/README.rst deleted file mode 100644 index d8f2633..0000000 --- a/README.rst +++ /dev/null @@ -1,60 +0,0 @@ -GraphQL-Server-Core -=================== - -|Build Status| |Coverage Status| |PyPI version| - -GraphQL-Server-Core is a base library that serves as a helper for -building GraphQL servers or integrations into existing web frameworks -using `GraphQL-Core `__. - -Existing integrations built with GraphQL-Server-Core ----------------------------------------------------- - -=========================== ========================================================================================================== -Server integration Package -=========================== ========================================================================================================== -Flask `flask-graphql `__ -Sanic `sanic-graphql `__ -AIOHTTP `aiohttp-graphql `__ -WebOb (Pyramid, TurboGears) `webob-graphql `__ -WSGI `wsgi-graphql `__ -Responder `responder.ext.graphql `__ -=========================== ========================================================================================================== - -Other integrations using GraphQL-Core or Graphene -------------------------------------------------- - -================== ======================================================================== -Server integration Package -================== ======================================================================== -Django `graphene-django `__ -================== ======================================================================== - -Documentation -------------- - -The ``graphql_server`` package provides these public helper functions: - -- ``run_http_query`` -- ``encode_execution_results`` -- ``laod_json_body`` -- ``json_encode`` -- ``json_encode_pretty`` - -All functions in the package are annotated with type hints and -docstrings, and you can build HTML documentation from these using -``bin/build_docs``. - -You can also use one of the existing integrations listed above as -blueprint to build your own integration or GraphQL server -implementations. - -Please let us know when you have built something new, so we can list it -here. - -.. |Build Status| image:: https://travis-ci.org/graphql-python/graphql-server-core.svg?branch=master - :target: https://travis-ci.org/graphql-python/graphql-server-core -.. |Coverage Status| image:: https://coveralls.io/repos/graphql-python/graphql-server-core/badge.svg?branch=master&service=github - :target: https://coveralls.io/github/graphql-python/graphql-server-core?branch=master -.. |PyPI version| image:: https://badge.fury.io/py/graphql-server-core.svg - :target: https://badge.fury.io/py/graphql-server-core diff --git a/bin/build_docs b/bin/build_docs deleted file mode 100644 index cdc7f8b..0000000 --- a/bin/build_docs +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -# the documentation can be created from the docstrings -# with pdoc (https://pdoc3.github.io/pdoc/) - -pdoc --html --overwrite --html-dir docs graphql_server diff --git a/bin/convert_readme b/bin/convert_readme deleted file mode 100755 index 3e21799..0000000 --- a/bin/convert_readme +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -# the README can be converted from MarkDown to reST -# with pandoc (https://pandoc.org/) - -pandoc README.md --from markdown --to rst -s -o README.rst diff --git a/codecov.yml b/codecov.yml index c393a12..c155caa 100644 --- a/codecov.yml +++ b/codecov.yml @@ -7,4 +7,4 @@ coverage: status: project: default: - target: auto \ No newline at end of file + target: auto diff --git a/docs/_static/graphql-server-logo.svg b/docs/_static/graphql-server-logo.svg new file mode 100644 index 0000000..7cf6592 --- /dev/null +++ b/docs/_static/graphql-server-logo.svg @@ -0,0 +1 @@ +graphql-server-logo \ No newline at end of file diff --git a/docs/aiohttp.md b/docs/aiohttp.md new file mode 100644 index 0000000..b301855 --- /dev/null +++ b/docs/aiohttp.md @@ -0,0 +1,74 @@ +# aiohttp-Graphql + +Adds GraphQL support to your aiohttp application. + +## Installation + +To install the integration with aiohttp, run the below command on your terminal. + +`pip install graphql-server[aiohttp]` + +## Usage + +Use the `GraphQLView` view from `graphql_server.aiohttp` + +```python +from aiohttp import web +from graphql_server.aiohttp import GraphQLView + +from schema import schema + +app = web.Application() + +GraphQLView.attach(app, schema=schema, graphiql=True) + +# Optional, for adding batch query support (used in Apollo-Client) +GraphQLView.attach(app, schema=schema, batch=True, route_path="/graphql/batch") + +if __name__ == '__main__': + web.run_app(app) +``` + +This will add `/graphql` endpoint to your app (customizable by passing `route_path='/mypath'` to `GraphQLView.attach`) and enable the GraphiQL IDE. + +Note: `GraphQLView.attach` is just a convenience function, and the same functionality can be achieved with + +```python +gql_view = GraphQLView(schema=schema, graphiql=True) +app.router.add_route('*', '/graphql', gql_view, name='graphql') +``` + +It's worth noting that the the "view function" of `GraphQLView` is contained in `GraphQLView.__call__`. So, when you create an instance, that instance is callable with the request object as the sole positional argument. To illustrate: + +```python +gql_view = GraphQLView(schema=Schema, **kwargs) +gql_view(request) # <-- the instance is callable and expects a `aiohttp.web.Request` object. +``` + +### Supported options for GraphQLView + + * `schema`: The GraphQL schema object that you want the view to execute when it gets a valid request. Accepts either an object of type `GraphQLSchema` from `graphql-core` or `Schema` from `graphene`. For Graphene v3, passing either `schema: graphene.Schema` or `schema.graphql_schema` is allowed. + * `context`: A value to pass as the `context_value` to graphql `execute` function. By default is set to `dict` with request object at key `request`. + * `root_value`: The `root_value` you want to provide to graphql `execute`. + * `pretty`: Whether or not you want the response to be pretty printed JSON. + * `graphiql`: If `True`, may present [GraphiQL](https://github.com/graphql/graphiql) when loaded directly from a browser (a useful tool for debugging and exploration). + * `graphiql_version`: The graphiql version to load. Defaults to **"2.2.0"**. + * `graphiql_template`: Inject a Jinja template string to customize GraphiQL. + * `graphiql_html_title`: The graphiql title to display. Defaults to **"GraphiQL"**. + * `jinja_env`: Sets jinja environment to be used to process GraphiQL template. If Jinja’s async mode is enabled (by `enable_async=True`), uses `Template.render_async` instead of `Template.render`. If environment is not set, fallbacks to simple regex-based renderer. + * `batch`: Set the GraphQL view as batch (for using in [Apollo-Client](http://dev.apollodata.com/core/network.html#query-batching) or [ReactRelayNetworkLayer](https://github.com/nodkz/react-relay-network-layer)) + * `middleware`: A list of graphql [middlewares](http://docs.graphene-python.org/en/latest/execution/middleware/). + * `validation_rules`: A list of graphql validation rules. + * `execution_context_class`: Specifies a custom execution context class. + * `max_age`: Sets the response header Access-Control-Max-Age for preflight requests. + * `encode`: the encoder to use for responses (sensibly defaults to `graphql_server.json_encode`). + * `format_error`: the error formatter to use for responses (sensibly defaults to `graphql_server.default_format_error`. + * `enable_async`: whether `async` mode will be enabled. + * `subscriptions`: The GraphiQL socket endpoint for using subscriptions in graphql-ws. + * `headers`: An optional GraphQL string to use as the initial displayed request headers, if not provided, the stored headers will be used. + * `default_query`: An optional GraphQL string to use when no query is provided and no stored query exists from a previous session. If not provided, GraphiQL will use its own default query. +* `header_editor_enabled`: An optional boolean which enables the header editor when true. Defaults to **false**. +* `should_persist_headers`: An optional boolean which enables to persist headers to storage when true. Defaults to **false**. + +## Contributing +See [CONTRIBUTING.md](../CONTRIBUTING.md) diff --git a/docs/flask.md b/docs/flask.md new file mode 100644 index 0000000..dfe0aa7 --- /dev/null +++ b/docs/flask.md @@ -0,0 +1,77 @@ +# Flask-GraphQL + +Adds GraphQL support to your Flask application. + +## Installation + +To install the integration with Flask, run the below command on your terminal. + +`pip install graphql-server[flask]` + +## Usage + +Use the `GraphQLView` view from `graphql_server.flask`. + +```python +from flask import Flask +from graphql_server.flask import GraphQLView + +from schema import schema + +app = Flask(__name__) + +app.add_url_rule('/graphql', view_func=GraphQLView.as_view( + 'graphql', + schema=schema, + graphiql=True, +)) + +# Optional, for adding batch query support (used in Apollo-Client) +app.add_url_rule('/graphql/batch', view_func=GraphQLView.as_view( + 'graphql', + schema=schema, + batch=True +)) + +if __name__ == '__main__': + app.run() +``` + +This will add `/graphql` endpoint to your app and enable the GraphiQL IDE. + +### Supported options for GraphQLView + + * `schema`: The GraphQL schema object that you want the view to execute when it gets a valid request. Accepts either an object of type `GraphQLSchema` from `graphql-core` or `Schema` from `graphene`. For Graphene v3, passing either `schema: graphene.Schema` or `schema.graphql_schema` is allowed. + * `context`: A value to pass as the `context_value` to graphql `execute` function. By default is set to `dict` with request object at key `request`. + * `root_value`: The `root_value` you want to provide to graphql `execute`. + * `pretty`: Whether or not you want the response to be pretty printed JSON. + * `graphiql`: If `True`, may present [GraphiQL](https://github.com/graphql/graphiql) when loaded directly from a browser (a useful tool for debugging and exploration). + * `graphiql_version`: The graphiql version to load. Defaults to **"2.2.0"**. + * `graphiql_template`: Inject a Jinja template string to customize GraphiQL. + * `graphiql_html_title`: The graphiql title to display. Defaults to **"GraphiQL"**. + * `jinja_env`: Sets jinja environment to be used to process GraphiQL template. If environment is not set, fallbacks to simple regex-based renderer. + * `batch`: Set the GraphQL view as batch (for using in [Apollo-Client](http://dev.apollodata.com/core/network.html#query-batching) or [ReactRelayNetworkLayer](https://github.com/nodkz/react-relay-network-layer)) + * `middleware`: A list of graphql [middlewares](http://docs.graphene-python.org/en/latest/execution/middleware/). + * `validation_rules`: A list of graphql validation rules. + * `execution_context_class`: Specifies a custom execution context class. + * `encode`: the encoder to use for responses (sensibly defaults to `graphql_server.json_encode`). + * `format_error`: the error formatter to use for responses (sensibly defaults to `graphql_server.default_format_error`. + * `subscriptions`: The GraphiQL socket endpoint for using subscriptions in graphql-ws. + * `headers`: An optional GraphQL string to use as the initial displayed request headers, if not provided, the stored headers will be used. + * `default_query`: An optional GraphQL string to use when no query is provided and no stored query exists from a previous session. If not provided, GraphiQL will use its own default query. +* `header_editor_enabled`: An optional boolean which enables the header editor when true. Defaults to **false**. +* `should_persist_headers`: An optional boolean which enables to persist headers to storage when true. Defaults to **false**. + + +You can also subclass `GraphQLView` and overwrite `get_root_value(self, request)` to have a dynamic root value +per request. + +```python +class UserRootValue(GraphQLView): + def get_root_value(self, request): + return request.user + +``` + +## Contributing +See [CONTRIBUTING.md](../CONTRIBUTING.md) diff --git a/docs/sanic.md b/docs/sanic.md new file mode 100644 index 0000000..102e38d --- /dev/null +++ b/docs/sanic.md @@ -0,0 +1,75 @@ +# Sanic-GraphQL + +Adds GraphQL support to your Sanic application. + +## Installation + +To install the integration with Sanic, run the below command on your terminal. + +`pip install graphql-server[sanic]` + +## Usage + +Use the `GraphQLView` view from `graphql_server.sanic` + +```python +from graphql_server.sanic import GraphQLView +from sanic import Sanic + +from schema import schema + +app = Sanic(name="Sanic Graphql App") + +app.add_route( + GraphQLView.as_view(schema=schema, graphiql=True), + '/graphql' +) + +# Optional, for adding batch query support (used in Apollo-Client) +app.add_route( + GraphQLView.as_view(schema=schema, batch=True), + '/graphql/batch' +) + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8000) +``` + +This will add `/graphql` endpoint to your app and enable the GraphiQL IDE. + +### Supported options for GraphQLView + + * `schema`: The GraphQL schema object that you want the view to execute when it gets a valid request. Accepts either an object of type `GraphQLSchema` from `graphql-core` or `Schema` from `graphene`. For Graphene v3, passing either `schema: graphene.Schema` or `schema.graphql_schema` is allowed. + * `context`: A value to pass as the `context_value` to graphql `execute` function. By default is set to `dict` with request object at key `request`. + * `root_value`: The `root_value` you want to provide to graphql `execute`. + * `pretty`: Whether or not you want the response to be pretty printed JSON. + * `graphiql`: If `True`, may present [GraphiQL](https://github.com/graphql/graphiql) when loaded directly from a browser (a useful tool for debugging and exploration). + * `graphiql_version`: The graphiql version to load. Defaults to **"2.2.0"**. + * `graphiql_template`: Inject a Jinja template string to customize GraphiQL. + * `graphiql_html_title`: The graphiql title to display. Defaults to **"GraphiQL"**. + * `jinja_env`: Sets jinja environment to be used to process GraphiQL template. If Jinja’s async mode is enabled (by `enable_async=True`), uses `Template.render_async` instead of `Template.render`. If environment is not set, fallbacks to simple regex-based renderer. + * `batch`: Set the GraphQL view as batch (for using in [Apollo-Client](http://dev.apollodata.com/core/network.html#query-batching) or [ReactRelayNetworkLayer](https://github.com/nodkz/react-relay-network-layer)) + * `middleware`: A list of graphql [middlewares](http://docs.graphene-python.org/en/latest/execution/middleware/). + * `validation_rules`: A list of graphql validation rules. + * `execution_context_class`: Specifies a custom execution context class. + * `max_age`: Sets the response header Access-Control-Max-Age for preflight requests. + * `encode`: the encoder to use for responses (sensibly defaults to `graphql_server.json_encode`). + * `format_error`: the error formatter to use for responses (sensibly defaults to `graphql_server.default_format_error`. + * `enable_async`: whether `async` mode will be enabled. + * `subscriptions`: The GraphiQL socket endpoint for using subscriptions in graphql-ws. + * `headers`: An optional GraphQL string to use as the initial displayed request headers, if not provided, the stored headers will be used. + * `default_query`: An optional GraphQL string to use when no query is provided and no stored query exists from a previous session. If not provided, GraphiQL will use its own default query. +* `header_editor_enabled`: An optional boolean which enables the header editor when true. Defaults to **false**. +* `should_persist_headers`: An optional boolean which enables to persist headers to storage when true. Defaults to **false**. + + +You can also subclass `GraphQLView` and overwrite `get_root_value(self, request)` to have a dynamic root value per request. + +```python +class UserRootValue(GraphQLView): + def get_root_value(self, request): + return request.user +``` + +## Contributing +See [CONTRIBUTING.md](../CONTRIBUTING.md) diff --git a/docs/webob.md b/docs/webob.md new file mode 100644 index 0000000..2f88a31 --- /dev/null +++ b/docs/webob.md @@ -0,0 +1,64 @@ +# WebOb-GraphQL + +Adds GraphQL support to your WebOb (Pyramid, Pylons, ...) application. + +## Installation + +To install the integration with WebOb, run the below command on your terminal. + +`pip install graphql-server[webob]` + +## Usage + +Use the `GraphQLView` view from `graphql_server.webob` + +### Pyramid + +```python +from wsgiref.simple_server import make_server +from pyramid.config import Configurator + +from graphql_server.webob import GraphQLView + +from schema import schema + +def graphql_view(request): + return GraphQLView(request=request, schema=schema, graphiql=True).dispatch_request(request) + +if __name__ == '__main__': + with Configurator() as config: + config.add_route('graphql', '/graphql') + config.add_view(graphql_view, route_name='graphql') + app = config.make_wsgi_app() + server = make_server('0.0.0.0', 6543, app) + server.serve_forever() +``` + +This will add `/graphql` endpoint to your app and enable the GraphiQL IDE. + +### Supported options for GraphQLView + + * `schema`: The GraphQL schema object that you want the view to execute when it gets a valid request. Accepts either an object of type `GraphQLSchema` from `graphql-core` or `Schema` from `graphene`. For Graphene v3, passing either `schema: graphene.Schema` or `schema.graphql_schema` is allowed. + * `context`: A value to pass as the `context_value` to graphql `execute` function. By default is set to `dict` with request object at key `request`. + * `root_value`: The `root_value` you want to provide to graphql `execute`. + * `pretty`: Whether or not you want the response to be pretty printed JSON. + * `graphiql`: If `True`, may present [GraphiQL](https://github.com/graphql/graphiql) when loaded directly from a browser (a useful tool for debugging and exploration). + * `graphiql_version`: The graphiql version to load. Defaults to **"2.2.0"**. + * `graphiql_template`: Inject a Jinja template string to customize GraphiQL. + * `graphiql_html_title`: The graphiql title to display. Defaults to **"GraphiQL"**. + * `jinja_env`: Sets jinja environment to be used to process GraphiQL template. If environment is not set, fallbacks to simple regex-based renderer. + * `batch`: Set the GraphQL view as batch (for using in [Apollo-Client](http://dev.apollodata.com/core/network.html#query-batching) or [ReactRelayNetworkLayer](https://github.com/nodkz/react-relay-network-layer)) + * `middleware`: A list of graphql [middlewares](http://docs.graphene-python.org/en/latest/execution/middleware/). + * `validation_rules`: A list of graphql validation rules. + * `execution_context_class`: Specifies a custom execution context class. + * `encode`: the encoder to use for responses (sensibly defaults to `graphql_server.json_encode`). + * `format_error`: the error formatter to use for responses (sensibly defaults to `graphql_server.default_format_error`. + * `enable_async`: whether `async` mode will be enabled. + * `subscriptions`: The GraphiQL socket endpoint for using subscriptions in graphql-ws. + * `headers`: An optional GraphQL string to use as the initial displayed request headers, if not provided, the stored headers will be used. + * `default_query`: An optional GraphQL string to use when no query is provided and no stored query exists from a previous session. If not provided, GraphiQL will use its own default query. +* `header_editor_enabled`: An optional boolean which enables the header editor when true. Defaults to **false**. +* `should_persist_headers`: An optional boolean which enables to persist headers to storage when true. Defaults to **false**. + +## Contributing +See [CONTRIBUTING.md](../CONTRIBUTING.md) diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py deleted file mode 100644 index e7457f7..0000000 --- a/graphql_server/__init__.py +++ /dev/null @@ -1,322 +0,0 @@ -""" -GraphQL-Server-Core -=================== - -GraphQL-Server-Core is a base library that serves as a helper -for building GraphQL servers or integrations into existing web frameworks using -[GraphQL-Core](https://github.com/graphql-python/graphql-core). -""" - - -import json -from collections import namedtuple - -import six -from graphql import get_default_backend -from graphql.error import format_error as default_format_error -from graphql.execution import ExecutionResult -from graphql.type import GraphQLSchema - -from .error import HttpQueryError - -try: # pragma: no cover (Python >= 3.3) - from collections.abc import MutableMapping -except ImportError: # pragma: no cover (Python < 3.3) - # noinspection PyUnresolvedReferences,PyProtectedMember - from collections import MutableMapping - -# Necessary for static type checking -# noinspection PyUnreachableCode -if False: # pragma: no cover - # flake8: noqa - from typing import Any, Callable, Dict, List, Optional, Type, Union - from graphql import GraphQLBackend - - -__all__ = [ - "run_http_query", - "encode_execution_results", - "load_json_body", - "json_encode", - "json_encode_pretty", - "HttpQueryError", - "RequestParams", - "ServerResults", - "ServerResponse", -] - - -# The public data structures - -RequestParams = namedtuple("RequestParams", "query variables operation_name") - -ServerResults = namedtuple("ServerResults", "results params") - -ServerResponse = namedtuple("ServerResponse", "body status_code") - - -# The public helper functions - - -def run_http_query( - schema, # type: GraphQLSchema - request_method, # type: str - data, # type: Union[Dict, List[Dict]] - query_data=None, # type: Optional[Dict] - batch_enabled=False, # type: bool - catch=False, # type: bool - **execute_options # type: Any -): - """Execute GraphQL coming from an HTTP query against a given schema. - - You need to pass the schema (that is supposed to be already validated), - the request_method (that must be either "get" or "post"), - the data from the HTTP request body, and the data from the query string. - By default, only one parameter set is expected, but if you set batch_enabled, - you can pass data that contains a list of parameter sets to run multiple - queries as a batch execution using a single HTTP request. You can specify - whether results returning HTTPQueryErrors should be caught and skipped. - All other keyword arguments are passed on to the GraphQL-Core function for - executing GraphQL queries. - - Returns a ServerResults tuple with the list of ExecutionResults as first item - and the list of parameters that have been used for execution as second item. - """ - if not isinstance(schema, GraphQLSchema): - raise TypeError("Expected a GraphQL schema, but received {!r}.".format(schema)) - if request_method not in ("get", "post"): - raise HttpQueryError( - 405, - "GraphQL only supports GET and POST requests.", - headers={"Allow": "GET, POST"}, - ) - if catch: - catch_exc = ( - HttpQueryError - ) # type: Union[Type[HttpQueryError], Type[_NoException]] - else: - catch_exc = _NoException - is_batch = isinstance(data, list) - - is_get_request = request_method == "get" - allow_only_query = is_get_request - - if not is_batch: - if not isinstance(data, (dict, MutableMapping)): - raise HttpQueryError( - 400, "GraphQL params should be a dict. Received {!r}.".format(data) - ) - data = [data] - elif not batch_enabled: - raise HttpQueryError(400, "Batch GraphQL requests are not enabled.") - - if not data: - raise HttpQueryError(400, "Received an empty list in the batch request.") - - extra_data = {} # type: Dict[str, Any] - # If is a batch request, we don't consume the data from the query - if not is_batch: - extra_data = query_data or {} - - all_params = [get_graphql_params(entry, extra_data) for entry in data] - - results = [ - get_response(schema, params, catch_exc, allow_only_query, **execute_options) - for params in all_params - ] - - return ServerResults(results, all_params) - - -def encode_execution_results( - execution_results, # type: List[Optional[ExecutionResult]] - format_error=None, # type: Callable[[Exception], Dict] - is_batch=False, # type: bool - encode=None, # type: Callable[[Dict], Any] -): - # type: (...) -> ServerResponse - """Serialize the ExecutionResults. - - This function takes the ExecutionResults that are returned by run_http_query() - and serializes them using JSON to produce an HTTP response. - If you set is_batch=True, then all ExecutionResults will be returned, otherwise only - the first one will be used. You can also pass a custom function that formats the - errors in the ExecutionResults, expecting a dictionary as result and another custom - function that is used to serialize the output. - - Returns a ServerResponse tuple with the serialized response as the first item and - a status code of 200 or 400 in case any result was invalid as the second item. - """ - results = [ - format_execution_result(execution_result, format_error or default_format_error) - for execution_result in execution_results - ] - result, status_codes = zip(*results) - status_code = max(status_codes) - - if not is_batch: - result = result[0] - - return ServerResponse((encode or json_encode)(result), status_code) - - -def load_json_body(data): - # type: (str) -> Union[Dict, List] - """Load the request body as a dictionary or a list. - - The body must be passed in a string and will be deserialized from JSON, - raising an HttpQueryError in case of invalid JSON. - """ - try: - return json.loads(data) - except Exception: - raise HttpQueryError(400, "POST body sent invalid JSON.") - - -def json_encode(data): - # type: (Union[Dict,List]) -> str - """Serialize the given data(a dictionary or a list) using JSON. - - The given data (a dictionary or a list) will be serialized using JSON - and returned as a string that will be nicely formatted if you set pretty=True. - """ - return json.dumps(data, separators=(",", ":")) - - -def json_encode_pretty(data): - # type: (Union[Dict,List]) -> str - """Serialize the given data using JSON with nice formatting.""" - return json.dumps(data, indent=2, separators=(",", ": ")) - - -# Some more private helpers - -FormattedResult = namedtuple("FormattedResult", "result status_code") - - -class _NoException(Exception): - """Private exception used when we don't want to catch any real exception.""" - - -def get_graphql_params(data, query_data): - # type: (Dict, Dict) -> RequestParams - """Fetch GraphQL query, variables and operation name parameters from given data. - - You need to pass both the data from the HTTP request body and the HTTP query string. - Params from the request body will take precedence over those from the query string. - - You will get a RequestParams tuple with these parameters in return. - """ - query = data.get("query") or query_data.get("query") - variables = data.get("variables") or query_data.get("variables") - # document_id = data.get('documentId') - operation_name = data.get("operationName") or query_data.get("operationName") - - return RequestParams(query, load_json_variables(variables), operation_name) - - -def load_json_variables(variables): - # type: (Optional[Union[str, Dict]]) -> Optional[Dict] - """Return the given GraphQL variables as a dictionary. - - The function returns the given GraphQL variables, making sure they are - deserialized from JSON to a dictionary first if necessary. In case of - invalid JSON input, an HttpQueryError will be raised. - """ - if variables and isinstance(variables, six.string_types): - try: - return json.loads(variables) - except Exception: - raise HttpQueryError(400, "Variables are invalid JSON.") - return variables # type: ignore - - -def execute_graphql_request( - schema, # type: GraphQLSchema - params, # type: RequestParams - allow_only_query=False, # type: bool - backend=None, # type: GraphQLBackend - **kwargs # type: Any -): - """Execute a GraphQL request and return an ExecutionResult. - - You need to pass the GraphQL schema and the GraphQLParams that you can get - with the get_graphql_params() function. If you only want to allow GraphQL query - operations, then set allow_only_query=True. You can also specify a custom - GraphQLBackend instance that shall be used by GraphQL-Core instead of the - default one. All other keyword arguments are passed on to the GraphQL-Core - function for executing GraphQL queries. - """ - if not params.query: - raise HttpQueryError(400, "Must provide query string.") - - try: - if not backend: - backend = get_default_backend() - document = backend.document_from_string(schema, params.query) - except Exception as e: - return ExecutionResult(errors=[e], invalid=True) - - if allow_only_query: - operation_type = document.get_operation_type(params.operation_name) - if operation_type and operation_type != "query": - raise HttpQueryError( - 405, - "Can only perform a {} operation from a POST request.".format( - operation_type - ), - headers={"Allow": "POST"}, - ) - - try: - return document.execute( - operation_name=params.operation_name, variables=params.variables, **kwargs - ) - except Exception as e: - return ExecutionResult(errors=[e], invalid=True) - - -def get_response( - schema, # type: GraphQLSchema - params, # type: RequestParams - catch_exc, # type: Type[BaseException] - allow_only_query=False, # type: bool - **kwargs # type: Any -): - # type: (...) -> Optional[ExecutionResult] - """Get an individual execution result as response, with option to catch errors. - - This does the same as execute_graphql_request() except that you can catch errors - that belong to an exception class that you need to pass as a parameter. - """ - # noinspection PyBroadException - try: - execution_result = execute_graphql_request( - schema, params, allow_only_query, **kwargs - ) - except catch_exc: - return None - - return execution_result - - -def format_execution_result( - execution_result, # type: Optional[ExecutionResult] - format_error, # type: Optional[Callable[[Exception], Dict]] -): - # type: (...) -> FormattedResult - """Format an execution result into a GraphQLResponse. - - This converts the given execution result into a FormattedResult that contains - the ExecutionResult converted to a dictionary and an appropriate status code. - """ - status_code = 200 - - if execution_result: - if execution_result.invalid: - status_code = 400 - response = execution_result.to_dict(format_error=format_error) - else: - response = None - - return FormattedResult(response, status_code) diff --git a/graphql_server/error.py b/graphql_server/error.py deleted file mode 100644 index b0ca74a..0000000 --- a/graphql_server/error.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Error classes provided by graphql_server""" - -__all__ = ["HttpQueryError"] - - -class HttpQueryError(Exception): - """The error class for HTTP errors produced by running GraphQL queries.""" - - def __init__(self, status_code, message=None, is_graphql_error=False, headers=None): - """Create a HTTP query error. - - You need to pass the HTTP status code, the message that shall be shown, - whether this is a GraphQL error, and the HTTP headers that shall be sent. - """ - self.status_code = status_code - self.message = message - self.is_graphql_error = is_graphql_error - self.headers = headers - super(HttpQueryError, self).__init__(message) - - def __eq__(self, other): - """Check whether this HTTP query error is equal to another one.""" - return ( - isinstance(other, HttpQueryError) - and other.status_code == self.status_code - and other.message == self.message - and other.headers == self.headers - ) - - def __hash__(self): - """Create a hash value for this HTTP query error.""" - headers_hash = tuple(self.headers.items()) if self.headers else None - return hash((self.status_code, self.message, headers_hash)) diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..b447ef4 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,81 @@ +[mypy] +files = graphql_server +plugins = pydantic.mypy +implicit_reexport = False +warn_unused_configs = True +warn_unused_ignores = True +check_untyped_defs = True +ignore_errors = False +strict_optional = True +show_error_codes = True +warn_redundant_casts = True +ignore_missing_imports = True +install_types = True +non_interactive = True +show_traceback = True +# TODO: enable strict at some point +;strict = True + +; Disabled because of this bug: https://github.com/python/mypy/issues/9689 +; disallow_untyped_decorators = True + +[mypy-graphql.*] +ignore_errors = True + +[mypy-pydantic.*] +ignore_errors = True + +[mypy-pydantic_core.*] +ignore_errors = True + +[mypy-rich.*] +ignore_errors = True + +[mypy-libcst.*] +ignore_errors = True + +[mypy-pygments.*] +ignore_missing_imports = True + +[mypy-email_validator.*] +ignore_missing_imports = True +ignore_errors = True + +[mypy-dotenv.*] +ignore_missing_imports = True + +[mypy-django.apps.*] +ignore_missing_imports = True + +[mypy-django.http.*] +ignore_missing_imports = True + +[mypy-cached_property.*] +ignore_missing_imports = True + +[mypy-importlib_metadata.*] +ignore_errors = True + +[mypy-anyio.*] +ignore_errors = True + +[mypy-dns.*] +ignore_errors = True + +[mypy-click.*] +ignore_errors = True + +[mypy-h11.*] +ignore_errors = True + +[mypy-httpx.*] +ignore_errors = True + +[mypy-httpcore.*] +ignore_errors = True + +[mypy-idna.*] +ignore_errors = True + +[mypy-markdown_it.*] +ignore_errors = True diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..c7cca39 --- /dev/null +++ b/noxfile.py @@ -0,0 +1,141 @@ +import itertools +from typing import Any, Callable + +import nox +from nox import Session, session + +nox.options.default_venv_backend = "uv|virtualenv" +# nox.options.reuse_existing_virtualenvs = True +# nox.options.error_on_external_run = True +# nox.options.default_venv_backend = "uv" + +PYTHON_VERSIONS = ["3.13", "3.12", "3.11", "3.10", "3.9"] + +GQL_CORE_VERSIONS = [ + "3.3.0a9", + "3.2.3", +] + +COMMON_PYTEST_OPTIONS = [ + "--cov=.", + "--cov-append", + "--cov-report=xml", + "-n", + "auto", + "--showlocals", + "-vv", + "--ignore=tests/typecheckers", + "--ignore=tests/cli", + "--ignore=tests/benchmarks", + "--ignore=tests/experimental/pydantic", +] + +INTEGRATIONS = [ + "asgi", + "aiohttp", + "chalice", + "channels", + "django", + "fastapi", + "flask", + "webob", + "quart", + "sanic", + "litestar", + "pydantic", +] + + +def _install_gql_core(session: Session, version: str) -> None: + session.install(f"graphql-core=={version}") + + +gql_core_parametrize = nox.parametrize( + "gql_core", + GQL_CORE_VERSIONS, +) + + +def with_gql_core_parametrize(name: str, params: list[str]) -> Callable[[Any], Any]: + # github cache doesn't support comma in the name, this is a workaround. + arg_names = f"{name}, gql_core" + combinations = list(itertools.product(params, GQL_CORE_VERSIONS)) + ids = [f"{name}-{comb[0]}__graphql-core-{comb[1]}" for comb in combinations] + return lambda fn: nox.parametrize(arg_names, combinations, ids=ids)(fn) + + +@session(python=PYTHON_VERSIONS, name="Tests", tags=["tests"]) +@gql_core_parametrize +def tests(session: Session, gql_core: str) -> None: + session.run_always("uv", "sync", "--group", "dev", external=True) + session.run_always("uv", "sync", "--group", "integrations", external=True) + _install_gql_core(session, gql_core) + markers = ( + ["-m", f"not {integration}", f"--ignore=tests/{integration}"] + for integration in INTEGRATIONS + ) + markers = [item for sublist in markers for item in sublist] + + session.run( + "uv", + "run", + "pytest", + "--ignore", + "tests/websockets/test_graphql_ws.py", + *COMMON_PYTEST_OPTIONS, + *markers, + ) + + +@session(python=["3.12"], name="Django tests", tags=["tests"]) +@with_gql_core_parametrize("django", ["5.1.3", "5.0.9", "4.2.0"]) +def tests_django(session: Session, django: str, gql_core: str) -> None: + session.run_always("uv", "sync", "--group", "dev", external=True) + session.run_always("uv", "sync", "--group", "integrations", external=True) + + _install_gql_core(session, gql_core) + session.install(f"django~={django}") # type: ignore + session.install("pytest-django") # type: ignore + + session.run("uv", "run", "pytest", *COMMON_PYTEST_OPTIONS, "-m", "django") + + +@session(python=["3.11"], name="Starlette tests", tags=["tests"]) +@gql_core_parametrize +def tests_starlette(session: Session, gql_core: str) -> None: + session.run_always("uv", "sync", "--group", "dev", external=True) + session.run_always("uv", "sync", "--group", "integrations", external=True) + + session.install("starlette") # type: ignore + _install_gql_core(session, gql_core) + session.run("uv", "run", "pytest", *COMMON_PYTEST_OPTIONS, "-m", "asgi") + + +@session(python=["3.11"], name="Test integrations", tags=["tests"]) +@with_gql_core_parametrize( + "integration", + [ + "aiohttp", + "chalice", + "channels", + "fastapi", + "flask", + "webob", + "quart", + "sanic", + "litestar", + ], +) +def tests_integrations(session: Session, integration: str, gql_core: str) -> None: + session.run_always("uv", "sync", "--group", "dev", external=True) + session.run_always("uv", "sync", "--group", "integrations", external=True) + + session.install(integration) # type: ignore + _install_gql_core(session, gql_core) + if integration == "aiohttp": + session.install("pytest-aiohttp") # type: ignore + elif integration == "channels": + session.install("pytest-django") # type: ignore + session.install("daphne") # type: ignore + + session.run("uv", "run", "pytest", *COMMON_PYTEST_OPTIONS, "-m", integration) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ce70fac --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,343 @@ +[project] +name = "graphql-server" +version = "3.0.0b8" +description = "A library for creating GraphQL APIs" +authors = [{ name = "Syrus Akbary", email = "me@syrusakbary.com" }] +license = { text = "MIT" } +readme = "README.md" +keywords = ["graphql", "api", "rest", "starlette", "async", "fastapi", "django", "flask", "litestar", "sanic", "channels", "aiohttp", "chalice", "webob", "pyright", "mypy", "codeflash"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + "License :: OSI Approved :: MIT License", +] +requires-python = ">=3.9,<4.0" +dependencies = [ + "graphql-core>=3.2.0,<3.4.0", +] + +[project.urls] +homepage = "https://graphql-server.org/" +repository = "https://github.com/graphql-python/graphql-server" +"Changelog" = "https://github.com/graphql-python/graphql-server/changelog" + +[project.optional-dependencies] +aiohttp = ["aiohttp>=3.7.4.post0,<4"] +asgi = ["starlette>=0.18.0", "python-multipart>=0.0.7"] +debug = ["rich>=12.0.0", "libcst"] +debug-server = [ + "starlette>=0.18.0", + "uvicorn>=0.11.6", + "websockets>=15.0.1,<16", + "python-multipart>=0.0.7", + "typer>=0.7.0", + "pygments~=2.3", + "rich>=12.0.0", + "libcst", +] +django = ["Django>=3.2", "asgiref~=3.2"] +channels = ["channels>=3.0.5", "asgiref~=3.2"] +flask = ["flask>=1.1"] +quart = ["quart>=0.19.3"] +opentelemetry = ["opentelemetry-api<2", "opentelemetry-sdk<2"] +sanic = ["sanic>=20.12.2"] +fastapi = ["fastapi>=0.65.2", "python-multipart>=0.0.7"] +chalice = ["chalice~=1.22"] +litestar = ["litestar>=2; python_version~='3.10'"] +pyinstrument = ["pyinstrument>=4.0.0"] +webob = ["WebOb>=1.8"] + +[tool.pytest.ini_options] +# addopts = "--emoji" +DJANGO_SETTINGS_MODULE = "tests.django.django_settings" +testpaths = ["tests/"] +django_find_project = false +markers = [ + "aiohttp", + "asgi", + "chalice", + "channels", + "django_db", + "django", + "fastapi", + "flaky", + "flask", + "litestar", + "webob", + "pydantic", + "quart", + "relay", + "sanic", + "starlette", +] +asyncio_mode = "auto" +filterwarnings = [ + "ignore::DeprecationWarning:graphql_server.*.resolver", + "ignore:LazyType is deprecated:DeprecationWarning", + "ignore::DeprecationWarning:ddtrace.internal", + "ignore::DeprecationWarning:django.utils.encoding", + # ignoring the text instead of the whole warning because we'd + # get an error when django is not installed + "ignore:The default value of USE_TZ", + "ignore::DeprecationWarning:pydantic_openapi_schema.*", + "ignore::DeprecationWarning:graphql.*", + "ignore::DeprecationWarning:websockets.*", + "ignore::DeprecationWarning:pydantic.*", + "ignore::UserWarning:pydantic.*", + "ignore::DeprecationWarning:pkg_resources.*", +] + + +# [tool.autopub] +# git-username = "GraphQL-bot" +# git-email = "me@syrusakbary.com" +# project-name = "GraphQL Server" +# append-github-contributor = true + +[tool.pyright] +# include = ["graphql_server"] +exclude = ["**/__pycache__", "**/.venv", "**/.pytest_cache", "**/.nox"] +reportMissingImports = true +reportMissingTypeStubs = false +pythonVersion = "3.9" +stubPath = "" + +[tool.ruff] +line-length = 88 +target-version = "py39" +fix = true +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".hg", + ".mypy_cache", + ".nox", + ".pants.d", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "venv", + "tests/*/snapshots", +] +src = ["src/graphql_server", "src/tests"] + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + # https://github.com/astral-sh/ruff/pull/4427 + # equivalent to keep-runtime-typing. We might want to enable those + # after we drop support for Python 3.9 + "UP006", + "UP007", + + # we use asserts in tests and to hint mypy + "S101", + + # Allow "Any" for annotations. We have too many Any annotations and some + # are legit. Maybe reconsider in the future, except for tests? + "ANN401", + + # Allow our exceptions to have names that don't end in "Error". Maybe refactor + # in the future? But that would be a breaking change. + "N818", + + # Allow "type: ignore" without rule code. Because we support both mypy and + # pyright, and they have different codes for the same error, we can't properly + # fix those issues. + "PGH003", + + # Variable `T` in function should be lowercase + # this seems a potential bug or opportunity for improvement in ruff + "N806", + + # shadowing builtins + "A001", + "A002", + "A003", + "A005", + + # Unused arguments + "ARG001", + "ARG002", + "ARG003", + "ARG004", + "ARG005", + + # Boolean positional arguments + "FBT001", + "FBT002", + "FBT003", + + # Too many arguments/branches/return statements + "PLR0913", + "PLR0912", + "PLR0911", + + # Do not force adding _co to covariant typevars + "PLC0105", + + # Allow private access to attributes + "SLF001", + + # code complexity + "C901", + + # Allow todo/fixme/etc comments + "TD002", + "TD003", + "FIX001", + "FIX002", + + # We don't want to add "from __future__ mport annotations" everywhere + "FA100", + + # Docstrings, maybe to enable later + "D100", + "D101", + "D102", + "D103", + "D104", + "D105", + "D106", + "D107", + "D412", + + # Allow to define exceptions text in the exception body + "TRY003", + "EM101", + "EM102", + "EM103", + + # Allow comparisons with magic numbers + "PLR2004", + + # Allow methods to use lru_cache + "B019", + + # Don't force if branches to be converted to "or" + "SIM114", + + # ruff formatter recommends to disable those, as they conflict with it + # we don't need to ever enable those. + "COM812", + "COM819", + "D206", + "E111", + "E114", + "E117", + "E501", + "ISC001", + "Q000", + "Q001", + "Q002", + "Q003", + "W191", +] + +[tool.ruff.lint.per-file-ignores] +".github/*" = ["INP001"] +"graphql_server/fastapi/*" = ["B008"] +"graphql_server/annotation.py" = ["RET505"] +"tests/*" = [ + "ANN001", + "ANN201", + "ANN202", + "ANN204", + "B008", + "B018", + "D", + "DTZ001", + "DTZ005", + "FA102", + "N805", + "PLC1901", + "PLR2004", + "PLW0603", + "PT011", + "RUF012", + "S105", + "S106", + "S603", + "S607", + "TCH001", + "TCH002", + "TCH003", + "TRY002", +] + +[tool.ruff.lint.isort] +known-first-party = ["graphql_server"] +known-third-party = ["django", "graphql"] +extra-standard-library = ["typing_extensions"] + +[tool.ruff.format] +exclude = ['tests/codegen/snapshots/', 'tests/cli/snapshots/'] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.codeflash] +# All paths are relative to this pyproject.toml's directory. +module-root = "graphql_server" +tests-root = "tests" +test-framework = "pytest" +ignore-paths = [] +formatter-cmds = ["ruff check --exit-zero --fix $file", "ruff format $file"] + +[build-system] +requires = ["uv_build>=0.7,<0.8"] +build-backend = "uv_build" + +[dependency-groups] +dev = [ + "ruff>=0.11.4,<0.12", + "asgiref>=3.2,<4.0", + "pygments>=2.3,<3.0", + "pytest>=7.2,<8.0", + "pytest-asyncio>=0.20.3", + "pytest-codspeed>=3.0.0 ; python_version>=\"3.9\"", + "pytest-cov>=4.0.0,<5.0", + "pytest-emoji>=0.2.0,<0.3", + "pytest-mock>=3.10,<4.0", + "pytest-snapshot>=0.9.0,<1.0", + "pytest-xdist[psutil]>=3.1.0,<4.0", + "python-multipart>=0.0.7", + "sanic-testing>=22.9,<24.0", + "poetry-plugin-export>=1.6.0,<2.0 ; python_version<\"4.0\"", + "urllib3<2", + "inline-snapshot>=0.10.1,<0.11", + "types-deprecated>=1.2.15.20241117,<2.0", + "types-six>=1.17.0.20250403,<2.0", + "mypy>=1.15.0,<2.0", + "pyright==1.1.401", + "codeflash>=0.9.2", + "nox>=2025.5.1", +] +integrations = [ + "aiohttp>=3.7.4.post0,<4.0", + "chalice>=1.22,<2.0", + "channels>=3.0.5,<5.0.0", + "Django>=3.2", + "fastapi>=0.65.0", + "flask>=1.1", + "quart>=0.19.3", + "pydantic>=2.0", + "pytest-aiohttp>=1.0.3,<2.0", + "pytest-django>=4.5,<5.0", + "sanic>=20.12.2", + "starlette>=0.13.6", + "litestar>=2 ; python_version>=\"3.10\" and python_version<\"4.0\"", + "uvicorn>=0.11.6", + "daphne>=4.0.0,<5.0", +] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 8de4f2d..0000000 --- a/setup.cfg +++ /dev/null @@ -1,12 +0,0 @@ -[flake8] -exclude = tests,scripts,setup.py,docs -max-line-length = 160 - -[isort] -known_first_party=graphql_server - -[tool:pytest] -norecursedirs = venv .tox .cache - -[bdist_wheel] -universal=1 diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index 64892dc..373d560 --- a/setup.py +++ b/setup.py @@ -1,37 +1,9 @@ -from setuptools import setup, find_packages +#!/usr/bin/env python -required_packages = ["graphql-core>=2.1,<3", "promise"] +# we use poetry for our build, but this file seems to be required +# in order to get GitHub dependencies graph to work -setup( - name="graphql-server-core", - version="1.1.1", - description="GraphQL Server tools for powering your server", - long_description=open("README.rst").read(), - url="https://github.com/graphql-python/graphql-server-core", - download_url="https://github.com/graphql-python/graphql-server-core/releases", - author="Syrus Akbary", - author_email="me@syrusakbary.com", - license="MIT", - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "Topic :: Software Development :: Libraries", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.3", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: Implementation :: PyPy", - "License :: OSI Approved :: MIT License", - ], - keywords="api graphql protocol rest", - packages=find_packages(exclude=["tests"]), - install_requires=required_packages, - tests_require=["pytest>=3.0"], - include_package_data=True, - zip_safe=False, - platforms="any", -) +import setuptools + +if __name__ == "__main__": + setuptools.setup(name="graphql_server") diff --git a/src/graphql_server/__init__.py b/src/graphql_server/__init__.py new file mode 100644 index 0000000..b8df3d7 --- /dev/null +++ b/src/graphql_server/__init__.py @@ -0,0 +1,33 @@ +"""GraphQL-Server +=================== + +GraphQL-Server is a base library that serves as a helper +for building GraphQL servers or integrations into existing web frameworks using +[GraphQL-Core](https://github.com/graphql-python/graphql-core). +""" + +from .runtime import ( + execute, + execute_sync, + introspect, + process_errors, + subscribe, + validate_document, +) +from .version import version, version_info + +# The GraphQL-Server 3 version info. + +__version__ = version +__version_info__ = version_info + +__all__ = [ + "execute", + "execute_sync", + "introspect", + "process_errors", + "subscribe", + "validate_document", + "version", + "version_info", +] diff --git a/src/graphql_server/aiohttp/__init__.py b/src/graphql_server/aiohttp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/graphql_server/aiohttp/test/__init__.py b/src/graphql_server/aiohttp/test/__init__.py new file mode 100644 index 0000000..47b4c12 --- /dev/null +++ b/src/graphql_server/aiohttp/test/__init__.py @@ -0,0 +1,3 @@ +from .client import GraphQLTestClient + +__all__ = ["GraphQLTestClient"] diff --git a/src/graphql_server/aiohttp/test/client.py b/src/graphql_server/aiohttp/test/client.py new file mode 100644 index 0000000..46ebe4b --- /dev/null +++ b/src/graphql_server/aiohttp/test/client.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import warnings +from typing import ( + TYPE_CHECKING, + Any, + Optional, +) + +from graphql_server.test.client import BaseGraphQLTestClient, Response + +if TYPE_CHECKING: + from collections.abc import Mapping + + +class GraphQLTestClient(BaseGraphQLTestClient): + async def query( + self, + query: str, + variables: Optional[dict[str, Mapping]] = None, + headers: Optional[dict[str, object]] = None, + asserts_errors: Optional[bool] = None, + files: Optional[dict[str, object]] = None, + assert_no_errors: Optional[bool] = True, + ) -> Response: + body = self._build_body(query, variables, files) + + resp = await self.request(body, headers, files) + data = await resp.json() + + response = Response( + errors=data.get("errors"), + data=data.get("data"), + extensions=data.get("extensions"), + ) + + if asserts_errors is not None: + warnings.warn( + "The `asserts_errors` argument has been renamed to `assert_no_errors`", + DeprecationWarning, + stacklevel=2, + ) + + assert_no_errors = ( + assert_no_errors if asserts_errors is None else asserts_errors + ) + + if assert_no_errors: + assert resp.status == 200 + assert response.errors is None + + return response + + async def request( + self, + body: dict[str, object], + headers: Optional[dict[str, object]] = None, + files: Optional[dict[str, object]] = None, + ) -> Any: + return await self._client.post( + self.url, + json=body if not files else None, + data=body if files else None, + ) + + +__all__ = ["GraphQLTestClient"] diff --git a/src/graphql_server/aiohttp/views.py b/src/graphql_server/aiohttp/views.py new file mode 100644 index 0000000..f777e54 --- /dev/null +++ b/src/graphql_server/aiohttp/views.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import asyncio +import warnings +from datetime import timedelta +from io import BytesIO +from json.decoder import JSONDecodeError +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Optional, + Union, + cast, +) +from typing_extensions import TypeGuard + +from aiohttp import http, web +from aiohttp.multipart import BodyPartReader +from graphql_server.http import GraphQLRequestData +from graphql_server.http.async_base_view import ( + AsyncBaseHTTPView, + AsyncHTTPRequestAdapter, + AsyncWebSocketAdapter, +) +from graphql_server.http.exceptions import ( + HTTPException, + NonJsonMessageReceived, + NonTextMessageReceived, + WebSocketDisconnected, +) +from graphql_server.http.types import FormData, HTTPMethod, QueryParams +from graphql_server.http.typevars import ( + Context, + RootValue, +) +from graphql_server.subscriptions import ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, +) + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Mapping, Sequence + + from graphql.type import GraphQLSchema + + from graphql_server.http import GraphQLHTTPResponse + from graphql_server.http.ides import GraphQL_IDE + + +class AiohttpHTTPRequestAdapter(AsyncHTTPRequestAdapter): + def __init__(self, request: web.Request) -> None: + self.request = request + + @property + def query_params(self) -> QueryParams: + return self.request.query.copy() # type: ignore[attr-defined] + + async def get_body(self) -> str: + return (await self.request.content.read()).decode() + + @property + def method(self) -> HTTPMethod: + return cast("HTTPMethod", self.request.method.upper()) + + @property + def headers(self) -> Mapping[str, str]: + return self.request.headers + + async def get_form_data(self) -> FormData: + reader = await self.request.multipart() + + data: dict[str, Any] = {} + files: dict[str, Any] = {} + + while field := await reader.next(): + assert isinstance(field, BodyPartReader) + assert field.name + + if field.filename: + files[field.name] = BytesIO(await field.read(decode=False)) + else: + data[field.name] = await field.text() + + return FormData(files=files, form=data) + + @property + def content_type(self) -> Optional[str]: + return self.headers.get("content-type") + + +class AiohttpWebSocketAdapter(AsyncWebSocketAdapter): + def __init__( + self, view: AsyncBaseHTTPView, request: web.Request, ws: web.WebSocketResponse + ) -> None: + super().__init__(view) + self.request = request + self.ws = ws + + async def iter_json( + self, *, ignore_parsing_errors: bool = False + ) -> AsyncGenerator[object, None]: + async for ws_message in self.ws: + if ws_message.type == http.WSMsgType.TEXT: + try: + yield self.view.decode_json(ws_message.data) + except JSONDecodeError as e: + if not ignore_parsing_errors: + raise NonJsonMessageReceived from e + + elif ws_message.type == http.WSMsgType.BINARY: + raise NonTextMessageReceived + + async def send_json(self, message: Mapping[str, object]) -> None: + try: + await self.ws.send_str(self.view.encode_json(message)) + except RuntimeError as exc: + raise WebSocketDisconnected from exc + + async def close(self, code: int, reason: str) -> None: + await self.ws.close(code=code, message=reason.encode()) + + +class GraphQLView( + AsyncBaseHTTPView[ + web.Request, + Union[web.Response, web.StreamResponse], + web.Response, + web.Request, + web.WebSocketResponse, + Context, + RootValue, + ] +): + # Mark the view as coroutine so that AIOHTTP does not confuse it with a deprecated + # bare handler function. + _is_coroutine = asyncio.coroutines._is_coroutine # type: ignore[attr-defined] + + allow_queries_via_get = True + request_adapter_class = AiohttpHTTPRequestAdapter + websocket_adapter_class = AiohttpWebSocketAdapter + + def __init__( + self, + schema: GraphQLSchema, + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + keep_alive: bool = True, + keep_alive_interval: float = 1, + debug: bool = False, + subscription_protocols: Sequence[str] = ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, + ), + connection_init_wait_timeout: timedelta = timedelta(minutes=1), + multipart_uploads_enabled: bool = False, + ) -> None: + self.schema = schema + self.allow_queries_via_get = allow_queries_via_get + self.keep_alive = keep_alive + self.keep_alive_interval = keep_alive_interval + self.debug = debug + self.subscription_protocols = subscription_protocols + self.connection_init_wait_timeout = connection_init_wait_timeout + self.multipart_uploads_enabled = multipart_uploads_enabled + + if graphiql is not None: + warnings.warn( + "The `graphiql` argument is deprecated in favor of `graphql_ide`", + DeprecationWarning, + stacklevel=2, + ) + self.graphql_ide = "graphiql" if graphiql else None + else: + self.graphql_ide = graphql_ide + + async def render_graphql_ide( + self, request: web.Request, request_data: GraphQLRequestData + ) -> web.Response: + return web.Response( + text=request_data.to_template_string(self.graphql_ide_html), + content_type="text/html", + ) + + async def get_sub_response(self, request: web.Request) -> web.Response: + return web.Response() + + def is_websocket_request(self, request: web.Request) -> TypeGuard[web.Request]: + ws = web.WebSocketResponse(protocols=self.subscription_protocols) + return ws.can_prepare(request).ok + + async def pick_websocket_subprotocol(self, request: web.Request) -> Optional[str]: + ws = web.WebSocketResponse(protocols=self.subscription_protocols) + return ws.can_prepare(request).protocol + + async def create_websocket_response( + self, request: web.Request, subprotocol: Optional[str] + ) -> web.WebSocketResponse: + protocols = [subprotocol] if subprotocol else [] + ws = web.WebSocketResponse(protocols=protocols) + await ws.prepare(request) + return ws + + async def __call__(self, request: web.Request) -> web.StreamResponse: + try: + return await self.run(request=request) + except HTTPException as e: + return web.Response( + body=e.reason, + status=e.status_code, + ) + + async def get_root_value(self, request: web.Request) -> Optional[RootValue]: + return None + + async def get_context( + self, request: web.Request, response: Union[web.Response, web.WebSocketResponse] + ) -> Context: + return {"request": request, "response": response} # type: ignore + + def create_response( + self, + response_data: GraphQLHTTPResponse, + sub_response: web.Response, + is_strict: bool, + ) -> web.Response: + status_code = getattr(sub_response, "status_code", None) + return web.Response( + text=self.encode_json(response_data), + content_type="application/graphql-response+json" + if is_strict + else "application/json", + headers=sub_response.headers, + status=status_code or sub_response.status, + ) + + async def create_streaming_response( + self, + request: web.Request, + stream: Callable[[], AsyncGenerator[str, None]], + sub_response: web.Response, + headers: dict[str, str], + ) -> web.StreamResponse: + response = web.StreamResponse( + status=sub_response.status, + headers={ + **sub_response.headers, + **headers, + }, + ) + + await response.prepare(request) + + async for data in stream(): + await response.write(data.encode()) + + await response.write_eof() + + return response + + +__all__ = ["GraphQLView"] diff --git a/src/graphql_server/asgi/__init__.py b/src/graphql_server/asgi/__init__.py new file mode 100644 index 0000000..da7c41c --- /dev/null +++ b/src/graphql_server/asgi/__init__.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import warnings +from datetime import timedelta +from json import JSONDecodeError +from typing import ( + TYPE_CHECKING, + Callable, + Optional, + Union, + cast, +) +from typing_extensions import TypeGuard + +from starlette import status +from starlette.requests import Request +from starlette.responses import ( + HTMLResponse, + PlainTextResponse, + Response, + StreamingResponse, +) +from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState + +from graphql_server.http import GraphQLRequestData +from graphql_server.http.async_base_view import ( + AsyncBaseHTTPView, + AsyncHTTPRequestAdapter, + AsyncWebSocketAdapter, +) +from graphql_server.http.exceptions import ( + HTTPException, + NonJsonMessageReceived, + NonTextMessageReceived, + WebSocketDisconnected, +) +from graphql_server.http.types import FormData, HTTPMethod, QueryParams +from graphql_server.http.typevars import ( + Context, + RootValue, +) +from graphql_server.subscriptions import ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, +) + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, AsyncIterator, Mapping, Sequence + + from graphql.type import GraphQLSchema + from starlette.types import Receive, Scope, Send + + from graphql_server.http import GraphQLHTTPResponse + from graphql_server.http.ides import GraphQL_IDE + + +class ASGIRequestAdapter(AsyncHTTPRequestAdapter): + def __init__(self, request: Request) -> None: + self.request = request + + @property + def query_params(self) -> QueryParams: + return self.request.query_params + + @property + def method(self) -> HTTPMethod: + return cast("HTTPMethod", self.request.method.upper()) + + @property + def headers(self) -> Mapping[str, str]: + return self.request.headers + + @property + def content_type(self) -> Optional[str]: + return self.request.headers.get("content-type") + + async def get_body(self) -> bytes: + return await self.request.body() + + async def get_form_data(self) -> FormData: + multipart_data = await self.request.form() + + return FormData( + files=multipart_data, + form=multipart_data, + ) + + +class ASGIWebSocketAdapter(AsyncWebSocketAdapter): + def __init__( + self, view: AsyncBaseHTTPView, request: WebSocket, response: WebSocket + ) -> None: + super().__init__(view) + self.ws = response + + async def iter_json( + self, *, ignore_parsing_errors: bool = False + ) -> AsyncGenerator[object, None]: + try: + while self.ws.application_state != WebSocketState.DISCONNECTED: + try: + text = await self.ws.receive_text() + yield self.view.decode_json(text) + except JSONDecodeError as e: # noqa: PERF203 + if not ignore_parsing_errors: + raise NonJsonMessageReceived from e + except KeyError as e: + raise NonTextMessageReceived from e + except WebSocketDisconnect: # pragma: no cover + pass + + async def send_json(self, message: Mapping[str, object]) -> None: + try: + await self.ws.send_text(self.view.encode_json(message)) + except WebSocketDisconnect as exc: + raise WebSocketDisconnected from exc + + async def close(self, code: int, reason: str) -> None: + await self.ws.close(code=code, reason=reason) + + +class GraphQL( + AsyncBaseHTTPView[ + Request, + Response, + Response, + WebSocket, + WebSocket, + Context, + RootValue, + ] +): + allow_queries_via_get = True + request_adapter_class = ASGIRequestAdapter + websocket_adapter_class = ASGIWebSocketAdapter + + def __init__( + self, + schema: GraphQLSchema, + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + keep_alive: bool = False, + keep_alive_interval: float = 1, + debug: bool = False, + subscription_protocols: Sequence[str] = ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, + ), + connection_init_wait_timeout: timedelta = timedelta(minutes=1), + multipart_uploads_enabled: bool = False, + ) -> None: + self.schema = schema + self.allow_queries_via_get = allow_queries_via_get + self.keep_alive = keep_alive + self.keep_alive_interval = keep_alive_interval + self.debug = debug + self.protocols = subscription_protocols + self.connection_init_wait_timeout = connection_init_wait_timeout + self.multipart_uploads_enabled = multipart_uploads_enabled + + if graphiql is not None: + warnings.warn( + "The `graphiql` argument is deprecated in favor of `graphql_ide`", + DeprecationWarning, + stacklevel=2, + ) + self.graphql_ide = "graphiql" if graphiql else None + else: + self.graphql_ide = graphql_ide + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "http": + http_request = Request(scope=scope, receive=receive) + + try: + response = await self.run(http_request) + except HTTPException as e: + response = PlainTextResponse(e.reason, status_code=e.status_code) + + await response(scope, receive, send) + elif scope["type"] == "websocket": + ws_request = WebSocket(scope, receive=receive, send=send) + await self.run(ws_request) + else: # pragma: no cover + raise ValueError("Unknown scope type: {!r}".format(scope["type"])) + + async def get_root_value( + self, request: Union[Request, WebSocket] + ) -> Optional[RootValue]: + return None + + async def get_context( + self, request: Union[Request, WebSocket], response: Union[Response, WebSocket] + ) -> Context: + return {"request": request, "response": response} # type: ignore + + async def get_sub_response( + self, + request: Union[Request, WebSocket], + ) -> Response: + sub_response = Response() + sub_response.status_code = None # type: ignore + del sub_response.headers["content-length"] + + return sub_response + + async def render_graphql_ide( + self, request: Request, request_data: GraphQLRequestData + ) -> Response: + return HTMLResponse(request_data.to_template_string(self.graphql_ide_html)) + + def create_response( + self, + response_data: GraphQLHTTPResponse, + sub_response: Response, + is_strict: bool, + ) -> Response: + response = Response( + self.encode_json(response_data), + status_code=sub_response.status_code or status.HTTP_200_OK, + headers=sub_response.headers, + media_type="application/graphql-response+json" + if is_strict + else "application/json", + ) + + if sub_response.background: + response.background = sub_response.background + + return response + + async def create_streaming_response( + self, + request: Request | WebSocket, + stream: Callable[[], AsyncIterator[str]], + sub_response: Response, + headers: dict[str, str], + ) -> Response: + return StreamingResponse( + stream(), + status_code=sub_response.status_code or status.HTTP_200_OK, + headers={ + **sub_response.headers, + **headers, + }, + ) + + def is_websocket_request( + self, request: Union[Request, WebSocket] + ) -> TypeGuard[WebSocket]: + return request.scope["type"] == "websocket" + + async def pick_websocket_subprotocol(self, request: WebSocket) -> Optional[str]: + protocols = request["subprotocols"] + intersection = set(protocols) & set(self.protocols) + sorted_intersection = sorted(intersection, key=protocols.index) + return next(iter(sorted_intersection), None) + + async def create_websocket_response( + self, request: WebSocket, subprotocol: Optional[str] + ) -> WebSocket: + await request.accept(subprotocol=subprotocol) + return request diff --git a/src/graphql_server/asgi/test/__init__.py b/src/graphql_server/asgi/test/__init__.py new file mode 100644 index 0000000..47b4c12 --- /dev/null +++ b/src/graphql_server/asgi/test/__init__.py @@ -0,0 +1,3 @@ +from .client import GraphQLTestClient + +__all__ = ["GraphQLTestClient"] diff --git a/src/graphql_server/asgi/test/client.py b/src/graphql_server/asgi/test/client.py new file mode 100644 index 0000000..7537df3 --- /dev/null +++ b/src/graphql_server/asgi/test/client.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any, Optional + +from graphql_server.test import BaseGraphQLTestClient + +if TYPE_CHECKING: + from collections.abc import Mapping + from typing_extensions import Literal + + +class GraphQLTestClient(BaseGraphQLTestClient): + def _build_body( + self, + query: str, + variables: Optional[dict[str, Mapping]] = None, + files: Optional[dict[str, object]] = None, + ) -> dict[str, object]: + body: dict[str, object] = {"query": query} + + if variables: + body["variables"] = variables + + if files: + assert variables is not None + assert files is not None + file_map = GraphQLTestClient._build_multipart_file_map(variables, files) + + body = { + "operations": json.dumps(body), + "map": json.dumps(file_map), + } + return body + + def request( + self, + body: dict[str, object], + headers: Optional[dict[str, object]] = None, + files: Optional[dict[str, object]] = None, + ) -> Any: + return self._client.post( + self.url, + json=body if not files else None, + data=body if files else None, + files=files, + headers=headers, + ) + + def _decode(self, response: Any, type: Literal["multipart", "json"]) -> Any: + return response.json() + + +__all__ = ["GraphQLTestClient"] diff --git a/src/graphql_server/chalice/__init__.py b/src/graphql_server/chalice/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/graphql_server/chalice/views.py b/src/graphql_server/chalice/views.py new file mode 100644 index 0000000..399adb5 --- /dev/null +++ b/src/graphql_server/chalice/views.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING, Any, Optional, Union, cast + +from chalice.app import Request, Response +from graphql_server.http import GraphQLRequestData +from graphql_server.http.exceptions import HTTPException +from graphql_server.http.sync_base_view import SyncBaseHTTPView, SyncHTTPRequestAdapter +from graphql_server.http.temporal_response import TemporalResponse +from graphql_server.http.typevars import Context, RootValue + +if TYPE_CHECKING: + from collections.abc import Mapping + + from graphql.type import GraphQLSchema + + from graphql_server.http import GraphQLHTTPResponse + from graphql_server.http.ides import GraphQL_IDE + from graphql_server.http.types import HTTPMethod, QueryParams + + +class ChaliceHTTPRequestAdapter(SyncHTTPRequestAdapter): + def __init__(self, request: Request) -> None: + self.request = request + + @property + def query_params(self) -> QueryParams: + return self.request.query_params or {} + + @property + def body(self) -> Union[str, bytes]: + return self.request.raw_body + + @property + def method(self) -> HTTPMethod: + return cast("HTTPMethod", self.request.method.upper()) + + @property + def headers(self) -> Mapping[str, str]: + return self.request.headers + + @property + def post_data(self) -> Mapping[str, Union[str, bytes]]: + raise NotImplementedError + + @property + def files(self) -> Mapping[str, Any]: + raise NotImplementedError + + @property + def content_type(self) -> Optional[str]: + return self.request.headers.get("Content-Type", None) + + +class GraphQLView( + SyncBaseHTTPView[Request, Response, TemporalResponse, Context, RootValue] +): + allow_queries_via_get: bool = True + request_adapter_class = ChaliceHTTPRequestAdapter + + def __init__( + self, + schema: GraphQLSchema, + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + ) -> None: + self.allow_queries_via_get = allow_queries_via_get + self.schema = schema + if graphiql is not None: + warnings.warn( + "The `graphiql` argument is deprecated in favor of `graphql_ide`", + DeprecationWarning, + stacklevel=2, + ) + self.graphql_ide = "graphiql" if graphiql else None + else: + self.graphql_ide = graphql_ide + + def get_root_value(self, request: Request) -> Optional[RootValue]: + return None + + def render_graphql_ide( + self, request: Request, request_data: GraphQLRequestData + ) -> Response: + return Response( + request_data.to_template_string(self.graphql_ide_html), + headers={"Content-Type": "text/html"}, + ) + + def get_sub_response(self, request: Request) -> TemporalResponse: + return TemporalResponse() + + @staticmethod + def error_response( + message: str, + error_code: str, + http_status_code: int, + headers: Optional[dict[str, str | list[str]]] = None, + ) -> Response: + """A wrapper for error responses. + + Args: + message: The error message. + error_code: The error code. + http_status_code: The HTTP status code. + headers: The headers to include in the response. + + Returns: + An errors response. + """ + body = {"Code": error_code, "Message": message} + + return Response(body=body, status_code=http_status_code, headers=headers) + + def get_context(self, request: Request, response: TemporalResponse) -> Context: + return {"request": request, "response": response} # type: ignore + + def create_response( + self, + response_data: GraphQLHTTPResponse, + sub_response: TemporalResponse, + is_strict: bool, + ) -> Response: + return Response( + body=self.encode_json(response_data), + status_code=sub_response.status_code, + headers={ + "Content-Type": "application/graphql-response+json" + if is_strict + else "application/json", + **sub_response.headers, + }, + ) + + def execute_request(self, request: Request) -> Response: + try: + return self.run(request=request) + except HTTPException as e: + error_code_map = { + 400: "BadRequestError", + 401: "UnauthorizedError", + 403: "ForbiddenError", + 404: "NotFoundError", + 409: "ConflictError", + 429: "TooManyRequestsError", + 500: "ChaliceViewError", + } + + return self.error_response( + error_code=error_code_map.get(e.status_code, "ChaliceViewError"), + message=e.reason, + http_status_code=e.status_code, + ) + + +__all__ = ["GraphQLView"] diff --git a/src/graphql_server/channels/__init__.py b/src/graphql_server/channels/__init__.py new file mode 100644 index 0000000..f680cbf --- /dev/null +++ b/src/graphql_server/channels/__init__.py @@ -0,0 +1,17 @@ +from .handlers.base import ChannelsConsumer +from .handlers.http_handler import ( + ChannelsRequest, + GraphQLHTTPConsumer, + SyncGraphQLHTTPConsumer, +) +from .handlers.ws_handler import GraphQLWSConsumer +from .router import GraphQLProtocolTypeRouter + +__all__ = [ + "ChannelsConsumer", + "ChannelsRequest", + "GraphQLHTTPConsumer", + "GraphQLProtocolTypeRouter", + "GraphQLWSConsumer", + "SyncGraphQLHTTPConsumer", +] diff --git a/src/graphql_server/channels/handlers/__init__.py b/src/graphql_server/channels/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/graphql_server/channels/handlers/base.py b/src/graphql_server/channels/handlers/base.py new file mode 100644 index 0000000..d4d8774 --- /dev/null +++ b/src/graphql_server/channels/handlers/base.py @@ -0,0 +1,213 @@ +import asyncio +import contextlib +import warnings +from collections import defaultdict +from collections.abc import AsyncGenerator, Awaitable, Sequence +from typing import ( + Any, + Callable, + Optional, +) +from typing_extensions import Literal, Protocol, TypedDict +from weakref import WeakSet + +from channels.consumer import AsyncConsumer +from channels.generic.websocket import AsyncWebsocketConsumer + + +class ChannelsMessage(TypedDict, total=False): + type: str + + +class ChannelsLayer(Protocol): # pragma: no cover + """Channels layer spec. + + Based on: https://channels.readthedocs.io/en/stable/channel_layer_spec.html + """ + + # Default channels API + + extensions: list[Literal["groups", "flush"]] + + async def send(self, channel: str, message: dict) -> None: ... + + async def receive(self, channel: str) -> dict: ... + + async def new_channel(self, prefix: str = ...) -> str: ... + + # If groups extension is supported + + group_expiry: int + + async def group_add(self, group: str, channel: str) -> None: ... + + async def group_discard(self, group: str, channel: str) -> None: ... + + async def group_send(self, group: str, message: dict) -> None: ... + + # If flush extension is supported + + async def flush(self) -> None: ... + + +class ChannelsConsumer(AsyncConsumer): + """Base channels async consumer.""" + + channel_name: str + channel_layer: Optional[ChannelsLayer] + channel_receive: Callable[[], Awaitable[dict]] + + def __init__(self, *args: str, **kwargs: Any) -> None: + self.listen_queues: defaultdict[str, WeakSet[asyncio.Queue]] = defaultdict( + WeakSet + ) + super().__init__(*args, **kwargs) + + async def dispatch(self, message: ChannelsMessage) -> None: + # AsyncConsumer will try to get a function for message["type"] to handle + # for both http/websocket types and also for layers communication. + # In case the type isn't one of those, pass it to the listen queue so + # that it can be consumed by self.channel_listen + type_ = message.get("type", "") + if type_ and not type_.startswith(("http.", "websocket.")): + for queue in self.listen_queues[type_]: + queue.put_nowait(message) + return + + await super().dispatch(message) + + async def channel_listen( + self, + type: str, + *, + timeout: Optional[float] = None, + groups: Sequence[str] = (), + ) -> AsyncGenerator[Any, None]: + """Listen for messages sent to this consumer. + + Utility to listen for channels messages for this consumer inside + a resolver (usually inside a subscription). + + Args: + type: + The type of the message to wait for. + timeout: + An optional timeout to wait for each subsequent message + groups: + An optional sequence of groups to receive messages from. + When passing this parameter, the groups will be registered + using `self.channel_layer.group_add` at the beggining of the + execution and then discarded using `self.channel_layer.group_discard` + at the end of the execution. + """ + warnings.warn("Use listen_to_channel instead", DeprecationWarning, stacklevel=2) + if self.channel_layer is None: + raise RuntimeError( + "Layers integration is required listening for channels.\n" + "Check https://channels.readthedocs.io/en/stable/topics/channel_layers.html " + "for more information" + ) + + added_groups = [] + try: + # This queue will receive incoming messages for this generator instance + queue: asyncio.Queue = asyncio.Queue() + # Create a weak reference to the queue. Once we leave the current scope, it + # will be garbage collected + self.listen_queues[type].add(queue) + + for group in groups: + await self.channel_layer.group_add(group, self.channel_name) + added_groups.append(group) + + while True: + awaitable = queue.get() + if timeout is not None: + awaitable = asyncio.wait_for(awaitable, timeout) + try: + yield await awaitable + except asyncio.TimeoutError: + # TODO: shall we add log here and maybe in the suppress below? + return + finally: + for group in added_groups: + with contextlib.suppress(Exception): + await self.channel_layer.group_discard(group, self.channel_name) + + @contextlib.asynccontextmanager + async def listen_to_channel( + self, + type: str, + *, + timeout: Optional[float] = None, + groups: Sequence[str] = (), + ) -> AsyncGenerator[Any, None]: + """Listen for messages sent to this consumer. + + Utility to listen for channels messages for this consumer inside + a resolver (usually inside a subscription). + + Args: + type: + The type of the message to wait for. + timeout: + An optional timeout to wait for each subsequent message + groups: + An optional sequence of groups to receive messages from. + When passing this parameter, the groups will be registered + using `self.channel_layer.group_add` at the beggining of the + execution and then discarded using `self.channel_layer.group_discard` + at the end of the execution. + """ + # Code to acquire resource (Channels subscriptions) + if self.channel_layer is None: + raise RuntimeError( + "Layers integration is required listening for channels.\n" + "Check https://channels.readthedocs.io/en/stable/topics/channel_layers.html " + "for more information" + ) + + added_groups = [] + # This queue will receive incoming messages for this generator instance + queue: asyncio.Queue = asyncio.Queue() + # Create a weak reference to the queue. Once we leave the current scope, it + # will be garbage collected + self.listen_queues[type].add(queue) + + # Subscribe to all groups but return generator object to allow user + # code to run before blocking on incoming messages + for group in groups: + await self.channel_layer.group_add(group, self.channel_name) + added_groups.append(group) + try: + yield self._listen_to_channel_generator(queue, timeout) + finally: + # Code to release resource (Channels subscriptions) + for group in added_groups: + with contextlib.suppress(Exception): + await self.channel_layer.group_discard(group, self.channel_name) + + async def _listen_to_channel_generator( + self, queue: asyncio.Queue, timeout: Optional[float] + ) -> AsyncGenerator[Any, None]: + """Generator for listen_to_channel method. + + Seperated to allow user code to be run after subscribing to channels + and before blocking to wait for incoming channel messages. + """ + while True: + awaitable = queue.get() + if timeout is not None: + awaitable = asyncio.wait_for(awaitable, timeout) + try: + yield await awaitable + except asyncio.TimeoutError: + # TODO: shall we add log here and maybe in the suppress below? + return + + +class ChannelsWSConsumer(ChannelsConsumer, AsyncWebsocketConsumer): + """Base channels websocket async consumer.""" + + +__all__ = ["ChannelsConsumer", "ChannelsWSConsumer"] diff --git a/src/graphql_server/channels/handlers/http_handler.py b/src/graphql_server/channels/handlers/http_handler.py new file mode 100644 index 0000000..00e8e27 --- /dev/null +++ b/src/graphql_server/channels/handlers/http_handler.py @@ -0,0 +1,384 @@ +from __future__ import annotations + +import dataclasses +import json +import warnings +from functools import cached_property +from io import BytesIO +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Optional, + Union, +) +from typing_extensions import TypeGuard, assert_never +from urllib.parse import parse_qs + +from django.conf import settings +from django.core.files import uploadhandler +from django.http.multipartparser import MultiPartParser + +from channels.db import database_sync_to_async +from channels.generic.http import AsyncHttpConsumer +from graphql_server.http import GraphQLRequestData +from graphql_server.http.async_base_view import ( + AsyncBaseHTTPView, + AsyncHTTPRequestAdapter, +) +from graphql_server.http.exceptions import HTTPException +from graphql_server.http.sync_base_view import SyncBaseHTTPView, SyncHTTPRequestAdapter +from graphql_server.http.temporal_response import TemporalResponse +from graphql_server.http.types import FormData +from graphql_server.http.typevars import Context, RootValue +from graphql_server.types.unset import UNSET + +from .base import ChannelsConsumer + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Mapping + + from graphql.type import GraphQLSchema + + from graphql_server.http import GraphQLHTTPResponse + from graphql_server.http.ides import GraphQL_IDE + from graphql_server.http.types import HTTPMethod, QueryParams + + +@dataclasses.dataclass +class ChannelsResponse: + content: bytes + status: int = 200 + content_type: str = "application/json" + headers: dict[bytes, bytes] = dataclasses.field(default_factory=dict) + + +@dataclasses.dataclass +class MultipartChannelsResponse: + stream: Callable[[], AsyncGenerator[str, None]] + status: int = 200 + content_type: str = "multipart/mixed;boundary=graphql;subscriptionSpec=1.0" + headers: dict[bytes, bytes] = dataclasses.field(default_factory=dict) + + +@dataclasses.dataclass +class ChannelsRequest: + consumer: ChannelsConsumer + body: bytes + + @property + def query_params(self) -> QueryParams: + query_params_str = self.consumer.scope["query_string"].decode() + + query_params = {} + for key, value in parse_qs(query_params_str, keep_blank_values=True).items(): + # Only one argument per key is expected here + query_params[key] = value[0] + + return query_params + + @property + def headers(self) -> Mapping[str, str]: + return { + header_name.decode().lower(): header_value.decode() + for header_name, header_value in self.consumer.scope["headers"] + } + + @property + def method(self) -> HTTPMethod: + return self.consumer.scope["method"].upper() + + @property + def content_type(self) -> Optional[str]: + return self.headers.get("content-type", None) + + @cached_property + def form_data(self) -> FormData: + upload_handlers = [ + uploadhandler.load_handler(handler) + for handler in settings.FILE_UPLOAD_HANDLERS + ] + + parser = MultiPartParser( + { + "CONTENT_TYPE": self.headers.get("content-type"), + "CONTENT_LENGTH": self.headers.get("content-length", "0"), + }, + BytesIO(self.body), + upload_handlers, + ) + + querydict, files = parser.parse() + + form = { + "operations": querydict.get("operations", "{}"), + "map": querydict.get("map", "{}"), + } + + return FormData(files=files, form=form) + + +class BaseChannelsRequestAdapter: + def __init__(self, request: ChannelsRequest) -> None: + self.request = request + + @property + def query_params(self) -> QueryParams: + return self.request.query_params + + @property + def method(self) -> HTTPMethod: + return self.request.method + + @property + def headers(self) -> Mapping[str, str]: + return self.request.headers + + @property + def content_type(self) -> Optional[str]: + return self.request.content_type + + +class ChannelsRequestAdapter(BaseChannelsRequestAdapter, AsyncHTTPRequestAdapter): + async def get_body(self) -> bytes: + return self.request.body + + async def get_form_data(self) -> FormData: + return self.request.form_data + + +class SyncChannelsRequestAdapter(BaseChannelsRequestAdapter, SyncHTTPRequestAdapter): + @property + def body(self) -> bytes: + return self.request.body + + @property + def post_data(self) -> Mapping[str, Union[str, bytes]]: + return self.request.form_data["form"] + + @property + def files(self) -> Mapping[str, Any]: + return self.request.form_data["files"] + + +class BaseGraphQLHTTPConsumer(ChannelsConsumer, AsyncHttpConsumer): + graphql_ide_html: str + graphql_ide: Optional[GraphQL_IDE] = "graphiql" + + def __init__( + self, + schema: GraphQLSchema, + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + multipart_uploads_enabled: bool = False, + **kwargs: Any, + ) -> None: + self.schema = schema + self.allow_queries_via_get = allow_queries_via_get + self.multipart_uploads_enabled = multipart_uploads_enabled + + if graphiql is not None: + warnings.warn( + "The `graphiql` argument is deprecated in favor of `graphql_ide`", + DeprecationWarning, + stacklevel=2, + ) + self.graphql_ide = "graphiql" if graphiql else None + else: + self.graphql_ide = graphql_ide + + super().__init__(**kwargs) + + def create_response( + self, + response_data: GraphQLHTTPResponse, + sub_response: TemporalResponse, + is_strict: bool, + ) -> ChannelsResponse: + return ChannelsResponse( + content=json.dumps(response_data).encode(), + status=sub_response.status_code, + content_type="application/graphql-response+json" + if is_strict + else "application/json", + headers={k.encode(): v.encode() for k, v in sub_response.headers.items()}, + ) + + async def handle(self, body: bytes) -> None: + request = ChannelsRequest(consumer=self, body=body) + try: + response = await self.run(request) + + if b"Content-Type" not in response.headers: + response.headers[b"Content-Type"] = response.content_type.encode() + + if isinstance(response, MultipartChannelsResponse): + response.headers[b"Transfer-Encoding"] = b"chunked" + await self.send_headers(headers=response.headers) + + async for chunk in response.stream(): + await self.send_body(chunk.encode("utf-8"), more_body=True) + + await self.send_body(b"", more_body=False) + + elif isinstance(response, ChannelsResponse): + await self.send_response( + response.status, + response.content, + headers=response.headers, + ) + else: + assert_never(response) + except HTTPException as e: + await self.send_response(e.status_code, e.reason.encode()) + + +class GraphQLHTTPConsumer( + BaseGraphQLHTTPConsumer, + AsyncBaseHTTPView[ + ChannelsRequest, + Union[ChannelsResponse, MultipartChannelsResponse], + TemporalResponse, + ChannelsRequest, + TemporalResponse, + Context, + RootValue, + ], +): + """A consumer to provide a view for GraphQL over HTTP. + + To use this, place it in your ProtocolTypeRouter for your channels project: + + ``` + from graphql_server.channels import GraphQLHTTPConsumer, GraphQLWSConsumer + from channels.routing import ProtocolTypeRouter + from django.core.asgi import get_asgi_application + + application = ProtocolTypeRouter({ + "http": URLRouter([ + re_path("^graphql", GraphQLHTTPConsumer.as_asgi(schema=schema)), + re_path("^", get_asgi_application()), + ]), + "websocket": URLRouter([ + re_path("^ws/graphql", GraphQLWSConsumer.as_asgi(schema=schema)), + ]), + }) + ``` + """ + + allow_queries_via_get: bool = True + request_adapter_class = ChannelsRequestAdapter + + async def get_root_value(self, request: ChannelsRequest) -> Optional[RootValue]: + return None # pragma: no cover + + async def get_context( + self, request: ChannelsRequest, response: TemporalResponse + ) -> Context: + return { + "request": request, + "response": response, + } # type: ignore + + async def get_sub_response(self, request: ChannelsRequest) -> TemporalResponse: + return TemporalResponse() + + async def create_streaming_response( + self, + request: ChannelsRequest, + stream: Callable[[], AsyncGenerator[str, None]], + sub_response: TemporalResponse, + headers: dict[str, str], + ) -> MultipartChannelsResponse: + status = sub_response.status_code or 200 + + response_headers = { + k.encode(): v.encode() for k, v in sub_response.headers.items() + } + response_headers.update({k.encode(): v.encode() for k, v in headers.items()}) + + return MultipartChannelsResponse( + stream=stream, status=status, headers=response_headers + ) + + async def render_graphql_ide( + self, request: ChannelsRequest, request_data: GraphQLRequestData + ) -> ChannelsResponse: + return ChannelsResponse( + content=request_data.to_template_string(self.graphql_ide_html).encode(), + content_type="text/html; charset=utf-8", + ) + + def is_websocket_request( + self, request: ChannelsRequest + ) -> TypeGuard[ChannelsRequest]: + return False + + async def pick_websocket_subprotocol( + self, request: ChannelsRequest + ) -> Optional[str]: + return None + + async def create_websocket_response( + self, request: ChannelsRequest, subprotocol: Optional[str] + ) -> TemporalResponse: + raise NotImplementedError + + +class SyncGraphQLHTTPConsumer( + BaseGraphQLHTTPConsumer, + SyncBaseHTTPView[ + ChannelsRequest, + ChannelsResponse, + TemporalResponse, + Context, + RootValue, + ], +): + """Synchronous version of the HTTPConsumer. + + This is the same as `GraphQLHTTPConsumer`, but it can be used with + synchronous schemas (i.e. the schema's resolvers are expected to be + synchronous and not asynchronous). + """ + + allow_queries_via_get: bool = True + request_adapter_class = SyncChannelsRequestAdapter + + def get_root_value(self, request: ChannelsRequest) -> Optional[RootValue]: + return None # pragma: no cover + + def get_context( + self, request: ChannelsRequest, response: TemporalResponse + ) -> Context: + return { + "request": request, + "response": response, + } # type: ignore + + def get_sub_response(self, request: ChannelsRequest) -> TemporalResponse: + return TemporalResponse() + + def render_graphql_ide( + self, request: ChannelsRequest, request_data: GraphQLRequestData + ) -> ChannelsResponse: + return ChannelsResponse( + content=request_data.to_template_string(self.graphql_ide_html).encode(), + content_type="text/html; charset=utf-8", + ) + + # Sync channels is actually async, but it uses database_sync_to_async to call + # handlers in a threadpool. Check SyncConsumer's documentation for more info: + # https://github.com/django/channels/blob/main/channels/consumer.py#L104 + @database_sync_to_async # pyright: ignore[reportIncompatibleMethodOverride] + def run( + self, + request: ChannelsRequest, + context: Context = UNSET, + root_value: Optional[RootValue] = UNSET, + ) -> ChannelsResponse | MultipartChannelsResponse: + return super().run(request, context, root_value) + + +__all__ = ["GraphQLHTTPConsumer", "SyncGraphQLHTTPConsumer"] diff --git a/src/graphql_server/channels/handlers/ws_handler.py b/src/graphql_server/channels/handlers/ws_handler.py new file mode 100644 index 0000000..62984f9 --- /dev/null +++ b/src/graphql_server/channels/handlers/ws_handler.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import asyncio +import datetime +import json +from typing import ( + TYPE_CHECKING, + Optional, + TypedDict, + Union, +) +from typing_extensions import TypeGuard + +from graphql_server.http import GraphQLRequestData +from graphql_server.http.async_base_view import AsyncBaseHTTPView, AsyncWebSocketAdapter +from graphql_server.http.exceptions import ( + NonJsonMessageReceived, + NonTextMessageReceived, +) +from graphql_server.http.typevars import Context, RootValue +from graphql_server.subscriptions import ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, +) + +from .base import ChannelsWSConsumer + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Mapping, Sequence + + from graphql.type import GraphQLSchema + + from graphql_server.http import GraphQLHTTPResponse + + +class ChannelsWebSocketAdapter(AsyncWebSocketAdapter): + def __init__( + self, + view: AsyncBaseHTTPView, + request: GraphQLWSConsumer, + response: GraphQLWSConsumer, + ) -> None: + super().__init__(view) + self.ws_consumer = response + + async def iter_json( + self, *, ignore_parsing_errors: bool = False + ) -> AsyncGenerator[object, None]: + while True: + message = await self.ws_consumer.message_queue.get() + + if message["disconnected"]: + break + + if message["message"] is None: + raise NonTextMessageReceived + + try: + yield self.view.decode_json(message["message"]) + except json.JSONDecodeError as e: + if not ignore_parsing_errors: + raise NonJsonMessageReceived from e + + async def send_json(self, message: Mapping[str, object]) -> None: + serialized_message: str = self.view.encode_json(message) + await self.ws_consumer.send(serialized_message) + + async def close(self, code: int, reason: str) -> None: + await self.ws_consumer.close(code=code, reason=reason) + + +class MessageQueueData(TypedDict): + message: Union[str, None] + disconnected: bool + + +class GraphQLWSConsumer( + ChannelsWSConsumer, + AsyncBaseHTTPView[ + "GraphQLWSConsumer", + "GraphQLWSConsumer", + "GraphQLWSConsumer", + "GraphQLWSConsumer", + "GraphQLWSConsumer", + Context, + RootValue, + ], +): + """A channels websocket consumer for GraphQL. + + This handles the connections, then hands off to the appropriate + handler based on the subprotocol. + + To use this, place it in your ProtocolTypeRouter for your channels project, e.g: + + ``` + from graphql_server.channels import GraphQLHttpRouter + from channels.routing import ProtocolTypeRouter + from django.core.asgi import get_asgi_application + + application = ProtocolTypeRouter({ + "http": URLRouter([ + re_path("^graphql", GraphQLHTTPRouter(schema=schema)), + re_path("^", get_asgi_application()), + ]), + "websocket": URLRouter([ + re_path("^ws/graphql", GraphQLWebSocketRouter(schema=schema)), + ]), + }) + ``` + """ + + websocket_adapter_class = ChannelsWebSocketAdapter + + def __init__( + self, + schema: GraphQLSchema, + keep_alive: bool = False, + keep_alive_interval: float = 1, + debug: bool = False, + subscription_protocols: Sequence[str] = ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, + ), + connection_init_wait_timeout: Optional[datetime.timedelta] = None, + ) -> None: + if connection_init_wait_timeout is None: + connection_init_wait_timeout = datetime.timedelta(minutes=1) + self.connection_init_wait_timeout = connection_init_wait_timeout + self.schema = schema + self.keep_alive = keep_alive + self.keep_alive_interval = keep_alive_interval + self.debug = debug + self.protocols = subscription_protocols + self.message_queue: asyncio.Queue[MessageQueueData] = asyncio.Queue() + self.run_task: Optional[asyncio.Task] = None + + super().__init__() + + async def connect(self) -> None: + self.run_task = asyncio.create_task(self.run(self)) + + async def receive( + self, text_data: Optional[str] = None, bytes_data: Optional[bytes] = None + ) -> None: + if text_data: + self.message_queue.put_nowait({"message": text_data, "disconnected": False}) + else: + self.message_queue.put_nowait({"message": None, "disconnected": False}) + + async def disconnect(self, code: int) -> None: + self.message_queue.put_nowait({"message": None, "disconnected": True}) + assert self.run_task + await self.run_task + + async def get_root_value(self, request: GraphQLWSConsumer) -> Optional[RootValue]: + return None + + async def get_context( + self, request: GraphQLWSConsumer, response: GraphQLWSConsumer + ) -> Context: + return { + "request": request, + "ws": request, + } # type: ignore + + @property + def allow_queries_via_get(self) -> bool: + return False + + async def get_sub_response(self, request: GraphQLWSConsumer) -> GraphQLWSConsumer: + raise NotImplementedError + + def create_response( + self, + response_data: GraphQLHTTPResponse, + sub_response: GraphQLWSConsumer, + is_strict: bool, + ) -> GraphQLWSConsumer: + raise NotImplementedError + + async def render_graphql_ide( + self, request: GraphQLWSConsumer, request_data: GraphQLRequestData + ) -> GraphQLWSConsumer: + raise NotImplementedError + + def is_websocket_request( + self, request: GraphQLWSConsumer + ) -> TypeGuard[GraphQLWSConsumer]: + return True + + async def pick_websocket_subprotocol( + self, request: GraphQLWSConsumer + ) -> Optional[str]: + protocols = request.scope["subprotocols"] + intersection = set(protocols) & set(self.protocols) + sorted_intersection = sorted(intersection, key=protocols.index) + return next(iter(sorted_intersection), None) + + async def create_websocket_response( + self, request: GraphQLWSConsumer, subprotocol: Optional[str] + ) -> GraphQLWSConsumer: + await request.accept(subprotocol=subprotocol) + return request + + +__all__ = ["GraphQLWSConsumer"] diff --git a/src/graphql_server/channels/router.py b/src/graphql_server/channels/router.py new file mode 100644 index 0000000..4694d99 --- /dev/null +++ b/src/graphql_server/channels/router.py @@ -0,0 +1,81 @@ +"""GraphQLWebSocketRouter. + +This is a simple router class that might be better placed as part of Channels itself. +It's a simple "SubProtocolRouter" that selects the websocket subprotocol based +on preferences and client support. Then it hands off to the appropriate consumer. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from django.urls import re_path + +from channels.routing import ProtocolTypeRouter, URLRouter + +from .handlers.http_handler import GraphQLHTTPConsumer +from .handlers.ws_handler import GraphQLWSConsumer + +if TYPE_CHECKING: + from graphql.type import GraphQLSchema + + +class GraphQLProtocolTypeRouter(ProtocolTypeRouter): + """HTTP and Websocket GraphQL type router. + + Convenience class to set up GraphQL on both HTTP and Websocket, + optionally with a Django application for all other HTTP routes. + + ```python + from graphql_server.channels import GraphQLProtocolTypeRouter + from django.core.asgi import get_asgi_application + + django_asgi = get_asgi_application() + + from myapi import schema + + application = GraphQLProtocolTypeRouter( + schema, + django_application=django_asgi, + ) + ``` + + This will route all requests to /graphql on either HTTP or websockets to us, + and everything else to the Django application. + """ + + def __init__( + self, + schema: GraphQLSchema, + django_application: Optional[str] = None, + url_pattern: str = "^graphql", + http_consumer_class: type[GraphQLHTTPConsumer] = GraphQLHTTPConsumer, + ws_consumer_class: type[GraphQLWSConsumer] = GraphQLWSConsumer, + *args, + **kwargs, + ) -> None: + http_urls = [ + re_path( + url_pattern, + http_consumer_class.as_asgi(*args, **kwargs), + ) + ] + if django_application is not None: + http_urls.append(re_path("^", django_application)) + + super().__init__( + { + "http": URLRouter(http_urls), + "websocket": URLRouter( + [ + re_path( + url_pattern, + ws_consumer_class.as_asgi(*args, **kwargs), + ), + ] + ), + } + ) + + +__all__ = ["GraphQLProtocolTypeRouter"] diff --git a/src/graphql_server/channels/testing.py b/src/graphql_server/channels/testing.py new file mode 100644 index 0000000..70f3041 --- /dev/null +++ b/src/graphql_server/channels/testing.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import uuid +from typing import ( + TYPE_CHECKING, + Any, + Optional, + Union, +) + +from graphql import ExecutionResult, GraphQLError, GraphQLFormattedError + +from channels.testing.websocket import WebsocketCommunicator +from graphql_server.subscriptions import ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, +) +from graphql_server.subscriptions.protocols.graphql_transport_ws import ( + types as transport_ws_types, +) +from graphql_server.subscriptions.protocols.graphql_ws import types as ws_types + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + from types import TracebackType + from typing_extensions import Self + + from asgiref.typing import ASGIApplication + + +class GraphQLWebsocketCommunicator(WebsocketCommunicator): + """A test communicator for GraphQL over Websockets. + + ```python + import pytest + from graphql_server.channels.testing import GraphQLWebsocketCommunicator + from myapp.asgi import application + + + @pytest.fixture + async def gql_communicator(): + async with GraphQLWebsocketCommunicator(application, path="/graphql") as client: + yield client + + + async def test_subscribe_echo(gql_communicator): + async for res in gql_communicator.subscribe( + query='subscription { echo(message: "Hi") }' + ): + assert res.data == {"echo": "Hi"} + ``` + """ + + def __init__( + self, + application: ASGIApplication, + path: str, + headers: Optional[list[tuple[bytes, bytes]]] = None, + protocol: str = GRAPHQL_TRANSPORT_WS_PROTOCOL, + connection_params: dict | None = None, + **kwargs: Any, + ) -> None: + """Create a new communicator. + + Args: + application: Your asgi application that encapsulates the GraphQL schema. + path: the url endpoint for the schema. + protocol: currently this supports `graphql-transport-ws` only. + connection_params: a dictionary of connection parameters to send to the server. + headers: a list of tuples to be sent as headers to the server. + subprotocols: an ordered list of preferred subprotocols to be sent to the server. + **kwargs: additional arguments to be passed to the `WebsocketCommunicator` constructor. + """ + if connection_params is None: + connection_params = {} + self.protocol = protocol + subprotocols = kwargs.get("subprotocols", []) + subprotocols.append(protocol) + self.connection_params = connection_params + super().__init__(application, path, headers, subprotocols=subprotocols) + + async def __aenter__(self) -> Self: + await self.gql_init() + return self + + async def __aexit__( + self, + exc_type: Optional[type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: + await self.disconnect() + + async def gql_init(self) -> None: + res = await self.connect() + if self.protocol == GRAPHQL_TRANSPORT_WS_PROTOCOL: + assert res == (True, GRAPHQL_TRANSPORT_WS_PROTOCOL) + await self.send_json_to( + transport_ws_types.ConnectionInitMessage( + {"type": "connection_init", "payload": self.connection_params} + ) + ) + transport_ws_connection_ack_message: transport_ws_types.ConnectionAckMessage = await self.receive_json_from() + assert transport_ws_connection_ack_message == {"type": "connection_ack"} + else: + assert res == (True, GRAPHQL_WS_PROTOCOL) + await self.send_json_to( + ws_types.ConnectionInitMessage({"type": "connection_init"}) + ) + ws_connection_ack_message: ws_types.ConnectionAckMessage = ( + await self.receive_json_from() + ) + assert ws_connection_ack_message["type"] == "connection_ack" + + # Actual `ExecutionResult`` objects are not available client-side, since they + # get transformed into `FormattedExecutionResult` on the wire, but we attempt + # to do a limited representation of them here, to make testing simpler. + async def subscribe( + self, query: str, variables: Optional[dict] = None + ) -> Union[ExecutionResult, AsyncIterator[ExecutionResult]]: + id_ = uuid.uuid4().hex + + if self.protocol == GRAPHQL_TRANSPORT_WS_PROTOCOL: + await self.send_json_to( + transport_ws_types.SubscribeMessage( + { + "id": id_, + "type": "subscribe", + "payload": {"query": query, "variables": variables}, + } + ) + ) + else: + start_message: ws_types.StartMessage = { + "type": "start", + "id": id_, + "payload": { + "query": query, + }, + } + + if variables is not None: + start_message["payload"]["variables"] = variables + + await self.send_json_to(start_message) + + while True: + message: transport_ws_types.Message = await self.receive_json_from( + timeout=5 + ) + if message["type"] == "next": + payload = message["payload"] + ret = ExecutionResult(payload.get("data"), None) + if "errors" in payload: + ret.errors = self.process_errors(payload.get("errors") or []) + ret.extensions = payload.get("extensions", None) + yield ret + elif message["type"] == "error": + error_payload = message["payload"] + yield ExecutionResult( + data=None, errors=self.process_errors(error_payload) + ) + return # an error message is the last message for a subscription + else: + return + + def process_errors(self, errors: list[GraphQLFormattedError]) -> list[GraphQLError]: + """Reconstructs a GraphQLError from a FormattedGraphQLError.""" + result = [] + for f_error in errors: + error = GraphQLError( + message=f_error["message"], + extensions=f_error.get("extensions", None), + ) + error.path = f_error.get("path", None) + result.append(error) + return result + + +__all__ = ["GraphQLWebsocketCommunicator"] diff --git a/src/graphql_server/django/__init__.py b/src/graphql_server/django/__init__.py new file mode 100644 index 0000000..7612f2d --- /dev/null +++ b/src/graphql_server/django/__init__.py @@ -0,0 +1,3 @@ +from .views import AsyncGraphQLView, GraphQLView + +__all__ = ["AsyncGraphQLView", "GraphQLView"] diff --git a/src/graphql_server/django/context.py b/src/graphql_server/django/context.py new file mode 100644 index 0000000..4351c2c --- /dev/null +++ b/src/graphql_server/django/context.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from django.http import HttpRequest, HttpResponse + + +@dataclass +class GraphQLDjangoContext: + request: HttpRequest + response: HttpResponse + + def __getitem__(self, key: str) -> Any: + # __getitem__ override needed to avoid issues for who's + # using info.context["request"] + return super().__getattribute__(key) + + def get(self, key: str) -> Any: + """Enable .get notation for accessing the request.""" + return super().__getattribute__(key) + + +__all__ = ["GraphQLDjangoContext"] diff --git a/src/graphql_server/django/test/__init__.py b/src/graphql_server/django/test/__init__.py new file mode 100644 index 0000000..47b4c12 --- /dev/null +++ b/src/graphql_server/django/test/__init__.py @@ -0,0 +1,3 @@ +from .client import GraphQLTestClient + +__all__ = ["GraphQLTestClient"] diff --git a/src/graphql_server/django/test/client.py b/src/graphql_server/django/test/client.py new file mode 100644 index 0000000..7ba5c09 --- /dev/null +++ b/src/graphql_server/django/test/client.py @@ -0,0 +1,23 @@ +from typing import Any, Optional + +from graphql_server.test import BaseGraphQLTestClient + + +class GraphQLTestClient(BaseGraphQLTestClient): + def request( + self, + body: dict[str, object], + headers: Optional[dict[str, object]] = None, + files: Optional[dict[str, object]] = None, + ) -> Any: + if files: + return self._client.post( + self.url, data=body, format="multipart", headers=headers + ) + + return self._client.post( + self.url, data=body, content_type="application/json", headers=headers + ) + + +__all__ = ["GraphQLTestClient"] diff --git a/src/graphql_server/django/views.py b/src/graphql_server/django/views.py new file mode 100644 index 0000000..3449ba0 --- /dev/null +++ b/src/graphql_server/django/views.py @@ -0,0 +1,342 @@ +from __future__ import annotations + +import json +import warnings +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Optional, + Union, + cast, +) +from typing_extensions import TypeGuard + +from asgiref.sync import markcoroutinefunction +from django.core.serializers.json import DjangoJSONEncoder +from django.http import ( + HttpRequest, + HttpResponse, + HttpResponseNotAllowed, + JsonResponse, + StreamingHttpResponse, +) +from django.http.response import HttpResponseBase +from django.template.exceptions import TemplateDoesNotExist +from django.template.loader import render_to_string +from django.utils.decorators import classonlymethod +from django.views.generic import View + +from graphql_server.http import GraphQLRequestData +from graphql_server.http.async_base_view import ( + AsyncBaseHTTPView, + AsyncHTTPRequestAdapter, +) +from graphql_server.http.exceptions import HTTPException +from graphql_server.http.sync_base_view import SyncBaseHTTPView, SyncHTTPRequestAdapter +from graphql_server.http.types import FormData, HTTPMethod, QueryParams +from graphql_server.http.typevars import ( + Context, + RootValue, +) + +from .context import GraphQLDjangoContext + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Mapping + + from django.template.response import TemplateResponse + from graphql.type import GraphQLSchema + + from graphql_server.http import GraphQLHTTPResponse + from graphql_server.http.ides import GraphQL_IDE + + +# TODO: remove this and unify temporal responses +class TemporalHttpResponse(JsonResponse): + status_code: Optional[int] = None # pyright: ignore + + def __init__(self) -> None: + super().__init__({}) + + def __repr__(self) -> str: + """Adopted from Django to handle `status_code=None`.""" + if self.status_code is not None: + return super().__repr__() + + return "<{cls} status_code={status_code}{content_type}>".format( # noqa: UP032 + cls=self.__class__.__name__, + status_code=self.status_code, + content_type=self._content_type_for_repr, # pyright: ignore + ) + + +class DjangoHTTPRequestAdapter(SyncHTTPRequestAdapter): + def __init__(self, request: HttpRequest) -> None: + self.request = request + + @property + def query_params(self) -> QueryParams: + return self.request.GET.dict() + + @property + def body(self) -> Union[str, bytes]: + return self.request.body.decode() + + @property + def method(self) -> HTTPMethod: + assert self.request.method is not None + + return cast("HTTPMethod", self.request.method.upper()) + + @property + def headers(self) -> Mapping[str, str]: + return self.request.headers + + @property + def post_data(self) -> Mapping[str, Union[str, bytes]]: + return self.request.POST + + @property + def files(self) -> Mapping[str, Any]: + return self.request.FILES + + @property + def content_type(self) -> Optional[str]: + return self.request.content_type + + +class AsyncDjangoHTTPRequestAdapter(AsyncHTTPRequestAdapter): + def __init__(self, request: HttpRequest) -> None: + self.request = request + + @property + def query_params(self) -> QueryParams: + return self.request.GET.dict() + + @property + def method(self) -> HTTPMethod: + assert self.request.method is not None + + return cast("HTTPMethod", self.request.method.upper()) + + @property + def headers(self) -> Mapping[str, str]: + return self.request.headers + + @property + def content_type(self) -> Optional[str]: + return self.headers.get("Content-type") + + async def get_body(self) -> str: + return self.request.body.decode() + + async def get_form_data(self) -> FormData: + return FormData( + files=self.request.FILES, + form=self.request.POST, + ) + + +class BaseView: + graphql_ide_html: str + + def __init__( + self, + schema: GraphQLSchema, + graphiql: Optional[str] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + multipart_uploads_enabled: bool = False, + **kwargs: Any, + ) -> None: + self.schema = schema + self.allow_queries_via_get = allow_queries_via_get + self.multipart_uploads_enabled = multipart_uploads_enabled + + if graphiql is not None: + warnings.warn( + "The `graphiql` argument is deprecated in favor of `graphql_ide`", + DeprecationWarning, + stacklevel=2, + ) + self.graphql_ide = "graphiql" if graphiql else None + else: + self.graphql_ide = graphql_ide + + super().__init__(**kwargs) + + def create_response( + self, + response_data: GraphQLHTTPResponse, + sub_response: HttpResponse, + is_strict: bool, + ) -> HttpResponseBase: + data = self.encode_json(response_data) + response = HttpResponse( + data, + status=sub_response.status_code, + ) + + for name, value in sub_response.items(): + response[name] = value + + if sub_response.status_code: + response.status_code = sub_response.status_code + + response.headers["content-type"] = ( + "application/graphql-response+json" if is_strict else "application/json" + ) + + for name, value in sub_response.cookies.items(): + response.cookies[name] = value + + return response + + async def create_streaming_response( + self, + request: HttpRequest, + stream: Callable[[], AsyncIterator[Any]], + sub_response: TemporalHttpResponse, + headers: dict[str, str], + ) -> HttpResponseBase: + return StreamingHttpResponse( + streaming_content=stream(), + status=sub_response.status_code, + headers={ + **sub_response.headers, + **headers, + }, + ) + + def encode_json(self, data: object) -> str: + return json.dumps(data, cls=DjangoJSONEncoder) + + +class GraphQLView( + BaseView, + SyncBaseHTTPView[ + HttpRequest, HttpResponseBase, TemporalHttpResponse, Context, RootValue + ], + View, +): + graphiql: Optional[bool] = None + graphql_ide: Optional[GraphQL_IDE] = "graphiql" + allow_queries_via_get = True + schema: GraphQLSchema = None # type: ignore + request_adapter_class = DjangoHTTPRequestAdapter + + def get_root_value(self, request: HttpRequest) -> Optional[RootValue]: + return None + + def get_context(self, request: HttpRequest, response: HttpResponse) -> Context: + return None + # raise NotImplementedError("Implement this method in your view") + + def get_sub_response(self, request: HttpRequest) -> TemporalHttpResponse: + return TemporalHttpResponse() + + def dispatch( + self, request: HttpRequest, *args: Any, **kwargs: Any + ) -> Union[HttpResponseNotAllowed, TemplateResponse, HttpResponseBase]: + try: + return self.run(request=request) + except HTTPException as e: + return HttpResponse( + content=e.reason, + status=e.status_code, + ) + + def render_graphql_ide( + self, request: HttpRequest, request_data: GraphQLRequestData + ) -> HttpResponse: + try: + content = render_to_string( + "graphql/graphiql.html", + request=request, + context=request_data.to_template_context(), + ) + except TemplateDoesNotExist: + content = request_data.to_template_string(self.graphql_ide_html) + + return HttpResponse(content) + + +class AsyncGraphQLView( + BaseView, + AsyncBaseHTTPView[ + HttpRequest, + HttpResponseBase, + TemporalHttpResponse, + HttpRequest, + TemporalHttpResponse, + Context, + RootValue, + ], + View, +): + graphiql: Optional[bool] = None + graphql_ide: Optional[GraphQL_IDE] = "graphiql" + allow_queries_via_get = True + schema: GraphQLSchema = None # type: ignore + request_adapter_class = AsyncDjangoHTTPRequestAdapter + + @classonlymethod # pyright: ignore[reportIncompatibleMethodOverride] + def as_view(cls, **initkwargs: Any) -> Callable[..., HttpResponse]: # noqa: N805 + # This code tells django that this view is async, see docs here: + # https://docs.djangoproject.com/en/3.1/topics/async/#async-views + + view = super().as_view(**initkwargs) + markcoroutinefunction(view) + + return view + + async def get_root_value(self, request: HttpRequest) -> Optional[RootValue]: + return None + + async def get_context( + self, request: HttpRequest, response: HttpResponse + ) -> Context: + return GraphQLDjangoContext(request=request, response=response) # type: ignore + + async def get_sub_response(self, request: HttpRequest) -> TemporalHttpResponse: + return TemporalHttpResponse() + + async def dispatch( # pyright: ignore + self, request: HttpRequest, *args: Any, **kwargs: Any + ) -> Union[HttpResponseNotAllowed, TemplateResponse, HttpResponseBase]: + try: + return await self.run(request=request) + except HTTPException as e: + return HttpResponse( + content=e.reason, + status=e.status_code, + ) + + async def render_graphql_ide( + self, request: HttpRequest, request_data: GraphQLRequestData + ) -> HttpResponse: + try: + content = render_to_string( + "graphql/graphiql.html", + request=request, + context=request_data.to_template_context(), + ) + except TemplateDoesNotExist: + content = request_data.to_template_string(self.graphql_ide_html) + + return HttpResponse(content=content) + + def is_websocket_request(self, request: HttpRequest) -> TypeGuard[HttpRequest]: + return False + + async def pick_websocket_subprotocol(self, request: HttpRequest) -> Optional[str]: + raise NotImplementedError + + async def create_websocket_response( + self, request: HttpRequest, subprotocol: Optional[str] + ) -> TemporalHttpResponse: + raise NotImplementedError + + +__all__ = ["AsyncGraphQLView", "GraphQLView"] diff --git a/src/graphql_server/exceptions.py b/src/graphql_server/exceptions.py new file mode 100644 index 0000000..9a7ce0e --- /dev/null +++ b/src/graphql_server/exceptions.py @@ -0,0 +1,44 @@ +from typing import Set + +from graphql import GraphQLError, OperationType + + +class GraphQLValidationError(GraphQLError): + errors: list[GraphQLError] + + def __init__(self, errors: list[GraphQLError]): + self.errors = errors + super().__init__("Validation failed") + + +class InvalidOperationTypeError(GraphQLError): + def __init__( + self, operation_type: OperationType, allowed_operation_types: Set[OperationType] + ) -> None: + message = f"{self.format_operation_type(operation_type)} are not allowed." + if allowed_operation_types: + message += f" Only {', '.join(map(self.format_operation_type, allowed_operation_types))} are allowed." + self.operation_type = operation_type + super().__init__(message) + + def format_operation_type(self, operation_type: OperationType) -> str: + return { + OperationType.QUERY: "queries", + OperationType.MUTATION: "mutations", + OperationType.SUBSCRIPTION: "subscriptions", + }[operation_type] + + def as_http_error_reason(self, method: str) -> str: + return f"{self.format_operation_type(self.operation_type)} are not allowed when using {method}" + + +class ConnectionRejectionError(Exception): + """Use it when you want to reject a WebSocket connection.""" + + def __init__(self, payload: dict[str, object] | None = None) -> None: + if payload is None: + payload = {} + self.payload = payload + + +__all__: list[str] = ["ConnectionRejectionError", "InvalidOperationTypeError"] diff --git a/src/graphql_server/fastapi/__init__.py b/src/graphql_server/fastapi/__init__.py new file mode 100644 index 0000000..9c0baf0 --- /dev/null +++ b/src/graphql_server/fastapi/__init__.py @@ -0,0 +1,4 @@ +from .context import BaseContext +from .router import GraphQLRouter + +__all__ = ["BaseContext", "GraphQLRouter"] diff --git a/src/graphql_server/fastapi/context.py b/src/graphql_server/fastapi/context.py new file mode 100644 index 0000000..1c79711 --- /dev/null +++ b/src/graphql_server/fastapi/context.py @@ -0,0 +1,23 @@ +from typing import Any, Optional, Union + +from starlette.background import BackgroundTasks +from starlette.requests import Request +from starlette.responses import Response +from starlette.websockets import WebSocket + +CustomContext = Union["BaseContext", dict[str, Any]] +MergedContext = Union[ + "BaseContext", dict[str, Union[Any, BackgroundTasks, Request, Response, WebSocket]] +] + + +class BaseContext: + connection_params: Optional[Any] = None + + def __init__(self) -> None: + self.request: Optional[Union[Request, WebSocket]] = None + self.background_tasks: Optional[BackgroundTasks] = None + self.response: Optional[Response] = None + + +__all__ = ["BaseContext"] diff --git a/src/graphql_server/fastapi/router.py b/src/graphql_server/fastapi/router.py new file mode 100644 index 0000000..5849cf2 --- /dev/null +++ b/src/graphql_server/fastapi/router.py @@ -0,0 +1,342 @@ +from __future__ import annotations + +import warnings +from datetime import timedelta +from inspect import signature +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Optional, + Union, + cast, +) +from typing_extensions import TypeGuard + +from starlette import status +from starlette.background import BackgroundTasks # noqa: TC002 +from starlette.requests import HTTPConnection, Request +from starlette.responses import ( + HTMLResponse, + JSONResponse, + PlainTextResponse, + Response, + StreamingResponse, +) +from starlette.websockets import WebSocket + +from fastapi import APIRouter, Depends, params +from fastapi.datastructures import Default +from fastapi.routing import APIRoute +from fastapi.utils import generate_unique_id +from graphql_server.asgi import ASGIRequestAdapter, ASGIWebSocketAdapter +from graphql_server.fastapi.context import BaseContext, CustomContext +from graphql_server.http import GraphQLRequestData +from graphql_server.http.async_base_view import AsyncBaseHTTPView +from graphql_server.http.exceptions import HTTPException +from graphql_server.http.typevars import Context, RootValue +from graphql_server.subscriptions import ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, +) + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Awaitable, Sequence + from enum import Enum + + from graphql.type import GraphQLSchema + from starlette.routing import BaseRoute + from starlette.types import ASGIApp, Lifespan + + from graphql_server.fastapi.context import MergedContext + from graphql_server.http import GraphQLHTTPResponse + from graphql_server.http.ides import GraphQL_IDE + + +class GraphQLRouter( + AsyncBaseHTTPView[ + Request, Response, Response, WebSocket, WebSocket, Context, RootValue + ], + APIRouter, +): + allow_queries_via_get = True + request_adapter_class = ASGIRequestAdapter + websocket_adapter_class = ASGIWebSocketAdapter + + @staticmethod + async def __get_root_value() -> None: + return None + + @staticmethod + def __get_context_getter( + custom_getter: Callable[ + ..., Union[Optional[CustomContext], Awaitable[Optional[CustomContext]]] + ], + ) -> Callable[..., Awaitable[CustomContext]]: + async def dependency( + custom_context: Optional[CustomContext], + background_tasks: BackgroundTasks, + connection: HTTPConnection, + response: Response = None, # type: ignore + ) -> MergedContext: + request = cast("Union[Request, WebSocket]", connection) + if isinstance(custom_context, BaseContext): + custom_context.request = request + custom_context.background_tasks = background_tasks + custom_context.response = response + return custom_context + default_context = { + "request": request, + "background_tasks": background_tasks, + "response": response, + } + if isinstance(custom_context, dict): + return { + **default_context, + **custom_context, + } + if custom_context is None: + return default_context + return custom_context + + # replace the signature parameters of dependency... + # ...with the old parameters minus the first argument as it will be replaced... + # ...with the value obtained by injecting custom_getter context as a dependency. + sig = signature(dependency) + sig = sig.replace( + parameters=[ + *list(sig.parameters.values())[1:], + sig.parameters["custom_context"].replace( + default=Depends(custom_getter) + ), + ], + ) + # there is an ongoing issue with types and .__signature__ applied to Callables: + # https://github.com/python/mypy/issues/5958, as of 14/12/21 + # as such, the below line has its typing ignored by MyPy + dependency.__signature__ = sig # type: ignore + return dependency + + def __init__( + self, + schema: GraphQLSchema, + path: str = "", + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + keep_alive: bool = False, + keep_alive_interval: float = 1, + debug: bool = False, + root_value_getter: Optional[Callable[[], RootValue]] = None, + context_getter: Optional[ + Callable[..., Union[Optional[Context], Awaitable[Optional[Context]]]] + ] = None, + subscription_protocols: Sequence[str] = ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, + ), + connection_init_wait_timeout: timedelta = timedelta(minutes=1), + prefix: str = "", + tags: Optional[list[Union[str, Enum]]] = None, + dependencies: Optional[Sequence[params.Depends]] = None, + default_response_class: type[Response] = Default(JSONResponse), + responses: Optional[dict[Union[int, str], dict[str, Any]]] = None, + callbacks: Optional[list[BaseRoute]] = None, + routes: Optional[list[BaseRoute]] = None, + redirect_slashes: bool = True, + default: Optional[ASGIApp] = None, + dependency_overrides_provider: Optional[Any] = None, + route_class: type[APIRoute] = APIRoute, + on_startup: Optional[Sequence[Callable[[], Any]]] = None, + on_shutdown: Optional[Sequence[Callable[[], Any]]] = None, + lifespan: Optional[Lifespan[Any]] = None, + deprecated: Optional[bool] = None, + include_in_schema: bool = True, + generate_unique_id_function: Callable[[APIRoute], str] = Default( + generate_unique_id + ), + multipart_uploads_enabled: bool = False, + **kwargs: Any, + ) -> None: + super().__init__( + prefix=prefix, + tags=tags, + dependencies=dependencies, + default_response_class=default_response_class, + responses=responses, + callbacks=callbacks, + routes=routes, + redirect_slashes=redirect_slashes, + default=default, + dependency_overrides_provider=dependency_overrides_provider, + route_class=route_class, + on_startup=on_startup, + on_shutdown=on_shutdown, + lifespan=lifespan, + deprecated=deprecated, + include_in_schema=include_in_schema, + generate_unique_id_function=generate_unique_id_function, + **kwargs, + ) + self.schema = schema + self.allow_queries_via_get = allow_queries_via_get + self.keep_alive = keep_alive + self.keep_alive_interval = keep_alive_interval + self.debug = debug + self.root_value_getter = root_value_getter or self.__get_root_value + # TODO: clean this type up + self.context_getter = self.__get_context_getter( + context_getter or (lambda: None) # type: ignore + ) + self.protocols = subscription_protocols + self.connection_init_wait_timeout = connection_init_wait_timeout + self.multipart_uploads_enabled = multipart_uploads_enabled + + if graphiql is not None: + warnings.warn( + "The `graphiql` argument is deprecated in favor of `graphql_ide`", + DeprecationWarning, + stacklevel=2, + ) + self.graphql_ide = "graphiql" if graphiql else None + else: + self.graphql_ide = graphql_ide + + @self.get( + path, + responses={ + 200: { + "description": "The GraphiQL integrated development environment.", + }, + 404: { + "description": ( + "Not found if GraphiQL or query via GET are not enabled." + ) + }, + }, + include_in_schema=graphiql or allow_queries_via_get, + ) + async def handle_http_get( # pyright: ignore + request: Request, + response: Response, + context: Context = Depends(self.context_getter), + root_value: RootValue = Depends(self.root_value_getter), + ) -> Response: + self.temporal_response = response + + try: + return await self.run( + request=request, context=context, root_value=root_value + ) + except HTTPException as e: + return PlainTextResponse( + e.reason, + status_code=e.status_code, + ) + + @self.post(path) + async def handle_http_post( # pyright: ignore + request: Request, + response: Response, + # TODO: use Annotated in future + context: Context = Depends(self.context_getter), + root_value: RootValue = Depends(self.root_value_getter), + ) -> Response: + self.temporal_response = response + + try: + return await self.run( + request=request, context=context, root_value=root_value + ) + except HTTPException as e: + return PlainTextResponse( + e.reason, + status_code=e.status_code, + ) + + @self.options(path) + async def handle_http_options( # pyright: ignore + request: Request, + response: Response, + ) -> Response: + return Response(status_code=200) + + @self.websocket(path) + async def websocket_endpoint( # pyright: ignore + websocket: WebSocket, + context: Context = Depends(self.context_getter), + root_value: RootValue = Depends(self.root_value_getter), + ) -> None: + await self.run(request=websocket, context=context, root_value=root_value) + + async def render_graphql_ide( + self, request: Request, request_data: GraphQLRequestData + ) -> HTMLResponse: + return HTMLResponse(request_data.to_template_string(self.graphql_ide_html)) + + async def get_context( + self, request: Union[Request, WebSocket], response: Union[Response, WebSocket] + ) -> Context: # pragma: no cover + raise ValueError("`get_context` is not used by FastAPI GraphQL Router") + + async def get_root_value( + self, request: Union[Request, WebSocket] + ) -> Optional[RootValue]: # pragma: no cover + raise ValueError("`get_root_value` is not used by FastAPI GraphQL Router") + + async def get_sub_response(self, request: Request) -> Response: + return self.temporal_response + + def create_response( + self, + response_data: GraphQLHTTPResponse, + sub_response: Response, + is_strict: bool, + ) -> Response: + response = Response( + self.encode_json(response_data), + media_type="application/graphql-response+json" + if is_strict + else "application/json", + status_code=sub_response.status_code or status.HTTP_200_OK, + ) + + response.headers.raw.extend(sub_response.headers.raw) + + return response + + async def create_streaming_response( + self, + request: Request, + stream: Callable[[], AsyncIterator[str]], + sub_response: Response, + headers: dict[str, str], + ) -> Response: + return StreamingResponse( + stream(), + status_code=sub_response.status_code or status.HTTP_200_OK, + headers={ + **sub_response.headers, + **headers, + }, + ) + + def is_websocket_request( + self, request: Union[Request, WebSocket] + ) -> TypeGuard[WebSocket]: + return request.scope["type"] == "websocket" + + async def pick_websocket_subprotocol(self, request: WebSocket) -> Optional[str]: + protocols = request["subprotocols"] + intersection = set(protocols) & set(self.protocols) + sorted_intersection = sorted(intersection, key=protocols.index) + return next(iter(sorted_intersection), None) + + async def create_websocket_response( + self, request: WebSocket, subprotocol: Optional[str] + ) -> WebSocket: + await request.accept(subprotocol=subprotocol) + return request + + +__all__ = ["GraphQLRouter"] diff --git a/src/graphql_server/file_uploads/__init__.py b/src/graphql_server/file_uploads/__init__.py new file mode 100644 index 0000000..608dff7 --- /dev/null +++ b/src/graphql_server/file_uploads/__init__.py @@ -0,0 +1,3 @@ +from .scalars import Upload + +__all__ = ["Upload"] diff --git a/src/graphql_server/file_uploads/scalars.py b/src/graphql_server/file_uploads/scalars.py new file mode 100644 index 0000000..48d68db --- /dev/null +++ b/src/graphql_server/file_uploads/scalars.py @@ -0,0 +1,5 @@ +from graphql.type import GraphQLScalarType + +Upload = GraphQLScalarType("Upload", serialize=bytes, parse_value=lambda x: bytes(x)) + +__all__ = ["Upload"] diff --git a/src/graphql_server/file_uploads/utils.py b/src/graphql_server/file_uploads/utils.py new file mode 100644 index 0000000..ea08c22 --- /dev/null +++ b/src/graphql_server/file_uploads/utils.py @@ -0,0 +1,36 @@ +import copy +from collections.abc import Mapping +from typing import Any + + +def replace_placeholders_with_files( + operations_with_placeholders: dict[str, Any], + files_map: Mapping[str, Any], + files: Mapping[str, Any], +) -> dict[str, Any]: + # TODO: test this with missing variables in operations_with_placeholders + operations = copy.deepcopy(operations_with_placeholders) + + for multipart_form_field_name, operations_paths in files_map.items(): + file_object = files[multipart_form_field_name] + + for path in operations_paths: + operations_path_keys = path.split(".") + value_key = operations_path_keys.pop() + + target_object = operations + for key in operations_path_keys: + if isinstance(target_object, list): + target_object = target_object[int(key)] + else: + target_object = target_object[key] + + if isinstance(target_object, list): + target_object[int(value_key)] = file_object + else: + target_object[value_key] = file_object + + return operations + + +__all__ = ["replace_placeholders_with_files"] diff --git a/src/graphql_server/flask/__init__.py b/src/graphql_server/flask/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/graphql_server/flask/views.py b/src/graphql_server/flask/views.py new file mode 100644 index 0000000..b637b5e --- /dev/null +++ b/src/graphql_server/flask/views.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import warnings +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + Optional, + Union, + cast, +) +from typing_extensions import TypeGuard + +from flask import Request, Response, render_template_string, request +from flask.views import View +from graphql_server.http import GraphQLRequestData +from graphql_server.http.async_base_view import ( + AsyncBaseHTTPView, + AsyncHTTPRequestAdapter, +) +from graphql_server.http.exceptions import HTTPException +from graphql_server.http.sync_base_view import ( + SyncBaseHTTPView, + SyncHTTPRequestAdapter, +) +from graphql_server.http.types import FormData, HTTPMethod, QueryParams +from graphql_server.http.typevars import Context, RootValue + +if TYPE_CHECKING: + from collections.abc import Mapping + + from graphql.type import GraphQLSchema + + from flask.typing import ResponseReturnValue + from graphql_server.http import GraphQLHTTPResponse + from graphql_server.http.ides import GraphQL_IDE + + +class FlaskHTTPRequestAdapter(SyncHTTPRequestAdapter): + def __init__(self, request: Request) -> None: + self.request = request + + @property + def query_params(self) -> QueryParams: + return self.request.args.to_dict() + + @property + def body(self) -> Union[str, bytes]: + return self.request.data.decode() + + @property + def method(self) -> HTTPMethod: + return cast("HTTPMethod", self.request.method.upper()) + + @property + def headers(self) -> Mapping[str, str]: + return self.request.headers # type: ignore + + @property + def post_data(self) -> Mapping[str, Union[str, bytes]]: + return self.request.form + + @property + def files(self) -> Mapping[str, Any]: + return self.request.files + + @property + def content_type(self) -> Optional[str]: + return self.request.content_type + + +class BaseGraphQLView: + graphql_ide: Optional[GraphQL_IDE] + + def __init__( + self, + schema: GraphQLSchema, + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + multipart_uploads_enabled: bool = False, + ) -> None: + self.schema = schema + self.graphiql = graphiql + self.allow_queries_via_get = allow_queries_via_get + self.multipart_uploads_enabled = multipart_uploads_enabled + + if graphiql is not None: + warnings.warn( + "The `graphiql` argument is deprecated in favor of `graphql_ide`", + DeprecationWarning, + stacklevel=2, + ) + self.graphql_ide = "graphiql" if graphiql else None + else: + self.graphql_ide = graphql_ide + + def create_response( + self, + response_data: GraphQLHTTPResponse, + sub_response: Response, + is_strict: bool, + ) -> Response: + sub_response.set_data(self.encode_json(response_data)) # type: ignore + sub_response.headers["content-type"] = ( + "application/graphql-response+json" if is_strict else "application/json" + ) + return sub_response + + +class GraphQLView( + BaseGraphQLView, + SyncBaseHTTPView[Request, Response, Response, Context, RootValue], + View, +): + methods: ClassVar[list[str]] = ["GET", "POST"] + allow_queries_via_get: bool = True + request_adapter_class = FlaskHTTPRequestAdapter + + def get_context(self, request: Request, response: Response) -> Context: + return {"request": request, "response": response} # type: ignore + + def get_root_value(self, request: Request) -> Optional[RootValue]: + return None + + def get_sub_response(self, request: Request) -> Response: + return Response(status=200, content_type="application/json") + + def dispatch_request(self) -> ResponseReturnValue: + try: + return self.run(request=request) + except HTTPException as e: + return Response( + response=e.reason, + status=e.status_code, + ) + + def render_graphql_ide( + self, request: Request, request_data: GraphQLRequestData + ) -> Response: + return render_template_string( + self.graphql_ide_html, + query=request_data.query, + variables=request_data.variables, + operationName=request_data.operation_name, + ) # type: ignore + + +class AsyncFlaskHTTPRequestAdapter(AsyncHTTPRequestAdapter): + def __init__(self, request: Request) -> None: + self.request = request + + @property + def query_params(self) -> QueryParams: + return self.request.args.to_dict() + + @property + def method(self) -> HTTPMethod: + return cast("HTTPMethod", self.request.method.upper()) + + @property + def content_type(self) -> Optional[str]: + return self.request.content_type + + @property + def headers(self) -> Mapping[str, str]: + return self.request.headers # type: ignore + + async def get_body(self) -> str: + return self.request.data.decode() + + async def get_form_data(self) -> FormData: + return FormData( + files=self.request.files, + form=self.request.form, + ) + + +class AsyncGraphQLView( + BaseGraphQLView, + AsyncBaseHTTPView[ + Request, Response, Response, Request, Response, Context, RootValue + ], + View, +): + methods: ClassVar[list[str]] = ["GET", "POST"] + allow_queries_via_get: bool = True + request_adapter_class = AsyncFlaskHTTPRequestAdapter + + async def get_context(self, request: Request, response: Response) -> Context: + return {"request": request, "response": response} # type: ignore + + async def get_root_value(self, request: Request) -> Optional[RootValue]: + return None + + async def get_sub_response(self, request: Request) -> Response: + return Response(status=200, content_type="application/json") + + async def dispatch_request(self) -> ResponseReturnValue: # type: ignore + try: + return await self.run(request=request) + except HTTPException as e: + return Response( + response=e.reason, + status=e.status_code, + ) + + async def render_graphql_ide( + self, request: Request, request_data: GraphQLRequestData + ) -> Response: + content = render_template_string( + self.graphql_ide_html, **request_data.to_template_context() + ) + return Response(content, status=200, content_type="text/html") + + def is_websocket_request(self, request: Request) -> TypeGuard[Request]: + return False + + async def pick_websocket_subprotocol(self, request: Request) -> Optional[str]: + raise NotImplementedError + + async def create_websocket_response( + self, request: Request, subprotocol: Optional[str] + ) -> Response: + raise NotImplementedError + + +__all__ = [ + "AsyncGraphQLView", + "GraphQLView", +] diff --git a/src/graphql_server/http/__init__.py b/src/graphql_server/http/__init__.py new file mode 100644 index 0000000..2af8274 --- /dev/null +++ b/src/graphql_server/http/__init__.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Optional +from typing_extensions import Literal, TypedDict + +from graphql.language import DocumentNode + +if TYPE_CHECKING: + from graphql import ExecutionResult + + +class GraphQLHTTPResponse(TypedDict, total=False): + data: Optional[dict[str, object]] + errors: Optional[list[object]] + extensions: Optional[dict[str, object]] + + +def process_result( + result: ExecutionResult, strict: bool = False +) -> GraphQLHTTPResponse: + if strict and not result.data: + data: GraphQLHTTPResponse = {} + else: + data: GraphQLHTTPResponse = {"data": result.data} + + if result.errors: + data["errors"] = [err.formatted for err in result.errors] + if result.extensions: + data["extensions"] = result.extensions + + return data + + +def tojson(value): + if value not in ["true", "false", "null", "undefined"]: + value = json.dumps(value) + # value = escape_js_value(value) + return value + + +def simple_renderer(template: str, **values: str) -> str: + def get_var(match_obj: re.Match[str]) -> str: + var_name = match_obj.group(1) + if var_name is not None: + return values.get(var_name) or tojson("") + return "" + + pattern = r"{{\s*([^}]+)\s*}}" + return re.sub(pattern, get_var, template) + + +@dataclass +class GraphQLRequestData: + # query is optional here as it can be added by an extensions + # (for example an extension for persisted queries) + query: Optional[str] + document: Optional[DocumentNode] + variables: Optional[dict[str, Any]] + operation_name: Optional[str] + extensions: Optional[dict[str, Any]] + protocol: Literal[ + "http", "http-strict", "multipart-subscription", "subscription" + ] = "http" + + def to_template_context(self) -> dict[str, Any]: + return { + "query": tojson(self.query), + "variables": tojson( + tojson(self.variables) if self.variables is not None else "" + ), + "operation_name": tojson(self.operation_name), + } + + def to_template_string(self, template: str) -> str: + return simple_renderer(template, **self.to_template_context()) + + +__all__ = [ + "GraphQLHTTPResponse", + "GraphQLRequestData", + "process_result", +] diff --git a/src/graphql_server/http/async_base_view.py b/src/graphql_server/http/async_base_view.py new file mode 100644 index 0000000..e8aceff --- /dev/null +++ b/src/graphql_server/http/async_base_view.py @@ -0,0 +1,624 @@ +import abc +import asyncio +import contextlib +import json +from collections.abc import AsyncGenerator, AsyncIterable, Mapping +from datetime import timedelta +from typing import ( + Any, + Callable, + Generic, + Optional, + Union, + cast, + overload, +) +from typing_extensions import Literal, TypeGuard + +from graphql import ExecutionResult, GraphQLError +from graphql.language import OperationType +from graphql.type import GraphQLSchema + +from graphql_server import execute, subscribe +from graphql_server.exceptions import GraphQLValidationError, InvalidOperationTypeError +from graphql_server.file_uploads.utils import replace_placeholders_with_files +from graphql_server.http import ( + GraphQLHTTPResponse, + GraphQLRequestData, + process_result, +) +from graphql_server.http.ides import GraphQL_IDE +from graphql_server.http.types import operation_type_from_http +from graphql_server.subscriptions import ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, +) +from graphql_server.subscriptions.protocols.graphql_transport_ws.handlers import ( + BaseGraphQLTransportWSHandler, +) +from graphql_server.subscriptions.protocols.graphql_ws.handlers import ( + BaseGraphQLWSHandler, +) +from graphql_server.types.unset import UNSET, UnsetType + +from .base import BaseView +from .exceptions import HTTPException +from .parse_content_type import parse_content_type +from .types import FormData, HTTPMethod, QueryParams +from .typevars import ( + Context, + Request, + Response, + RootValue, + SubResponse, + WebSocketRequest, + WebSocketResponse, +) + + +class AsyncHTTPRequestAdapter(abc.ABC): + @property + @abc.abstractmethod + def query_params(self) -> QueryParams: ... + + @property + @abc.abstractmethod + def method(self) -> HTTPMethod: ... + + @property + @abc.abstractmethod + def headers(self) -> Mapping[str, str]: ... + + @property + @abc.abstractmethod + def content_type(self) -> Optional[str]: ... + + @abc.abstractmethod + async def get_body(self) -> Union[str, bytes]: ... + + @abc.abstractmethod + async def get_form_data(self) -> FormData: ... + + +class AsyncWebSocketAdapter(abc.ABC): + def __init__(self, view: "AsyncBaseHTTPView") -> None: + self.view = view + + @abc.abstractmethod + def iter_json( + self, *, ignore_parsing_errors: bool = False + ) -> AsyncGenerator[object, None]: ... + + @abc.abstractmethod + async def send_json(self, message: Mapping[str, object]) -> None: ... + + @abc.abstractmethod + async def close(self, code: int, reason: str) -> None: ... + + +class AsyncBaseHTTPView( + abc.ABC, + BaseView[Request], + Generic[ + Request, + Response, + SubResponse, + WebSocketRequest, + WebSocketResponse, + Context, + RootValue, + ], +): + schema: GraphQLSchema + graphql_ide: Optional[GraphQL_IDE] + debug: bool + keep_alive = False + keep_alive_interval: Optional[float] = None + connection_init_wait_timeout: timedelta = timedelta(minutes=1) + request_adapter_class: Callable[[Request], AsyncHTTPRequestAdapter] + websocket_adapter_class: Callable[ + [ + "AsyncBaseHTTPView[Any, Any, Any, Any, Any, Context, RootValue]", + WebSocketRequest, + WebSocketResponse, + ], + AsyncWebSocketAdapter, + ] + graphql_transport_ws_handler_class: type[ + BaseGraphQLTransportWSHandler[Context, RootValue] + ] = BaseGraphQLTransportWSHandler[Context, RootValue] + graphql_ws_handler_class: type[BaseGraphQLWSHandler[Context, RootValue]] = ( + BaseGraphQLWSHandler[Context, RootValue] + ) + + @property + @abc.abstractmethod + def allow_queries_via_get(self) -> bool: ... + + @abc.abstractmethod + async def get_sub_response(self, request: Request) -> SubResponse: ... + + async def setup_connection_params( + self, + connection_params: Optional[dict[str, object]], + websocket: WebSocketRequest, + context: Context, + root_value: Optional[RootValue], + ) -> None: + if isinstance(context, dict): + context["connection_params"] = connection_params + elif hasattr(context, "connection_params"): + context.connection_params = connection_params + + @abc.abstractmethod + async def get_context( + self, + request: Union[Request, WebSocketRequest], + response: Union[SubResponse, WebSocketResponse], + ) -> Context: ... + + @abc.abstractmethod + async def get_root_value( + self, request: Union[Request, WebSocketRequest] + ) -> Optional[RootValue]: ... + + @abc.abstractmethod + def create_response( + self, + response_data: GraphQLHTTPResponse, + sub_response: SubResponse, + is_strict: bool, + ) -> Response: ... + + @abc.abstractmethod + async def render_graphql_ide( + self, request: Request, request_data: GraphQLRequestData + ) -> Response: ... + + async def create_streaming_response( + self, + request: Request, + stream: Callable[[], AsyncGenerator[str, None]], + sub_response: SubResponse, + headers: dict[str, str], + ) -> Response: + raise ValueError("Multipart responses are not supported") + + @abc.abstractmethod + def is_websocket_request( + self, request: Union[Request, WebSocketRequest] + ) -> TypeGuard[WebSocketRequest]: ... + + @abc.abstractmethod + async def pick_websocket_subprotocol( + self, request: WebSocketRequest + ) -> Optional[str]: ... + + @abc.abstractmethod + async def create_websocket_response( + self, request: WebSocketRequest, subprotocol: Optional[str] + ) -> WebSocketResponse: ... + + async def execute_operation( + self, + request_adapter: AsyncHTTPRequestAdapter, + request_data: GraphQLRequestData, + context: Context, + root_value: Optional[RootValue], + allowed_operation_types: set[OperationType], + ) -> ExecutionResult: + assert self.schema + + if request_data.protocol == "multipart-subscription": + return await subscribe( + schema=self.schema, + query=request_data.document or request_data.query, # type: ignore + variable_values=request_data.variables, + context_value=context, + root_value=root_value, + operation_name=request_data.operation_name, + operation_extensions=request_data.extensions, + ) + + return await execute( + schema=self.schema, + query=request_data.document or request_data.query, + root_value=root_value, + variable_values=request_data.variables, + context_value=context, + operation_name=request_data.operation_name, + allowed_operation_types=allowed_operation_types, + operation_extensions=request_data.extensions, + ) + + async def parse_multipart(self, request: AsyncHTTPRequestAdapter) -> dict[str, str]: + try: + form_data = await request.get_form_data() + except ValueError as e: + raise HTTPException(400, "Unable to parse the multipart body") from e + + operations = form_data["form"].get("operations", "{}") + files_map = form_data["form"].get("map", "{}") + + if isinstance(operations, (bytes, str)): + operations = self.parse_json(operations) + + if isinstance(files_map, (bytes, str)): + files_map = self.parse_json(files_map) + + try: + return replace_placeholders_with_files( + operations, files_map, form_data["files"] + ) + except KeyError as e: + raise HTTPException(400, "File(s) missing in form data") from e + + def _handle_errors( + self, errors: list[GraphQLError], response_data: GraphQLHTTPResponse + ) -> None: + """Hook to allow custom handling of errors, used by the Sentry Integration.""" + + @overload + async def run( + self, + request: Request, + context: Context = UNSET, + root_value: Optional[RootValue] = UNSET, + ) -> Response: ... + + @overload + async def run( + self, + request: WebSocketRequest, + context: Context = UNSET, + root_value: Optional[RootValue] = UNSET, + ) -> WebSocketResponse: ... + + async def run( + self, + request: Union[Request, WebSocketRequest], + context: Context = UNSET, + root_value: Optional[RootValue] = UNSET, + ) -> Union[Response, WebSocketResponse]: + if self.is_websocket_request(request): + websocket_subprotocol = await self.pick_websocket_subprotocol(request) + websocket_response = await self.create_websocket_response( + request, websocket_subprotocol + ) + websocket = self.websocket_adapter_class(self, request, websocket_response) + + root_value = ( + await self.get_root_value(request) + if root_value is UNSET + else root_value + ) + context = ( + await self.get_context(request, response=websocket_response) + if context is UNSET + else context + ) + + if websocket_subprotocol == GRAPHQL_TRANSPORT_WS_PROTOCOL: + await self.graphql_transport_ws_handler_class( + view=self, + websocket=websocket, + context=context, + root_value=root_value, # type: ignore + schema=self.schema, + debug=self.debug, + connection_init_wait_timeout=self.connection_init_wait_timeout, + ).handle() + elif websocket_subprotocol == GRAPHQL_WS_PROTOCOL: + await self.graphql_ws_handler_class( + view=self, + websocket=websocket, + context=context, + root_value=root_value, # type: ignore + schema=self.schema, + debug=self.debug, + keep_alive=self.keep_alive, + keep_alive_interval=self.keep_alive_interval, + ).handle() + else: + await websocket.close(4406, "Subprotocol not acceptable") + + return websocket_response + request = cast("Request", request) + + request_adapter = self.request_adapter_class(request) + if request_adapter.method == "OPTIONS": + # We are in a CORS preflight request, we can return a 200 OK by default + # as further checks will need to be done by the middleware + raise HTTPException(200, "") + + sub_response = await self.get_sub_response(request) + + root_value = ( + await self.get_root_value(request) if root_value is UNSET else root_value + ) + context = ( + await self.get_context(request, response=sub_response) + if context is UNSET + else context + ) + + if not self.is_request_allowed(request_adapter): + raise HTTPException(405, "GraphQL only supports GET and POST requests.") + + try: + request_data = await self.parse_http_body(request_adapter, context) + except json.decoder.JSONDecodeError as e: + raise HTTPException(400, "Unable to parse request body as JSON") from e + # DO this only when doing files + except KeyError as e: + raise HTTPException(400, "File(s) missing in form data") from e + + if request_data.variables is not None and not isinstance( + request_data.variables, dict + ): + raise HTTPException(400, "Variables must be a JSON object") + + if request_data.extensions is not None and not isinstance( + request_data.extensions, dict + ): + raise HTTPException(400, "Extensions must be a JSON object") + + allowed_operation_types = operation_type_from_http(request_adapter.method) + + if request_adapter.method == "GET": + if not self.allow_queries_via_get: + allowed_operation_types = allowed_operation_types - { + OperationType.QUERY + } + + if self.graphql_ide and self.should_render_graphql_ide(request_adapter): + return await self.render_graphql_ide(request, request_data) + + is_strict = request_data.protocol == "http-strict" + try: + result = await self.execute_operation( + request_adapter=request_adapter, + request_data=request_data, + context=context, + root_value=root_value, + allowed_operation_types=allowed_operation_types, + ) + except GraphQLValidationError as e: + if is_strict: + sub_response.status_code = 400 # type: ignore + result = ExecutionResult(data=None, errors=e.errors) + except HTTPException: + raise + except InvalidOperationTypeError as e: + raise HTTPException( + 400, e.as_http_error_reason(request_adapter.method) + ) from e + except Exception as e: + raise HTTPException(400, str(e)) from e + + if isinstance(result, AsyncIterable): + stream = self._get_stream(request, result) + + return await self.create_streaming_response( + request, + stream, + sub_response, + headers={ + "Transfer-Encoding": "chunked", + "Content-Type": "multipart/mixed;boundary=graphql;subscriptionSpec=1.0,application/json", + }, + ) + + response_data = await self.process_result( + request=request, result=result, strict=is_strict + ) + + if result.errors: + self._handle_errors(result.errors, response_data) + + return self.create_response( + response_data=response_data, sub_response=sub_response, is_strict=is_strict + ) + + def encode_multipart_data(self, data: Any, separator: str) -> str: + return "".join( + [ + f"\r\n--{separator}\r\n", + "Content-Type: application/json\r\n\r\n", + self.encode_json(data), + "\n", + ] + ) + + def _stream_with_heartbeat( + self, stream: Callable[[], AsyncGenerator[str, None]], separator: str + ) -> Callable[[], AsyncGenerator[str, None]]: + """Add heartbeat messages to a GraphQL stream to prevent connection timeouts. + + This method wraps an async stream generator with heartbeat functionality by: + 1. Creating a queue to coordinate between data and heartbeat messages + 2. Running two concurrent tasks: one for original stream data, one for heartbeats + 3. Merging both message types into a single output stream + + Messages in the queue are tuples of (raised, done, data) where: + - raised (bool): True if this contains an exception to be re-raised + - done (bool): True if this is the final signal indicating stream completion + - data: The actual message content to yield, or exception if raised=True + Note: data is always None when done=True and can be ignored + + Note: This implementation addresses two critical concerns: + + 1. Race condition: There's a potential race between checking task.done() and + processing the final message. We solve this by having the drain task send + an explicit (False, True, None) completion signal as its final action. + Without this signal, we might exit before processing the final boundary. + + Since the queue size is 1 and the drain task will only complete after + successfully queueing the done signal, task.done() guarantees the done + signal is either in the queue or has already been processed. This ensures + we never miss the final boundary. + + 2. Flow control: The queue has maxsize=1, which is essential because: + - It provides natural backpressure between producers and consumer + - Prevents heartbeat messages from accumulating when drain is active + - Ensures proper task coordination without complex synchronization + - Guarantees the done signal is queued before drain task completes + + Heartbeats are sent every 5 seconds when the drain task isn't sending data. + + Note: Due to the asynchronous nature of the heartbeat task, an extra heartbeat + message may be sent after the final stream boundary message. This is safe because + both the MIME specification (RFC 2046) and Apollo's GraphQL Multipart HTTP protocol + require clients to ignore any content after the final boundary marker. Additionally, + Apollo's protocol defines heartbeats as empty JSON objects that clients must + silently ignore. + """ + queue: asyncio.Queue[tuple[bool, bool, Any]] = asyncio.Queue( + maxsize=1, # Critical: maxsize=1 for flow control. + ) + cancelling = False + + async def drain() -> None: + try: + async for item in stream(): + await queue.put((False, False, item)) + except Exception as e: + if not cancelling: + await queue.put((True, False, e)) + else: + raise + # Send completion signal to prevent race conditions. The queue.put() + # blocks until space is available (due to maxsize=1), guaranteeing that + # when task.done() is True, the final stream message has been dequeued. + await queue.put((False, True, None)) # Always use None with done=True + + async def heartbeat() -> None: + while True: + item = self.encode_multipart_data({}, separator) + await queue.put((False, False, item)) + + await asyncio.sleep(5) + + async def merged() -> AsyncGenerator[str, None]: + heartbeat_task = asyncio.create_task(heartbeat()) + task = asyncio.create_task(drain()) + + async def cancel_tasks() -> None: + nonlocal cancelling + cancelling = True + task.cancel() + + with contextlib.suppress(asyncio.CancelledError): + await task + + heartbeat_task.cancel() + + with contextlib.suppress(asyncio.CancelledError): + await heartbeat_task + + try: + # When task.done() is True, the final stream message has been + # dequeued due to queue size 1 and the blocking nature of queue.put(). + while not task.done(): + raised, done, data = await queue.get() + + if done: + # Received done signal (data is None), stream is complete. + # Note that we may not get here because of the race between + # task.done() and queue.get(), but that's OK because if + # task.done() is True, the actual final message (including any + # exception) has been consumed. The only intent here is to + # ensure that data=None is not yielded. + break + + if raised: + await cancel_tasks() + raise data + + yield data + finally: + await cancel_tasks() + + return merged + + def _get_stream( + self, + request: Request, + result: AsyncGenerator[ExecutionResult, None], + separator: str = "graphql", + ) -> Callable[[], AsyncGenerator[str, None]]: + async def stream() -> AsyncGenerator[str, None]: + async for value in result: + response = await self.process_result(request, value) + yield self.encode_multipart_data({"payload": response}, separator) + + yield f"\r\n--{separator}--\r\n" + + return self._stream_with_heartbeat(stream, separator) + + async def parse_multipart_subscriptions( + self, request: AsyncHTTPRequestAdapter + ) -> dict[str, str]: + if request.method == "GET": + return self.parse_query_params(request.query_params) + + return self.parse_json(await request.get_body()) + + async def get_graphql_request_data( + self, + request: Union[AsyncHTTPRequestAdapter, WebSocketRequest], + context: Context, + data: dict[str, Any], + protocol: Literal[ + "http", "http-strict", "multipart-subscription", "subscription" + ], + ) -> GraphQLRequestData: + return GraphQLRequestData( + query=data.get("query"), + document=None, + variables=data.get("variables"), + operation_name=data.get("operationName"), + extensions=data.get("extensions"), + protocol=protocol, + ) + + async def parse_http_body( + self, + request: AsyncHTTPRequestAdapter, + context: Context, + ) -> GraphQLRequestData: + headers = {key.lower(): value for key, value in request.headers.items()} + content_type, _ = parse_content_type(request.content_type or "") + accept = headers.get("accept", "") or headers.get("http-accept", "") + + accept_type = parse_content_type(accept) + protocol: Literal["http", "http-strict", "multipart-subscription"] = "http" + + if self._is_multipart_subscriptions(*accept_type): + protocol = "multipart-subscription" + elif "application/graphql-response+json" in accept_type: + protocol = "http-strict" + + if request.method == "GET": + data = self.parse_query_params(request.query_params) + elif "application/json" in content_type: + data = self.parse_json(await request.get_body()) + elif self.multipart_uploads_enabled and content_type == "multipart/form-data": + data = await self.parse_multipart(request) + else: + raise HTTPException(400, "Unsupported content type") + + return await self.get_graphql_request_data(request, context, data, protocol) + + async def process_result( + self, request: Request, result: ExecutionResult, strict: bool = False + ) -> GraphQLHTTPResponse: + return process_result(result, strict) + + async def on_ws_connect( + self, context: Context + ) -> Union[UnsetType, None, dict[str, object]]: + return UNSET + + +__all__ = ["AsyncBaseHTTPView"] diff --git a/src/graphql_server/http/base.py b/src/graphql_server/http/base.py new file mode 100644 index 0000000..aba934e --- /dev/null +++ b/src/graphql_server/http/base.py @@ -0,0 +1,82 @@ +import json +from collections.abc import Mapping +from typing import Any, Generic, Optional, Union +from typing_extensions import Protocol + +from graphql_server.http.ides import GraphQL_IDE, get_graphql_ide_html +from graphql_server.http.types import HTTPMethod, QueryParams + +from .exceptions import HTTPException +from .typevars import Request + + +class BaseRequestProtocol(Protocol): + @property + def query_params(self) -> Mapping[str, Optional[Union[str, list[str]]]]: ... + + @property + def method(self) -> HTTPMethod: ... + + @property + def headers(self) -> Mapping[str, str]: ... + + +class BaseView(Generic[Request]): + graphql_ide: Optional[GraphQL_IDE] + multipart_uploads_enabled: bool = False + + def should_render_graphql_ide(self, request: BaseRequestProtocol) -> bool: + return request.method == "GET" and any( + supported_header in request.headers.get("accept", "") + for supported_header in ("text/html",) + ) + + def is_request_allowed(self, request: BaseRequestProtocol) -> bool: + return request.method in ("GET", "POST") + + def parse_json(self, data: Union[str, bytes]) -> Any: + try: + return self.decode_json(data) + except json.JSONDecodeError as e: + raise HTTPException(400, "Unable to parse request body as JSON") from e + + def decode_json(self, data: Union[str, bytes]) -> object: + return json.loads(data) + + def encode_json(self, data: object) -> str: + return json.dumps(data) + + def parse_query_params(self, params: QueryParams) -> dict[str, Any]: + params = dict(params) + + if "variables" in params: + variables = params["variables"] + + if variables: + params["variables"] = self.parse_json(variables) + + if "extensions" in params: + extensions = params["extensions"] + + if extensions: + params["extensions"] = self.parse_json(extensions) + + return params + + @property + def graphql_ide_html(self) -> str: + return get_graphql_ide_html(graphql_ide=self.graphql_ide) + + def _is_multipart_subscriptions( + self, content_type: str, params: dict[str, str] + ) -> bool: + if content_type != "multipart/mixed": + return False + + if params.get("boundary") != "graphql": + return False + + return params.get("subscriptionspec", "").startswith("1.0") + + +__all__ = ["BaseView"] diff --git a/src/graphql_server/http/exceptions.py b/src/graphql_server/http/exceptions.py new file mode 100644 index 0000000..4390ab1 --- /dev/null +++ b/src/graphql_server/http/exceptions.py @@ -0,0 +1,19 @@ +class HTTPException(Exception): + def __init__(self, status_code: int, reason: str) -> None: + self.status_code = status_code + self.reason = reason + + +class NonTextMessageReceived(Exception): + pass + + +class NonJsonMessageReceived(Exception): + pass + + +class WebSocketDisconnected(Exception): + pass + + +__all__ = ["HTTPException"] diff --git a/src/graphql_server/http/ides.py b/src/graphql_server/http/ides.py new file mode 100644 index 0000000..be72fc1 --- /dev/null +++ b/src/graphql_server/http/ides.py @@ -0,0 +1,23 @@ +import pathlib +from typing import Optional +from typing_extensions import Literal + +GraphQL_IDE = Literal["graphiql", "apollo-sandbox", "pathfinder"] + + +def get_graphql_ide_html( + graphql_ide: Optional[GraphQL_IDE] = "graphiql", +) -> str: + here = pathlib.Path(__file__).parents[1] + + if graphql_ide == "apollo-sandbox": + path = here / "static/apollo-sandbox.html" + elif graphql_ide == "pathfinder": + path = here / "static/pathfinder.html" + else: + path = here / "static/graphiql.html" + + return path.read_text(encoding="utf-8") + + +__all__ = ["GraphQL_IDE", "get_graphql_ide_html"] diff --git a/src/graphql_server/http/parse_content_type.py b/src/graphql_server/http/parse_content_type.py new file mode 100644 index 0000000..da54798 --- /dev/null +++ b/src/graphql_server/http/parse_content_type.py @@ -0,0 +1,15 @@ +from email.message import Message + + +def parse_content_type(content_type: str) -> tuple[str, dict[str, str]]: + """Parse a content type header into a mime-type and a dictionary of parameters.""" + email = Message() + email["content-type"] = content_type + + params = email.get_params() + + assert params + + mime_type, _ = params.pop(0) + + return mime_type, dict(params) diff --git a/src/graphql_server/http/sync_base_view.py b/src/graphql_server/http/sync_base_view.py new file mode 100644 index 0000000..3b0b0d7 --- /dev/null +++ b/src/graphql_server/http/sync_base_view.py @@ -0,0 +1,276 @@ +import abc +import json +from collections.abc import Mapping +from typing import ( + Any, + Callable, + Generic, + Literal, + Optional, + Union, +) + +from graphql import ExecutionResult, GraphQLError +from graphql.language import OperationType +from graphql.type import GraphQLSchema + +from graphql_server import execute_sync +from graphql_server.exceptions import GraphQLValidationError, InvalidOperationTypeError +from graphql_server.file_uploads.utils import replace_placeholders_with_files +from graphql_server.http import ( + GraphQLHTTPResponse, + GraphQLRequestData, + process_result, +) +from graphql_server.http.ides import GraphQL_IDE +from graphql_server.http.types import operation_type_from_http +from graphql_server.types.unset import UNSET + +from .base import BaseView +from .exceptions import HTTPException +from .parse_content_type import parse_content_type +from .types import HTTPMethod, QueryParams +from .typevars import Context, Request, Response, RootValue, SubResponse + + +class SyncHTTPRequestAdapter(abc.ABC): + @property + @abc.abstractmethod + def query_params(self) -> QueryParams: ... + + @property + @abc.abstractmethod + def body(self) -> Union[str, bytes]: ... + + @property + @abc.abstractmethod + def method(self) -> HTTPMethod: ... + + @property + @abc.abstractmethod + def headers(self) -> Mapping[str, str]: ... + + @property + @abc.abstractmethod + def content_type(self) -> Optional[str]: ... + + @property + @abc.abstractmethod + def post_data(self) -> Mapping[str, Union[str, bytes]]: ... + + @property + @abc.abstractmethod + def files(self) -> Mapping[str, Any]: ... + + +class SyncBaseHTTPView( + abc.ABC, + BaseView[Request], + Generic[Request, Response, SubResponse, Context, RootValue], +): + schema: GraphQLSchema + graphiql: Optional[bool] + graphql_ide: Optional[GraphQL_IDE] + request_adapter_class: Callable[[Request], SyncHTTPRequestAdapter] + + # Methods that need to be implemented by individual frameworks + + @property + @abc.abstractmethod + def allow_queries_via_get(self) -> bool: ... + + @abc.abstractmethod + def get_sub_response(self, request: Request) -> SubResponse: ... + + @abc.abstractmethod + def get_context(self, request: Request, response: SubResponse) -> Context: ... + + @abc.abstractmethod + def get_root_value(self, request: Request) -> Optional[RootValue]: ... + + @abc.abstractmethod + def create_response( + self, + response_data: GraphQLHTTPResponse, + sub_response: SubResponse, + is_strict: bool, + ) -> Response: ... + + @abc.abstractmethod + def render_graphql_ide( + self, request: Request, request_data: GraphQLRequestData + ) -> Response: ... + + def execute_operation( + self, + request_adapter: SyncHTTPRequestAdapter, + request_data: GraphQLRequestData, + context: Context, + root_value: Optional[RootValue], + allowed_operation_types: set[OperationType], + ) -> ExecutionResult: + assert self.schema + + return execute_sync( + schema=self.schema, + query=request_data.document or request_data.query, + root_value=root_value, + variable_values=request_data.variables, + context_value=context, + operation_name=request_data.operation_name, + allowed_operation_types=allowed_operation_types, + operation_extensions=request_data.extensions, + ) + + def parse_multipart(self, request: SyncHTTPRequestAdapter) -> dict[str, str]: + operations = self.parse_json(request.post_data.get("operations", "{}")) + files_map = self.parse_json(request.post_data.get("map", "{}")) + + try: + return replace_placeholders_with_files(operations, files_map, request.files) + except KeyError as e: + raise HTTPException(400, "File(s) missing in form data") from e + + def get_graphql_request_data( + self, + request: SyncHTTPRequestAdapter, + context: Context, + data: dict[str, Any], + protocol: Literal["http", "http-strict", "multipart-subscription"], + ) -> GraphQLRequestData: + return GraphQLRequestData( + query=data.get("query"), + document=None, + variables=data.get("variables"), + operation_name=data.get("operationName"), + extensions=data.get("extensions"), + protocol=protocol, + ) + + def parse_http_body( + self, + request: SyncHTTPRequestAdapter, + context: Context, + ) -> GraphQLRequestData: + accept_type = request.headers.get("accept", "") or request.headers.get( + "http-accept", "" + ) + content_type, params = parse_content_type(request.content_type or "") + + protocol = "http" + if "application/graphql-response+json" in accept_type: + protocol = "http-strict" + + if request.method == "GET": + data = self.parse_query_params(request.query_params) + elif "application/json" in content_type: + data = self.parse_json(request.body) + # TODO: multipart via get? + elif self.multipart_uploads_enabled and content_type == "multipart/form-data": + data = self.parse_multipart(request) + elif self._is_multipart_subscriptions(content_type, params): + raise HTTPException( + 400, "Multipart subcriptions are not supported in sync mode" + ) + else: + raise HTTPException(400, "Unsupported content type") + + return self.get_graphql_request_data(request, context, data, protocol) + + def _handle_errors( + self, errors: list[GraphQLError], response_data: GraphQLHTTPResponse + ) -> None: + """Hook to allow custom handling of errors, used by the Sentry Integration.""" + + def run( + self, + request: Request, + context: Context = UNSET, + root_value: Optional[RootValue] = UNSET, + ) -> Response: + request_adapter = self.request_adapter_class(request) + if request_adapter.method == "OPTIONS": + # We are in a CORS preflight request, we can return a 200 OK by default + # as further checks will need to be done by the middleware + raise HTTPException(200, "") + + if not self.is_request_allowed(request_adapter): + raise HTTPException(405, "GraphQL only supports GET and POST requests.") + + sub_response = self.get_sub_response(request) + context = ( + self.get_context(request, response=sub_response) + if context is UNSET + else context + ) + + try: + request_data = self.parse_http_body(request_adapter, context) + except json.decoder.JSONDecodeError as e: + raise HTTPException(400, "Unable to parse request body as JSON") from e + # DO this only when doing files + except KeyError as e: + raise HTTPException(400, "File(s) missing in form data") from e + + if request_data.variables is not None and not isinstance( + request_data.variables, dict + ): + raise HTTPException(400, "Variables must be a JSON object") + + if request_data.extensions is not None and not isinstance( + request_data.extensions, dict + ): + raise HTTPException(400, "Extensions must be a JSON object") + + allowed_operation_types = operation_type_from_http(request_adapter.method) + + if request_adapter.method == "GET": + if not self.allow_queries_via_get: + allowed_operation_types = allowed_operation_types - { + OperationType.QUERY + } + + if self.graphql_ide and self.should_render_graphql_ide(request_adapter): + return self.render_graphql_ide(request, request_data) + + root_value = self.get_root_value(request) if root_value is UNSET else root_value + is_strict = request_data.protocol == "http-strict" + try: + result = self.execute_operation( + request_adapter=request_adapter, + request_data=request_data, + context=context, + root_value=root_value, + allowed_operation_types=allowed_operation_types, + ) + except HTTPException: + raise + except GraphQLValidationError as e: + if is_strict: + sub_response.status_code = 400 # type: ignore + result = ExecutionResult(data=None, errors=e.errors) + except InvalidOperationTypeError as e: + raise HTTPException( + 400, e.as_http_error_reason(request_adapter.method) + ) from e + except Exception as e: + raise HTTPException(400, str(e)) from e + + response_data = self.process_result( + request=request, result=result, strict=is_strict + ) + + if result.errors: + self._handle_errors(result.errors, response_data) + + return self.create_response( + response_data=response_data, sub_response=sub_response, is_strict=is_strict + ) + + def process_result( + self, request: Request, result: ExecutionResult, strict: bool = False + ) -> GraphQLHTTPResponse: + return process_result(result, strict) + + +__all__ = ["SyncBaseHTTPView"] diff --git a/src/graphql_server/http/temporal_response.py b/src/graphql_server/http/temporal_response.py new file mode 100644 index 0000000..7212a61 --- /dev/null +++ b/src/graphql_server/http/temporal_response.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass, field + + +@dataclass +class TemporalResponse: + status_code: int = 200 + headers: dict[str, str | list[str]] = field(default_factory=dict) + + +__all__ = ["TemporalResponse"] diff --git a/src/graphql_server/http/types.py b/src/graphql_server/http/types.py new file mode 100644 index 0000000..08bfbfa --- /dev/null +++ b/src/graphql_server/http/types.py @@ -0,0 +1,37 @@ +from collections.abc import Mapping +from typing import Any, Optional +from typing_extensions import Literal, TypedDict + +from graphql.language import OperationType + +HTTPMethod = Literal[ + "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE" +] + +QueryParams = Mapping[str, Optional[str]] + + +class FormData(TypedDict): + files: Mapping[str, Any] + form: Mapping[str, Any] + + +def operation_type_from_http(method: HTTPMethod) -> set[OperationType]: + if method == "GET": + return { + OperationType.QUERY, + # subscriptions are supported via GET in the multipart protocol + OperationType.SUBSCRIPTION, + } + + if method == "POST": + return { + OperationType.QUERY, + OperationType.MUTATION, + OperationType.SUBSCRIPTION, + } + + raise ValueError(f"Unsupported HTTP method: {method}") # pragma: no cover + + +__all__ = ["FormData", "HTTPMethod", "QueryParams"] diff --git a/src/graphql_server/http/typevars.py b/src/graphql_server/http/typevars.py new file mode 100644 index 0000000..a1f6020 --- /dev/null +++ b/src/graphql_server/http/typevars.py @@ -0,0 +1,20 @@ +from typing_extensions import TypeVar + +Request = TypeVar("Request", contravariant=True) +Response = TypeVar("Response") +SubResponse = TypeVar("SubResponse") +WebSocketRequest = TypeVar("WebSocketRequest") +WebSocketResponse = TypeVar("WebSocketResponse") +Context = TypeVar("Context", default=None) +RootValue = TypeVar("RootValue", default=None) + + +__all__ = [ + "Context", + "Request", + "Response", + "RootValue", + "SubResponse", + "WebSocketRequest", + "WebSocketResponse", +] diff --git a/src/graphql_server/litestar/__init__.py b/src/graphql_server/litestar/__init__.py new file mode 100644 index 0000000..b5eaf26 --- /dev/null +++ b/src/graphql_server/litestar/__init__.py @@ -0,0 +1,13 @@ +from .controller import ( + BaseContext, + HTTPContextType, + WebSocketContextType, + make_graphql_controller, +) + +__all__ = [ + "BaseContext", + "HTTPContextType", + "WebSocketContextType", + "make_graphql_controller", +] diff --git a/src/graphql_server/litestar/controller.py b/src/graphql_server/litestar/controller.py new file mode 100644 index 0000000..828c6e2 --- /dev/null +++ b/src/graphql_server/litestar/controller.py @@ -0,0 +1,486 @@ +"""Litestar integration for GraphQL.""" + +from __future__ import annotations + +import json +import warnings +from datetime import timedelta +from typing import ( + TYPE_CHECKING, + Any, + Callable, + ClassVar, + Optional, + TypedDict, + Union, + cast, +) +from typing_extensions import TypeGuard + +from msgspec import Struct + +from graphql_server.http import GraphQLRequestData +from graphql_server.http.async_base_view import ( + AsyncBaseHTTPView, + AsyncHTTPRequestAdapter, + AsyncWebSocketAdapter, +) +from graphql_server.http.exceptions import ( + HTTPException, + NonJsonMessageReceived, + NonTextMessageReceived, + WebSocketDisconnected, +) +from graphql_server.http.types import FormData, HTTPMethod, QueryParams +from graphql_server.http.typevars import Context, RootValue +from graphql_server.subscriptions import ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, +) +from litestar import ( + Controller, + MediaType, + Request, + Response, + WebSocket, + get, + post, + websocket, +) +from litestar.background_tasks import BackgroundTasks +from litestar.di import Provide +from litestar.exceptions import ( + NotFoundException, + ValidationException, + WebSocketDisconnect, +) +from litestar.response.streaming import Stream +from litestar.status_codes import HTTP_200_OK + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, AsyncIterator, Mapping, Sequence + + from graphql.type import GraphQLSchema + + from graphql_server.http import GraphQLHTTPResponse + from graphql_server.http.ides import GraphQL_IDE + from litestar.types import AnyCallable, Dependencies + + +class BaseContext(Struct, kw_only=True): + request: Optional[Request] = None + websocket: Optional[WebSocket] = None + response: Optional[Response] = None + + +class HTTPContextType: + """This class does not exists at runtime, it only set proper types for context attributes.""" + + request: Request + response: Response + + +class WebSocketContextType: + """This class does not exists at runtime, it only set proper types for context attributes.""" + + websocket: WebSocket + + +class HTTPContextDict(TypedDict): + request: Request[Any, Any, Any] + response: Response[Any] + + +class WebSocketContextDict(TypedDict): + socket: WebSocket + + +MergedContext = Union[ + BaseContext, WebSocketContextDict, HTTPContextDict, dict[str, Any] +] + + +async def _none_custom_context_getter() -> None: + return None + + +async def _none_root_value_getter() -> None: + return None + + +async def _context_getter_ws( + custom_context: Optional[Any], socket: WebSocket +) -> MergedContext: + if isinstance(custom_context, BaseContext): + custom_context.websocket = socket + return custom_context + + default_context = WebSocketContextDict(socket=socket) + + if isinstance(custom_context, dict): + return {**default_context, **custom_context} + + if custom_context is None: + return default_context + + return custom_context + + +def _response_getter() -> Response: + return Response({}, background=BackgroundTasks([])) + + +async def _context_getter_http( + custom_context: Optional[Any], + response: Response, + request: Request[Any, Any, Any], +) -> MergedContext: + if isinstance(custom_context, BaseContext): + custom_context.request = request + custom_context.response = response + return custom_context + + default_context = HTTPContextDict(request=request, response=response) + + if isinstance(custom_context, dict): + return {**default_context, **custom_context} + + if custom_context is None: + return default_context + + return custom_context + + +class GraphQLResource(Struct): + data: Optional[dict[str, object]] + errors: Optional[list[object]] + extensions: Optional[dict[str, object]] + + +class LitestarRequestAdapter(AsyncHTTPRequestAdapter): + def __init__(self, request: Request[Any, Any, Any]) -> None: + self.request = request + + @property + def query_params(self) -> QueryParams: + return self.request.query_params + + @property + def method(self) -> HTTPMethod: + return cast("HTTPMethod", self.request.method.upper()) + + @property + def headers(self) -> Mapping[str, str]: + return self.request.headers + + @property + def content_type(self) -> Optional[str]: + content_type, params = self.request.content_type + + # combine content type and params + if params: + content_type += "; " + "; ".join(f"{k}={v}" for k, v in params.items()) + + return content_type + + async def get_body(self) -> bytes: + return await self.request.body() + + async def get_form_data(self) -> FormData: + multipart_data = await self.request.form() + + return FormData(form=multipart_data, files=multipart_data) + + +class LitestarWebSocketAdapter(AsyncWebSocketAdapter): + def __init__( + self, view: AsyncBaseHTTPView, request: WebSocket, response: WebSocket + ) -> None: + super().__init__(view) + self.ws = response + + async def iter_json( + self, *, ignore_parsing_errors: bool = False + ) -> AsyncGenerator[object, None]: + try: + while self.ws.connection_state != "disconnect": + text = await self.ws.receive_text() + + # Litestar internally defaults to an empty string for non-text messages + if text == "": + raise NonTextMessageReceived + + try: + yield self.view.decode_json(text) + except json.JSONDecodeError as e: + if not ignore_parsing_errors: + raise NonJsonMessageReceived from e + except WebSocketDisconnect: + pass + + async def send_json(self, message: Mapping[str, object]) -> None: + try: + await self.ws.send_data(data=self.view.encode_json(message)) + except WebSocketDisconnect as exc: + raise WebSocketDisconnected from exc + + async def close(self, code: int, reason: str) -> None: + await self.ws.close(code=code, reason=reason) + + +class GraphQLController( + Controller, + AsyncBaseHTTPView[ + Request[Any, Any, Any], + Response[Any], + Response[Any], + WebSocket, + WebSocket, + Context, + RootValue, + ], +): + path: str = "" + dependencies: ClassVar[Dependencies] = { # type: ignore[misc] + "custom_context": Provide(_none_custom_context_getter), + "context": Provide(_context_getter_http), + "context_ws": Provide(_context_getter_ws), + "root_value": Provide(_none_root_value_getter), + "response": Provide(_response_getter, sync_to_thread=True), + } + + request_adapter_class = LitestarRequestAdapter + websocket_adapter_class = LitestarWebSocketAdapter + + allow_queries_via_get: bool = True + graphiql_allowed_accept: frozenset[str] = frozenset({"text/html", "*/*"}) + graphql_ide: Optional[GraphQL_IDE] = "graphiql" + debug: bool = False + connection_init_wait_timeout: timedelta = timedelta(minutes=1) + protocols: Sequence[str] = ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, + ) + keep_alive: bool = False + keep_alive_interval: float = 1 + + def is_websocket_request( + self, request: Union[Request, WebSocket] + ) -> TypeGuard[WebSocket]: + return isinstance(request, WebSocket) + + async def pick_websocket_subprotocol(self, request: WebSocket) -> Optional[str]: + subprotocols = request.scope["subprotocols"] + intersection = set(subprotocols) & set(self.protocols) + sorted_intersection = sorted(intersection, key=subprotocols.index) + return next(iter(sorted_intersection), None) + + async def create_websocket_response( + self, request: WebSocket, subprotocol: Optional[str] + ) -> WebSocket: + await request.accept(subprotocols=subprotocol) + return request + + async def execute_request( + self, + request: Request[Any, Any, Any], + context: Any, + root_value: Any, + ) -> Response[Union[GraphQLResource, str]]: + try: + return await self.run( + request, + context=context, + root_value=root_value, + ) + except HTTPException as e: + return Response( + e.reason, + status_code=e.status_code, + media_type=MediaType.TEXT, + ) + + async def render_graphql_ide( + self, request: Request[Any, Any, Any], request_data: GraphQLRequestData + ) -> Response[str]: + return Response( + request_data.to_template_string(self.graphql_ide_html), + media_type=MediaType.HTML, + ) + + def create_response( + self, + response_data: GraphQLHTTPResponse, + sub_response: Response[bytes], + is_strict: bool, + ) -> Response[bytes]: + response = Response( + self.encode_json(response_data).encode(), + status_code=HTTP_200_OK, + media_type="application/graphql-response+json" + if is_strict + else MediaType.JSON, + ) + + response.headers.update(sub_response.headers) + response.cookies.extend(sub_response.cookies) + response.background = sub_response.background + + if sub_response.status_code: + response.status_code = sub_response.status_code + + return response + + async def create_streaming_response( + self, + request: Request, + stream: Callable[[], AsyncIterator[str]], + sub_response: Response, + headers: dict[str, str], + ) -> Response: + return Stream( + stream(), + status_code=sub_response.status_code, + headers={ + **sub_response.headers, + **headers, + }, + ) + + @get(raises=[ValidationException, NotFoundException]) + async def handle_http_get( + self, + request: Request[Any, Any, Any], + context: Any, + root_value: Any, + response: Response, + ) -> Response[Union[GraphQLResource, str]]: + self.temporal_response = response + + return await self.execute_request( + request=request, + context=context, + root_value=root_value, + ) + + @post(status_code=HTTP_200_OK) + async def handle_http_post( + self, + request: Request[Any, Any, Any], + context: Any, + root_value: Any, + response: Response, + ) -> Response[Union[GraphQLResource, str]]: + self.temporal_response = response + + return await self.execute_request( + request=request, + context=context, + root_value=root_value, + ) + + @websocket() + async def websocket_endpoint( + self, + socket: WebSocket, + context_ws: Any, + root_value: Any, + ) -> None: + await self.run( + request=socket, + context=context_ws, + root_value=root_value, + ) + + async def get_context( + self, + request: Union[Request[Any, Any, Any], WebSocket], + response: Union[Response, WebSocket], + ) -> Context: # pragma: no cover + msg = "`get_context` is not used by Litestar's controller" + raise ValueError(msg) + + async def get_root_value( + self, request: Union[Request[Any, Any, Any], WebSocket] + ) -> RootValue | None: # pragma: no cover + msg = "`get_root_value` is not used by Litestar's controller" + raise ValueError(msg) + + async def get_sub_response(self, request: Request[Any, Any, Any]) -> Response: + return self.temporal_response + + +def make_graphql_controller( + schema: GraphQLSchema, + path: str = "", + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + keep_alive: bool = False, + keep_alive_interval: float = 1, + debug: bool = False, + # TODO: root typevar + root_value_getter: Optional[AnyCallable] = None, + # TODO: context typevar + context_getter: Optional[AnyCallable] = None, + subscription_protocols: Sequence[str] = ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, + ), + connection_init_wait_timeout: timedelta = timedelta(minutes=1), + multipart_uploads_enabled: bool = False, +) -> type[GraphQLController]: # sourcery skip: move-assign + if context_getter is None: + custom_context_getter_ = _none_custom_context_getter + else: + custom_context_getter_ = context_getter + + if root_value_getter is None: + root_value_getter_ = _none_root_value_getter + else: + root_value_getter_ = root_value_getter + + schema_: GraphQLSchema = schema + allow_queries_via_get_: bool = allow_queries_via_get + graphql_ide_: Optional[GraphQL_IDE] + + if graphiql is not None: + warnings.warn( + "The `graphiql` argument is deprecated in favor of `graphql_ide`", + DeprecationWarning, + stacklevel=2, + ) + graphql_ide_ = "graphiql" if graphiql else None + else: + graphql_ide_ = graphql_ide + + routes_path: str = path + + class _GraphQLController(GraphQLController): + path: str = routes_path + dependencies: ClassVar[Dependencies] = { # type: ignore[misc] + "custom_context": Provide(custom_context_getter_), + "context": Provide(_context_getter_http), + "context_ws": Provide(_context_getter_ws), + "root_value": Provide(root_value_getter_), + "response": Provide(_response_getter, sync_to_thread=True), + } + + _GraphQLController.keep_alive = keep_alive + _GraphQLController.keep_alive_interval = keep_alive_interval + _GraphQLController.debug = debug + _GraphQLController.protocols = subscription_protocols + _GraphQLController.connection_init_wait_timeout = connection_init_wait_timeout + _GraphQLController.graphiql_allowed_accept = frozenset({"text/html", "*/*"}) + _GraphQLController.schema = schema_ + _GraphQLController.allow_queries_via_get = allow_queries_via_get_ + _GraphQLController.graphql_ide = graphql_ide_ + _GraphQLController.multipart_uploads_enabled = multipart_uploads_enabled + + return _GraphQLController + + +__all__ = [ + "GraphQLController", + "make_graphql_controller", +] diff --git a/src/graphql_server/py.typed b/src/graphql_server/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/graphql_server/quart/__init__.py b/src/graphql_server/quart/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/graphql_server/quart/views.py b/src/graphql_server/quart/views.py new file mode 100644 index 0000000..ab82c50 --- /dev/null +++ b/src/graphql_server/quart/views.py @@ -0,0 +1,228 @@ +import asyncio +import warnings +from collections.abc import AsyncGenerator, Mapping, Sequence +from datetime import timedelta +from json.decoder import JSONDecodeError +from typing import TYPE_CHECKING, Callable, ClassVar, Optional, Union, cast +from typing_extensions import TypeGuard + +from graphql_server.http import GraphQLRequestData +from graphql_server.http.async_base_view import ( + AsyncBaseHTTPView, + AsyncHTTPRequestAdapter, + AsyncWebSocketAdapter, +) +from graphql_server.http.exceptions import ( + HTTPException, + NonJsonMessageReceived, + NonTextMessageReceived, + WebSocketDisconnected, +) +from graphql_server.http.ides import GraphQL_IDE +from graphql_server.http.types import FormData, HTTPMethod, QueryParams +from graphql_server.http.typevars import Context, RootValue +from graphql_server.subscriptions import ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, +) +from quart import Request, Response, Websocket, request, websocket +from quart.ctx import has_websocket_context +from quart.views import View + +if TYPE_CHECKING: + from graphql.type import GraphQLSchema + + from graphql_server.http import GraphQLHTTPResponse + from quart.typing import ResponseReturnValue + + +class QuartHTTPRequestAdapter(AsyncHTTPRequestAdapter): + def __init__(self, request: Request) -> None: + self.request = request + + @property + def query_params(self) -> QueryParams: + return self.request.args.to_dict() + + @property + def method(self) -> HTTPMethod: + return cast("HTTPMethod", self.request.method.upper()) + + @property + def content_type(self) -> Optional[str]: + return self.request.content_type + + @property + def headers(self) -> Mapping[str, str]: + return self.request.headers # type: ignore + + async def get_body(self) -> str: + return (await self.request.data).decode() + + async def get_form_data(self) -> FormData: + files = await self.request.files + form = await self.request.form + return FormData(files=files, form=form) + + +class QuartWebSocketAdapter(AsyncWebSocketAdapter): + def __init__( + self, view: AsyncBaseHTTPView, request: Websocket, response: Response + ) -> None: + super().__init__(view) + self.ws = request + + async def iter_json( + self, *, ignore_parsing_errors: bool = False + ) -> AsyncGenerator[object, None]: + try: + while True: + # Raises asyncio.CancelledError when the connection is closed. + # https://quart.palletsprojects.com/en/latest/how_to_guides/websockets.html#detecting-disconnection + message = await self.ws.receive() + + if not isinstance(message, str): + raise NonTextMessageReceived + + try: + yield self.view.decode_json(message) + except JSONDecodeError as e: + if not ignore_parsing_errors: + raise NonJsonMessageReceived from e + except asyncio.CancelledError: + pass + + async def send_json(self, message: Mapping[str, object]) -> None: + try: + # Raises asyncio.CancelledError when the connection is closed. + # https://quart.palletsprojects.com/en/latest/how_to_guides/websockets.html#detecting-disconnection + await self.ws.send(self.view.encode_json(message)) + except asyncio.CancelledError as exc: + raise WebSocketDisconnected from exc + + async def close(self, code: int, reason: str) -> None: + await self.ws.close(code, reason=reason) + + +class GraphQLView( + AsyncBaseHTTPView[ + Request, Response, Response, Websocket, Response, Context, RootValue + ], + View, +): + methods: ClassVar[list[str]] = ["GET", "POST"] + allow_queries_via_get: bool = True + request_adapter_class = QuartHTTPRequestAdapter + websocket_adapter_class = QuartWebSocketAdapter + + def __init__( + self, + schema: "GraphQLSchema", + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + keep_alive: bool = True, + keep_alive_interval: float = 1, + debug: bool = False, + subscription_protocols: Sequence[str] = ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, + ), + connection_init_wait_timeout: timedelta = timedelta(minutes=1), + multipart_uploads_enabled: bool = False, + ) -> None: + self.schema = schema + self.allow_queries_via_get = allow_queries_via_get + self.keep_alive = keep_alive + self.keep_alive_interval = keep_alive_interval + self.debug = debug + self.subscription_protocols = subscription_protocols + self.connection_init_wait_timeout = connection_init_wait_timeout + self.multipart_uploads_enabled = multipart_uploads_enabled + + if graphiql is not None: + warnings.warn( + "The `graphiql` argument is deprecated in favor of `graphql_ide`", + DeprecationWarning, + stacklevel=2, + ) + self.graphql_ide = "graphiql" if graphiql else None + else: + self.graphql_ide = graphql_ide + + async def render_graphql_ide( + self, request: Request, request_data: GraphQLRequestData + ) -> Response: + return Response(request_data.to_template_string(self.graphql_ide_html)) + + def create_response( + self, + response_data: "GraphQLHTTPResponse", + sub_response: Response, + is_strict: bool, + ) -> Response: + sub_response.set_data(self.encode_json(response_data)) + sub_response.headers["content-type"] = ( + "application/graphql-response+json" if is_strict else "application/json" + ) + return sub_response + + async def get_context( + self, request: Union[Request, Websocket], response: Response + ) -> Context: + return {"request": request, "response": response} # type: ignore + + async def get_root_value( + self, request: Union[Request, Websocket] + ) -> Optional[RootValue]: + return None + + async def get_sub_response(self, request: Request) -> Response: + return Response(status=200, content_type="application/json") + + async def dispatch_request(self, **kwargs: object) -> "ResponseReturnValue": + try: + return await self.run( + request=websocket if has_websocket_context() else request + ) + except HTTPException as e: + return Response( + response=e.reason, + status=e.status_code, + ) + + async def create_streaming_response( + self, + request: Request, + stream: Callable[[], AsyncGenerator[str, None]], + sub_response: Response, + headers: dict[str, str], + ) -> Response: + return ( + stream(), + sub_response.status_code, + { # type: ignore + **sub_response.headers, + **headers, + }, + ) + + def is_websocket_request( + self, request: Union[Request, Websocket] + ) -> TypeGuard[Websocket]: + return has_websocket_context() + + async def pick_websocket_subprotocol(self, request: Websocket) -> Optional[str]: + protocols = request.requested_subprotocols + intersection = set(protocols) & set(self.subscription_protocols) + sorted_intersection = sorted(intersection, key=protocols.index) + return next(iter(sorted_intersection), None) + + async def create_websocket_response( + self, request: Websocket, subprotocol: Optional[str] + ) -> Response: + await request.accept(subprotocol=subprotocol) + return Response() + + +__all__ = ["GraphQLView"] diff --git a/src/graphql_server/runtime.py b/src/graphql_server/runtime.py new file mode 100644 index 0000000..c4e6f84 --- /dev/null +++ b/src/graphql_server/runtime.py @@ -0,0 +1,459 @@ +from __future__ import annotations + +from asyncio import ensure_future +from collections.abc import AsyncGenerator, AsyncIterator, Awaitable +from inspect import isawaitable +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Optional, + Set, + Union, + cast, +) + +from graphql import ( + ExecutionContext, + ExecutionResult, + GraphQLError, + GraphQLSchema, + OperationDefinitionNode, + get_introspection_query, + parse, + print_schema, +) +from graphql.error import GraphQLError +from graphql.execution import execute as graphql_execute +from graphql.execution import execute_sync as graphql_execute_sync +from graphql.execution import subscribe as graphql_subscribe +from graphql.execution.middleware import MiddlewareManager +from graphql.language import DocumentNode, OperationType +from graphql.type import GraphQLSchema +from graphql.validation import validate + +from graphql_server.exceptions import GraphQLValidationError, InvalidOperationTypeError +from graphql_server.utils import IS_GQL_32, IS_GQL_33 +from graphql_server.utils.aio import aclosing +from graphql_server.utils.await_maybe import await_maybe +from graphql_server.utils.logs import GraphQLServerLogger + +if TYPE_CHECKING: + from typing_extensions import TypeAlias + + from graphql.validation import ASTValidationRule + +SubscriptionResult: TypeAlias = AsyncGenerator[ExecutionResult, None] + +OriginSubscriptionResult = Union[ + ExecutionResult, + AsyncIterator[ExecutionResult], +] + +DEFAULT_ALLOWED_OPERATION_TYPES = { + OperationType.QUERY, + OperationType.MUTATION, + OperationType.SUBSCRIPTION, +} +ProcessErrors: TypeAlias = ( + "Callable[[list[GraphQLError], Optional[ExecutionContext]], None]" +) + + +def validate_document( + schema: GraphQLSchema, + document: DocumentNode, + validation_rules: Optional[tuple[type[ASTValidationRule], ...]] = None, +) -> list[GraphQLError]: + if validation_rules is not None: + validation_rules = (*validation_rules,) + return validate( + schema, + document, + validation_rules, + ) + + +def _run_validation( + schema: GraphQLSchema, + graphql_document: DocumentNode, + validation_rules: Optional[tuple[type[ASTValidationRule], ...]] = None, +) -> list[GraphQLError] | None: + assert graphql_document, "GraphQL document is required for validation" + errors = validate_document( + schema, + graphql_document, + validation_rules, + ) + if errors: + raise GraphQLValidationError(errors) + return None + + +def _coerce_error(error: Union[GraphQLError, Exception]) -> GraphQLError: + if isinstance(error, GraphQLError): + return error + return GraphQLError(str(error), original_error=error) + + +def _get_custom_context_kwargs( + operation_extensions: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + if not IS_GQL_33: + return {} + + return {"operation_extensions": operation_extensions} + + +def _get_operation_type( + document: DocumentNode, operation_name: Optional[str] = None +) -> OperationType: + if operation_name is not None: + if not isinstance(operation_name, str): + raise GraphQLError("Must provide a valid operation name.") + + operation: Optional[OperationType] = None + for definition in document.definitions: + if isinstance(definition, OperationDefinitionNode): + if operation_name is None: + if operation: + raise Exception( + "Must provide operation name" + " if query contains multiple operations." + ) + operation = definition.operation + elif definition.name and definition.name.value == operation_name: + operation = definition.operation + if not operation: + if operation_name is not None: + raise GraphQLError(f'Unknown operation named "{operation_name}".') + raise GraphQLError("Can't get GraphQL operation type") + return operation + + +def _parse_and_validate( + schema: GraphQLSchema, + query: Union[Optional[str], DocumentNode], + allowed_operation_types: Optional[Set[OperationType]], + validation_rules: Optional[tuple[type[ASTValidationRule], ...]] = None, + operation_name: Optional[str] = None, + validate_document: Optional[bool] = None, + # extensions_runner: SchemaExtensionsRunner +) -> DocumentNode: + if allowed_operation_types is None: + allowed_operation_types = DEFAULT_ALLOWED_OPERATION_TYPES + + # async with extensions_runner.parsing(): + if not query: + raise GraphQLError("No GraphQL query found in the request") + if not isinstance(query, str) and not isinstance(query, DocumentNode): + raise GraphQLError( + f"Provided GraphQL query must be a string or DocumentNode, got {type(query)}" + ) + + try: + if isinstance(query, str): + document_node = parse(query) + if validate_document is None: + # Validate the document by default for string queries + validate_document = True + else: + document_node = query + if validate_document is None: + # Don't validate the document by default for DocumentNode queries + validate_document = False + except GraphQLError as e: + raise GraphQLValidationError([e]) from e + + operation_type = _get_operation_type(document_node, operation_name) + if operation_type not in allowed_operation_types: + raise InvalidOperationTypeError(operation_type, allowed_operation_types) + + # async with extensions_runner.validation(): + if validate_document: + _run_validation(schema, document_node, validation_rules) + + return document_node + + +def _handle_execution_result( + result: ExecutionResult, +) -> ExecutionResult: + # Set errors on the context so that it's easier + # to access in extensions + if result.errors: + # if not skipprocess_errors: + process_errors(result.errors) + # result.extensions = await extensions_runner.get_extensions_results(context) + return result + + +async def execute( + schema: GraphQLSchema, + query: Union[Optional[str], DocumentNode], + variable_values: Optional[dict[str, Any]] = None, + context_value: Optional[Any] = None, + root_value: Optional[Any] = None, + operation_name: Optional[str] = None, + allowed_operation_types: Optional[Set[OperationType]] = None, + operation_extensions: Optional[dict[str, Any]] = None, + middleware: Optional[MiddlewareManager] = None, + custom_context_kwargs: Optional[dict[str, Any]] = None, + execution_context_class: type[ExecutionContext] | None = None, + validation_rules: Optional[tuple[type[ASTValidationRule], ...]] = None, + validate_document: Optional[bool] = None, +) -> ExecutionResult: + if allowed_operation_types is None: + allowed_operation_types = DEFAULT_ALLOWED_OPERATION_TYPES + if custom_context_kwargs is None: + custom_context_kwargs = {} + # extensions = self.get_extensions() + # # TODO (#3571): remove this when we implement execution context as parameter. + # for extension in extensions: + # extension.execution_context = execution_context + + # extensions_runner = self.create_extensions_runner(execution_context, extensions) + + try: + # async with extensions_runner.operation(): + # # Note: In graphql-core the schema would be validated here but + # # we are validating it at initialisation time instead + + graphql_document = _parse_and_validate( + schema, + query, + allowed_operation_types, + validation_rules, + operation_name, + validate_document, + ) + + # async with extensions_runner.executing(): + result = await await_maybe( + graphql_execute( + schema, + graphql_document, + root_value=root_value, + middleware=middleware, + variable_values=variable_values, + operation_name=operation_name, + context_value=context_value, + execution_context_class=execution_context_class, + **custom_context_kwargs, + ) + ) + except GraphQLError: + raise + except Exception as exc: # noqa: BLE001 + result = ExecutionResult(data=None, errors=[_coerce_error(exc)]) + # return results after all the operation completed. + return _handle_execution_result( + result, + # extensions_runner, + ) + + +def execute_sync( + schema: GraphQLSchema, + query: Union[Optional[str], DocumentNode], + variable_values: Optional[dict[str, Any]] = None, + context_value: Optional[Any] = None, + root_value: Optional[Any] = None, + operation_name: Optional[str] = None, + allowed_operation_types: Optional[Set[OperationType]] = None, + operation_extensions: Optional[dict[str, Any]] = None, + middleware: Optional[MiddlewareManager] = None, + custom_context_kwargs: Optional[dict[str, Any]] = None, + execution_context_class: type[ExecutionContext] | None = None, + validation_rules: Optional[tuple[type[ASTValidationRule], ...]] = None, + validate_document: Optional[bool] = None, +) -> ExecutionResult: + if custom_context_kwargs is None: + custom_context_kwargs = {} + + # extensions = self._sync_extensions + # # TODO (#3571): remove this when we implement execution context as parameter. + # for extension in extensions: + # extension.execution_context = execution_context + + # extensions_runner = self.create_extensions_runner(execution_context, extensions) + + try: + # with extensions_runner.operation(): + # Note: In graphql-core the schema would be validated here but + # we are validating it at initialisation time instead + + graphql_document = _parse_and_validate( + schema, + query, + allowed_operation_types, + validation_rules, + operation_name, + validate_document, + ) + + # with extensions_runner.executing(): + result = graphql_execute_sync( + schema, + graphql_document, + root_value=root_value, + middleware=middleware, + variable_values=variable_values, + operation_name=operation_name, + context_value=context_value, + execution_context_class=execution_context_class, + **custom_context_kwargs, + ) + + if isawaitable(result): + result = cast("Awaitable[ExecutionResult]", result) # type: ignore[redundant-cast] + ensure_future(result).cancel() + raise RuntimeError( # noqa: TRY301 + "GraphQL execution failed to complete synchronously." + ) + + result = cast("ExecutionResult", result) # type: ignore[redundant-cast] + except GraphQLError: + raise + except Exception as exc: # noqa: BLE001 + result = ExecutionResult( + data=None, + errors=[_coerce_error(exc)], + # extensions=extensions_runner.get_extensions_results_sync(), + ) + return _handle_execution_result( + result, + # extensions_runner, + ) + + +async def subscribe( + schema: GraphQLSchema, + query: Union[Optional[str], DocumentNode], + root_value: Optional[Any] = None, + variable_values: Optional[dict[str, Any]] = None, + operation_name: Optional[str] = None, + context_value: Optional[Any] = None, + middleware_manager: Optional[MiddlewareManager] = None, + execution_context_class: Optional[type[ExecutionContext]] = None, + operation_extensions: Optional[dict[str, Any]] = None, + validation_rules: Optional[tuple[type[ASTValidationRule], ...]] = None, + validate_document: Optional[bool] = None, +) -> AsyncGenerator[ExecutionResult, None]: + allowed_operation_types = { + OperationType.SUBSCRIPTION, + } + graphql_document = _parse_and_validate( + schema, + query, + allowed_operation_types, + validation_rules, + operation_name, + validate_document, + ) + return _subscribe_generator( + schema, + graphql_document, + root_value, + variable_values, + operation_name, + context_value, + middleware_manager, + execution_context_class, + operation_extensions, + validation_rules, + ) + + +async def _subscribe_generator( + schema: GraphQLSchema, + graphql_document: DocumentNode, + root_value: Optional[Any] = None, + variable_values: Optional[dict[str, Any]] = None, + operation_name: Optional[str] = None, + context_value: Optional[Any] = None, + middleware_manager: Optional[MiddlewareManager] = None, + execution_context_class: Optional[type[ExecutionContext]] = None, + operation_extensions: Optional[dict[str, Any]] = None, + validation_rules: Optional[tuple[type[ASTValidationRule], ...]] = None, +) -> AsyncGenerator[ExecutionResult, None]: + try: + # async with extensions_runner.executing(): + assert graphql_document is not None + gql_33_kwargs = { + "middleware": middleware_manager, + "execution_context_class": execution_context_class, + } + try: + # Might not be awaitable for pre-execution errors. + aiter_or_result: OriginSubscriptionResult = await await_maybe( + graphql_subscribe( + schema, + graphql_document, + root_value=root_value, + variable_values=variable_values, + operation_name=operation_name, + context_value=context_value, + **{} if IS_GQL_32 else gql_33_kwargs, # type: ignore[arg-type] + ) + ) + # graphql-core 3.2 doesn't handle some of the pre-execution errors. + # see `test_subscription_immediate_error` + except Exception as exc: # noqa: BLE001 + aiter_or_result = ExecutionResult(data=None, errors=[_coerce_error(exc)]) + + # Handle pre-execution errors. + if isinstance(aiter_or_result, ExecutionResult): + if aiter_or_result.errors: + raise GraphQLValidationError(aiter_or_result.errors) + else: + try: + async with aclosing(aiter_or_result): + async for result in aiter_or_result: + yield _handle_execution_result( + result, + # extensions_runner, + ) + # graphql-core doesn't handle exceptions raised while executing. + except Exception as exc: # noqa: BLE001 + yield _handle_execution_result( + ExecutionResult(data=None, errors=[_coerce_error(exc)]), + # extensions_runner, + ) + # catch exceptions raised in `on_execute` hook. + except Exception as exc: # noqa: BLE001 + origin_result = ExecutionResult(data=None, errors=[_coerce_error(exc)]) + yield _handle_execution_result( + origin_result, + # extensions_runner, + ) + + +def as_str(self) -> str: + return print_schema(self) + + +__str__ = as_str + + +def introspect(schema: GraphQLSchema) -> dict[str, Any]: + """Return the introspection query result for the current schema. + + Raises: + ValueError: If the introspection query fails due to an invalid schema + """ + introspection = execute_sync(schema, get_introspection_query()) + if introspection.errors or not introspection.data: + raise ValueError(f"Invalid Schema. Errors {introspection.errors!r}") + + return introspection.data + + +def process_errors( + errors: list[GraphQLError], +) -> None: + for error in errors: + GraphQLServerLogger.error(error) + + +__all__ = ["Schema", "execute", "execute_sync", "introspect", "subscribe"] diff --git a/src/graphql_server/sanic/__init__.py b/src/graphql_server/sanic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/graphql_server/sanic/utils.py b/src/graphql_server/sanic/utils.py new file mode 100644 index 0000000..4b6e347 --- /dev/null +++ b/src/graphql_server/sanic/utils.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional, Union, cast + +if TYPE_CHECKING: + from sanic.request import File, Request + + +def convert_request_to_files_dict(request: Request) -> dict[str, Any]: + """Converts the request.files dictionary to a dictionary of sanic Request objects. + + `request.files` has the following format, even if only a single file is uploaded: + + ```python + { + "textFile": [ + sanic.request.File(type="text/plain", body=b"graphql_server", name="textFile.txt") + ] + } + ``` + + Note that the dictionary entries are lists. + """ + request_files = cast("Optional[dict[str, list[File]]]", request.files) + + if not request_files: + return {} + + files_dict: dict[str, Union[File, list[File]]] = {} + + for field_name, file_list in request_files.items(): + assert len(file_list) == 1 + + files_dict[field_name] = file_list[0] + + return files_dict + + +__all__ = ["convert_request_to_files_dict"] diff --git a/src/graphql_server/sanic/views.py b/src/graphql_server/sanic/views.py new file mode 100644 index 0000000..cc17b23 --- /dev/null +++ b/src/graphql_server/sanic/views.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import json +import warnings +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Optional, + cast, +) +from typing_extensions import TypeGuard + +from graphql_server.http import GraphQLRequestData +from graphql_server.http.async_base_view import ( + AsyncBaseHTTPView, + AsyncHTTPRequestAdapter, +) +from graphql_server.http.exceptions import HTTPException +from graphql_server.http.temporal_response import TemporalResponse +from graphql_server.http.types import FormData, HTTPMethod, QueryParams +from graphql_server.http.typevars import ( + Context, + RootValue, +) +from graphql_server.sanic.utils import convert_request_to_files_dict +from sanic.request import Request +from sanic.response import HTTPResponse, html +from sanic.views import HTTPMethodView + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Mapping + + from graphql.type import GraphQLSchema + + from graphql_server.http import GraphQLHTTPResponse + from graphql_server.http.ides import GraphQL_IDE + + +class SanicHTTPRequestAdapter(AsyncHTTPRequestAdapter): + def __init__(self, request: Request) -> None: + self.request = request + + @property + def query_params(self) -> QueryParams: + # Just a heads up, Sanic's request.args uses urllib.parse.parse_qs + # to parse query string parameters. This returns a dictionary where + # the keys are the unique variable names and the values are lists + # of values for each variable name. To ensure consistency, we're + # enforcing the use of the first value in each list. + args = self.request.get_args(keep_blank_values=True) + return {k: args.get(k, None) for k in args} + + @property + def method(self) -> HTTPMethod: + return cast("HTTPMethod", self.request.method.upper()) + + @property + def headers(self) -> Mapping[str, str]: + return self.request.headers + + @property + def content_type(self) -> Optional[str]: + return self.request.content_type + + async def get_body(self) -> str: + return self.request.body.decode() + + async def get_form_data(self) -> FormData: + assert self.request.form is not None + + files = convert_request_to_files_dict(self.request) + + return FormData(form=self.request.form, files=files) + + +class GraphQLView( + AsyncBaseHTTPView[ + Request, + HTTPResponse, + TemporalResponse, + Request, + TemporalResponse, + Context, + RootValue, + ], + HTTPMethodView, +): + """Class based view to handle GraphQL HTTP Requests. + + Args: + schema: graphql.GraphQLSchema + graphiql: bool, default is True + allow_queries_via_get: bool, default is True + + Returns: + None + + Example: + app.add_route( + GraphQLView.as_view(schema=schema, graphiql=True), + "/graphql" + ) + """ + + allow_queries_via_get = True + request_adapter_class = SanicHTTPRequestAdapter + + def __init__( + self, + schema: GraphQLSchema, + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + json_encoder: Optional[type[json.JSONEncoder]] = None, + json_dumps_params: Optional[dict[str, Any]] = None, + multipart_uploads_enabled: bool = False, + ) -> None: + self.schema = schema + self.allow_queries_via_get = allow_queries_via_get + self.json_encoder = json_encoder + self.json_dumps_params = json_dumps_params + self.multipart_uploads_enabled = multipart_uploads_enabled + + if self.json_encoder is not None: # pragma: no cover + warnings.warn( + "json_encoder is deprecated, override encode_json instead", + DeprecationWarning, + stacklevel=2, + ) + + if self.json_dumps_params is not None: # pragma: no cover + warnings.warn( + "json_dumps_params is deprecated, override encode_json instead", + DeprecationWarning, + stacklevel=2, + ) + + self.json_encoder = json.JSONEncoder + + if graphiql is not None: + warnings.warn( + "The `graphiql` argument is deprecated in favor of `graphql_ide`", + DeprecationWarning, + stacklevel=2, + ) + self.graphql_ide = "graphiql" if graphiql else None + else: + self.graphql_ide = graphql_ide + + async def get_root_value(self, request: Request) -> Optional[RootValue]: + return None + + async def get_context( + self, request: Request, response: TemporalResponse + ) -> Context: + return {"request": request, "response": response} # type: ignore + + async def render_graphql_ide( + self, request: Request, request_data: GraphQLRequestData + ) -> HTTPResponse: + return html(request_data.to_template_string(self.graphql_ide_html)) + + async def get_sub_response(self, request: Request) -> TemporalResponse: + return TemporalResponse() + + def create_response( + self, + response_data: GraphQLHTTPResponse, + sub_response: TemporalResponse, + is_strict: bool, + ) -> HTTPResponse: + status_code = sub_response.status_code + + data = self.encode_json(response_data) + + return HTTPResponse( + data, + status=status_code, + content_type="application/graphql-response+json" + if is_strict + else "application/json", + headers=sub_response.headers, + ) + + async def post(self, request: Request) -> HTTPResponse: + self.request = request + + try: + return await self.run(request) + except HTTPException as e: + return HTTPResponse(e.reason, status=e.status_code) + + async def get(self, request: Request) -> HTTPResponse: + self.request = request + + try: + return await self.run(request) + except HTTPException as e: + return HTTPResponse(e.reason, status=e.status_code) + + async def options(self, request: Request) -> HTTPResponse: + return HTTPResponse(status=200) + + async def create_streaming_response( + self, + request: Request, + stream: Callable[[], AsyncGenerator[str, None]], + sub_response: TemporalResponse, + headers: dict[str, str], + ) -> HTTPResponse: + response = await self.request.respond( + status=sub_response.status_code, + headers={ + **sub_response.headers, + **headers, + }, + ) + + async for chunk in stream(): + await response.send(chunk) + + await response.eof() + + # returning the response will basically tell sanic to send it again + # to the client, so we return None to avoid that, and we ignore the type + # error mostly so we don't have to update the types everywhere for this + # corner case + return None # type: ignore + + def is_websocket_request(self, request: Request) -> TypeGuard[Request]: + return False + + async def pick_websocket_subprotocol(self, request: Request) -> Optional[str]: + raise NotImplementedError + + async def create_websocket_response( + self, request: Request, subprotocol: Optional[str] + ) -> TemporalResponse: + raise NotImplementedError + + +__all__ = ["GraphQLView"] diff --git a/src/graphql_server/static/apollo-sandbox.html b/src/graphql_server/static/apollo-sandbox.html new file mode 100644 index 0000000..3505170 --- /dev/null +++ b/src/graphql_server/static/apollo-sandbox.html @@ -0,0 +1,47 @@ + + + + Apollo Sandbox + + + + + +
+ + + + + diff --git a/src/graphql_server/static/graphiql.html b/src/graphql_server/static/graphiql.html new file mode 100644 index 0000000..7b3b2fd --- /dev/null +++ b/src/graphql_server/static/graphiql.html @@ -0,0 +1,209 @@ + + + + GraphiQL + + + + + + + + + + + + + + +
Loading...
+ + + + + diff --git a/src/graphql_server/static/pathfinder.html b/src/graphql_server/static/pathfinder.html new file mode 100644 index 0000000..487da3b --- /dev/null +++ b/src/graphql_server/static/pathfinder.html @@ -0,0 +1,72 @@ + + + + GraphQL Pathfinder + + + + + + + +
+ + + + + diff --git a/src/graphql_server/subscriptions/__init__.py b/src/graphql_server/subscriptions/__init__.py new file mode 100644 index 0000000..7d38a1a --- /dev/null +++ b/src/graphql_server/subscriptions/__init__.py @@ -0,0 +1,8 @@ +GRAPHQL_TRANSPORT_WS_PROTOCOL = "graphql-transport-ws" +GRAPHQL_WS_PROTOCOL = "graphql-ws" + + +__all__ = [ + "GRAPHQL_TRANSPORT_WS_PROTOCOL", + "GRAPHQL_WS_PROTOCOL", +] diff --git a/src/graphql_server/subscriptions/protocols/__init__.py b/src/graphql_server/subscriptions/protocols/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/graphql_server/subscriptions/protocols/graphql_transport_ws/__init__.py b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/__init__.py new file mode 100644 index 0000000..c17c6ee --- /dev/null +++ b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/__init__.py @@ -0,0 +1,7 @@ +# Code 4406 is "Subprotocol not acceptable" +WS_4406_PROTOCOL_NOT_ACCEPTABLE = 4406 + + +__all__ = [ + "WS_4406_PROTOCOL_NOT_ACCEPTABLE", +] diff --git a/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py new file mode 100644 index 0000000..5871f92 --- /dev/null +++ b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py @@ -0,0 +1,451 @@ +from __future__ import annotations + +import asyncio +import logging +from collections.abc import AsyncGenerator +from contextlib import suppress +from typing import ( + TYPE_CHECKING, + Any, + Generic, + Optional, + cast, +) + +from graphql import ExecutionResult, GraphQLError, GraphQLSyntaxError, parse +from graphql.language import OperationType + +from graphql_server import execute, subscribe +from graphql_server.exceptions import ConnectionRejectionError, GraphQLValidationError +from graphql_server.http import GraphQLRequestData +from graphql_server.http.exceptions import ( + NonJsonMessageReceived, + NonTextMessageReceived, + WebSocketDisconnected, +) +from graphql_server.http.typevars import Context, RootValue +from graphql_server.subscriptions.protocols.graphql_transport_ws.types import ( + CompleteMessage, + ConnectionInitMessage, + Message, + NextMessagePayload, + PingMessage, + PongMessage, + SubscribeMessage, +) +from graphql_server.types.unset import UnsetType +from graphql_server.utils.debug import pretty_print_graphql_operation +from graphql_server.utils.operation import get_operation_type + +if TYPE_CHECKING: + from datetime import timedelta + + from graphql.type import GraphQLSchema + + from graphql_server.http.async_base_view import ( + AsyncBaseHTTPView, + AsyncWebSocketAdapter, + ) + + +class BaseGraphQLTransportWSHandler(Generic[Context, RootValue]): + task_logger: logging.Logger = logging.getLogger("graphql_server.ws.task") + + def __init__( + self, + view: AsyncBaseHTTPView[Any, Any, Any, Any, Any, Context, RootValue], + websocket: AsyncWebSocketAdapter, + context: Context, + root_value: RootValue, + schema: GraphQLSchema, + debug: bool, + connection_init_wait_timeout: timedelta, + ) -> None: + self.view = view + self.websocket = websocket + self.context = context + self.root_value = root_value + self.schema = schema + self.debug = debug + self.connection_init_wait_timeout = connection_init_wait_timeout + self.connection_init_timeout_task: Optional[asyncio.Task] = None + self.connection_init_received = False + self.connection_acknowledged = False + self.connection_timed_out = False + self.operations: dict[str, Operation[Context, RootValue]] = {} + self.completed_tasks: list[asyncio.Task] = [] + + async def handle(self) -> None: + self.on_request_accepted() + + try: + try: + async for message in self.websocket.iter_json(): + await self.handle_message(cast("Message", message)) + except NonTextMessageReceived: + await self.handle_invalid_message("WebSocket message type must be text") + except NonJsonMessageReceived: + await self.handle_invalid_message( + "WebSocket message must be valid JSON" + ) + except WebSocketDisconnected: + pass + finally: + await self.shutdown() + + async def shutdown(self) -> None: + if self.connection_init_timeout_task: + self.connection_init_timeout_task.cancel() + with suppress(asyncio.CancelledError): + await self.connection_init_timeout_task + + for operation_id in list(self.operations.keys()): + await self.cleanup_operation(operation_id) + await self.reap_completed_tasks() + + def on_request_accepted(self) -> None: + # handle_request should call this once it has sent the + # websocket.accept() response to start the timeout. + assert not self.connection_init_timeout_task + self.connection_init_timeout_task = asyncio.create_task( + self.handle_connection_init_timeout() + ) + + async def handle_connection_init_timeout(self) -> None: + task = asyncio.current_task() + assert task + try: + delay = self.connection_init_wait_timeout.total_seconds() + await asyncio.sleep(delay=delay) + + if self.connection_init_received: + return # pragma: no cover + + self.connection_timed_out = True + reason = "Connection initialisation timeout" + await self.websocket.close(code=4408, reason=reason) + except Exception as error: # noqa: BLE001 + await self.handle_task_exception(error) # pragma: no cover + finally: + # do not clear self.connection_init_timeout_task + # so that unittests can inspect it. + self.completed_tasks.append(task) + + async def handle_task_exception(self, error: Exception) -> None: # pragma: no cover + self.task_logger.exception("Exception in worker task", exc_info=error) + + async def handle_message(self, message: Message) -> None: + try: + if message["type"] == "connection_init": + await self.handle_connection_init(message) + + elif message["type"] == "ping": + await self.handle_ping(message) + + elif message["type"] == "pong": + await self.handle_pong(message) + + elif message["type"] == "subscribe": + await self.handle_subscribe(message) + + elif message["type"] == "complete": + await self.handle_complete(message) + + else: + error_message = f"Unknown message type: {message['type']}" + await self.handle_invalid_message(error_message) + + except KeyError: + await self.handle_invalid_message("Failed to parse message") + finally: + await self.reap_completed_tasks() + + async def handle_connection_init(self, message: ConnectionInitMessage) -> None: + if self.connection_timed_out: + # No way to reliably excercise this case during testing + return # pragma: no cover + + if self.connection_init_timeout_task: + self.connection_init_timeout_task.cancel() + + payload = message.get("payload", {}) + + if not isinstance(payload, dict): + await self.websocket.close( + code=4400, reason="Invalid connection init payload" + ) + return + + if self.connection_init_received: + reason = "Too many initialisation requests" + await self.websocket.close(code=4429, reason=reason) + return + + self.connection_init_received = True + + await self.view.setup_connection_params( + payload, + websocket=self.websocket, + root_value=self.root_value, + context=self.context, + ) + + try: + connection_ack_payload = await self.view.on_ws_connect(self.context) + except ConnectionRejectionError: + await self.websocket.close(code=4403, reason="Forbidden") + return + + if isinstance(connection_ack_payload, UnsetType): + await self.send_message({"type": "connection_ack"}) + else: + await self.send_message( + {"type": "connection_ack", "payload": connection_ack_payload} + ) + + self.connection_acknowledged = True + + async def handle_ping(self, message: PingMessage) -> None: + await self.send_message({"type": "pong"}) + + async def handle_pong(self, message: PongMessage) -> None: + pass + + async def handle_subscribe(self, message: SubscribeMessage) -> None: + if not self.connection_acknowledged: + await self.websocket.close(code=4401, reason="Unauthorized") + return + + request_data = await self.view.get_graphql_request_data( + self.websocket, self.context, message["payload"], "subscription" + ) + + if request_data.document is not None: + graphql_document = request_data.document + else: + try: + graphql_document = parse(request_data.query) + except GraphQLSyntaxError as exc: + await self.websocket.close(code=4400, reason=exc.message) + return + + operation_name = request_data.operation_name + + try: + operation_type = get_operation_type(graphql_document, operation_name) + except RuntimeError: + # Unlike in the other protocol implementations, we access the operation type + # before executing the operation. Therefore, we don't get a nice + # CannotGetOperationTypeError, but rather the underlying RuntimeError. + if operation_name is None: + e = "Can't get GraphQL operation type" + else: + e = f'Unknown operation named "{operation_name}".' + + await self.websocket.close( + code=4400, + reason=e, + ) + return + + if message["id"] in self.operations: + reason = f"Subscriber for {message['id']} already exists" + await self.websocket.close(code=4409, reason=reason) + return + + if self.debug: # pragma: no cover + pretty_print_graphql_operation( + request_data.operation_name, + request_data.query, + request_data.variables, + ) + + operation = Operation( + self, + message["id"], + operation_type, + request_data, + ) + + operation.task = asyncio.create_task(self.run_operation(operation)) + self.operations[message["id"]] = operation + + async def run_operation(self, operation: Operation[Context, RootValue]) -> None: + """The operation task's top level method. Cleans-up and de-registers the operation once it is done.""" + result_source: ExecutionResult | AsyncGenerator[ExecutionResult, None] + + try: + # Get an AsyncGenerator yielding the results + if operation.operation_type == OperationType.SUBSCRIPTION: + result_source = await subscribe( + schema=self.schema, + query=operation.request_data.document + or operation.request_data.query, + variable_values=operation.variables, + operation_name=operation.operation_name, + context_value=self.context, + root_value=self.root_value, + ) + + else: + result_source = await execute( + schema=self.schema, + query=operation.request_data.document + or operation.request_data.query, + variable_values=operation.variables, + context_value=self.context, + root_value=self.root_value, + operation_name=operation.operation_name, + ) + + if isinstance(result_source, ExecutionResult): + await operation.send_next(result_source) + else: + is_first_result = True + async for result in result_source: + if ( + is_first_result + and result.errors + and isinstance(result.errors[0], GraphQLValidationError) + ): + assert result.errors + await operation.send_initial_errors(result.errors) + break + + await operation.send_next(result) + is_first_result = False + + await operation.send_operation_message( + CompleteMessage(id=operation.id, type="complete") + ) + except GraphQLValidationError as e: + from graphql_server.runtime import process_errors + + processed_errors = process_errors(e.errors) + await operation.send_initial_errors(e.errors) + except Exception as error: # pragma: no cover + await self.handle_task_exception(error) + + with suppress(Exception): + await operation.send_operation_message( + {"id": operation.id, "type": "complete"} + ) + + self.operations.pop(operation.id, None) + + raise + finally: + # add this task to a list to be reaped later + try: + task = asyncio.current_task() + except RuntimeError: + # If there's no running loop + return + assert task is not None + self.completed_tasks.append(task) + + def forget_id(self, id: str) -> None: + # de-register the operation id making it immediately available + # for re-use + del self.operations[id] + + async def handle_complete(self, message: CompleteMessage) -> None: + await self.cleanup_operation(operation_id=message["id"]) + + async def handle_invalid_message(self, error_message: str) -> None: + await self.websocket.close(code=4400, reason=error_message) + + async def send_message(self, message: Message) -> None: + await self.websocket.send_json(message) + + async def cleanup_operation(self, operation_id: str) -> None: + if operation_id not in self.operations: + return + operation = self.operations.pop(operation_id) + assert operation.task + operation.task.cancel() + # do not await the task here, lest we block the main + # websocket handler Task. + + async def reap_completed_tasks(self) -> None: + """Await tasks that have completed.""" + tasks, self.completed_tasks = self.completed_tasks, [] + for task in tasks: + with suppress(BaseException): + await task + + +class Operation(Generic[Context, RootValue]): + """A class encapsulating a single operation with its id. Helps enforce protocol state transition.""" + + __slots__ = [ + "completed", + "handler", + "id", + "operation_type", + "request_data", + "task", + ] + + def __init__( + self, + handler: BaseGraphQLTransportWSHandler[Context, RootValue], + id: str, + operation_type: OperationType, + request_data: GraphQLRequestData, + ) -> None: + self.handler = handler + self.id = id + self.operation_type = operation_type + self.request_data = request_data + self.completed = False + self.task: Optional[asyncio.Task] = None + + @property + def query(self) -> Optional[str]: + return self.request_data.query + + @property + def variables(self) -> Optional[dict[str, Any]]: + return self.request_data.variables + + @property + def operation_name(self) -> Optional[str]: + return self.request_data.operation_name + + async def send_operation_message(self, message: Message) -> None: + if self.completed: + return + if message["type"] == "complete" or message["type"] == "error": + self.completed = True + # de-register the operation _before_ sending the final message + self.handler.forget_id(self.id) + await self.handler.send_message(message) + + async def send_initial_errors(self, errors: list[GraphQLError]) -> None: + # Initial errors see https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md#error + # "This can occur before execution starts, + # usually due to validation errors, or during the execution of the request" + await self.send_operation_message( + { + "id": self.id, + "type": "error", + "payload": [err.formatted for err in errors], + } + ) + + async def send_next(self, execution_result: ExecutionResult) -> None: + next_payload: NextMessagePayload = {"data": execution_result.data} + + if execution_result.errors: + next_payload["errors"] = [err.formatted for err in execution_result.errors] + + if execution_result.extensions: + next_payload["extensions"] = execution_result.extensions + + await self.send_operation_message( + {"id": self.id, "type": "next", "payload": next_payload} + ) + + +__all__ = ["BaseGraphQLTransportWSHandler", "Operation"] diff --git a/src/graphql_server/subscriptions/protocols/graphql_transport_ws/types.py b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/types.py new file mode 100644 index 0000000..d08f38e --- /dev/null +++ b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/types.py @@ -0,0 +1,101 @@ +from typing import TypedDict, Union +from typing_extensions import Literal, NotRequired + +from graphql import GraphQLFormattedError + + +class ConnectionInitMessage(TypedDict): + """Direction: Client -> Server.""" + + type: Literal["connection_init"] + payload: NotRequired[Union[dict[str, object], None]] + + +class ConnectionAckMessage(TypedDict): + """Direction: Server -> Client.""" + + type: Literal["connection_ack"] + payload: NotRequired[Union[dict[str, object], None]] + + +class PingMessage(TypedDict): + """Direction: bidirectional.""" + + type: Literal["ping"] + payload: NotRequired[Union[dict[str, object], None]] + + +class PongMessage(TypedDict): + """Direction: bidirectional.""" + + type: Literal["pong"] + payload: NotRequired[Union[dict[str, object], None]] + + +class SubscribeMessagePayload(TypedDict): + operationName: NotRequired[Union[str, None]] + query: str + variables: NotRequired[Union[dict[str, object], None]] + extensions: NotRequired[Union[dict[str, object], None]] + + +class SubscribeMessage(TypedDict): + """Direction: Client -> Server.""" + + id: str + type: Literal["subscribe"] + payload: SubscribeMessagePayload + + +class NextMessagePayload(TypedDict): + errors: NotRequired[list[GraphQLFormattedError]] + data: NotRequired[Union[dict[str, object], None]] + extensions: NotRequired[dict[str, object]] + + +class NextMessage(TypedDict): + """Direction: Server -> Client.""" + + id: str + type: Literal["next"] + payload: NextMessagePayload + + +class ErrorMessage(TypedDict): + """Direction: Server -> Client.""" + + id: str + type: Literal["error"] + payload: list[GraphQLFormattedError] + + +class CompleteMessage(TypedDict): + """Direction: bidirectional.""" + + id: str + type: Literal["complete"] + + +Message = Union[ + ConnectionInitMessage, + ConnectionAckMessage, + PingMessage, + PongMessage, + SubscribeMessage, + NextMessage, + ErrorMessage, + CompleteMessage, +] + + +__all__ = [ + "CompleteMessage", + "ConnectionAckMessage", + "ConnectionInitMessage", + "ErrorMessage", + "Message", + "NextMessage", + "PingMessage", + "PongMessage", + "SubscribeMessage", +] diff --git a/src/graphql_server/subscriptions/protocols/graphql_ws/__init__.py b/src/graphql_server/subscriptions/protocols/graphql_ws/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/graphql_server/subscriptions/protocols/graphql_ws/handlers.py b/src/graphql_server/subscriptions/protocols/graphql_ws/handlers.py new file mode 100644 index 0000000..9b94137 --- /dev/null +++ b/src/graphql_server/subscriptions/protocols/graphql_ws/handlers.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator +from contextlib import suppress +from typing import ( + TYPE_CHECKING, + Any, + Generic, + Optional, + cast, +) + +from graphql_server import subscribe +from graphql_server.exceptions import ConnectionRejectionError, GraphQLValidationError +from graphql_server.http.exceptions import NonTextMessageReceived, WebSocketDisconnected +from graphql_server.http.typevars import Context, RootValue +from graphql_server.subscriptions.protocols.graphql_ws.types import ( + CompleteMessage, + ConnectionInitMessage, + ConnectionTerminateMessage, + DataMessage, + ErrorMessage, + OperationMessage, + StartMessage, + StopMessage, +) +from graphql_server.types.unset import UnsetType +from graphql_server.utils.debug import pretty_print_graphql_operation + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from graphql.type import GraphQLSchema + + from graphql_server.http.async_base_view import ( + AsyncBaseHTTPView, + AsyncWebSocketAdapter, + ) + + +class BaseGraphQLWSHandler(Generic[Context, RootValue]): + def __init__( + self, + view: AsyncBaseHTTPView[Any, Any, Any, Any, Any, Context, RootValue], + websocket: AsyncWebSocketAdapter, + context: Context, + root_value: RootValue, + schema: GraphQLSchema, + debug: bool, + keep_alive: bool, + keep_alive_interval: Optional[float], + ) -> None: + self.view = view + self.websocket = websocket + self.context = context + self.root_value = root_value + self.schema = schema + self.debug = debug + self.keep_alive = keep_alive + self.keep_alive_interval = keep_alive_interval + self.keep_alive_task: Optional[asyncio.Task] = None + self.subscriptions: dict[str, AsyncGenerator] = {} + self.tasks: dict[str, asyncio.Task] = {} + + async def handle(self) -> None: + try: + try: + async for message in self.websocket.iter_json( + ignore_parsing_errors=True + ): + await self.handle_message(cast("OperationMessage", message)) + except NonTextMessageReceived: + await self.websocket.close( + code=1002, reason="WebSocket message type must be text" + ) + except WebSocketDisconnected: + pass + finally: + if self.keep_alive_task: + self.keep_alive_task.cancel() + with suppress(BaseException): + await self.keep_alive_task + + await self.cleanup() + + async def handle_message( + self, + message: OperationMessage, + ) -> None: + if message["type"] == "connection_init": + await self.handle_connection_init(message) + elif message["type"] == "connection_terminate": + await self.handle_connection_terminate(message) + elif message["type"] == "start": + await self.handle_start(message) + elif message["type"] == "stop": + await self.handle_stop(message) + + async def handle_connection_init(self, message: ConnectionInitMessage) -> None: + payload = message.get("payload") + if payload is not None and not isinstance(payload, dict): + await self.send_message({"type": "connection_error"}) + await self.websocket.close(code=1000, reason="") + return + + await self.view.setup_connection_params( + payload, + websocket=self.websocket, + root_value=self.root_value, + context=self.context, + ) + + try: + connection_ack_payload = await self.view.on_ws_connect(self.context) + except ConnectionRejectionError as e: + await self.send_message({"type": "connection_error", "payload": e.payload}) + await self.websocket.close(code=1011, reason="") + return + + if ( + isinstance(connection_ack_payload, UnsetType) + or connection_ack_payload is None + ): + await self.send_message({"type": "connection_ack"}) + else: + await self.send_message( + {"type": "connection_ack", "payload": connection_ack_payload} + ) + + if self.keep_alive: + keep_alive_handler = self.handle_keep_alive() + self.keep_alive_task = asyncio.create_task(keep_alive_handler) + + async def handle_connection_terminate( + self, message: ConnectionTerminateMessage + ) -> None: + await self.websocket.close(code=1000, reason="") + + async def handle_start(self, message: StartMessage) -> None: + operation_id = message["id"] + payload = message["payload"] + query = payload["query"] + operation_name = payload.get("operationName") + variables = payload.get("variables") + + if self.debug: + pretty_print_graphql_operation(operation_name, query, variables) + + result_handler = self.handle_async_results( + operation_id, query, operation_name, variables + ) + self.tasks[operation_id] = asyncio.create_task(result_handler) + + async def handle_stop(self, message: StopMessage) -> None: + operation_id = message["id"] + await self.cleanup_operation(operation_id) + + async def handle_keep_alive(self) -> None: + assert self.keep_alive_interval + while True: + await self.send_message({"type": "ka"}) + await asyncio.sleep(self.keep_alive_interval) + + async def handle_async_results( + self, + operation_id: str, + query: str, + operation_name: Optional[str], + variables: Optional[dict[str, object]], + ) -> None: + try: + result_source = await subscribe( + schema=self.schema, + query=query, + variable_values=variables, + operation_name=operation_name, + context_value=self.context, + root_value=self.root_value, + ) + self.subscriptions[operation_id] = result_source + + is_first_result = True + + async for result in result_source: + # if is_first_result and isinstance(result, PreExecutionError): + # assert result.errors + + # await self.send_message( + # ErrorMessage( + # type="error", + # id=operation_id, + # payload=result.errors[0].formatted, + # ) + # ) + # return + + await self.send_data_message(result, operation_id) + is_first_result = False + + await self.send_message(CompleteMessage(type="complete", id=operation_id)) + + except GraphQLValidationError as e: + from graphql_server.runtime import process_errors + + processed_errors = process_errors(e.errors) + # for error in e.errors: + error = e.errors[0] + await self.send_message( + ErrorMessage( + type="error", + id=operation_id, + payload=error.formatted, + ) + ) + except asyncio.CancelledError: + await self.send_message(CompleteMessage(type="complete", id=operation_id)) + except Exception as e: + with suppress(Exception): + await self.send_message( + ErrorMessage( + type="error", + id=operation_id, + payload={"message": str(e)}, + ) + ) + raise + + async def cleanup_operation(self, operation_id: str) -> None: + if operation_id in self.subscriptions: + with suppress(RuntimeError): + await self.subscriptions[operation_id].aclose() + del self.subscriptions[operation_id] + + self.tasks[operation_id].cancel() + with suppress(BaseException): + await self.tasks[operation_id] + del self.tasks[operation_id] + + async def cleanup(self) -> None: + for operation_id in list(self.tasks.keys()): + await self.cleanup_operation(operation_id) + + async def send_data_message( + self, execution_result: ExecutionResult, operation_id: str + ) -> None: + data_message: DataMessage = { + "type": "data", + "id": operation_id, + "payload": {"data": execution_result.data}, + } + + if execution_result.errors: + data_message["payload"]["errors"] = [ + err.formatted for err in execution_result.errors + ] + + if execution_result.extensions: + data_message["payload"]["extensions"] = execution_result.extensions + + await self.send_message(data_message) + + async def send_message(self, message: OperationMessage) -> None: + await self.websocket.send_json(message) + + +__all__ = ["BaseGraphQLWSHandler"] diff --git a/src/graphql_server/subscriptions/protocols/graphql_ws/types.py b/src/graphql_server/subscriptions/protocols/graphql_ws/types.py new file mode 100644 index 0000000..8918028 --- /dev/null +++ b/src/graphql_server/subscriptions/protocols/graphql_ws/types.py @@ -0,0 +1,98 @@ +from typing import TypedDict, Union +from typing_extensions import Literal, NotRequired + +from graphql import GraphQLFormattedError + + +class ConnectionInitMessage(TypedDict): + type: Literal["connection_init"] + payload: NotRequired[dict[str, object]] + + +class StartMessagePayload(TypedDict): + query: str + variables: NotRequired[dict[str, object]] + operationName: NotRequired[str] + + +class StartMessage(TypedDict): + type: Literal["start"] + id: str + payload: StartMessagePayload + + +class StopMessage(TypedDict): + type: Literal["stop"] + id: str + + +class ConnectionTerminateMessage(TypedDict): + type: Literal["connection_terminate"] + + +class ConnectionErrorMessage(TypedDict): + type: Literal["connection_error"] + payload: NotRequired[dict[str, object]] + + +class ConnectionAckMessage(TypedDict): + type: Literal["connection_ack"] + payload: NotRequired[dict[str, object]] + + +class DataMessagePayload(TypedDict): + data: object + errors: NotRequired[list[GraphQLFormattedError]] + + # Non-standard field: + extensions: NotRequired[dict[str, object]] + + +class DataMessage(TypedDict): + type: Literal["data"] + id: str + payload: DataMessagePayload + + +class ErrorMessage(TypedDict): + type: Literal["error"] + id: str + payload: GraphQLFormattedError + + +class CompleteMessage(TypedDict): + type: Literal["complete"] + id: str + + +class ConnectionKeepAliveMessage(TypedDict): + type: Literal["ka"] + + +OperationMessage = Union[ + ConnectionInitMessage, + StartMessage, + StopMessage, + ConnectionTerminateMessage, + ConnectionErrorMessage, + ConnectionAckMessage, + DataMessage, + ErrorMessage, + CompleteMessage, + ConnectionKeepAliveMessage, +] + + +__all__ = [ + "CompleteMessage", + "ConnectionAckMessage", + "ConnectionErrorMessage", + "ConnectionInitMessage", + "ConnectionKeepAliveMessage", + "ConnectionTerminateMessage", + "DataMessage", + "ErrorMessage", + "OperationMessage", + "StartMessage", + "StopMessage", +] diff --git a/src/graphql_server/test/__init__.py b/src/graphql_server/test/__init__.py new file mode 100644 index 0000000..c81b963 --- /dev/null +++ b/src/graphql_server/test/__init__.py @@ -0,0 +1,3 @@ +from .client import BaseGraphQLTestClient, Body, Response + +__all__ = ["BaseGraphQLTestClient", "Body", "Response"] diff --git a/src/graphql_server/test/client.py b/src/graphql_server/test/client.py new file mode 100644 index 0000000..10400d0 --- /dev/null +++ b/src/graphql_server/test/client.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import json +import warnings +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Optional, Union +from typing_extensions import Literal, TypedDict + +if TYPE_CHECKING: + from collections.abc import Coroutine, Mapping + + from graphql import GraphQLFormattedError + + +@dataclass +class Response: + errors: Optional[list[GraphQLFormattedError]] + data: Optional[dict[str, object]] + extensions: Optional[dict[str, object]] + + +class Body(TypedDict, total=False): + query: str + variables: Optional[dict[str, object]] + + +class BaseGraphQLTestClient(ABC): + def __init__( + self, + client: Any, + url: str = "/graphql/", + ) -> None: + self._client = client + self.url = url + + def query( + self, + query: str, + variables: Optional[dict[str, Mapping]] = None, + headers: Optional[dict[str, object]] = None, + asserts_errors: Optional[bool] = None, + files: Optional[dict[str, object]] = None, + assert_no_errors: Optional[bool] = True, + ) -> Union[Coroutine[Any, Any, Response], Response]: + body = self._build_body(query, variables, files) + + resp = self.request(body, headers, files) + data = self._decode(resp, type="multipart" if files else "json") + + response = Response( + errors=data.get("errors"), + data=data.get("data"), + extensions=data.get("extensions"), + ) + + if asserts_errors is not None: + warnings.warn( + "The `asserts_errors` argument has been renamed to `assert_no_errors`", + DeprecationWarning, + stacklevel=2, + ) + + assert_no_errors = ( + assert_no_errors if asserts_errors is None else asserts_errors + ) + + if assert_no_errors: + assert response.errors is None + + return response + + @abstractmethod + def request( + self, + body: dict[str, object], + headers: Optional[dict[str, object]] = None, + files: Optional[dict[str, object]] = None, + ) -> Any: + raise NotImplementedError + + def _build_body( + self, + query: str, + variables: Optional[dict[str, Mapping]] = None, + files: Optional[dict[str, object]] = None, + ) -> dict[str, object]: + body: dict[str, object] = {"query": query} + + if variables: + body["variables"] = variables + + if files: + assert variables is not None + assert files is not None + file_map = BaseGraphQLTestClient._build_multipart_file_map(variables, files) + + body = { + "operations": json.dumps(body), + "map": json.dumps(file_map), + **files, + } + + return body + + @staticmethod + def _build_multipart_file_map( + variables: dict[str, Mapping], files: dict[str, object] + ) -> dict[str, list[str]]: + """Creates the file mapping between the variables and the files objects passed as key arguments. + + Args: + variables: A dictionary with the variables that are going to be passed to the + query. + files: A dictionary with the files that are going to be passed to the query. + + Example usages: + + ```python + _build_multipart_file_map(variables={"textFile": None}, files={"textFile": f}) + # {"textFile": ["variables.textFile"]} + ``` + + If the variable is a list we have to enumerate files in the mapping + + ```python + _build_multipart_file_map( + variables={"files": [None, None]}, + files={"file1": file1, "file2": file2}, + ) + # {"file1": ["variables.files.0"], "file2": ["variables.files.1"]} + ``` + + If `variables` contains another keyword (a folder) we must include that keyword + in the mapping + + ```python + _build_multipart_file_map( + variables={"folder": {"files": [None, None]}}, + files={"file1": file1, "file2": file2}, + ) + # { + # "file1": ["variables.files.folder.files.0"], + # "file2": ["variables.files.folder.files.1"] + # } + ``` + + If `variables` includes both a list of files and other single values, we must + map them accordingly + + ```python + _build_multipart_file_map( + variables={"files": [None, None], "textFile": None}, + files={"file1": file1, "file2": file2, "textFile": file3}, + ) + # { + # "file1": ["variables.files.0"], + # "file2": ["variables.files.1"], + # "textFile": ["variables.textFile"], + # } + ``` + """ + map: dict[str, list[str]] = {} + for key, values in variables.items(): + reference = key + variable_values = values + + # In case of folders the variables will look like + # `{"folder": {"files": ...]}}` + if isinstance(values, dict): + folder_key = next(iter(values.keys())) + reference += f".{folder_key}" + # the list of file is inside the folder keyword + variable_values = variable_values[folder_key] + + # If the variable is an array of files we must number the keys + if isinstance(variable_values, list): + # copying `files` as when we map a file we must discard from the dict + _kwargs = files.copy() + for index, _ in enumerate(variable_values): + k = next(iter(_kwargs.keys())) + _kwargs.pop(k) + map.setdefault(k, []) + map[k].append(f"variables.{reference}.{index}") + else: + map[key] = [f"variables.{reference}"] + + # Variables can be mixed files and other data, we don't want to map non-files + # vars so we need to remove them, we can't remove them before + # because they can be part of a list of files or folder + return {k: v for k, v in map.items() if k in files} + + def _decode(self, response: Any, type: Literal["multipart", "json"]) -> Any: + if type == "multipart": + return json.loads(response.content.decode()) + return response.json() + + +__all__ = ["BaseGraphQLTestClient", "Body", "Response"] diff --git a/src/graphql_server/types/__init__.py b/src/graphql_server/types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/graphql_server/types/unset.py b/src/graphql_server/types/unset.py new file mode 100644 index 0000000..52fc1b7 --- /dev/null +++ b/src/graphql_server/types/unset.py @@ -0,0 +1,50 @@ +import warnings +from typing import Any, Optional + +DEPRECATED_NAMES: dict[str, str] = { + "is_unset": "`is_unset` is deprecated use `value is UNSET` instead", +} + + +class UnsetType: + __instance: Optional["UnsetType"] = None + + def __new__(cls: type["UnsetType"]) -> "UnsetType": + if cls.__instance is None: + ret = super().__new__(cls) + cls.__instance = ret + return ret + return cls.__instance + + def __str__(self) -> str: + return "" + + def __repr__(self) -> str: + return "UNSET" + + def __bool__(self) -> bool: + return False + + +UNSET: Any = UnsetType() +"""A special value that can be used to represent an unset value in a field or argument. +Similar to `undefined` in JavaScript, this value can be used to differentiate between +a field that was not set and a field that was set to `None` or `null`. +""" + + +def _deprecated_is_unset(value: Any) -> bool: + warnings.warn(DEPRECATED_NAMES["is_unset"], DeprecationWarning, stacklevel=2) + return value is UNSET + + +def __getattr__(name: str) -> Any: + if name in DEPRECATED_NAMES: + warnings.warn(DEPRECATED_NAMES[name], DeprecationWarning, stacklevel=2) + return globals()[f"_deprecated_{name}"] + raise AttributeError(f"module {__name__} has no attribute {name}") + + +__all__ = [ + "UNSET", +] diff --git a/src/graphql_server/utils/__init__.py b/src/graphql_server/utils/__init__.py new file mode 100644 index 0000000..b5ae4db --- /dev/null +++ b/src/graphql_server/utils/__init__.py @@ -0,0 +1,4 @@ +from graphql.version import VersionInfo, version_info + +IS_GQL_33 = version_info >= VersionInfo.from_str("3.3.0a0") +IS_GQL_32 = not IS_GQL_33 diff --git a/src/graphql_server/utils/aio.py b/src/graphql_server/utils/aio.py new file mode 100644 index 0000000..76b74dc --- /dev/null +++ b/src/graphql_server/utils/aio.py @@ -0,0 +1,91 @@ +import sys +from collections.abc import AsyncGenerator, AsyncIterable, AsyncIterator, Awaitable +from contextlib import asynccontextmanager, suppress +from typing import ( + Any, + Callable, + Optional, + TypeVar, + Union, + cast, +) + +_T = TypeVar("_T") +_R = TypeVar("_R") + + +@asynccontextmanager +async def aclosing(thing: _T) -> AsyncGenerator[_T, None]: + """Ensure that an async generator is closed properly. + + Port from the stdlib contextlib.asynccontextmanager. Can be removed + and replaced with the stdlib version when we drop support for Python + versions before 3.10. + """ + try: + yield thing + finally: + with suppress(Exception): + await cast("AsyncGenerator", thing).aclose() + + +async def aenumerate( + iterable: Union[AsyncIterator[_T], AsyncIterable[_T]], +) -> AsyncIterator[tuple[int, _T]]: + """Async version of enumerate.""" + i = 0 + async for element in iterable: + yield i, element + i += 1 + + +async def aislice( + aiterable: Union[AsyncIterator[_T], AsyncIterable[_T]], + start: Optional[int] = None, + stop: Optional[int] = None, + step: Optional[int] = None, +) -> AsyncIterator[_T]: + """Async version of itertools.islice.""" + # This is based on + it = iter( + range( + start if start is not None else 0, + stop if stop is not None else sys.maxsize, + step if step is not None else 1, + ) + ) + try: + nexti = next(it) + except StopIteration: + return + + i = 0 + try: + async for element in aiterable: + if i == nexti: + yield element + nexti = next(it) + i += 1 + except StopIteration: + return + + +async def asyncgen_to_list(generator: AsyncGenerator[_T, Any]) -> list[_T]: + """Convert an async generator to a list.""" + return [element async for element in generator] + + +async def resolve_awaitable( + awaitable: Awaitable[_T], + callback: Callable[[_T], _R], +) -> _R: + """Resolves an awaitable object and calls a callback with the resolved value.""" + return callback(await awaitable) + + +__all__ = [ + "aenumerate", + "aislice", + "asyncgen_to_list", + "resolve_awaitable", +] diff --git a/src/graphql_server/utils/await_maybe.py b/src/graphql_server/utils/await_maybe.py new file mode 100644 index 0000000..6833d26 --- /dev/null +++ b/src/graphql_server/utils/await_maybe.py @@ -0,0 +1,18 @@ +import inspect +from collections.abc import AsyncIterator, Awaitable, Iterator +from typing import TypeVar, Union + +T = TypeVar("T") + +AwaitableOrValue = Union[Awaitable[T], T] +AsyncIteratorOrIterator = Union[AsyncIterator[T], Iterator[T]] + + +async def await_maybe(value: AwaitableOrValue[T]) -> T: + if inspect.isawaitable(value): + return await value + + return value + + +__all__ = ["AsyncIteratorOrIterator", "AwaitableOrValue", "await_maybe"] diff --git a/src/graphql_server/utils/debug.py b/src/graphql_server/utils/debug.py new file mode 100644 index 0000000..90bb550 --- /dev/null +++ b/src/graphql_server/utils/debug.py @@ -0,0 +1,46 @@ +import datetime +import json +from json import JSONEncoder +from typing import Any, Optional + + +class GraphQLJSONEncoder(JSONEncoder): + def default(self, o: Any) -> Any: + return repr(o) + + +def pretty_print_graphql_operation( + operation_name: Optional[str], query: str, variables: Optional[dict["str", Any]] +) -> None: + """Pretty print a GraphQL operation using pygments. + + Won't print introspection operation to prevent noise in the output. + """ + try: + from pygments import highlight, lexers + from pygments.formatters import Terminal256Formatter + except ImportError as e: + raise ImportError( + "pygments is not installed but is required for debug output, install it " + "directly or run `pip install graphql_server[debug-server]`" + ) from e + + from .graphql_lexer import GraphQLLexer + + if operation_name == "IntrospectionQuery": + return + + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") # noqa: DTZ005 + + print(f"[{now}]: {operation_name or 'No operation name'}") # noqa: T201 + print(highlight(query, GraphQLLexer(), Terminal256Formatter())) # noqa: T201 + + if variables: + variables_json = json.dumps(variables, indent=4, cls=GraphQLJSONEncoder) + + print( # noqa: T201 + highlight(variables_json, lexers.JsonLexer(), Terminal256Formatter()) + ) + + +__all__ = ["pretty_print_graphql_operation"] diff --git a/src/graphql_server/utils/graphql_lexer.py b/src/graphql_server/utils/graphql_lexer.py new file mode 100644 index 0000000..9c361e7 --- /dev/null +++ b/src/graphql_server/utils/graphql_lexer.py @@ -0,0 +1,35 @@ +from typing import Any, ClassVar + +from pygments import token +from pygments.lexer import RegexLexer + + +class GraphQLLexer(RegexLexer): + """GraphQL Lexer for Pygments, used by the debug server.""" + + name = "GraphQL" + aliases: ClassVar[list[str]] = ["graphql", "gql"] + filenames: ClassVar[list[str]] = ["*.graphql", "*.gql"] + mimetypes: ClassVar[list[str]] = ["application/graphql"] + + tokens: ClassVar[dict[str, list[tuple[str, Any]]]] = { + "root": [ + (r"#.*", token.Comment.Singline), + (r"\.\.\.", token.Operator), + (r'"[\u0009\u000A\u000D\u0020-\uFFFF]*"', token.String.Double), + ( + r"(-?0|-?[1-9][0-9]*)(\.[0-9]+[eE][+-]?[0-9]+|\.[0-9]+|[eE][+-]?[0-9]+)", + token.Number.Float, + ), + (r"(-?0|-?[1-9][0-9]*)", token.Number.Integer), + (r"\$+[_A-Za-z][_0-9A-Za-z]*", token.Name.Variable), + (r"[_A-Za-z][_0-9A-Za-z]+\s?:", token.Text), + (r"(type|query|mutation|@[a-z]+|on|true|false|null)\b", token.Keyword.Type), + (r"[!$():=@\[\]{|}]+?", token.Punctuation), + (r"[_A-Za-z][_0-9A-Za-z]*", token.Keyword), + (r"(\s|,)", token.Text), + ] + } + + +__all__ = ["GraphQLLexer"] diff --git a/src/graphql_server/utils/logs.py b/src/graphql_server/utils/logs.py new file mode 100644 index 0000000..630dd2a --- /dev/null +++ b/src/graphql_server/utils/logs.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from typing import Final + + from graphql.error import GraphQLError + + +class GraphQLServerLogger: + logger: Final[logging.Logger] = logging.getLogger("graphql_server.execution") + + @classmethod + def error( + cls, + error: GraphQLError, + # https://www.python.org/dev/peps/pep-0484/#arbitrary-argument-lists-and-default-argument-values + **logger_kwargs: Any, + ) -> None: + cls.logger.error(error, exc_info=error.original_error, **logger_kwargs) + + +__all__ = ["GraphQLServerLogger"] diff --git a/src/graphql_server/utils/operation.py b/src/graphql_server/utils/operation.py new file mode 100644 index 0000000..8709394 --- /dev/null +++ b/src/graphql_server/utils/operation.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from graphql.language import OperationDefinitionNode, OperationType + +if TYPE_CHECKING: + from graphql.language import DocumentNode + + +def get_first_operation( + graphql_document: DocumentNode, +) -> Optional[OperationDefinitionNode]: + for definition in graphql_document.definitions: + if isinstance(definition, OperationDefinitionNode): + return definition + + return None + + +def get_operation_type( + graphql_document: DocumentNode, operation_name: Optional[str] = None +) -> OperationType: + definition: Optional[OperationDefinitionNode] = None + + if operation_name is not None: + for d in graphql_document.definitions: + if not isinstance(d, OperationDefinitionNode): + continue + if d.name and d.name.value == operation_name: + definition = d + break + else: + definition = get_first_operation(graphql_document) + + if not definition: + raise RuntimeError("Can't get GraphQL operation type") + + return definition.operation + + +__all__ = ["get_first_operation", "get_operation_type"] diff --git a/src/graphql_server/version.py b/src/graphql_server/version.py new file mode 100644 index 0000000..0a484c6 --- /dev/null +++ b/src/graphql_server/version.py @@ -0,0 +1,5 @@ +__all__ = ["version", "version_info"] + + +version = "3.0.0b8" +version_info = (3, 0, 0, "beta", 8) diff --git a/src/graphql_server/webob/__init__.py b/src/graphql_server/webob/__init__.py new file mode 100644 index 0000000..61aa119 --- /dev/null +++ b/src/graphql_server/webob/__init__.py @@ -0,0 +1,3 @@ +from .views import GraphQLView + +__all__ = ["GraphQLView"] diff --git a/src/graphql_server/webob/views.py b/src/graphql_server/webob/views.py new file mode 100644 index 0000000..04cb08f --- /dev/null +++ b/src/graphql_server/webob/views.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING, Any, Mapping, Optional, Union, cast +from typing_extensions import TypeGuard + +from webob import Request, Response + +from graphql_server.http import GraphQLRequestData +from graphql_server.http.exceptions import HTTPException +from graphql_server.http.sync_base_view import SyncBaseHTTPView, SyncHTTPRequestAdapter +from graphql_server.http.typevars import Context, RootValue +from graphql_server.http.types import HTTPMethod, QueryParams + +if TYPE_CHECKING: + from graphql.type import GraphQLSchema + from graphql_server.http import GraphQLHTTPResponse + from graphql_server.http.ides import GraphQL_IDE + + +class WebobHTTPRequestAdapter(SyncHTTPRequestAdapter): + def __init__(self, request: Request) -> None: + self.request = request + + @property + def query_params(self) -> QueryParams: + return dict(self.request.GET.items()) + + @property + def body(self) -> Union[str, bytes]: + return self.request.body + + @property + def method(self) -> HTTPMethod: + return cast("HTTPMethod", self.request.method.upper()) + + @property + def headers(self) -> Mapping[str, str]: + return self.request.headers + + @property + def post_data(self) -> Mapping[str, Union[str, bytes]]: + return self.request.POST + + @property + def files(self) -> Mapping[str, Any]: + return { + name: value.file + for name, value in self.request.POST.items() + if hasattr(value, "file") + } + + @property + def content_type(self) -> Optional[str]: + return self.request.content_type + + +class GraphQLView( + SyncBaseHTTPView[Request, Response, Response, Context, RootValue], +): + allow_queries_via_get: bool = True + request_adapter_class = WebobHTTPRequestAdapter + + def __init__( + self, + schema: GraphQLSchema, + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + multipart_uploads_enabled: bool = False, + ) -> None: + self.schema = schema + self.allow_queries_via_get = allow_queries_via_get + self.multipart_uploads_enabled = multipart_uploads_enabled + + if graphiql is not None: + warnings.warn( + "The `graphiql` argument is deprecated in favor of `graphql_ide`", + DeprecationWarning, + stacklevel=2, + ) + self.graphql_ide = "graphiql" if graphiql else None + else: + self.graphql_ide = graphql_ide + + def get_root_value(self, request: Request) -> Optional[RootValue]: + return None + + def get_context(self, request: Request, response: Response) -> Context: + return {"request": request, "response": response} # type: ignore + + def get_sub_response(self, request: Request) -> Response: + return Response(status=200, content_type="application/json") + + def create_response( + self, + response_data: GraphQLHTTPResponse, + sub_response: Response, + is_strict: bool, + ) -> Response: + sub_response.text = self.encode_json(response_data) + sub_response.content_type = ( + "application/graphql-response+json" if is_strict else "application/json" + ) + return sub_response + + def render_graphql_ide( + self, request: Request, request_data: GraphQLRequestData + ) -> Response: + return Response( + text=request_data.to_template_string(self.graphql_ide_html), + content_type="text/html", + status=200, + ) + + def dispatch_request(self, request: Request) -> Response: + try: + return self.run(request=request) + except HTTPException as e: + return Response(text=e.reason, status=e.status_code) + + +__all__ = ["GraphQLView"] diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/a.py b/src/tests/a.py new file mode 100644 index 0000000..33369da --- /dev/null +++ b/src/tests/a.py @@ -0,0 +1,49 @@ +from graphql import ( + GraphQLField, + GraphQLID, + GraphQLNonNull, + GraphQLObjectType, +) + + +class A: + def __init__(self, id: str): + self.id = id + + +def fields(): + import tests.b + from tests.b import BType + + async def resolve_b(root, info): + # mirrors: return B(id=self.id) + return tests.b.B(id=root.id) + + async def resolve_optional_b(root, info): + return tests.b.B(id=root.id) + + async def resolve_optional_b2(root, info): + return tests.b.B(id=root.id) + + return { + "id": GraphQLField(GraphQLNonNull(GraphQLID)), + "b": GraphQLField( + GraphQLNonNull(BType), + resolve=resolve_b, + ), + "optionalB": GraphQLField( + BType, + resolve=resolve_optional_b, + ), + "optionalB2": GraphQLField( + BType, + resolve=resolve_optional_b2, + ), + } + + +AType = GraphQLObjectType( + name="A", + # use a thunk so that BType is available even though it’s defined above + fields=fields, +) diff --git a/src/tests/asgi/__init__.py b/src/tests/asgi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/asgi/test_async.py b/src/tests/asgi/test_async.py new file mode 100644 index 0000000..b7a6abf --- /dev/null +++ b/src/tests/asgi/test_async.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from graphql import ( + GraphQLArgument, + GraphQLField, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, +) + +if TYPE_CHECKING: + from starlette.testclient import TestClient + + +@pytest.fixture +def test_client() -> TestClient: + from starlette.testclient import TestClient + + from graphql_server.asgi import GraphQL + + # Resolver ------------------------------------------------------ + async def resolve_hello(obj, info, name: str | None = None) -> str: + return f"Hello {name or 'world'}" + + # Root "Query" type -------------------------------------------- + QueryType = GraphQLObjectType( + name="Query", + fields={ + "hello": GraphQLField( + type_=GraphQLString, # → String! + args={"name": GraphQLArgument(GraphQLString)}, # Optional by default + resolve=resolve_hello, + ) + }, + ) + + # Final schema -------------------------------------------------- + schema: GraphQLSchema = GraphQLSchema(query=QueryType) + app = GraphQL[None, None](schema) + return TestClient(app) + + +def test_simple_query(test_client: TestClient): + response = test_client.post("/", json={"query": "{ hello }"}) + print(response.text) + assert response.json() == {"data": {"hello": "Hello world"}} diff --git a/src/tests/b.py b/src/tests/b.py new file mode 100644 index 0000000..6df2608 --- /dev/null +++ b/src/tests/b.py @@ -0,0 +1,55 @@ +from graphql import ( + GraphQLField, + GraphQLID, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, +) + + +class B: + def __init__(self, id: str): + self.id = id + + +def fields(): + import tests.a + from tests.a import AType + + async def resolve_a(root, info): + return tests.a.A(id=root.id) + + async def resolve_a_list(root, info): + return [tests.a.A(id=root.id)] + + async def resolve_optional_a(root, info): + return tests.a.A(id=root.id) + + async def resolve_optional_a2(root, info): + return tests.a.A(id=root.id) + + return { + "id": GraphQLField(GraphQLNonNull(GraphQLID)), + "a": GraphQLField( + GraphQLNonNull(AType), + resolve=resolve_a, + ), + "aList": GraphQLField( + GraphQLNonNull(GraphQLList(GraphQLNonNull(AType))), + resolve=resolve_a_list, + ), + "optionalA": GraphQLField( + AType, + resolve=resolve_optional_a, + ), + "optionalA2": GraphQLField( + AType, + resolve=resolve_optional_a2, + ), + } + + +BType = GraphQLObjectType( + name="B", + fields=fields, +) diff --git a/src/tests/c.py b/src/tests/c.py new file mode 100644 index 0000000..1a3169a --- /dev/null +++ b/src/tests/c.py @@ -0,0 +1,12 @@ +from graphql import GraphQLField, GraphQLID, GraphQLNonNull, GraphQLObjectType + + +class C: + def __init__(self, id: str): + self.id = id + + +CType = GraphQLObjectType( + name="C", + fields={"id": GraphQLField(GraphQLNonNull(GraphQLID))}, +) diff --git a/src/tests/channels/__init__.py b/src/tests/channels/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/channels/test_layers.py b/src/tests/channels/test_layers.py new file mode 100644 index 0000000..1966221 --- /dev/null +++ b/src/tests/channels/test_layers.py @@ -0,0 +1,741 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator +from typing import TYPE_CHECKING + +import pytest + +from graphql_server.subscriptions import GRAPHQL_TRANSPORT_WS_PROTOCOL +from graphql_server.subscriptions.protocols.graphql_transport_ws.types import ( + CompleteMessage, + ConnectionAckMessage, + ConnectionInitMessage, + NextMessage, + SubscribeMessage, +) +from tests.views.schema import schema + +if TYPE_CHECKING: + from channels.testing import WebsocketCommunicator + + +@pytest.fixture +async def ws() -> AsyncGenerator[WebsocketCommunicator, None]: + from channels.testing import WebsocketCommunicator + from graphql_server.channels import GraphQLWSConsumer + + client = WebsocketCommunicator( + GraphQLWSConsumer.as_asgi(schema=schema), + "/graphql", + subprotocols=[GRAPHQL_TRANSPORT_WS_PROTOCOL], + ) + res = await client.connect() + assert res == (True, GRAPHQL_TRANSPORT_WS_PROTOCOL) + + yield client + + await client.disconnect() + + +async def test_no_layers(): + from graphql_server.channels.handlers.base import ChannelsConsumer + + consumer = ChannelsConsumer() + # Mimic lack of layers. If layers is not installed/configured in channels, + # consumer.channel_layer will be `None` + consumer.channel_layer = None + + msg = ( + "Layers integration is required listening for channels.\n" + "Check https://channels.readthedocs.io/en/stable/topics/channel_layers.html " + "for more information" + ) + with ( + pytest.deprecated_call(match="Use listen_to_channel instead"), + pytest.raises(RuntimeError, match=msg), + ): + await consumer.channel_listen("foobar").__anext__() + + with pytest.raises(RuntimeError, match=msg): + async with consumer.listen_to_channel("foobar"): + pass + + +@pytest.mark.django_db +async def test_channel_listen(ws: WebsocketCommunicator): + from channels.layers import get_channel_layer + + await ws.send_json_to(ConnectionInitMessage({"type": "connection_init"})) + + connection_ack_message: ConnectionAckMessage = await ws.receive_json_from() + assert connection_ack_message == {"type": "connection_ack"} + + await ws.send_json_to( + SubscribeMessage( + { + "id": "sub1", + "type": "subscribe", + "payload": { + "query": "subscription { listener }", + }, + } + ) + ) + + channel_layer = get_channel_layer() + assert channel_layer + + next_message1: NextMessage = await ws.receive_json_from() + assert "data" in next_message1["payload"] + assert next_message1["payload"]["data"] is not None + channel_name = next_message1["payload"]["data"]["listener"] + + await channel_layer.send( + channel_name, + { + "type": "test.message", + "text": "Hello there!", + }, + ) + + next_message2: NextMessage = await ws.receive_json_from() + assert next_message2 == { + "id": "sub1", + "type": "next", + "payload": { + "data": {"listener": "Hello there!"}, + # "extensions": {"example": "example"}, + }, + } + + await ws.send_json_to(CompleteMessage({"id": "sub1", "type": "complete"})) + + +@pytest.mark.django_db +async def test_channel_listen_with_confirmation(ws: WebsocketCommunicator): + from channels.layers import get_channel_layer + + await ws.send_json_to(ConnectionInitMessage({"type": "connection_init"})) + + connection_ack_message: ConnectionAckMessage = await ws.receive_json_from() + assert connection_ack_message == {"type": "connection_ack"} + + await ws.send_json_to( + SubscribeMessage( + { + "id": "sub1", + "type": "subscribe", + "payload": { + "query": "subscription { listenerWithConfirmation }", + }, + } + ) + ) + + channel_layer = get_channel_layer() + assert channel_layer + + next_message1: NextMessage = await ws.receive_json_from() + assert "data" in next_message1["payload"] + assert next_message1["payload"]["data"] is not None + confirmation = next_message1["payload"]["data"]["listenerWithConfirmation"] + assert confirmation is None + + next_message2: NextMessage = await ws.receive_json_from() + assert "data" in next_message2["payload"] + assert next_message2["payload"]["data"] is not None + channel_name = next_message2["payload"]["data"]["listenerWithConfirmation"] + + await channel_layer.send( + channel_name, + { + "type": "test.message", + "text": "Hello there!", + }, + ) + + next_message3: NextMessage = await ws.receive_json_from() + assert next_message3 == { + "id": "sub1", + "type": "next", + "payload": { + "data": {"listenerWithConfirmation": "Hello there!"}, + # "extensions": {"example": "example"}, + }, + } + + await ws.send_json_to(CompleteMessage({"id": "sub1", "type": "complete"})) + + +@pytest.mark.django_db +async def test_channel_listen_timeout(ws: WebsocketCommunicator): + from channels.layers import get_channel_layer + + await ws.send_json_to(ConnectionInitMessage({"type": "connection_init"})) + + connection_ack_message: ConnectionAckMessage = await ws.receive_json_from() + assert connection_ack_message == {"type": "connection_ack"} + + await ws.send_json_to( + SubscribeMessage( + { + "id": "sub1", + "type": "subscribe", + "payload": { + "query": "subscription { listener(timeout: 0.5) }", + }, + } + ) + ) + + channel_layer = get_channel_layer() + assert channel_layer + + next_message: NextMessage = await ws.receive_json_from() + assert "data" in next_message["payload"] + assert next_message["payload"]["data"] is not None + channel_name = next_message["payload"]["data"]["listener"] + assert channel_name + + complete_message = await ws.receive_json_from() + assert complete_message == {"id": "sub1", "type": "complete"} + + +@pytest.mark.django_db +async def test_channel_listen_timeout_cm(ws: WebsocketCommunicator): + from channels.layers import get_channel_layer + + await ws.send_json_to(ConnectionInitMessage({"type": "connection_init"})) + + connection_ack_message: ConnectionAckMessage = await ws.receive_json_from() + assert connection_ack_message == {"type": "connection_ack"} + + await ws.send_json_to( + SubscribeMessage( + { + "id": "sub1", + "type": "subscribe", + "payload": { + "query": "subscription { listenerWithConfirmation(timeout: 0.5) }", + }, + } + ) + ) + + channel_layer = get_channel_layer() + assert channel_layer + + next_message1: NextMessage = await ws.receive_json_from() + assert "data" in next_message1["payload"] + assert next_message1["payload"]["data"] is not None + confirmation = next_message1["payload"]["data"]["listenerWithConfirmation"] + assert confirmation is None + + next_message2 = await ws.receive_json_from() + assert "data" in next_message2["payload"] + assert next_message2["payload"]["data"] is not None + channel_name = next_message2["payload"]["data"]["listenerWithConfirmation"] + assert channel_name + + complete_message: CompleteMessage = await ws.receive_json_from() + assert complete_message == {"id": "sub1", "type": "complete"} + + +@pytest.mark.django_db +async def test_channel_listen_no_message_on_channel(ws: WebsocketCommunicator): + from channels.layers import get_channel_layer + + await ws.send_json_to(ConnectionInitMessage({"type": "connection_init"})) + + connection_ack_message: ConnectionAckMessage = await ws.receive_json_from() + assert connection_ack_message == {"type": "connection_ack"} + + await ws.send_json_to( + SubscribeMessage( + { + "id": "sub1", + "type": "subscribe", + "payload": { + "query": "subscription { listener(timeout: 0.5) }", + }, + } + ) + ) + + channel_layer = get_channel_layer() + assert channel_layer + + next_message: NextMessage = await ws.receive_json_from() + assert "data" in next_message["payload"] + assert next_message["payload"]["data"] is not None + channel_name = next_message["payload"]["data"]["listener"] + assert channel_name + + await channel_layer.send( + "totally-not-out-channel", + { + "type": "test.message", + "text": "Hello there!", + }, + ) + + complete_message: CompleteMessage = await ws.receive_json_from() + assert complete_message == {"id": "sub1", "type": "complete"} + + +@pytest.mark.django_db +async def test_channel_listen_no_message_on_channel_cm(ws: WebsocketCommunicator): + from channels.layers import get_channel_layer + + await ws.send_json_to(ConnectionInitMessage({"type": "connection_init"})) + + connection_ack_message: ConnectionAckMessage = await ws.receive_json_from() + assert connection_ack_message == {"type": "connection_ack"} + + await ws.send_json_to( + SubscribeMessage( + { + "id": "sub1", + "type": "subscribe", + "payload": { + "query": "subscription { listenerWithConfirmation(timeout: 0.5) }", + }, + } + ) + ) + + channel_layer = get_channel_layer() + assert channel_layer + + next_message1: NextMessage = await ws.receive_json_from() + assert "data" in next_message1["payload"] + assert next_message1["payload"]["data"] is not None + confirmation = next_message1["payload"]["data"]["listenerWithConfirmation"] + assert confirmation is None + + next_message2 = await ws.receive_json_from() + assert "data" in next_message2["payload"] + assert next_message2["payload"]["data"] is not None + channel_name = next_message2["payload"]["data"]["listenerWithConfirmation"] + assert channel_name + + await channel_layer.send( + "totally-not-out-channel", + { + "type": "test.message", + "text": "Hello there!", + }, + ) + + complete_message: CompleteMessage = await ws.receive_json_from() + assert complete_message == {"id": "sub1", "type": "complete"} + + +@pytest.mark.django_db +async def test_channel_listen_group(ws: WebsocketCommunicator): + from channels.layers import get_channel_layer + + await ws.send_json_to(ConnectionInitMessage({"type": "connection_init"})) + + connection_ack_message: ConnectionAckMessage = await ws.receive_json_from() + assert connection_ack_message == {"type": "connection_ack"} + + await ws.send_json_to( + SubscribeMessage( + { + "id": "sub1", + "type": "subscribe", + "payload": { + "query": 'subscription { listener(group: "foobar") }', + }, + } + ) + ) + + channel_layer = get_channel_layer() + assert channel_layer + + next_message1 = await ws.receive_json_from() + assert "data" in next_message1["payload"] + assert next_message1["payload"]["data"] is not None + channel_name = next_message1["payload"]["data"]["listener"] + + # Sent at least once to the consumer to make sure the groups were registered + await channel_layer.send( + channel_name, + { + "type": "test.message", + "text": "Hello there!", + }, + ) + + next_message2: NextMessage = await ws.receive_json_from() + assert next_message2 == { + "id": "sub1", + "type": "next", + "payload": { + "data": {"listener": "Hello there!"}, + # "extensions": {"example": "example"}, + }, + } + + await channel_layer.group_send( + "foobar", + { + "type": "test.message", + "text": "Hello there!", + }, + ) + + next_message3: NextMessage = await ws.receive_json_from() + assert next_message3 == { + "id": "sub1", + "type": "next", + "payload": { + "data": {"listener": "Hello there!"}, + # "extensions": {"example": "example"}, + }, + } + + await ws.send_json_to(CompleteMessage({"id": "sub1", "type": "complete"})) + + +@pytest.mark.django_db +async def test_channel_listen_group_cm(ws: WebsocketCommunicator): + from channels.layers import get_channel_layer + + await ws.send_json_to(ConnectionInitMessage({"type": "connection_init"})) + + connection_ack_message: ConnectionAckMessage = await ws.receive_json_from() + assert connection_ack_message == {"type": "connection_ack"} + + await ws.send_json_to( + SubscribeMessage( + { + "id": "sub1", + "type": "subscribe", + "payload": { + "query": 'subscription { listenerWithConfirmation(group: "foobar") }', + }, + } + ) + ) + + channel_layer = get_channel_layer() + assert channel_layer + + next_message1: NextMessage = await ws.receive_json_from() + assert "data" in next_message1["payload"] + assert next_message1["payload"]["data"] is not None + confirmation = next_message1["payload"]["data"]["listenerWithConfirmation"] + assert confirmation is None + + next_message2 = await ws.receive_json_from() + assert "data" in next_message2["payload"] + assert next_message2["payload"]["data"] is not None + channel_name = next_message2["payload"]["data"]["listenerWithConfirmation"] + + # Sent at least once to the consumer to make sure the groups were registered + await channel_layer.send( + channel_name, + { + "type": "test.message", + "text": "Hello there!", + }, + ) + + next_message3: NextMessage = await ws.receive_json_from() + assert next_message3 == { + "id": "sub1", + "type": "next", + "payload": { + "data": {"listenerWithConfirmation": "Hello there!"}, + # "extensions": {"example": "example"}, + }, + } + + await channel_layer.group_send( + "foobar", + { + "type": "test.message", + "text": "Hello there!", + }, + ) + + next_message4: NextMessage = await ws.receive_json_from() + assert next_message4 == { + "id": "sub1", + "type": "next", + "payload": { + "data": {"listenerWithConfirmation": "Hello there!"}, + # "extensions": {"example": "example"}, + }, + } + + await ws.send_json_to(CompleteMessage({"id": "sub1", "type": "complete"})) + + +@pytest.mark.django_db +async def test_channel_listen_group_twice(ws: WebsocketCommunicator): + from channels.layers import get_channel_layer + + await ws.send_json_to(ConnectionInitMessage({"type": "connection_init"})) + + connection_ack_message: ConnectionAckMessage = await ws.receive_json_from() + assert connection_ack_message == {"type": "connection_ack"} + + await ws.send_json_to( + SubscribeMessage( + { + "id": "sub1", + "type": "subscribe", + "payload": { + "query": 'subscription { listener(group: "group1") }', + }, + } + ) + ) + + await ws.send_json_to( + SubscribeMessage( + { + "id": "sub2", + "type": "subscribe", + "payload": { + "query": 'subscription { listener(group: "group2") }', + }, + } + ) + ) + + channel_layer = get_channel_layer() + assert channel_layer + + # Wait for channel subscriptions to start + next_message1: NextMessage = await ws.receive_json_from() + next_message2: NextMessage = await ws.receive_json_from() + assert {"sub1", "sub2"} == {next_message1["id"], next_message2["id"]} + + assert "data" in next_message1["payload"] + assert next_message1["payload"]["data"] is not None + channel_name = next_message1["payload"]["data"]["listener"] + + # Sent at least once to the consumer to make sure the groups were registered + await channel_layer.send( + channel_name, + { + "type": "test.message", + "text": "Hello there!", + }, + ) + + next_message3: NextMessage = await ws.receive_json_from() + next_message4: NextMessage = await ws.receive_json_from() + assert {"sub1", "sub2"} == {next_message3["id"], next_message4["id"]} + + assert "data" in next_message3["payload"] + assert next_message3["payload"]["data"] is not None + assert next_message3["payload"]["data"]["listener"] == "Hello there!" + + assert "data" in next_message4["payload"] + assert next_message4["payload"]["data"] is not None + assert next_message4["payload"]["data"]["listener"] == "Hello there!" + + # We now have two channel_listen AsyncGenerators waiting, one for id="sub1" + # and one for id="sub2". This group message will be received by both of them + # as they are both running on the same ChannelsConsumer instance so even + # though "sub2" was initialised with "group2" as the argument, it will receive + # this message for "group1" + await channel_layer.group_send( + "group1", + { + "type": "test.message", + "text": "Hello group 1!", + }, + ) + + next_message5: NextMessage = await ws.receive_json_from() + next_message6: NextMessage = await ws.receive_json_from() + assert {"sub1", "sub2"} == {next_message5["id"], next_message6["id"]} + + assert "data" in next_message5["payload"] + assert next_message5["payload"]["data"] is not None + assert next_message5["payload"]["data"]["listener"] == "Hello group 1!" + + assert "data" in next_message6["payload"] + assert next_message6["payload"]["data"] is not None + assert next_message6["payload"]["data"]["listener"] == "Hello group 1!" + + await channel_layer.group_send( + "group2", + { + "type": "test.message", + "text": "Hello group 2!", + }, + ) + + next_message7: NextMessage = await ws.receive_json_from() + next_message8: NextMessage = await ws.receive_json_from() + assert {"sub1", "sub2"} == {next_message7["id"], next_message8["id"]} + + assert "data" in next_message7["payload"] + assert next_message7["payload"]["data"] is not None + assert next_message7["payload"]["data"]["listener"] == "Hello group 2!" + + assert "data" in next_message8["payload"] + assert next_message8["payload"]["data"] is not None + assert next_message8["payload"]["data"]["listener"] == "Hello group 2!" + + await ws.send_json_to(CompleteMessage({"id": "sub1", "type": "complete"})) + await ws.send_json_to(CompleteMessage({"id": "sub2", "type": "complete"})) + + +async def test_channel_listen_group_twice_cm(ws: WebsocketCommunicator): + from channels.layers import get_channel_layer + + await ws.send_json_to(ConnectionInitMessage({"type": "connection_init"})) + + connection_ack_message: ConnectionAckMessage = await ws.receive_json_from() + assert connection_ack_message == {"type": "connection_ack"} + + await ws.send_json_to( + SubscribeMessage( + { + "id": "sub1", + "type": "subscribe", + "payload": { + "query": 'subscription { listenerWithConfirmation(group: "group1") }', + }, + } + ) + ) + + await ws.send_json_to( + SubscribeMessage( + { + "id": "sub2", + "type": "subscribe", + "payload": { + "query": 'subscription { listenerWithConfirmation(group: "group2") }', + }, + } + ) + ) + + channel_layer = get_channel_layer() + assert channel_layer + + # Wait for confirmation for channel subscriptions + messages = await asyncio.gather( + ws.receive_json_from(), + ws.receive_json_from(), + ws.receive_json_from(), + ws.receive_json_from(), + ) + confirmation1 = next( + i + for i in messages + if not i["payload"]["data"]["listenerWithConfirmation"] and i["id"] == "sub1" + ) + confirmation2 = next( + i + for i in messages + if not i["payload"]["data"]["listenerWithConfirmation"] and i["id"] == "sub2" + ) + channel_name1 = next( + i + for i in messages + if i["payload"]["data"]["listenerWithConfirmation"] and i["id"] == "sub1" + ) + channel_name2 = next( + i + for i in messages + if i["payload"]["data"]["listenerWithConfirmation"] and i["id"] == "sub2" + ) + + # Ensure correct ordering of responses + assert messages.index(confirmation1) < messages.index(channel_name1) + assert messages.index(confirmation2) < messages.index(channel_name2) + channel_name = channel_name1["payload"]["data"]["listenerWithConfirmation"] + + # Sent at least once to the consumer to make sure the groups were registered + await channel_layer.send( + channel_name, + { + "type": "test.message", + "text": "Hello there!", + }, + ) + + next_message1: NextMessage = await ws.receive_json_from() + next_message2: NextMessage = await ws.receive_json_from() + assert {"sub1", "sub2"} == {next_message1["id"], next_message2["id"]} + + assert "data" in next_message1["payload"] + assert next_message1["payload"]["data"] is not None + assert ( + next_message1["payload"]["data"]["listenerWithConfirmation"] == "Hello there!" + ) + + assert "data" in next_message2["payload"] + assert next_message2["payload"]["data"] is not None + assert ( + next_message2["payload"]["data"]["listenerWithConfirmation"] == "Hello there!" + ) + + # We now have two channel_listen AsyncGenerators waiting, one for id="sub1" + # and one for id="sub2". This group message will be received by both of them + # as they are both running on the same ChannelsConsumer instance so even + # though "sub2" was initialised with "group2" as the argument, it will receive + # this message for "group1" + await channel_layer.group_send( + "group1", + { + "type": "test.message", + "text": "Hello group 1!", + }, + ) + + next_message3: NextMessage = await ws.receive_json_from() + next_message4: NextMessage = await ws.receive_json_from() + assert {"sub1", "sub2"} == {next_message3["id"], next_message4["id"]} + + assert "data" in next_message3["payload"] + assert next_message3["payload"]["data"] is not None + assert ( + next_message3["payload"]["data"]["listenerWithConfirmation"] == "Hello group 1!" + ) + + assert "data" in next_message4["payload"] + assert next_message4["payload"]["data"] is not None + assert ( + next_message4["payload"]["data"]["listenerWithConfirmation"] == "Hello group 1!" + ) + + await channel_layer.group_send( + "group2", + { + "type": "test.message", + "text": "Hello group 2!", + }, + ) + + next_message5: NextMessage = await ws.receive_json_from() + next_message6: NextMessage = await ws.receive_json_from() + assert {"sub1", "sub2"} == {next_message5["id"], next_message6["id"]} + + assert "data" in next_message5["payload"] + assert next_message5["payload"]["data"] is not None + assert ( + next_message5["payload"]["data"]["listenerWithConfirmation"] == "Hello group 2!" + ) + + assert "data" in next_message6["payload"] + assert next_message6["payload"]["data"] is not None + assert ( + next_message6["payload"]["data"]["listenerWithConfirmation"] == "Hello group 2!" + ) + + await ws.send_json_to(CompleteMessage({"id": "sub1", "type": "complete"})) + await ws.send_json_to(CompleteMessage({"id": "sub2", "type": "complete"})) diff --git a/src/tests/channels/test_router.py b/src/tests/channels/test_router.py new file mode 100644 index 0000000..ccc11f6 --- /dev/null +++ b/src/tests/channels/test_router.py @@ -0,0 +1,74 @@ +from unittest import mock + +import pytest + +from tests.views.schema import schema + + +def _fake_asgi(): + return lambda: None + + +@mock.patch("graphql_server.channels.router.GraphQLHTTPConsumer.as_asgi") +@mock.patch("graphql_server.channels.router.GraphQLWSConsumer.as_asgi") +@pytest.mark.parametrize("pattern", ["^graphql", "^foo"]) +def test_included_paths(ws_asgi: mock.Mock, http_asgi: mock.Mock, pattern: str): + from graphql_server.channels.router import GraphQLProtocolTypeRouter + + http_ret = _fake_asgi() + http_asgi.return_value = http_ret + + ws_ret = _fake_asgi() + ws_asgi.return_value = ws_ret + + router = GraphQLProtocolTypeRouter(schema, url_pattern=pattern) + assert set(router.application_mapping) == {"http", "websocket"} + + assert len(router.application_mapping["http"].routes) == 1 + http_route = router.application_mapping["http"].routes[0] + assert http_route.pattern._regex == pattern + assert http_route.callback is http_ret + + assert len(router.application_mapping["websocket"].routes) == 1 + http_route = router.application_mapping["websocket"].routes[0] + assert http_route.pattern._regex == pattern + assert http_route.callback is ws_ret + + +@mock.patch("graphql_server.channels.router.GraphQLHTTPConsumer.as_asgi") +@mock.patch("graphql_server.channels.router.GraphQLWSConsumer.as_asgi") +@pytest.mark.parametrize("pattern", ["^graphql", "^foo"]) +def test_included_paths_with_django_app( + ws_asgi: mock.Mock, + http_asgi: mock.Mock, + pattern: str, +): + from graphql_server.channels.router import GraphQLProtocolTypeRouter + + http_ret = _fake_asgi() + http_asgi.return_value = http_ret + + ws_ret = _fake_asgi() + ws_asgi.return_value = ws_ret + + django_app = _fake_asgi() + router = GraphQLProtocolTypeRouter( + schema, + django_application=django_app, + url_pattern=pattern, + ) + assert set(router.application_mapping) == {"http", "websocket"} + + assert len(router.application_mapping["http"].routes) == 2 + http_route = router.application_mapping["http"].routes[0] + assert http_route.pattern._regex == pattern + assert http_route.callback is http_ret + + django_route = router.application_mapping["http"].routes[1] + assert django_route.pattern._regex == "^" + assert django_route.callback is django_app + + assert len(router.application_mapping["websocket"].routes) == 1 + http_route = router.application_mapping["websocket"].routes[0] + assert http_route.pattern._regex == pattern + assert http_route.callback is ws_ret diff --git a/src/tests/channels/test_testing.py b/src/tests/channels/test_testing.py new file mode 100644 index 0000000..75d28f1 --- /dev/null +++ b/src/tests/channels/test_testing.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from collections.abc import AsyncGenerator +from typing import TYPE_CHECKING, Any + +import pytest + +from graphql_server.subscriptions import ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, +) +from tests.views.schema import schema + +if TYPE_CHECKING: + from graphql_server.channels.testing import GraphQLWebsocketCommunicator + + +@pytest.fixture(params=[GRAPHQL_TRANSPORT_WS_PROTOCOL, GRAPHQL_WS_PROTOCOL]) +async def communicator( + request: Any, +) -> AsyncGenerator[GraphQLWebsocketCommunicator, None]: + from graphql_server.channels import GraphQLWSConsumer + from graphql_server.channels.testing import GraphQLWebsocketCommunicator + + application = GraphQLWSConsumer.as_asgi(schema=schema, keep_alive_interval=50) + + async with GraphQLWebsocketCommunicator( + protocol=request.param, + application=application, + path="/graphql", + connection_params={"graphql_server": "Hi"}, + ) as client: + yield client + + +async def test_simple_subscribe(communicator: GraphQLWebsocketCommunicator): + async for res in communicator.subscribe( + query='subscription { echo(message: "Hi") }' + ): + assert res.data == {"echo": "Hi"} + + +async def test_subscribe_unexpected_error(communicator): + async for res in communicator.subscribe( + query='subscription { exception(message: "Hi") }' + ): + assert res.errors[0].message == "Hi" + + +async def test_graphql_error(communicator): + async for res in communicator.subscribe( + query='subscription { error(message: "Hi") }' + ): + assert res.errors[0].message == "Hi" + + +async def test_simple_connection_params(communicator): + async for res in communicator.subscribe(query="subscription { connectionParams }"): + assert res.data["connectionParams"]["graphql_server"] == "Hi" diff --git a/src/tests/conftest.py b/src/tests/conftest.py new file mode 100644 index 0000000..cb47327 --- /dev/null +++ b/src/tests/conftest.py @@ -0,0 +1,57 @@ +import pathlib +import sys +from typing import Any + +import pytest + +from graphql_server.utils import IS_GQL_32 + + +def pytest_emoji_xfailed(config: pytest.Config) -> tuple[str, str]: + return "🤷‍♂️ ", "XFAIL 🤷‍♂️ " + + +def pytest_emoji_skipped(config: pytest.Config) -> tuple[str, str]: + return "🦘 ", "SKIPPED 🦘" + + +# @pytest.hookimpl # type: ignore +# def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]): +# rootdir = pathlib.Path(config.rootdir) # type: ignore + +# for item in items: +# rel_path = pathlib.Path(item.fspath).relative_to(rootdir) + +# markers = [ +# "aiohttp", +# "asgi", +# "chalice", +# "channels", +# "django", +# "fastapi", +# "flask", +# "quart", +# "pydantic", +# "sanic", +# "litestar", +# ] + +# for marker in markers: +# if marker in rel_path.parts: +# item.add_marker(getattr(pytest.mark, marker)) + + +@pytest.hookimpl +def pytest_ignore_collect( + collection_path: pathlib.Path, path: Any, config: pytest.Config +): + if sys.version_info < (3, 12) and "python_312" in collection_path.parts: + return True + return None + + +def skip_if_gql_32(reason: str) -> pytest.MarkDecorator: + return pytest.mark.skipif( + IS_GQL_32, + reason=reason, + ) diff --git a/src/tests/d.py b/src/tests/d.py new file mode 100644 index 0000000..b76d5ec --- /dev/null +++ b/src/tests/d.py @@ -0,0 +1,31 @@ +from graphql import ( + GraphQLField, + GraphQLID, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, +) + +import tests.c + + +def fields(): + from tests.c import CType + + async def resolve_c_list(root, info): + return [tests.c.C(id=root.id)] + + return { + "id": GraphQLField(GraphQLNonNull(GraphQLID)), + "cList": GraphQLField( + # non-null list of non-null C + GraphQLNonNull(GraphQLList(GraphQLNonNull(CType))), + resolve=resolve_c_list, + ), + } + + +DType = GraphQLObjectType( + name="D", + fields=fields, +) diff --git a/src/tests/django/__init__.py b/src/tests/django/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/django/app/__init__.py b/src/tests/django/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/django/app/models.py b/src/tests/django/app/models.py new file mode 100644 index 0000000..b4bcfab --- /dev/null +++ b/src/tests/django/app/models.py @@ -0,0 +1,5 @@ +from django.db import models + + +class Example(models.Model): # noqa: DJ008 + name = models.CharField(max_length=100) diff --git a/src/tests/django/app/urls.py b/src/tests/django/app/urls.py new file mode 100644 index 0000000..0f87699 --- /dev/null +++ b/src/tests/django/app/urls.py @@ -0,0 +1,14 @@ +from django.urls import path + +from graphql_server.django.views import GraphQLView as BaseGraphQLView +from tests.views.schema import Query, schema + + +class GraphQLView(BaseGraphQLView): + def get_root_value(self, request) -> Query: + return Query() + + +urlpatterns = [ + path("graphql/", GraphQLView.as_view(schema=schema)), +] diff --git a/src/tests/django/conftest.py b/src/tests/django/conftest.py new file mode 100644 index 0000000..757137c --- /dev/null +++ b/src/tests/django/conftest.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from graphql_server.django.test import GraphQLTestClient + + +@pytest.fixture +def graphql_client() -> GraphQLTestClient: + from django.test.client import Client + + from graphql_server.django.test import GraphQLTestClient + + return GraphQLTestClient(Client()) diff --git a/src/tests/django/django_settings.py b/src/tests/django/django_settings.py new file mode 100644 index 0000000..8e559ca --- /dev/null +++ b/src/tests/django/django_settings.py @@ -0,0 +1,18 @@ +SECRET_KEY = 1 + +INSTALLED_APPS = ["tests.django.app"] +ROOT_URLCONF = "tests.django.app.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + } +] + +DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} + +# This is for channels integration, but only one django settings can be used +# per pytest_django settings +CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}} diff --git a/src/tests/django/test_extensions.py b/src/tests/django/test_extensions.py new file mode 100644 index 0000000..9562de3 --- /dev/null +++ b/src/tests/django/test_extensions.py @@ -0,0 +1,10 @@ +import pytest + + +@pytest.mark.skip +def test_extensions(graphql_client): + query = "{ hello }" + + response = graphql_client.query(query) + + assert response.extensions["example"] == "example" diff --git a/src/tests/fastapi/__init__.py b/src/tests/fastapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/fastapi/app.py b/src/tests/fastapi/app.py new file mode 100644 index 0000000..b86216c --- /dev/null +++ b/src/tests/fastapi/app.py @@ -0,0 +1,39 @@ +from typing import Any, Union + +from fastapi import BackgroundTasks, Depends, FastAPI, Request, WebSocket +from graphql_server.fastapi import GraphQLRouter +from tests.views.schema import schema + + +def custom_context_dependency() -> str: + return "Hi!" + + +async def get_context( + background_tasks: BackgroundTasks, + request: Request = None, + ws: WebSocket = None, + custom_value=Depends(custom_context_dependency), +) -> dict[str, Any]: + return { + "custom_value": custom_value, + "request": request or ws, + "background_tasks": background_tasks, + } + + +async def get_root_value( + request: Request = None, ws: WebSocket = None +) -> Union[Request, WebSocket]: + return request or ws + + +def create_app(schema=schema, **kwargs: Any) -> FastAPI: + app = FastAPI() + + graphql_app = GraphQLRouter( + schema, context_getter=get_context, root_value_getter=get_root_value, **kwargs + ) + app.include_router(graphql_app, prefix="/graphql") + + return app diff --git a/src/tests/fastapi/test_async.py b/src/tests/fastapi/test_async.py new file mode 100644 index 0000000..a3bd8df --- /dev/null +++ b/src/tests/fastapi/test_async.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import Optional + +import pytest +from graphql import ( + GraphQLArgument, + GraphQLField, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, +) +from starlette.testclient import TestClient + +from tests.fastapi.app import create_app + + +async def resolve_hello(_root, _info, name: Optional[str] = None) -> str: + return f"Hello {name or 'world'}" + + +QueryType = GraphQLObjectType( + name="Query", + fields={ + "hello": GraphQLField( + GraphQLNonNull(GraphQLString), + args={"name": GraphQLArgument(GraphQLString)}, + resolve=resolve_hello, + ) + }, +) + + +@pytest.fixture +def test_client() -> TestClient: + schema = GraphQLSchema(query=QueryType) + app = create_app(schema=schema) + return TestClient(app) + + +def test_simple_query(test_client: TestClient): + response = test_client.post("/graphql", json={"query": "{ hello }"}) + + assert response.json() == {"data": {"hello": "Hello world"}} diff --git a/src/tests/fastapi/test_context.py b/src/tests/fastapi/test_context.py new file mode 100644 index 0000000..94a094b --- /dev/null +++ b/src/tests/fastapi/test_context.py @@ -0,0 +1,420 @@ +import asyncio +from collections.abc import AsyncGenerator + +import pytest +from graphql import ( + GraphQLArgument, + GraphQLField, + GraphQLFloat, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, +) + + +def test_base_context(): + from graphql_server.fastapi import BaseContext + + base_context = BaseContext() + assert base_context.request is None + assert base_context.background_tasks is None + assert base_context.response is None + + +def test_with_explicit_class_context_getter(): + from fastapi import Depends, FastAPI + from fastapi.testclient import TestClient + from graphql_server.fastapi import BaseContext, GraphQLRouter + + class CustomContext(BaseContext): + def __init__(self, rocks: str): + self.graphql_server = rocks + + def custom_context_dependency() -> CustomContext: + return CustomContext(rocks="explicitly rocks") + + def get_context(custom_context: CustomContext = Depends(custom_context_dependency)): + return custom_context + + async def resolve_abc(_root, info) -> str: + assert info.context.request is not None + assert info.context.graphql_server == "explicitly rocks" + assert info.context.connection_params is None + return "abc" + + QueryType = GraphQLObjectType( + name="Query", + fields={ + "abc": GraphQLField( + GraphQLString, + resolve=resolve_abc, + ) + }, + ) + + app = FastAPI() + schema = GraphQLSchema(query=QueryType) + graphql_app = GraphQLRouter(schema=schema, context_getter=get_context) + app.include_router(graphql_app, prefix="/graphql") + + test_client = TestClient(app) + response = test_client.post("/graphql", json={"query": "{ abc }"}) + + assert response.status_code == 200 + assert response.json() == {"data": {"abc": "abc"}} + + +def test_with_implicit_class_context_getter(): + from fastapi import Depends, FastAPI + from fastapi.testclient import TestClient + from graphql_server.fastapi import BaseContext, GraphQLRouter + + class CustomContext(BaseContext): + def __init__(self, rocks: str = "implicitly rocks"): + super().__init__() + self.graphql_server = rocks + + def get_context(context: CustomContext = Depends()): + return context + + async def resolve_abc(_root, info) -> str: + assert info.context.request is not None + assert info.context.graphql_server == "implicitly rocks" + assert info.context.connection_params is None + return "abc" + + app = FastAPI() + QueryType = GraphQLObjectType( + name="Query", + fields={ + "abc": GraphQLField( + GraphQLString, + resolve=resolve_abc, + ) + }, + ) + + schema = GraphQLSchema(query=QueryType) + graphql_app = GraphQLRouter(schema=schema, context_getter=get_context) + app.include_router(graphql_app, prefix="/graphql") + + test_client = TestClient(app) + response = test_client.post("/graphql", json={"query": "{ abc }"}) + + assert response.status_code == 200 + assert response.json() == {"data": {"abc": "abc"}} + + +def test_with_dict_context_getter(): + from fastapi import Depends, FastAPI + from fastapi.testclient import TestClient + from graphql_server.fastapi import GraphQLRouter + + def custom_context_dependency() -> str: + return "rocks" + + def get_context(value: str = Depends(custom_context_dependency)) -> dict[str, str]: + return {"graphql_server": value} + + async def resolve_abc(_root, info) -> str: + assert info.context.get("request") is not None + assert "connection_params" not in info.context + assert info.context.get("graphql_server") == "rocks" + return "abc" + + app = FastAPI() + QueryType = GraphQLObjectType( + name="Query", + fields={ + "abc": GraphQLField( + GraphQLString, + resolve=resolve_abc, + ) + }, + ) + + schema = GraphQLSchema(query=QueryType) + graphql_app = GraphQLRouter(schema=schema, context_getter=get_context) + app.include_router(graphql_app, prefix="/graphql") + + test_client = TestClient(app) + response = test_client.post("/graphql", json={"query": "{ abc }"}) + + assert response.status_code == 200 + assert response.json() == {"data": {"abc": "abc"}} + + +def test_without_context_getter(): + from fastapi import FastAPI + from fastapi.testclient import TestClient + from graphql_server.fastapi import GraphQLRouter + + async def resolve_abc(_root, info) -> str: + assert info.context.get("request") is not None + assert info.context.get("graphql_server") is None + return "abc" + + app = FastAPI() + QueryType = GraphQLObjectType( + name="Query", + fields={ + "abc": GraphQLField( + GraphQLString, + resolve=resolve_abc, + ) + }, + ) + + schema = GraphQLSchema(query=QueryType) + graphql_app = GraphQLRouter(schema=schema, context_getter=None) + app.include_router(graphql_app, prefix="/graphql") + + test_client = TestClient(app) + response = test_client.post("/graphql", json={"query": "{ abc }"}) + + assert response.status_code == 200 + assert response.json() == {"data": {"abc": "abc"}} + + +@pytest.mark.skip(reason="This is no longer supported") +def test_with_invalid_context_getter(): + from fastapi import Depends, FastAPI + from fastapi.testclient import TestClient + from graphql_server.fastapi import GraphQLRouter + + def custom_context_dependency() -> str: + return "rocks" + + def get_context(value: str = Depends(custom_context_dependency)) -> str: + return value + + async def resolve_abc(_root, info) -> str: + assert info.context.get("request") is not None + assert info.context.get("graphql_server") is None + return "abc" + + app = FastAPI() + QueryType = GraphQLObjectType( + name="Query", + fields={ + "abc": GraphQLField( + GraphQLString, + resolve=resolve_abc, + ) + }, + ) + + schema = GraphQLSchema(query=QueryType) + graphql_app = GraphQLRouter(schema=schema, context_getter=get_context) + app.include_router(graphql_app, prefix="/graphql") + + test_client = TestClient(app) + with pytest.raises( + Exception, + match=( + "The custom context must be either a class " + "that inherits from BaseContext or a dictionary" + ), + ): + test_client.post("/graphql", json={"query": "{ abc }"}) + + +def test_class_context_injects_connection_params_over_transport_ws(): + from fastapi import Depends, FastAPI + from fastapi.testclient import TestClient + from graphql_server.fastapi import BaseContext, GraphQLRouter + from graphql_server.subscriptions import GRAPHQL_TRANSPORT_WS_PROTOCOL + from graphql_server.subscriptions.protocols.graphql_transport_ws import ( + types as transport_ws_types, + ) + + # Resolver for the Subscription.connectionParams field + async def subscribe_connection_params( + _root, info, delay: float = 0 + ) -> AsyncGenerator[str, None]: + assert info.context.request is not None + await asyncio.sleep(delay) + yield info.context.connection_params["graphql_server"] + + QueryType = GraphQLObjectType( + name="Query", + fields={ + "x": GraphQLField( + GraphQLString, + resolve=lambda _root, _info: "hi", + ) + }, + ) + + # Subscription type (replacing @graphql_server.type class Subscription) + SubscriptionType = GraphQLObjectType( + name="Subscription", + fields={ + "connectionParams": GraphQLField( + GraphQLString, + args={"delay": GraphQLArgument(GraphQLFloat)}, + subscribe=subscribe_connection_params, + resolve=lambda payload, *args, **kwargs: payload, + ) + }, + ) + + class Context(BaseContext): + graphql_server: str + + def __init__(self): + self.graphql_server = "rocks" + + def get_context(context: Context = Depends()) -> Context: + return context + + app = FastAPI() + + QueryType = GraphQLObjectType( + name="Query", + fields={"x": GraphQLField(GraphQLString, resolve=lambda *_: "hi")}, + ) + + async def subscribe_connection_params(_root, info, delay: float = 0): + assert info.context.request is not None + await asyncio.sleep(delay) + yield info.context.connection_params["graphql_server"] + + SubscriptionType = GraphQLObjectType( + name="Subscription", + fields={ + "connectionParams": GraphQLField( + GraphQLString, + args={"delay": GraphQLArgument(GraphQLFloat)}, + subscribe=subscribe_connection_params, + resolve=lambda payload, *args, **kwargs: payload, + ) + }, + ) + + schema = GraphQLSchema(query=QueryType, subscription=SubscriptionType) + graphql_app = GraphQLRouter(schema=schema, context_getter=get_context) + app.include_router(graphql_app, prefix="/graphql") + + test_client = TestClient(app) + with test_client.websocket_connect( + "/graphql", [GRAPHQL_TRANSPORT_WS_PROTOCOL] + ) as ws: + ws.send_json( + transport_ws_types.ConnectionInitMessage( + {"type": "connection_init", "payload": {"graphql_server": "rocks"}} + ) + ) + + connection_ack_message = ws.receive_json() + assert connection_ack_message == {"type": "connection_ack"} + + ws.send_json( + transport_ws_types.SubscribeMessage( + { + "id": "sub1", + "type": "subscribe", + "payload": {"query": "subscription { connectionParams }"}, + } + ) + ) + + next_message = ws.receive_json() + assert next_message == { + "id": "sub1", + "type": "next", + "payload": {"data": {"connectionParams": "rocks"}}, + } + + ws.send_json( + transport_ws_types.CompleteMessage({"id": "sub1", "type": "complete"}) + ) + + ws.close() + + +def test_class_context_injects_connection_params_over_ws(): + from starlette.websockets import WebSocketDisconnect + + from fastapi import Depends, FastAPI + from fastapi.testclient import TestClient + from graphql_server.fastapi import BaseContext, GraphQLRouter + from graphql_server.subscriptions import GRAPHQL_WS_PROTOCOL + from graphql_server.subscriptions.protocols.graphql_ws import types as ws_types + + class Context(BaseContext): + graphql_server: str + + def __init__(self): + self.graphql_server = "rocks" + + def get_context(context: Context = Depends()) -> Context: + return context + + app = FastAPI() + + QueryType = GraphQLObjectType( + name="Query", + fields={"x": GraphQLField(GraphQLString, resolve=lambda *_: "hi")}, + ) + + async def subscribe_connection_params(_root, info, delay: float = 0): + assert info.context.request is not None + await asyncio.sleep(delay) + yield info.context.connection_params["graphql_server"] + + SubscriptionType = GraphQLObjectType( + name="Subscription", + fields={ + "connectionParams": GraphQLField( + GraphQLString, + args={"delay": GraphQLArgument(GraphQLFloat)}, + subscribe=subscribe_connection_params, + resolve=lambda payload, *args, **kwargs: payload, + ) + }, + ) + + schema = GraphQLSchema(query=QueryType, subscription=SubscriptionType) + graphql_app = GraphQLRouter(schema=schema, context_getter=get_context) + app.include_router(graphql_app, prefix="/graphql") + + test_client = TestClient(app) + with test_client.websocket_connect("/graphql", [GRAPHQL_WS_PROTOCOL]) as ws: + ws.send_json( + ws_types.ConnectionInitMessage( + {"type": "connection_init", "payload": {"graphql_server": "rocks"}} + ) + ) + ws.send_json( + ws_types.StartMessage( + { + "type": "start", + "id": "demo", + "payload": { + "query": "subscription { connectionParams }", + }, + } + ) + ) + + connection_ack_message = ws.receive_json() + assert connection_ack_message["type"] == "connection_ack" + + data_message = ws.receive_json() + assert data_message["type"] == "data" + assert data_message["id"] == "demo" + assert data_message["payload"]["data"] == {"connectionParams": "rocks"} + + ws.send_json(ws_types.StopMessage({"type": "stop", "id": "demo"})) + + complete_message = ws.receive_json() + assert complete_message["type"] == "complete" + assert complete_message["id"] == "demo" + + ws.send_json( + ws_types.ConnectionTerminateMessage({"type": "connection_terminate"}) + ) + + with pytest.raises(WebSocketDisconnect): + ws.receive_json() diff --git a/src/tests/fastapi/test_openapi.py b/src/tests/fastapi/test_openapi.py new file mode 100644 index 0000000..ebd48fa --- /dev/null +++ b/src/tests/fastapi/test_openapi.py @@ -0,0 +1,66 @@ +import pytest +from graphql import GraphQLField, GraphQLObjectType, GraphQLSchema, GraphQLString + + +def resolve_abc(_root, _info): + return "abc" + + +def test_include_router_prefix(): + from starlette.testclient import TestClient + + from fastapi import FastAPI + from graphql_server.fastapi import GraphQLRouter + + app = FastAPI() + QueryType = GraphQLObjectType( + name="Query", + fields={"abc": GraphQLField(GraphQLString, resolve=resolve_abc)}, + ) + schema = GraphQLSchema(query=QueryType) + graphql_app = GraphQLRouter[None, None](schema) + app.include_router(graphql_app, prefix="/graphql") + + test_client = TestClient(app) + response = test_client.post("/graphql", json={"query": "{ abc }"}) + assert response.status_code == 200 + assert response.json() == {"data": {"abc": "abc"}} + + +def test_graphql_router_path(): + from starlette.testclient import TestClient + + from fastapi import FastAPI + from graphql_server.fastapi import GraphQLRouter + + app = FastAPI() + QueryType = GraphQLObjectType( + name="Query", + fields={"abc": GraphQLField(GraphQLString, resolve=resolve_abc)}, + ) + schema = GraphQLSchema(query=QueryType) + graphql_app = GraphQLRouter[None, None](schema, path="/graphql") + app.include_router(graphql_app) + + test_client = TestClient(app) + response = test_client.post("/graphql", json={"query": "{ abc }"}) + assert response.status_code == 200 + assert response.json() == {"data": {"abc": "abc"}} + + +def test_missing_path_and_prefix(): + from fastapi import FastAPI + from graphql_server.fastapi import GraphQLRouter + + app = FastAPI() + QueryType = GraphQLObjectType( + name="Query", + fields={"abc": GraphQLField(GraphQLString, resolve=resolve_abc)}, + ) + schema = GraphQLSchema(query=QueryType) + graphql_app = GraphQLRouter[None, None](schema) + + with pytest.raises(Exception) as exc: + app.include_router(graphql_app) + + assert "Prefix and path cannot be both empty" in str(exc) diff --git a/src/tests/fastapi/test_router.py b/src/tests/fastapi/test_router.py new file mode 100644 index 0000000..4a35ba0 --- /dev/null +++ b/src/tests/fastapi/test_router.py @@ -0,0 +1,71 @@ +import pytest +from graphql import GraphQLField, GraphQLObjectType, GraphQLSchema, GraphQLString + + +def resolve_abc(_root, _info) -> str: + return "abc" + + +def test_include_router_prefix(): + from starlette.testclient import TestClient + + from fastapi import FastAPI + from graphql_server.fastapi import GraphQLRouter + + QueryType = GraphQLObjectType( + name="Query", + fields={"abc": GraphQLField(GraphQLString, resolve=resolve_abc)}, + ) + + app = FastAPI() + schema = GraphQLSchema(query=QueryType) + graphql_app = GraphQLRouter[None, None](schema) + app.include_router(graphql_app, prefix="/graphql") + + test_client = TestClient(app) + response = test_client.post("/graphql", json={"query": "{ abc }"}) + + assert response.status_code == 200 + assert response.json() == {"data": {"abc": "abc"}} + + +def test_graphql_router_path(): + from starlette.testclient import TestClient + + from fastapi import FastAPI + from graphql_server.fastapi import GraphQLRouter + + QueryType = GraphQLObjectType( + name="Query", + fields={"abc": GraphQLField(GraphQLString, resolve=resolve_abc)}, + ) + + app = FastAPI() + schema = GraphQLSchema(query=QueryType) + graphql_app = GraphQLRouter[None, None](schema, path="/graphql") + app.include_router(graphql_app) + + test_client = TestClient(app) + response = test_client.post("/graphql", json={"query": "{ abc }"}) + + assert response.status_code == 200 + assert response.json() == {"data": {"abc": "abc"}} + + +def test_missing_path_and_prefix(): + from fastapi import FastAPI + from graphql_server.fastapi import GraphQLRouter + + QueryType = GraphQLObjectType( + name="Query", + fields={"abc": GraphQLField(GraphQLString, resolve=resolve_abc)}, + ) + + app = FastAPI() + schema = GraphQLSchema(query=QueryType) + graphql_app = GraphQLRouter[None, None](schema) + + with pytest.raises(Exception) as exc: + app.include_router(graphql_app) + + assert "Prefix and path cannot be both empty" in str(exc) diff --git a/src/tests/file_uploads/__init__.py b/src/tests/file_uploads/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/file_uploads/test_utils.py b/src/tests/file_uploads/test_utils.py new file mode 100644 index 0000000..06c2380 --- /dev/null +++ b/src/tests/file_uploads/test_utils.py @@ -0,0 +1,133 @@ +from io import BytesIO + +from graphql_server.file_uploads.utils import replace_placeholders_with_files + + +def test_does_deep_copy(): + operations = { + "query": "mutation($file: Upload!) { upload_file(file: $file) { id } }", + "variables": {"file": None}, + } + files_map = {} + files = {} + + result = replace_placeholders_with_files(operations, files_map, files) + assert result == operations + assert result is not operations + + +def test_empty_files_map(): + operations = { + "query": "mutation($files: [Upload!]!) { upload_files(files: $files) { id } }", + "variables": {"files": [None, None]}, + } + files_map = {} + files = {"0": BytesIO(), "1": BytesIO()} + + result = replace_placeholders_with_files(operations, files_map, files) + assert result == operations + + +def test_empty_operations_paths(): + operations = { + "query": "mutation($files: [Upload!]!) { upload_files(files: $files) { id } }", + "variables": {"files": [None, None]}, + } + files_map = {"0": [], "1": []} + files = {"0": BytesIO(), "1": BytesIO()} + + result = replace_placeholders_with_files(operations, files_map, files) + assert result == operations + + +def test_single_file_in_single_location(): + operations = { + "query": "mutation($file: Upload!) { upload_file(file: $file) { id } }", + "variables": {"file": None}, + } + files_map = {"0": ["variables.file"]} + file0 = BytesIO() + files = {"0": file0} + + result = replace_placeholders_with_files(operations, files_map, files) + assert result["query"] == operations["query"] + assert result["variables"]["file"] == file0 + + +def test_single_file_in_multiple_locations(): + operations = { + "query": "mutation($a: Upload!, $b: Upload!) { pair(a: $a, b: $a) { id } }", + "variables": {"a": None, "b": None}, + } + files_map = {"0": ["variables.a", "variables.b"]} + file0 = BytesIO() + files = {"0": file0} + + result = replace_placeholders_with_files(operations, files_map, files) + assert result["query"] == operations["query"] + assert result["variables"]["a"] == file0 + assert result["variables"]["b"] == file0 + + +def test_file_list(): + operations = { + "query": "mutation($files: [Upload!]!) { upload_files(files: $files) { id } }", + "variables": {"files": [None, None]}, + } + files_map = {"0": ["variables.files.0"], "1": ["variables.files.1"]} + file0 = BytesIO() + file1 = BytesIO() + files = {"0": file0, "1": file1} + + result = replace_placeholders_with_files(operations, files_map, files) + assert result["query"] == operations["query"] + assert result["variables"]["files"][0] == file0 + assert result["variables"]["files"][1] == file1 + + +def test_single_file_reuse_in_list(): + operations = { + "query": "mutation($a: [Upload!]!, $b: Upload!) { mixed(a: $a, b: $b) { id } }", + "variables": {"a": [None, None], "b": None}, + } + files_map = {"0": ["variables.a.0"], "1": ["variables.a.1", "variables.b"]} + file0 = BytesIO() + file1 = BytesIO() + files = {"0": file0, "1": file1} + + result = replace_placeholders_with_files(operations, files_map, files) + assert result["query"] == operations["query"] + assert result["variables"]["a"][0] == file0 + assert result["variables"]["a"][1] == file1 + assert result["variables"]["b"] == file1 + + +def test_using_single_file_multiple_times_in_same_list(): + operations = { + "query": "mutation($files: [Upload!]!) { upload_files(files: $files) { id } }", + "variables": {"files": [None, None]}, + } + files_map = {"0": ["variables.files.0", "variables.files.1"]} + file0 = BytesIO() + files = {"0": file0} + + result = replace_placeholders_with_files(operations, files_map, files) + assert result["query"] == operations["query"] + assert result["variables"]["files"][0] == file0 + assert result["variables"]["files"][1] == file0 + + +def test_deep_nesting(): + operations = { + "query": "mutation($list: [ComplexInput!]!) { mutate(list: $list) { id } }", + "variables": {"a": [{"files": [None, None]}]}, + } + files_map = {"0": ["variables.a.0.files.0"], "1": ["variables.a.0.files.1"]} + file0 = BytesIO() + file1 = BytesIO() + files = {"0": file0, "1": file1} + + result = replace_placeholders_with_files(operations, files_map, files) + assert result["query"] == operations["query"] + assert result["variables"]["a"][0]["files"][0] == file0 + assert result["variables"]["a"][0]["files"][1] == file1 diff --git a/src/tests/http/__init__.py b/src/tests/http/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/http/clients/__init__.py b/src/tests/http/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/http/clients/aiohttp.py b/src/tests/http/clients/aiohttp.py new file mode 100644 index 0000000..0cc2edf --- /dev/null +++ b/src/tests/http/clients/aiohttp.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import contextlib +import json +from collections.abc import AsyncGenerator, Mapping, Sequence +from datetime import timedelta +from io import BytesIO +from typing import Any, Optional, Union +from typing_extensions import Literal + +from graphql import ExecutionResult + +from aiohttp import web +from aiohttp.client_ws import ClientWebSocketResponse +from aiohttp.http_websocket import WSMsgType +from aiohttp.test_utils import TestClient, TestServer +from graphql_server.aiohttp.views import GraphQLView as BaseGraphQLView +from graphql_server.http import GraphQLHTTPResponse +from graphql_server.http.ides import GraphQL_IDE +from graphql_server.subscriptions import ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, +) +from tests.http.context import get_context +from tests.views.schema import Query, schema +from tests.websockets.views import OnWSConnectMixin + +from .base import ( + JSON, + DebuggableGraphQLTransportWSHandler, + DebuggableGraphQLWSHandler, + HttpClient, + Message, + Response, + ResultOverrideFunction, + WebSocketClient, +) + + +class GraphQLView(OnWSConnectMixin, BaseGraphQLView[dict[str, object], object]): + result_override: ResultOverrideFunction = None + graphql_transport_ws_handler_class = DebuggableGraphQLTransportWSHandler + graphql_ws_handler_class = DebuggableGraphQLWSHandler + + async def get_context( + self, request: web.Request, response: Union[web.Response, web.WebSocketResponse] + ) -> dict[str, object]: + context = await super().get_context(request, response) + + return get_context(context) + + async def get_root_value(self, request: web.Request) -> Query: + await super().get_root_value(request) # for coverage + return Query() + + async def process_result( + self, request: web.Request, result: ExecutionResult, strict: bool = False + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return await super().process_result(request, result, strict) + + +class AioHttpClient(HttpClient): + def __init__( + self, + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + keep_alive: bool = False, + keep_alive_interval: float = 1, + debug: bool = False, + subscription_protocols: Sequence[str] = ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, + ), + connection_init_wait_timeout: timedelta = timedelta(minutes=1), + result_override: ResultOverrideFunction = None, + multipart_uploads_enabled: bool = False, + ): + view = GraphQLView( + schema=schema, + graphiql=graphiql, + graphql_ide=graphql_ide, + allow_queries_via_get=allow_queries_via_get, + keep_alive=keep_alive, + keep_alive_interval=keep_alive_interval, + debug=debug, + subscription_protocols=subscription_protocols, + connection_init_wait_timeout=connection_init_wait_timeout, + multipart_uploads_enabled=multipart_uploads_enabled, + ) + view.result_override = result_override + + self.app = web.Application() + self.app.router.add_route("*", "/graphql", view) + + async def _graphql_request( + self, + method: Literal["get", "post"], + query: Optional[str] = None, + operation_name: Optional[str] = None, + variables: Optional[dict[str, object]] = None, + files: Optional[dict[str, BytesIO]] = None, + headers: Optional[dict[str, str]] = None, + extensions: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> Response: + async with TestClient(TestServer(self.app)) as client: + body = self._build_body( + query=query, + operation_name=operation_name, + variables=variables, + files=files, + method=method, + extensions=extensions, + ) + + if body and files: + body.update(files) + + if method == "get": + kwargs["params"] = body + else: + kwargs["data"] = body if files else json.dumps(body) + + response = await getattr(client, method)( + "/graphql", + headers=self._get_headers(method=method, headers=headers, files=files), + **kwargs, + ) + + return Response( + status_code=response.status, + data=(await response.text()).encode(), + headers=response.headers, + ) + + async def request( + self, + url: str, + method: Literal["head", "get", "post", "patch", "put", "delete"], + headers: Optional[dict[str, str]] = None, + ) -> Response: + async with TestClient(TestServer(self.app)) as client: + response = await getattr(client, method)(url, headers=headers) + + return Response( + status_code=response.status, + data=(await response.text()).encode(), + headers=response.headers, + ) + + async def get( + self, + url: str, + headers: Optional[dict[str, str]] = None, + ) -> Response: + return await self.request(url, "get", headers=headers) + + async def post( + self, + url: str, + data: Optional[bytes] = None, + json: Optional[JSON] = None, + headers: Optional[dict[str, str]] = None, + ) -> Response: + async with TestClient(TestServer(self.app)) as client: + response = await client.post( + "/graphql", headers=headers, data=data, json=json + ) + + return Response( + status_code=response.status, + data=(await response.text()).encode(), + headers=dict(response.headers), + ) + + @contextlib.asynccontextmanager + async def ws_connect( + self, + url: str, + *, + protocols: list[str], + ) -> AsyncGenerator[WebSocketClient, None]: + async with ( + TestClient(TestServer(self.app)) as client, + client.ws_connect(url, protocols=protocols) as ws, + ): + yield AioWebSocketClient(ws) + + +class AioWebSocketClient(WebSocketClient): + def __init__(self, ws: ClientWebSocketResponse): + self.ws = ws + self._reason: Optional[str] = None + + async def send_text(self, payload: str) -> None: + await self.ws.send_str(payload) + + async def send_json(self, payload: Mapping[str, object]) -> None: + await self.ws.send_json(payload) + + async def send_bytes(self, payload: bytes) -> None: + await self.ws.send_bytes(payload) + + async def receive(self, timeout: Optional[float] = None) -> Message: + m = await self.ws.receive(timeout) + self._reason = m.extra + return Message(type=m.type, data=m.data, extra=m.extra) + + async def receive_json(self, timeout: Optional[float] = None) -> object: + m = await self.ws.receive(timeout) + assert m.type == WSMsgType.TEXT + return json.loads(m.data) + + async def close(self) -> None: + await self.ws.close() + + @property + def accepted_subprotocol(self) -> Optional[str]: + return self.ws.protocol + + @property + def closed(self) -> bool: + return self.ws.closed + + @property + def close_code(self) -> int: + assert self.ws.close_code is not None + return self.ws.close_code + + @property + def close_reason(self) -> Optional[str]: + return self._reason diff --git a/src/tests/http/clients/asgi.py b/src/tests/http/clients/asgi.py new file mode 100644 index 0000000..441359b --- /dev/null +++ b/src/tests/http/clients/asgi.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import contextlib +import json +from collections.abc import AsyncGenerator, Mapping, Sequence +from datetime import timedelta +from io import BytesIO +from typing import Any, Optional, Union +from typing_extensions import Literal + +from graphql import ExecutionResult +from starlette.requests import Request +from starlette.responses import Response as StarletteResponse +from starlette.testclient import TestClient, WebSocketTestSession +from starlette.websockets import WebSocket + +from graphql_server.asgi import GraphQL as BaseGraphQLView +from graphql_server.http import GraphQLHTTPResponse +from graphql_server.http.ides import GraphQL_IDE +from graphql_server.subscriptions import ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, +) +from tests.http.context import get_context +from tests.views.schema import Query, schema +from tests.websockets.views import OnWSConnectMixin + +from .base import ( + JSON, + DebuggableGraphQLTransportWSHandler, + DebuggableGraphQLWSHandler, + HttpClient, + Message, + Response, + ResultOverrideFunction, + WebSocketClient, +) + + +class GraphQLView(OnWSConnectMixin, BaseGraphQLView[dict[str, object], object]): + result_override: ResultOverrideFunction = None + graphql_transport_ws_handler_class = DebuggableGraphQLTransportWSHandler + graphql_ws_handler_class = DebuggableGraphQLWSHandler + + async def get_root_value(self, request: Union[WebSocket, Request]) -> Query: + return Query() + + async def get_context( + self, + request: Union[Request, WebSocket], + response: Union[StarletteResponse, WebSocket], + ) -> dict[str, object]: + context = await super().get_context(request, response) + + return get_context(context) + + async def process_result( + self, request: Request, result: ExecutionResult, strict: bool = False + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return await super().process_result(request, result, strict) + + +class AsgiHttpClient(HttpClient): + def __init__( + self, + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + keep_alive: bool = False, + keep_alive_interval: float = 1, + debug: bool = False, + subscription_protocols: Sequence[str] = ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, + ), + connection_init_wait_timeout: timedelta = timedelta(minutes=1), + result_override: ResultOverrideFunction = None, + multipart_uploads_enabled: bool = False, + ): + view = GraphQLView( + schema, + graphiql=graphiql, + graphql_ide=graphql_ide, + allow_queries_via_get=allow_queries_via_get, + keep_alive=keep_alive, + keep_alive_interval=keep_alive_interval, + debug=debug, + subscription_protocols=subscription_protocols, + connection_init_wait_timeout=connection_init_wait_timeout, + multipart_uploads_enabled=multipart_uploads_enabled, + ) + view.result_override = result_override + + self.client = TestClient(view) + + async def _graphql_request( + self, + method: Literal["get", "post"], + query: Optional[str] = None, + operation_name: Optional[str] = None, + variables: Optional[dict[str, object]] = None, + files: Optional[dict[str, BytesIO]] = None, + headers: Optional[dict[str, str]] = None, + extensions: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> Response: + body = self._build_body( + query=query, + operation_name=operation_name, + variables=variables, + files=files, + method=method, + extensions=extensions, + ) + + if method == "get": + kwargs["params"] = body + elif body: + if files: + kwargs["data"] = body + else: + kwargs["content"] = json.dumps(body) + + if files is not None: + kwargs["files"] = files + + response = getattr(self.client, method)( + "/graphql", + headers=self._get_headers(method=method, headers=headers, files=files), + **kwargs, + ) + + return Response( + status_code=response.status_code, + data=response.content, + headers=response.headers, + ) + + async def request( + self, + url: str, + method: Literal["get", "post", "patch", "put", "delete"], + headers: Optional[dict[str, str]] = None, + ) -> Response: + response = getattr(self.client, method)(url, headers=headers) + + return Response( + status_code=response.status_code, + data=response.content, + headers=response.headers, + ) + + async def get( + self, + url: str, + headers: Optional[dict[str, str]] = None, + ) -> Response: + return await self.request(url, "get", headers=headers) + + async def post( + self, + url: str, + data: Optional[bytes] = None, + json: Optional[JSON] = None, + headers: Optional[dict[str, str]] = None, + ) -> Response: + response = self.client.post(url, headers=headers, content=data, json=json) + + return Response( + status_code=response.status_code, + data=response.content, + headers=dict(response.headers), + ) + + @contextlib.asynccontextmanager + async def ws_connect( + self, + url: str, + *, + protocols: list[str], + ) -> AsyncGenerator[WebSocketClient, None]: + with self.client.websocket_connect(url, protocols) as ws: + yield AsgiWebSocketClient(ws) + + +class AsgiWebSocketClient(WebSocketClient): + def __init__(self, ws: WebSocketTestSession): + self.ws = ws + self._closed: bool = False + self._close_code: Optional[int] = None + self._close_reason: Optional[str] = None + + async def send_text(self, payload: str) -> None: + self.ws.send_text(payload) + + async def send_json(self, payload: Mapping[str, object]) -> None: + self.ws.send_json(payload) + + async def send_bytes(self, payload: bytes) -> None: + self.ws.send_bytes(payload) + + async def receive(self, timeout: Optional[float] = None) -> Message: + if self._closed: + # if close was received via exception, fake it so that recv works + return Message( + type="websocket.close", data=self._close_code, extra=self._close_reason + ) + m = self.ws.receive() + if m["type"] == "websocket.close": + self._closed = True + self._close_code = m["code"] + self._close_reason = m["reason"] + return Message(type=m["type"], data=m["code"], extra=m["reason"]) + if m["type"] == "websocket.send": + return Message(type=m["type"], data=m["text"]) + return Message(type=m["type"], data=m["data"], extra=m["extra"]) + + async def receive_json(self, timeout: Optional[float] = None) -> Any: + m = self.ws.receive() + assert m["type"] == "websocket.send" + assert "text" in m + return json.loads(m["text"]) + + async def close(self) -> None: + self.ws.close() + self._closed = True + + @property + def accepted_subprotocol(self) -> Optional[str]: + return self.ws.accepted_subprotocol + + @property + def closed(self) -> bool: + return self._closed + + @property + def close_code(self) -> int: + assert self._close_code is not None + return self._close_code + + @property + def close_reason(self) -> Optional[str]: + return self._close_reason diff --git a/src/tests/http/clients/async_django.py b/src/tests/http/clients/async_django.py new file mode 100644 index 0000000..e197038 --- /dev/null +++ b/src/tests/http/clients/async_django.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from collections.abc import AsyncIterable +from typing import Optional + +from django.core.exceptions import BadRequest, SuspiciousOperation +from django.http import Http404, HttpRequest, HttpResponse, StreamingHttpResponse +from graphql import ExecutionResult + +from graphql_server.django.views import AsyncGraphQLView as BaseAsyncGraphQLView +from graphql_server.http import GraphQLHTTPResponse +from graphql_server.http.ides import GraphQL_IDE +from tests.http.context import get_context +from tests.views.schema import Query, schema + +from .base import Response, ResultOverrideFunction +from .django import DjangoHttpClient + + +class AsyncGraphQLView(BaseAsyncGraphQLView[dict[str, object], object]): + result_override: ResultOverrideFunction = None + + async def get_root_value(self, request: HttpRequest) -> Query: + await super().get_root_value(request) # for coverage + return Query() + + async def get_context( + self, request: HttpRequest, response: HttpResponse + ) -> dict[str, object]: + context = {"request": request, "response": response} + + return get_context(context) + + async def process_result( + self, request: HttpRequest, result: ExecutionResult, strict: bool = False + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return await super().process_result(request, result, strict) + + +class AsyncDjangoHttpClient(DjangoHttpClient): + def __init__( + self, + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + result_override: ResultOverrideFunction = None, + multipart_uploads_enabled: bool = False, + ): + self.view = AsyncGraphQLView.as_view( + schema=schema, + graphiql=graphiql, + graphql_ide=graphql_ide, + allow_queries_via_get=allow_queries_via_get, + result_override=result_override, + multipart_uploads_enabled=multipart_uploads_enabled, + ) + + async def _do_request(self, request: HttpRequest) -> Response: + try: + response = await self.view(request) + except Http404: + return Response(status_code=404, data=b"Not found", headers={}) + except (BadRequest, SuspiciousOperation) as e: + return Response( + status_code=400, + data=e.args[0].encode(), + headers={}, + ) + + data = ( + response.streaming_content + if isinstance(response, StreamingHttpResponse) + and isinstance(response.streaming_content, AsyncIterable) + else response.content + ) + + return Response( + status_code=response.status_code, + data=data, + headers=dict(response.headers), + ) diff --git a/src/tests/http/clients/async_flask.py b/src/tests/http/clients/async_flask.py new file mode 100644 index 0000000..f8683b6 --- /dev/null +++ b/src/tests/http/clients/async_flask.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from typing import Any, Optional + +from graphql import ExecutionResult + +from flask import Flask +from flask import Request as FlaskRequest +from flask import Response as FlaskResponse +from graphql_server.flask.views import AsyncGraphQLView as BaseAsyncGraphQLView +from graphql_server.http import GraphQLHTTPResponse +from graphql_server.http.ides import GraphQL_IDE +from tests.http.context import get_context +from tests.views.schema import Query, schema + +from .base import ResultOverrideFunction +from .flask import FlaskHttpClient + + +class GraphQLView(BaseAsyncGraphQLView[dict[str, object], object]): + methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"] + + result_override: ResultOverrideFunction = None + + def __init__(self, *args: Any, **kwargs: Any): + self.result_override = kwargs.pop("result_override") + super().__init__(*args, **kwargs) + + async def get_root_value(self, request: FlaskRequest) -> Query: + await super().get_root_value(request) # for coverage + return Query() + + async def get_context( + self, request: FlaskRequest, response: FlaskResponse + ) -> dict[str, object]: + context = await super().get_context(request, response) + + return get_context(context) + + async def process_result( + self, request: FlaskRequest, result: ExecutionResult, strict: bool = False + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return await super().process_result(request, result, strict) + + +class AsyncFlaskHttpClient(FlaskHttpClient): + def __init__( + self, + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + result_override: ResultOverrideFunction = None, + multipart_uploads_enabled: bool = False, + ): + self.app = Flask(__name__) + self.app.debug = True + + view = GraphQLView.as_view( + "graphql_view", + schema=schema, + graphiql=graphiql, + graphql_ide=graphql_ide, + allow_queries_via_get=allow_queries_via_get, + result_override=result_override, + multipart_uploads_enabled=multipart_uploads_enabled, + ) + + self.app.add_url_rule( + "/graphql", + view_func=view, + ) diff --git a/src/tests/http/clients/base.py b/src/tests/http/clients/base.py new file mode 100644 index 0000000..3e6514f --- /dev/null +++ b/src/tests/http/clients/base.py @@ -0,0 +1,379 @@ +import abc +import contextlib +import json +import logging +from collections.abc import AsyncGenerator, AsyncIterable, Mapping, Sequence +from dataclasses import dataclass +from datetime import timedelta +from functools import cached_property +from io import BytesIO +from typing import Any, Callable, Optional, Union +from typing_extensions import Literal + +from graphql import ExecutionResult + +from graphql_server.http import GraphQLHTTPResponse +from graphql_server.http.ides import GraphQL_IDE +from graphql_server.subscriptions.protocols.graphql_transport_ws.handlers import ( + BaseGraphQLTransportWSHandler, +) +from graphql_server.subscriptions.protocols.graphql_transport_ws.types import ( + Message as GraphQLTransportWSMessage, +) +from graphql_server.subscriptions.protocols.graphql_ws.handlers import ( + BaseGraphQLWSHandler, +) +from graphql_server.subscriptions.protocols.graphql_ws.types import OperationMessage + +logger = logging.getLogger("graphql_server.test.http_client") + +JSON = dict[str, object] +ResultOverrideFunction = Optional[Callable[[ExecutionResult], GraphQLHTTPResponse]] + + +@dataclass +class Response: + status_code: int + data: Union[bytes, AsyncIterable[bytes]] + + def __init__( + self, + status_code: int, + data: Union[bytes, AsyncIterable[bytes]], + *, + headers: Optional[dict[str, str]] = None, + ) -> None: + self.status_code = status_code + self.data = data + self._headers = headers or {} + + @cached_property + def headers(self) -> Mapping[str, str]: + return {k.lower(): v for k, v in self._headers.items()} + + @property + def is_multipart(self) -> bool: + return self.headers.get("content-type", "").startswith("multipart/mixed") + + @property + def text(self) -> str: + assert isinstance(self.data, bytes) + return self.data.decode() + + @property + def json(self) -> JSON: + assert isinstance(self.data, bytes) + return json.loads(self.data) + + async def streaming_json(self) -> AsyncIterable[JSON]: + if not self.is_multipart: + raise ValueError("Streaming not supported") + + def parse_chunk(text: str) -> Union[JSON, None]: + # TODO: better parsing? :) + with contextlib.suppress(json.JSONDecodeError): + return json.loads(text) + + if isinstance(self.data, AsyncIterable): + chunks = self.data + + async for chunk in chunks: + lines = chunk.decode("utf-8").split("\r\n") + + for text in lines: + if data := parse_chunk(text): + yield data + else: + # TODO: we do this because httpx doesn't support streaming + # it would be nice to fix httpx instead of doing this, + # but we might have the same issue in other clients too + # TODO: better message + logger.warning("Didn't receive a stream, parsing it sync") + + chunks = self.data.decode("utf-8").split("\r\n") + + for chunk in chunks: + if data := parse_chunk(chunk): + yield data + + +class HttpClient(abc.ABC): + @abc.abstractmethod + def __init__( + self, + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + keep_alive: bool = False, + keep_alive_interval: float = 1, + debug: bool = False, + subscription_protocols: Sequence[str] = (), + connection_init_wait_timeout: timedelta = timedelta(minutes=1), + result_override: ResultOverrideFunction = None, + multipart_uploads_enabled: bool = False, + ): ... + + @abc.abstractmethod + async def _graphql_request( + self, + method: Literal["get", "post"], + query: Optional[str] = None, + operation_name: Optional[str] = None, + variables: Optional[dict[str, object]] = None, + files: Optional[dict[str, BytesIO]] = None, + headers: Optional[dict[str, str]] = None, + extensions: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> Response: ... + + @abc.abstractmethod + async def request( + self, + url: str, + method: Literal["head", "get", "post", "patch", "put", "delete"], + headers: Optional[dict[str, str]] = None, + ) -> Response: ... + + @abc.abstractmethod + async def get( + self, + url: str, + headers: Optional[dict[str, str]] = None, + ) -> Response: ... + + @abc.abstractmethod + async def post( + self, + url: str, + data: Optional[bytes] = None, + json: Optional[JSON] = None, + headers: Optional[dict[str, str]] = None, + ) -> Response: ... + + async def query( + self, + query: str, + method: Literal["get", "post"] = "post", + operation_name: Optional[str] = None, + variables: Optional[dict[str, object]] = None, + files: Optional[dict[str, BytesIO]] = None, + headers: Optional[dict[str, str]] = None, + extensions: Optional[dict[str, Any]] = None, + ) -> Response: + return await self._graphql_request( + method, + query=query, + operation_name=operation_name, + headers=headers, + variables=variables, + files=files, + extensions=extensions, + ) + + def _get_headers( + self, + method: Literal["get", "post"], + headers: Optional[dict[str, str]], + files: Optional[dict[str, BytesIO]], + ) -> dict[str, str]: + additional_headers = {} + headers = headers or {} + + # TODO: fix case sensitivity + content_type = headers.get("content-type") + + if not content_type and method == "post" and not files: + content_type = "application/json" + + additional_headers = {"Content-Type": content_type} if content_type else {} + + return {**additional_headers, **headers} + + def _build_body( + self, + query: Optional[str] = None, + operation_name: Optional[str] = None, + variables: Optional[dict[str, object]] = None, + files: Optional[dict[str, BytesIO]] = None, + method: Literal["get", "post"] = "post", + extensions: Optional[dict[str, Any]] = None, + ) -> Optional[dict[str, object]]: + if query is None: + assert files is None + assert variables is None + + return None + + body: dict[str, object] = {"query": query} + + if operation_name is not None: + body["operationName"] = operation_name + + if variables: + body["variables"] = variables + + if extensions: + body["extensions"] = extensions + + if files: + assert variables is not None + + file_map = self._build_multipart_file_map(variables, files) + + body = { + "operations": json.dumps(body), + "map": json.dumps(file_map), + } + + if method == "get" and variables: + body["variables"] = json.dumps(variables) + + if method == "get" and extensions: + body["extensions"] = json.dumps(extensions) + + return body + + @staticmethod + def _build_multipart_file_map( + variables: dict[str, object], files: dict[str, BytesIO] + ) -> dict[str, list[str]]: + # TODO: remove code duplication + + files_map: dict[str, list[str]] = {} + for key, values in variables.items(): + if isinstance(values, dict): + folder_key = next(iter(values.keys())) + key += f".{folder_key}" # noqa: PLW2901 + # the list of file is inside the folder keyword + values = values[folder_key] # noqa: PLW2901 + + # If the variable is an array of files we must number the keys + if isinstance(values, list): + # copying `files` as when we map a file we must discard from the dict + _kwargs = files.copy() + for index, _ in enumerate(values): + k = next(iter(_kwargs.keys())) + _kwargs.pop(k) + files_map.setdefault(k, []) + files_map[k].append(f"variables.{key}.{index}") + else: + files_map[key] = [f"variables.{key}"] + + return files_map + + def ws_connect( + self, + url: str, + *, + protocols: list[str], + ) -> contextlib.AbstractAsyncContextManager["WebSocketClient"]: + raise NotImplementedError + + +@dataclass +class Message: + type: Any + data: Any + extra: Optional[str] = None + + def json(self) -> Any: + return json.loads(self.data) + + +class WebSocketClient(abc.ABC): + def name(self) -> str: + return "" + + @abc.abstractmethod + async def send_text(self, payload: str) -> None: ... + + @abc.abstractmethod + async def send_json(self, payload: Mapping[str, object]) -> None: ... + + @abc.abstractmethod + async def send_bytes(self, payload: bytes) -> None: ... + + @abc.abstractmethod + async def receive(self, timeout: Optional[float] = None) -> Message: ... + + @abc.abstractmethod + async def receive_json(self, timeout: Optional[float] = None) -> Any: ... + + @abc.abstractmethod + async def close(self) -> None: ... + + @property + @abc.abstractmethod + def accepted_subprotocol(self) -> Optional[str]: ... + + @property + @abc.abstractmethod + def closed(self) -> bool: ... + + @property + @abc.abstractmethod + def close_code(self) -> int: ... + + @property + @abc.abstractmethod + def close_reason(self) -> Optional[str]: ... + + async def __aiter__(self) -> AsyncGenerator[Message, None]: + while not self.closed: + yield await self.receive() + + async def send_message(self, message: GraphQLTransportWSMessage) -> None: + await self.send_json(message) + + async def send_legacy_message(self, message: OperationMessage) -> None: + await self.send_json(message) + + +class DebuggableGraphQLTransportWSHandler( + BaseGraphQLTransportWSHandler[dict[str, object], object] +): + def on_init(self) -> None: + """This method can be patched by unit tests to get the instance of the + transport handler when it is initialized. + """ + + def __init__(self, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + self.original_context = kwargs.get("context", {}) + DebuggableGraphQLTransportWSHandler.on_init(self) + + def get_tasks(self) -> list: + return [op.task for op in self.operations.values()] + + @property + def context(self): + self.original_context["ws"] = self.websocket + self.original_context["get_tasks"] = self.get_tasks + self.original_context["connectionInitTimeoutTask"] = ( + self.connection_init_timeout_task + ) + return self.original_context + + @context.setter + def context(self, value): + self.original_context = value + + +class DebuggableGraphQLWSHandler(BaseGraphQLWSHandler[dict[str, object], object]): + def __init__(self, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + self.original_context = kwargs.get("context", {}) + + def get_tasks(self) -> list: + return list(self.tasks.values()) + + @property + def context(self): + self.original_context["ws"] = self.websocket + self.original_context["get_tasks"] = self.get_tasks + self.original_context["connectionInitTimeoutTask"] = None + return self.original_context + + @context.setter + def context(self, value): + self.original_context = value diff --git a/src/tests/http/clients/chalice.py b/src/tests/http/clients/chalice.py new file mode 100644 index 0000000..2b1904b --- /dev/null +++ b/src/tests/http/clients/chalice.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import urllib.parse +from io import BytesIO +from json import dumps +from typing import Any, Optional, Union +from typing_extensions import Literal + +from graphql import ExecutionResult + +from chalice.app import Chalice +from chalice.app import Request as ChaliceRequest +from chalice.test import Client +from graphql_server.chalice.views import GraphQLView as BaseGraphQLView +from graphql_server.http import GraphQLHTTPResponse +from graphql_server.http.ides import GraphQL_IDE +from graphql_server.http.temporal_response import TemporalResponse +from tests.http.context import get_context +from tests.views.schema import Query, schema + +from .base import JSON, HttpClient, Response, ResultOverrideFunction + + +class GraphQLView(BaseGraphQLView[dict[str, object], object]): + result_override: ResultOverrideFunction = None + + def get_root_value(self, request: ChaliceRequest) -> Query: + super().get_root_value(request) # for coverage + return Query() + + def get_context( + self, request: ChaliceRequest, response: TemporalResponse + ) -> dict[str, object]: + context = super().get_context(request, response) + + return get_context(context) + + def process_result( + self, request: ChaliceRequest, result: ExecutionResult, strict: bool = False + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return super().process_result(request, result, strict) + + +class ChaliceHttpClient(HttpClient): + def __init__( + self, + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + result_override: ResultOverrideFunction = None, + multipart_uploads_enabled: bool = False, + ): + self.app = Chalice(app_name="TheStackBadger") + + view = GraphQLView( + schema=schema, + graphiql=graphiql, + graphql_ide=graphql_ide, + allow_queries_via_get=allow_queries_via_get, + ) + view.result_override = result_override + + @self.app.route( + "/graphql", methods=["GET", "POST"], content_types=["application/json"] + ) + def handle_graphql(): + assert self.app.current_request is not None + return view.execute_request(self.app.current_request) + + async def _graphql_request( + self, + method: Literal["get", "post"], + query: Optional[str] = None, + operation_name: Optional[str] = None, + variables: Optional[dict[str, object]] = None, + files: Optional[dict[str, BytesIO]] = None, + headers: Optional[dict[str, str]] = None, + extensions: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> Response: + body = self._build_body( + query=query, + operation_name=operation_name, + variables=variables, + files=files, + method=method, + extensions=extensions, + ) + + data: Union[dict[str, object], str, None] = None + + if body and files: + body.update({name: (file, name) for name, file in files.items()}) + + url = "/graphql" + + if method == "get": + body_encoded = urllib.parse.urlencode(body or {}) + url = f"{url}?{body_encoded}" + else: + if body: + data = body if files else dumps(body) + kwargs["body"] = data + + with Client(self.app) as client: + response = getattr(client.http, method)( + url, + headers=self._get_headers(method=method, headers=headers, files=files), + **kwargs, + ) + + return Response( + status_code=response.status_code, + data=response.body, + headers=response.headers, + ) + + async def request( + self, + url: str, + method: Literal["head", "get", "post", "patch", "put", "delete"], + headers: Optional[dict[str, str]] = None, + ) -> Response: + with Client(self.app) as client: + response = getattr(client.http, method)(url, headers=headers) + + return Response( + status_code=response.status_code, + data=response.body, + headers=response.headers, + ) + + async def get( + self, + url: str, + headers: Optional[dict[str, str]] = None, + ) -> Response: + return await self.request(url, "get", headers=headers) + + async def post( + self, + url: str, + data: Optional[bytes] = None, + json: Optional[JSON] = None, + headers: Optional[dict[str, str]] = None, + ) -> Response: + body = dumps(json) if json is not None else data + + with Client(self.app) as client: + response = client.http.post(url, headers=headers, body=body) + + return Response( + status_code=response.status_code, + data=response.body, + headers=response.headers, + ) diff --git a/src/tests/http/clients/channels.py b/src/tests/http/clients/channels.py new file mode 100644 index 0000000..a134b0a --- /dev/null +++ b/src/tests/http/clients/channels.py @@ -0,0 +1,364 @@ +from __future__ import annotations + +import contextlib +import json as json_module +from collections.abc import AsyncGenerator, Mapping, Sequence +from datetime import timedelta +from io import BytesIO +from typing import Any, Optional +from typing_extensions import Literal + +from graphql import ExecutionResult +from urllib3 import encode_multipart_formdata + +from channels.testing import HttpCommunicator, WebsocketCommunicator +from graphql_server.channels import ( + GraphQLHTTPConsumer, + GraphQLWSConsumer, + SyncGraphQLHTTPConsumer, +) +from graphql_server.channels.handlers.http_handler import ChannelsRequest +from graphql_server.http import GraphQLHTTPResponse +from graphql_server.http.ides import GraphQL_IDE +from graphql_server.http.temporal_response import TemporalResponse +from graphql_server.subscriptions import ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, +) +from tests.http.context import get_context +from tests.views.schema import Query, schema +from tests.websockets.views import OnWSConnectMixin + +from .base import ( + JSON, + DebuggableGraphQLTransportWSHandler, + DebuggableGraphQLWSHandler, + HttpClient, + Message, + Response, + ResultOverrideFunction, + WebSocketClient, +) + + +def generate_get_path( + path: str, + query: str, + variables: Optional[dict[str, Any]] = None, + extensions: Optional[dict[str, Any]] = None, +) -> str: + body: dict[str, Any] = {"query": query} + if variables is not None: + body["variables"] = json_module.dumps(variables) + if extensions is not None: + body["extensions"] = json_module.dumps(extensions) + + parts = [f"{k}={v}" for k, v in body.items()] + return f"{path}?{'&'.join(parts)}" + + +def create_multipart_request_body( + body: dict[str, object], files: dict[str, BytesIO] +) -> tuple[list[tuple[str, str]], bytes]: + fields = { + "operations": body["operations"], + "map": body["map"], + } + + for filename, data in files.items(): + fields[filename] = (filename, data.read().decode(), "text/plain") + + request_body, content_type_header = encode_multipart_formdata(fields) + + headers = [ + ("Content-Type", content_type_header), + ("Content-Length", f"{len(request_body)}"), + ] + + return headers, request_body + + +class DebuggableGraphQLHTTPConsumer(GraphQLHTTPConsumer[dict[str, object], object]): + result_override: ResultOverrideFunction = None + + def __init__(self, *args: Any, **kwargs: Any): + self.result_override = kwargs.pop("result_override") + super().__init__(*args, **kwargs) + + async def get_root_value(self, request: ChannelsRequest): + return Query() + + async def get_context(self, request: ChannelsRequest, response: TemporalResponse): + context = await super().get_context(request, response) + + return get_context(context) + + async def process_result( + self, request: ChannelsRequest, result: ExecutionResult, strict: bool = False + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return await super().process_result(request, result, strict) + + +class DebuggableSyncGraphQLHTTPConsumer( + SyncGraphQLHTTPConsumer[dict[str, object], object] +): + result_override: ResultOverrideFunction = None + + def __init__(self, *args: Any, **kwargs: Any): + self.result_override = kwargs.pop("result_override") + super().__init__(*args, **kwargs) + + def get_root_value(self, request: ChannelsRequest): + return Query() + + def get_context(self, request: ChannelsRequest, response: TemporalResponse): + context = super().get_context(request, response) + + return get_context(context) + + def process_result( + self, request: ChannelsRequest, result: ExecutionResult, strict: bool = False + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return super().process_result(request, result, strict) + + +class DebuggableGraphQLWSConsumer( + OnWSConnectMixin, GraphQLWSConsumer[dict[str, object], object] +): + graphql_transport_ws_handler_class = DebuggableGraphQLTransportWSHandler + graphql_ws_handler_class = DebuggableGraphQLWSHandler + + async def get_context( + self, request: GraphQLWSConsumer, response: GraphQLWSConsumer + ): + context = await super().get_context(request, response) + + return get_context(context) + + +class ChannelsHttpClient(HttpClient): + """A client to test websockets over channels.""" + + def __init__( + self, + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + keep_alive: bool = False, + keep_alive_interval: float = 1, + debug: bool = False, + subscription_protocols: Sequence[str] = ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, + ), + connection_init_wait_timeout: timedelta = timedelta(minutes=1), + result_override: ResultOverrideFunction = None, + multipart_uploads_enabled: bool = False, + ): + self.ws_app = DebuggableGraphQLWSConsumer.as_asgi( + schema=schema, + keep_alive=keep_alive, + keep_alive_interval=keep_alive_interval, + debug=debug, + subscription_protocols=subscription_protocols, + connection_init_wait_timeout=connection_init_wait_timeout, + ) + + self.http_app = DebuggableGraphQLHTTPConsumer.as_asgi( + schema=schema, + graphiql=graphiql, + graphql_ide=graphql_ide, + allow_queries_via_get=allow_queries_via_get, + result_override=result_override, + multipart_uploads_enabled=multipart_uploads_enabled, + ) + + async def _graphql_request( + self, + method: Literal["get", "post"], + query: Optional[str] = None, + operation_name: Optional[str] = None, + variables: Optional[dict[str, object]] = None, + files: Optional[dict[str, BytesIO]] = None, + headers: Optional[dict[str, str]] = None, + extensions: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> Response: + body = self._build_body( + query=query, + operation_name=operation_name, + variables=variables, + files=files, + method=method, + extensions=extensions, + ) + + headers = self._get_headers(method=method, headers=headers, files=files) + + if method == "post": + if body and files: + header_pairs, body = create_multipart_request_body(body, files) + headers = dict(header_pairs) + else: + body = json_module.dumps(body).encode() + endpoint_url = "/graphql" + else: + body = b"" + endpoint_url = generate_get_path("/graphql", query, variables, extensions) + + return await self.request( + url=endpoint_url, method=method, body=body, headers=headers + ) + + async def request( + self, + url: str, + method: Literal["head", "get", "post", "patch", "put", "delete"], + headers: Optional[dict[str, str]] = None, + body: bytes = b"", + ) -> Response: + # HttpCommunicator expects tuples of bytestrings + header_tuples = ( + [(k.encode(), v.encode()) for k, v in headers.items()] if headers else [] + ) + + communicator = HttpCommunicator( + self.http_app, + method.upper(), + url, + body=body, + headers=header_tuples, + ) + response = await communicator.get_response() + + return Response( + status_code=response["status"], + data=response["body"], + headers={k.decode(): v.decode() for k, v in response["headers"]}, + ) + + async def get( + self, + url: str, + headers: Optional[dict[str, str]] = None, + ) -> Response: + return await self.request(url, "get", headers=headers) + + async def post( + self, + url: str, + data: Optional[bytes] = None, + json: Optional[JSON] = None, + headers: Optional[dict[str, str]] = None, + ) -> Response: + body = b"" + if data is not None: + body = data + elif json is not None: + body = json_module.dumps(json).encode() + return await self.request(url, "post", body=body, headers=headers) + + @contextlib.asynccontextmanager + async def ws_connect( + self, + url: str, + *, + protocols: list[str], + ) -> AsyncGenerator[WebSocketClient, None]: + client = WebsocketCommunicator(self.ws_app, url, subprotocols=protocols) + + connected, subprotocol_or_close_code = await client.connect() + assert connected + + try: + yield ChannelsWebSocketClient( + client, accepted_subprotocol=subprotocol_or_close_code + ) + finally: + await client.disconnect() + + +class SyncChannelsHttpClient(ChannelsHttpClient): + def __init__( + self, + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + result_override: ResultOverrideFunction = None, + multipart_uploads_enabled: bool = False, + ): + self.http_app = DebuggableSyncGraphQLHTTPConsumer.as_asgi( + schema=schema, + graphiql=graphiql, + graphql_ide=graphql_ide, + allow_queries_via_get=allow_queries_via_get, + result_override=result_override, + multipart_uploads_enabled=multipart_uploads_enabled, + ) + + +class ChannelsWebSocketClient(WebSocketClient): + def __init__( + self, client: WebsocketCommunicator, accepted_subprotocol: Optional[str] + ): + self.ws = client + self._closed: bool = False + self._close_code: Optional[int] = None + self._close_reason: Optional[str] = None + self._accepted_subprotocol = accepted_subprotocol + + def name(self) -> str: + return "channels" + + async def send_text(self, payload: str) -> None: + await self.ws.send_to(text_data=payload) + + async def send_json(self, payload: Mapping[str, object]) -> None: + await self.ws.send_json_to(payload) + + async def send_bytes(self, payload: bytes) -> None: + await self.ws.send_to(bytes_data=payload) + + async def receive(self, timeout: Optional[float] = None) -> Message: + m = await self.ws.receive_output(timeout=timeout) # type: ignore + if m["type"] == "websocket.close": + self._closed = True + self._close_code = m["code"] + self._close_reason = m.get("reason") + return Message(type=m["type"], data=m["code"], extra=m.get("reason")) + if m["type"] == "websocket.send": + return Message(type=m["type"], data=m["text"]) + return Message(type=m["type"], data=m["data"], extra=m["extra"]) + + async def receive_json(self, timeout: Optional[float] = None) -> Any: + m = await self.ws.receive_output(timeout=timeout) # type: ignore + assert m["type"] == "websocket.send" + assert "text" in m + return json_module.loads(m["text"]) + + async def close(self) -> None: + await self.ws.disconnect() + self._closed = True + + @property + def accepted_subprotocol(self) -> Optional[str]: + return self._accepted_subprotocol + + @property + def closed(self) -> bool: + return self._closed + + @property + def close_code(self) -> int: + assert self._close_code is not None + return self._close_code + + @property + def close_reason(self) -> Optional[str]: + return self._close_reason diff --git a/src/tests/http/clients/django.py b/src/tests/http/clients/django.py new file mode 100644 index 0000000..262c3b9 --- /dev/null +++ b/src/tests/http/clients/django.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from io import BytesIO +from json import dumps +from typing import Any, Optional, Union +from typing_extensions import Literal + +from django.core.exceptions import BadRequest, SuspiciousOperation +from django.core.files.uploadedfile import SimpleUploadedFile +from django.http import Http404, HttpRequest, HttpResponse +from django.test.client import RequestFactory +from graphql import ExecutionResult + +from graphql_server.django.views import GraphQLView as BaseGraphQLView +from graphql_server.http import GraphQLHTTPResponse +from graphql_server.http.ides import GraphQL_IDE +from tests.http.context import get_context +from tests.views.schema import Query, schema + +from .base import JSON, HttpClient, Response, ResultOverrideFunction + + +class GraphQLView(BaseGraphQLView[dict[str, object], object]): + result_override: ResultOverrideFunction = None + + def get_root_value(self, request) -> Query: + super().get_root_value(request) # for coverage + return Query() + + def get_context( + self, request: HttpRequest, response: HttpResponse + ) -> dict[str, object]: + context = {"request": request, "response": response} + + return get_context(context) + + def process_result( + self, request: HttpRequest, result: ExecutionResult, strict: bool = False + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return super().process_result(request, result, strict) + + +class DjangoHttpClient(HttpClient): + def __init__( + self, + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + result_override: ResultOverrideFunction = None, + multipart_uploads_enabled: bool = False, + ): + self.view = GraphQLView.as_view( + schema=schema, + graphiql=graphiql, + graphql_ide=graphql_ide, + allow_queries_via_get=allow_queries_via_get, + result_override=result_override, + multipart_uploads_enabled=multipart_uploads_enabled, + ) + + def _get_header_name(self, key: str) -> str: + return f"HTTP_{key.upper().replace('-', '_')}" + + def _to_django_headers(self, headers: dict[str, str]) -> dict[str, str]: + return {self._get_header_name(key): value for key, value in headers.items()} + + def _get_headers( + self, + method: Literal["get", "post"], + headers: Optional[dict[str, str]], + files: Optional[dict[str, BytesIO]], + ) -> dict[str, str]: + headers = headers or {} + headers = self._to_django_headers(headers) + + return super()._get_headers(method=method, headers=headers, files=files) + + async def _do_request(self, request: HttpRequest) -> Response: + try: + response = self.view(request) + except Http404: + return Response(status_code=404, data=b"Not found") + except (BadRequest, SuspiciousOperation) as e: + return Response(status_code=400, data=e.args[0].encode()) + else: + return Response( + status_code=response.status_code, + data=response.content, + headers=dict(response.headers), + ) + + async def _graphql_request( + self, + method: Literal["get", "post"], + query: Optional[str] = None, + operation_name: Optional[str] = None, + variables: Optional[dict[str, object]] = None, + files: Optional[dict[str, BytesIO]] = None, + headers: Optional[dict[str, str]] = None, + extensions: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> Response: + headers = self._get_headers(method=method, headers=headers, files=files) + additional_arguments = {**kwargs, **headers} + + body = self._build_body( + query=query, + operation_name=operation_name, + variables=variables, + files=files, + method=method, + extensions=extensions, + ) + + data: Union[dict[str, object], str, None] = None + + if body and files: + body.update( + { + name: SimpleUploadedFile(name, file.read()) + for name, file in files.items() + } + ) + else: + additional_arguments["content_type"] = "application/json" + + if body: + data = body if files or method == "get" else dumps(body) + + factory = RequestFactory() + request = getattr(factory, method)( + "/graphql", + data=data, + **additional_arguments, + ) + + return await self._do_request(request) + + async def request( + self, + url: str, + method: Literal["head", "get", "post", "patch", "put", "delete"], + headers: Optional[dict[str, str]] = None, + ) -> Response: + headers = headers or {} + + factory = RequestFactory() + request = getattr(factory, method)(url, **headers) + + return await self._do_request(request) + + async def get( + self, + url: str, + headers: Optional[dict[str, str]] = None, + ) -> Response: + django_headers = self._to_django_headers(headers or {}) + return await self.request(url, "get", headers=django_headers) + + async def post( + self, + url: str, + data: Optional[bytes] = None, + json: Optional[JSON] = None, + headers: Optional[dict[str, str]] = None, + ) -> Response: + django_headers = self._to_django_headers(headers or {}) + content_type = django_headers.pop("HTTP_CONTENT_TYPE", "") + + body = dumps(json) if json is not None else data + + factory = RequestFactory() + request = factory.post( + url, + data=body, + content_type=content_type, + headers=django_headers, + ) + + return await self._do_request(request) diff --git a/src/tests/http/clients/fastapi.py b/src/tests/http/clients/fastapi.py new file mode 100644 index 0000000..76672fb --- /dev/null +++ b/src/tests/http/clients/fastapi.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import contextlib +import json +from collections.abc import AsyncGenerator, Sequence +from datetime import timedelta +from io import BytesIO +from typing import Any, Optional +from typing_extensions import Literal + +from graphql import ExecutionResult + +from fastapi import BackgroundTasks, Depends, FastAPI, Request, WebSocket +from fastapi.testclient import TestClient +from graphql_server.fastapi import GraphQLRouter as BaseGraphQLRouter +from graphql_server.http import GraphQLHTTPResponse +from graphql_server.http.ides import GraphQL_IDE +from graphql_server.subscriptions import ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, +) +from tests.http.context import get_context +from tests.views.schema import Query, schema +from tests.websockets.views import OnWSConnectMixin + +from .asgi import AsgiWebSocketClient +from .base import ( + JSON, + DebuggableGraphQLTransportWSHandler, + DebuggableGraphQLWSHandler, + HttpClient, + Response, + ResultOverrideFunction, + WebSocketClient, +) + + +def custom_context_dependency() -> str: + return "Hi!" + + +def fastapi_get_context( + background_tasks: BackgroundTasks, + request: Request = None, # type: ignore + ws: WebSocket = None, # type: ignore + custom_value: str = Depends(custom_context_dependency), +) -> dict[str, object]: + return get_context( + { + "request": request or ws, + "background_tasks": background_tasks, + } + ) + + +def get_root_value( + request: Request = None, # type: ignore - FastAPI + ws: WebSocket = None, # type: ignore - FastAPI +) -> Query: + return Query() + + +class GraphQLRouter(OnWSConnectMixin, BaseGraphQLRouter[dict[str, object], object]): + result_override: ResultOverrideFunction = None + graphql_transport_ws_handler_class = DebuggableGraphQLTransportWSHandler + graphql_ws_handler_class = DebuggableGraphQLWSHandler + + async def process_result( + self, request: Request, result: ExecutionResult, strict: bool = False + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return await super().process_result(request, result, strict) + + +class FastAPIHttpClient(HttpClient): + def __init__( + self, + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + keep_alive: bool = False, + keep_alive_interval: float = 1, + debug: bool = False, + subscription_protocols: Sequence[str] = ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, + ), + connection_init_wait_timeout: timedelta = timedelta(minutes=1), + result_override: ResultOverrideFunction = None, + multipart_uploads_enabled: bool = False, + ): + self.app = FastAPI() + + graphql_app = GraphQLRouter( + schema, + graphiql=graphiql, + graphql_ide=graphql_ide, + allow_queries_via_get=allow_queries_via_get, + keep_alive=keep_alive, + keep_alive_interval=keep_alive_interval, + debug=debug, + subscription_protocols=subscription_protocols, + connection_init_wait_timeout=connection_init_wait_timeout, + multipart_uploads_enabled=multipart_uploads_enabled, + context_getter=fastapi_get_context, + root_value_getter=get_root_value, + ) + graphql_app.result_override = result_override + self.app.include_router(graphql_app, prefix="/graphql") + + self.client = TestClient(self.app) + + async def _handle_response(self, response: Any) -> Response: + # TODO: here we should handle the stream + return Response( + status_code=response.status_code, + data=response.content, + headers=response.headers, + ) + + async def _graphql_request( + self, + method: Literal["get", "post"], + query: Optional[str] = None, + operation_name: Optional[str] = None, + variables: Optional[dict[str, object]] = None, + files: Optional[dict[str, BytesIO]] = None, + headers: Optional[dict[str, str]] = None, + extensions: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> Response: + body = self._build_body( + query=query, + operation_name=operation_name, + variables=variables, + files=files, + method=method, + extensions=extensions, + ) + + if body: + if method == "get": + kwargs["params"] = body + elif files: + kwargs["data"] = body + else: + kwargs["content"] = json.dumps(body) + + if files: + kwargs["files"] = files + + response = getattr(self.client, method)( + "/graphql", + headers=self._get_headers(method=method, headers=headers, files=files), + **kwargs, + ) + + return await self._handle_response(response) + + async def request( + self, + url: str, + method: Literal["head", "get", "post", "patch", "put", "delete"], + headers: Optional[dict[str, str]] = None, + ) -> Response: + response = getattr(self.client, method)(url, headers=headers) + + return await self._handle_response(response) + + async def get( + self, + url: str, + headers: Optional[dict[str, str]] = None, + ) -> Response: + return await self.request(url, "get", headers=headers) + + async def post( + self, + url: str, + data: Optional[bytes] = None, + json: Optional[JSON] = None, + headers: Optional[dict[str, str]] = None, + ) -> Response: + response = self.client.post(url, headers=headers, content=data, json=json) + + return await self._handle_response(response) + + @contextlib.asynccontextmanager + async def ws_connect( + self, + url: str, + *, + protocols: list[str], + ) -> AsyncGenerator[WebSocketClient, None]: + with self.client.websocket_connect(url, protocols) as ws: + yield AsgiWebSocketClient(ws) diff --git a/src/tests/http/clients/flask.py b/src/tests/http/clients/flask.py new file mode 100644 index 0000000..3f87c70 --- /dev/null +++ b/src/tests/http/clients/flask.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import asyncio +import contextvars +import functools +import json +import urllib.parse +from io import BytesIO +from typing import Any, Optional, Union +from typing_extensions import Literal + +from graphql import ExecutionResult + +from flask import Flask +from flask import Request as FlaskRequest +from flask import Response as FlaskResponse +from graphql_server.flask.views import GraphQLView as BaseGraphQLView +from graphql_server.http import GraphQLHTTPResponse +from graphql_server.http.ides import GraphQL_IDE +from tests.http.context import get_context +from tests.views.schema import Query, schema + +from .base import JSON, HttpClient, Response, ResultOverrideFunction + + +class GraphQLView(BaseGraphQLView[dict[str, object], object]): + # this allows to test our code path for checking the request type + # TODO: we might want to remove our check since it is done by flask + # already + methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"] + + result_override: ResultOverrideFunction = None + + def __init__(self, *args: Any, **kwargs: Any): + self.result_override = kwargs.pop("result_override") + super().__init__(*args, **kwargs) + + def get_root_value(self, request: FlaskRequest) -> object: + super().get_root_value(request) # for coverage + return Query() + + def get_context( + self, request: FlaskRequest, response: FlaskResponse + ) -> dict[str, object]: + context = super().get_context(request, response) + + return get_context(context) + + def process_result( + self, request: FlaskRequest, result: ExecutionResult, strict: bool = False + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return super().process_result(request, result, strict) + + +class FlaskHttpClient(HttpClient): + def __init__( + self, + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + result_override: ResultOverrideFunction = None, + multipart_uploads_enabled: bool = False, + ): + self.app = Flask(__name__) + self.app.debug = True + + view = GraphQLView.as_view( + "graphql_view", + schema=schema, + graphiql=graphiql, + graphql_ide=graphql_ide, + allow_queries_via_get=allow_queries_via_get, + result_override=result_override, + multipart_uploads_enabled=multipart_uploads_enabled, + ) + + self.app.add_url_rule( + "/graphql", + view_func=view, + ) + + async def _graphql_request( + self, + method: Literal["get", "post"], + query: Optional[str] = None, + operation_name: Optional[str] = None, + variables: Optional[dict[str, object]] = None, + files: Optional[dict[str, BytesIO]] = None, + headers: Optional[dict[str, str]] = None, + extensions: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> Response: + body = self._build_body( + query=query, + operation_name=operation_name, + variables=variables, + files=files, + method=method, + extensions=extensions, + ) + + data: Union[dict[str, object], str, None] = None + + if body and files: + body.update({name: (file, name) for name, file in files.items()}) + + url = "/graphql" + + if method == "get": + body_encoded = urllib.parse.urlencode(body or {}) + url = f"{url}?{body_encoded}" + else: + if body: + data = body if files else json.dumps(body) + kwargs["data"] = data + + headers = self._get_headers(method=method, headers=headers, files=files) + + return await self.request(url, method, headers=headers, **kwargs) + + def _do_request( + self, + url: str, + method: Literal["get", "post", "patch", "put", "delete"], + headers: Optional[dict[str, str]] = None, + **kwargs: Any, + ): + with self.app.test_client() as client: + response = getattr(client, method)(url, headers=headers, **kwargs) + + return Response( + status_code=response.status_code, + data=response.data, + headers=response.headers, + ) + + async def request( + self, + url: str, + method: Literal["head", "get", "post", "patch", "put", "delete"], + headers: Optional[dict[str, str]] = None, + **kwargs: Any, + ) -> Response: + loop = asyncio.get_running_loop() + ctx = contextvars.copy_context() + func_call = functools.partial( + ctx.run, self._do_request, url=url, method=method, headers=headers, **kwargs + ) + return await loop.run_in_executor(None, func_call) # type: ignore + + async def get( + self, + url: str, + headers: Optional[dict[str, str]] = None, + ) -> Response: + return await self.request(url, "get", headers=headers) + + async def post( + self, + url: str, + data: Optional[bytes] = None, + json: Optional[JSON] = None, + headers: Optional[dict[str, str]] = None, + ) -> Response: + return await self.request(url, "post", headers=headers, data=data, json=json) diff --git a/src/tests/http/clients/litestar.py b/src/tests/http/clients/litestar.py new file mode 100644 index 0000000..8317364 --- /dev/null +++ b/src/tests/http/clients/litestar.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +import contextlib +import json +from collections.abc import AsyncGenerator, Mapping, Sequence +from datetime import timedelta +from io import BytesIO +from typing import Any, Optional +from typing_extensions import Literal + +from graphql import ExecutionResult + +from graphql_server.http import GraphQLHTTPResponse +from graphql_server.http.ides import GraphQL_IDE +from graphql_server.litestar import make_graphql_controller +from graphql_server.subscriptions import ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, +) +from litestar import Litestar, Request +from litestar.exceptions import WebSocketDisconnect +from litestar.testing import TestClient +from litestar.testing.websocket_test_session import WebSocketTestSession +from tests.http.context import get_context +from tests.views.schema import Query, schema +from tests.websockets.views import OnWSConnectMixin + +from .base import ( + JSON, + DebuggableGraphQLTransportWSHandler, + DebuggableGraphQLWSHandler, + HttpClient, + Message, + Response, + ResultOverrideFunction, + WebSocketClient, +) + + +def custom_context_dependency() -> str: + return "Hi!" + + +async def litestar_get_context(request: Request = None): + return get_context({"request": request}) + + +async def get_root_value(request: Request = None): + return Query() + + +class LitestarHttpClient(HttpClient): + def __init__( + self, + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + keep_alive: bool = False, + keep_alive_interval: float = 1, + debug: bool = False, + subscription_protocols: Sequence[str] = ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, + ), + connection_init_wait_timeout: timedelta = timedelta(minutes=1), + result_override: ResultOverrideFunction = None, + multipart_uploads_enabled: bool = False, + ): + BaseGraphQLController = make_graphql_controller( + schema=schema, + graphiql=graphiql, + graphql_ide=graphql_ide, + allow_queries_via_get=allow_queries_via_get, + keep_alive=keep_alive, + keep_alive_interval=keep_alive_interval, + debug=debug, + subscription_protocols=subscription_protocols, + connection_init_wait_timeout=connection_init_wait_timeout, + multipart_uploads_enabled=multipart_uploads_enabled, + path="/graphql", + context_getter=litestar_get_context, + root_value_getter=get_root_value, + ) + + class GraphQLController(OnWSConnectMixin, BaseGraphQLController): + graphql_transport_ws_handler_class = DebuggableGraphQLTransportWSHandler + graphql_ws_handler_class = DebuggableGraphQLWSHandler + + async def process_result( + self, request: Request, result: ExecutionResult, strict: bool = False + ) -> GraphQLHTTPResponse: + if result_override: + return result_override(result) + + return await super().process_result(request, result, strict) + + self.app = Litestar(route_handlers=[GraphQLController]) + self.client = TestClient(self.app) + + async def _graphql_request( + self, + method: Literal["get", "post"], + query: Optional[str] = None, + operation_name: Optional[str] = None, + variables: Optional[dict[str, object]] = None, + files: Optional[dict[str, BytesIO]] = None, + headers: Optional[dict[str, str]] = None, + extensions: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> Response: + if body := self._build_body( + query=query, + operation_name=operation_name, + variables=variables, + files=files, + method=method, + extensions=extensions, + ): + if method == "get": + kwargs["params"] = body + elif files: + kwargs["data"] = body + else: + kwargs["content"] = json.dumps(body) + + if files: + kwargs["files"] = files + + response = getattr(self.client, method)( + "/graphql", + headers=self._get_headers(method=method, headers=headers, files=files), + **kwargs, + ) + + return Response( + status_code=response.status_code, + data=response.content, + headers=response.headers, + ) + + async def request( + self, + url: str, + method: Literal["head", "get", "post", "patch", "put", "delete"], + headers: Optional[dict[str, str]] = None, + ) -> Response: + response = getattr(self.client, method)(url, headers=headers) + + return Response( + status_code=response.status_code, + data=response.content, + headers=response.headers, + ) + + async def get( + self, + url: str, + headers: Optional[dict[str, str]] = None, + ) -> Response: + return await self.request(url, "get", headers=headers) + + async def post( + self, + url: str, + data: Optional[bytes] = None, + json: Optional[JSON] = None, + headers: Optional[dict[str, str]] = None, + ) -> Response: + response = self.client.post(url, headers=headers, content=data, json=json) + + return Response( + status_code=response.status_code, + data=response.content, + headers=dict(response.headers), + ) + + @contextlib.asynccontextmanager + async def ws_connect( + self, + url: str, + *, + protocols: list[str], + ) -> AsyncGenerator[WebSocketClient, None]: + with self.client.websocket_connect(url, protocols) as ws: + yield LitestarWebSocketClient(ws) + + +class LitestarWebSocketClient(WebSocketClient): + def __init__(self, ws: WebSocketTestSession): + self.ws = ws + self._closed: bool = False + self._close_code: Optional[int] = None + self._close_reason: Optional[str] = None + + async def send_text(self, payload: str) -> None: + self.ws.send_text(payload) + + async def send_json(self, payload: Mapping[str, object]) -> None: + self.ws.send_json(payload) + + async def send_bytes(self, payload: bytes) -> None: + self.ws.send_bytes(payload) + + async def receive(self, timeout: Optional[float] = None) -> Message: + if self._closed: + # if close was received via exception, fake it so that recv works + return Message( + type="websocket.close", data=self._close_code, extra=self._close_reason + ) + try: + m = self.ws.receive() + except WebSocketDisconnect as exc: + self._closed = True + self._close_code = exc.code + self._close_reason = exc.detail + return Message(type="websocket.close", data=exc.code, extra=exc.detail) + if m["type"] == "websocket.close": + # Probably never happens + self._closed = True + self._close_code = m["code"] + self._close_reason = m["reason"] + return Message(type=m["type"], data=m["code"], extra=m["reason"]) + if m["type"] == "websocket.send": + return Message(type=m["type"], data=m["text"]) + + assert "data" in m + return Message(type=m["type"], data=m["data"], extra=m["extra"]) + + async def receive_json(self, timeout: Optional[float] = None) -> Any: + m = self.ws.receive() + assert m["type"] == "websocket.send" + assert "text" in m + assert m["text"] is not None + return json.loads(m["text"]) + + async def close(self) -> None: + self.ws.close() + self._closed = True + + @property + def accepted_subprotocol(self) -> Optional[str]: + return self.ws.accepted_subprotocol + + @property + def closed(self) -> bool: + return self._closed + + @property + def close_code(self) -> int: + assert self._close_code is not None + return self._close_code + + @property + def close_reason(self) -> Optional[str]: + return self._close_reason diff --git a/src/tests/http/clients/quart.py b/src/tests/http/clients/quart.py new file mode 100644 index 0000000..b92920d --- /dev/null +++ b/src/tests/http/clients/quart.py @@ -0,0 +1,220 @@ +import contextlib +import json +import urllib.parse +from collections.abc import AsyncGenerator, Sequence +from datetime import timedelta +from io import BytesIO +from typing import Any, Optional, Union +from typing_extensions import Literal + +from graphql import ExecutionResult +from starlette.testclient import TestClient +from starlette.types import Receive, Scope, Send + +from graphql_server.http import GraphQLHTTPResponse +from graphql_server.http.ides import GraphQL_IDE +from graphql_server.quart.views import GraphQLView as BaseGraphQLView +from graphql_server.subscriptions import ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, +) +from quart import Quart +from quart import Request as QuartRequest +from quart import Response as QuartResponse +from quart import Websocket as QuartWebsocket +from quart.datastructures import FileStorage +from tests.http.context import get_context +from tests.views.schema import Query, schema +from tests.websockets.views import OnWSConnectMixin + +from .asgi import AsgiWebSocketClient +from .base import ( + JSON, + DebuggableGraphQLTransportWSHandler, + DebuggableGraphQLWSHandler, + HttpClient, + Response, + ResultOverrideFunction, + WebSocketClient, +) + + +class GraphQLView(OnWSConnectMixin, BaseGraphQLView[dict[str, object], object]): + methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"] + result_override: ResultOverrideFunction = None + graphql_transport_ws_handler_class = DebuggableGraphQLTransportWSHandler + graphql_ws_handler_class = DebuggableGraphQLWSHandler + + def __init__(self, *args: Any, **kwargs: Any): + self.result_override = kwargs.pop("result_override", None) + super().__init__(*args, **kwargs) + + async def get_root_value( + self, request: Union[QuartRequest, QuartWebsocket] + ) -> Query: + await super().get_root_value(request) # for coverage + return Query() + + async def get_context( + self, request: Union[QuartRequest, QuartWebsocket], response: QuartResponse + ) -> dict[str, object]: + context = await super().get_context(request, response) + + return get_context(context) + + async def process_result( + self, request: QuartRequest, result: ExecutionResult, strict: bool = False + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return await super().process_result(request, result, strict) + + +class QuartAsgiAppAdapter: + def __init__(self, app: Quart): + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + scope["asgi"] = scope.get("asgi", {}) + + # Our WebSocket tests depend on WebSocket close reasons. + # Quart only sends close reason if the ASGI spec version in the scope is => 2.3 + # https://github.com/pallets/quart/blob/b5593ca4c8c657564cdf2d35c9f0298fce63636b/src/quart/asgi.py#L347-L348 + scope["asgi"]["spec_version"] = "2.3" + + await self.app(scope, receive, send) # type: ignore + + +class QuartHttpClient(HttpClient): + def __init__( + self, + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + keep_alive: bool = False, + keep_alive_interval: float = 1, + debug: bool = False, + subscription_protocols: Sequence[str] = ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, + ), + connection_init_wait_timeout: timedelta = timedelta(minutes=1), + result_override: ResultOverrideFunction = None, + multipart_uploads_enabled: bool = False, + ): + self.app = Quart(__name__) + self.app.debug = True + + view = GraphQLView.as_view( + "graphql_view", + schema=schema, + graphiql=graphiql, + graphql_ide=graphql_ide, + allow_queries_via_get=allow_queries_via_get, + result_override=result_override, + keep_alive=keep_alive, + keep_alive_interval=keep_alive_interval, + debug=debug, + subscription_protocols=subscription_protocols, + connection_init_wait_timeout=connection_init_wait_timeout, + multipart_uploads_enabled=multipart_uploads_enabled, + ) + + self.app.add_url_rule( + "/graphql", + view_func=view, + ) + + self.app.add_url_rule( + "/graphql", + view_func=view, + methods=["GET"], + websocket=True, + ) + + self.client = TestClient(QuartAsgiAppAdapter(self.app)) + + async def _graphql_request( + self, + method: Literal["get", "post"], + query: Optional[str] = None, + operation_name: Optional[str] = None, + variables: Optional[dict[str, object]] = None, + files: Optional[dict[str, BytesIO]] = None, + headers: Optional[dict[str, str]] = None, + extensions: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> Response: + body = self._build_body( + query=query, + operation_name=operation_name, + variables=variables, + files=files, + method=method, + extensions=extensions, + ) + + url = "/graphql" + + if method == "get": + body_encoded = urllib.parse.urlencode(body or {}) + url = f"{url}?{body_encoded}" + elif body: + if files: + kwargs["form"] = body + kwargs["files"] = { + k: FileStorage(v, filename=k) for k, v in files.items() + } + else: + kwargs["data"] = json.dumps(body) + + headers = self._get_headers(method=method, headers=headers, files=files) + + return await self.request(url, method, headers=headers, **kwargs) + + async def request( + self, + url: str, + method: Literal["head", "get", "post", "patch", "put", "delete"], + headers: Optional[dict[str, str]] = None, + **kwargs: Any, + ) -> Response: + async with self.app.test_app() as test_app, self.app.app_context(): + client = test_app.test_client() + response = await getattr(client, method)(url, headers=headers, **kwargs) + + return Response( + status_code=response.status_code, + data=(await response.data), + headers=response.headers, + ) + + async def get( + self, + url: str, + headers: Optional[dict[str, str]] = None, + ) -> Response: + return await self.request(url, "get", headers=headers) + + async def post( + self, + url: str, + data: Optional[bytes] = None, + json: Optional[JSON] = None, + headers: Optional[dict[str, str]] = None, + ) -> Response: + kwargs = {"headers": headers, "data": data, "json": json} + return await self.request( + url, "post", **{k: v for k, v in kwargs.items() if v is not None} + ) + + @contextlib.asynccontextmanager + async def ws_connect( + self, + url: str, + *, + protocols: list[str], + ) -> AsyncGenerator[WebSocketClient, None]: + with self.client.websocket_connect(url, protocols) as ws: + yield AsgiWebSocketClient(ws) diff --git a/src/tests/http/clients/sanic.py b/src/tests/http/clients/sanic.py new file mode 100644 index 0000000..835bc31 --- /dev/null +++ b/src/tests/http/clients/sanic.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from io import BytesIO +from json import dumps +from random import randint +from typing import Any, Optional +from typing_extensions import Literal + +from graphql import ExecutionResult + +from graphql_server.http import GraphQLHTTPResponse +from graphql_server.http.ides import GraphQL_IDE +from graphql_server.http.temporal_response import TemporalResponse +from graphql_server.sanic.views import GraphQLView as BaseGraphQLView +from sanic import Sanic +from sanic.request import Request as SanicRequest +from tests.http.context import get_context +from tests.views.schema import Query, schema + +from .base import JSON, HttpClient, Response, ResultOverrideFunction + + +class GraphQLView(BaseGraphQLView[object, Query]): + result_override: ResultOverrideFunction = None + + def __init__(self, *args: Any, **kwargs: Any): + self.result_override = kwargs.pop("result_override") + super().__init__(*args, **kwargs) + + async def get_root_value(self, request: SanicRequest) -> Query: + await super().get_root_value(request) # for coverage + return Query() + + async def get_context( + self, request: SanicRequest, response: TemporalResponse + ) -> object: + context = await super().get_context(request, response) + + return get_context(context) + + async def process_result( + self, request: SanicRequest, result: ExecutionResult, strict: bool = False + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return await super().process_result(request, result, strict) + + +class SanicHttpClient(HttpClient): + def __init__( + self, + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + result_override: ResultOverrideFunction = None, + multipart_uploads_enabled: bool = False, + ): + self.app = Sanic( + f"test_{int(randint(0, 1000))}", # noqa: S311 + ) + view = GraphQLView.as_view( + schema=schema, + graphiql=graphiql, + graphql_ide=graphql_ide, + allow_queries_via_get=allow_queries_via_get, + result_override=result_override, + multipart_uploads_enabled=multipart_uploads_enabled, + ) + self.app.add_route(view, "/graphql") + + async def _graphql_request( + self, + method: Literal["get", "post"], + query: Optional[str] = None, + operation_name: Optional[str] = None, + variables: Optional[dict[str, object]] = None, + files: Optional[dict[str, BytesIO]] = None, + headers: Optional[dict[str, str]] = None, + extensions: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> Response: + body = self._build_body( + query=query, + operation_name=operation_name, + variables=variables, + files=files, + method=method, + extensions=extensions, + ) + + if body: + if method == "get": + kwargs["params"] = body + elif files: + kwargs["data"] = body + else: + kwargs["content"] = dumps(body) + + request, response = await self.app.asgi_client.request( + method, + "/graphql", + headers=self._get_headers(method=method, headers=headers, files=files), + files=files, + **kwargs, + ) + + return Response( + status_code=response.status_code, + data=response.content, + headers=response.headers, + ) + + async def request( + self, + url: str, + method: Literal["head", "get", "post", "patch", "put", "delete"], + headers: Optional[dict[str, str]] = None, + ) -> Response: + request, response = await self.app.asgi_client.request( + method, + url, + headers=headers, + ) + + return Response( + status_code=response.status_code, + data=response.content, + headers=response.headers, + ) + + async def get( + self, + url: str, + headers: Optional[dict[str, str]] = None, + ) -> Response: + return await self.request(url, "get", headers=headers) + + async def post( + self, + url: str, + data: Optional[bytes] = None, + json: Optional[JSON] = None, + headers: Optional[dict[str, str]] = None, + ) -> Response: + body = dumps(json) if json is not None else data + + request, response = await self.app.asgi_client.request( + "post", url, content=body, headers=headers + ) + + return Response( + status_code=response.status_code, + data=response.content, + headers=response.headers, + ) diff --git a/src/tests/http/clients/webob.py b/src/tests/http/clients/webob.py new file mode 100644 index 0000000..26e5fc1 --- /dev/null +++ b/src/tests/http/clients/webob.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import asyncio +import contextvars +import functools +import json +import urllib.parse +from io import BytesIO +from typing import Any, Optional, Union +from typing_extensions import Literal + +from graphql import ExecutionResult +from webob import Request, Response + +from graphql_server.http import GraphQLHTTPResponse +from graphql_server.http.ides import GraphQL_IDE +from graphql_server.webob import GraphQLView as BaseGraphQLView +from tests.http.context import get_context +from tests.views.schema import Query, schema + +from .base import JSON, HttpClient, Response as ClientResponse, ResultOverrideFunction + + +class GraphQLView(BaseGraphQLView[dict[str, object], object]): + result_override: ResultOverrideFunction = None + + def get_root_value(self, request: Request) -> Query: + super().get_root_value(request) # for coverage + return Query() + + def get_context(self, request: Request, response: Response) -> dict[str, object]: + context = super().get_context(request, response) + return get_context(context) + + def process_result( + self, request: Request, result: ExecutionResult, strict: bool = False + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + return super().process_result(request, result, strict) + + +class WebobHttpClient(HttpClient): + def __init__( + self, + graphiql: Optional[bool] = None, + graphql_ide: Optional[GraphQL_IDE] = "graphiql", + allow_queries_via_get: bool = True, + result_override: ResultOverrideFunction = None, + multipart_uploads_enabled: bool = False, + ) -> None: + self.view = GraphQLView( + schema=schema, + graphiql=graphiql, + graphql_ide=graphql_ide, + allow_queries_via_get=allow_queries_via_get, + multipart_uploads_enabled=multipart_uploads_enabled, + ) + self.view.result_override = result_override + + async def _graphql_request( + self, + method: Literal["get", "post"], + query: Optional[str] = None, + operation_name: Optional[str] = None, + variables: Optional[dict[str, object]] = None, + files: Optional[dict[str, BytesIO]] = None, + headers: Optional[dict[str, str]] = None, + extensions: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> ClientResponse: + body = self._build_body( + query=query, + operation_name=operation_name, + variables=variables, + files=files, + method=method, + extensions=extensions, + ) + + data: Union[dict[str, object], str, None] = None + + url = "/graphql" + + if body and files: + body.update({name: (file, name) for name, file in files.items()}) + + if method == "get": + body_encoded = urllib.parse.urlencode(body or {}) + url = f"{url}?{body_encoded}" + else: + if body: + data = body if files else json.dumps(body) + kwargs["body"] = data + + headers = self._get_headers(method=method, headers=headers, files=files) + + return await self.request(url, method, headers=headers, **kwargs) + + def _do_request( + self, + url: str, + method: Literal["get", "post", "patch", "put", "delete"], + headers: Optional[dict[str, str]] = None, + **kwargs: Any, + ) -> ClientResponse: + body = kwargs.get("body", None) + req = Request.blank( + url, method=method.upper(), headers=headers or {}, body=body + ) + resp = self.view.dispatch_request(req) + return ClientResponse( + status_code=resp.status_code, data=resp.body, headers=resp.headers + ) + + async def request( + self, + url: str, + method: Literal["head", "get", "post", "patch", "put", "delete"], + headers: Optional[dict[str, str]] = None, + **kwargs: Any, + ) -> ClientResponse: + loop = asyncio.get_running_loop() + ctx = contextvars.copy_context() + func_call = functools.partial( + ctx.run, self._do_request, url=url, method=method, headers=headers, **kwargs + ) + return await loop.run_in_executor(None, func_call) # type: ignore + + async def get( + self, url: str, headers: Optional[dict[str, str]] = None + ) -> ClientResponse: + return await self.request(url, "get", headers=headers) + + async def post( + self, + url: str, + data: Optional[bytes] = None, + json: Optional[JSON] = None, + headers: Optional[dict[str, str]] = None, + ) -> ClientResponse: + body = json if json is not None else data + return await self.request(url, "post", headers=headers, body=body) diff --git a/src/tests/http/conftest.py b/src/tests/http/conftest.py new file mode 100644 index 0000000..2b7a563 --- /dev/null +++ b/src/tests/http/conftest.py @@ -0,0 +1,57 @@ +import importlib +from collections.abc import Generator +from typing import Any + +import pytest + +from .clients.base import HttpClient + + +def _get_http_client_classes() -> Generator[Any, None, None]: + for client, module, marks in [ + ("AioHttpClient", "aiohttp", [pytest.mark.aiohttp]), + ("AsgiHttpClient", "asgi", [pytest.mark.asgi]), + ("AsyncDjangoHttpClient", "async_django", [pytest.mark.django]), + ("AsyncFlaskHttpClient", "async_flask", [pytest.mark.flask]), + ("ChannelsHttpClient", "channels", [pytest.mark.channels]), + ("ChaliceHttpClient", "chalice", [pytest.mark.chalice]), + ("DjangoHttpClient", "django", [pytest.mark.django]), + ("FastAPIHttpClient", "fastapi", [pytest.mark.fastapi]), + ("FlaskHttpClient", "flask", [pytest.mark.flask]), + ("WebobHttpClient", "webob", [pytest.mark.webob]), + ("QuartHttpClient", "quart", [pytest.mark.quart]), + ("SanicHttpClient", "sanic", [pytest.mark.sanic]), + ("LitestarHttpClient", "litestar", [pytest.mark.litestar]), + ( + "SyncChannelsHttpClient", + "channels", + [pytest.mark.channels, pytest.mark.django_db], + ), + ]: + try: + client_class = getattr( + importlib.import_module(f".{module}", package="tests.http.clients"), + client, + ) + except ImportError: + client_class = None + + yield pytest.param( + client_class, + marks=[ + *marks, + pytest.mark.skipif( + client_class is None, reason=f"Client {client} not found" + ), + ], + ) + + +@pytest.fixture(params=_get_http_client_classes()) +def http_client_class(request: Any) -> type[HttpClient]: + return request.param + + +@pytest.fixture +def http_client(http_client_class: type[HttpClient]) -> HttpClient: + return http_client_class() diff --git a/src/tests/http/context.py b/src/tests/http/context.py new file mode 100644 index 0000000..fbd92e9 --- /dev/null +++ b/src/tests/http/context.py @@ -0,0 +1,4 @@ +def get_context(context: object) -> dict[str, object]: + assert isinstance(context, dict) + + return {**context, "custom_value": "a value from context"} diff --git a/src/tests/http/test_async_base_view.py b/src/tests/http/test_async_base_view.py new file mode 100644 index 0000000..e0c21dc --- /dev/null +++ b/src/tests/http/test_async_base_view.py @@ -0,0 +1,78 @@ +import asyncio +from asyncio import sleep +from collections import Counter +from collections.abc import AsyncGenerator +from random import random +from typing import Any, cast + +import pytest + +from graphql_server.http.async_base_view import AsyncBaseHTTPView + + +@pytest.mark.parametrize( + "expected", + [ + pytest.param(["last"], id="single_item"), + pytest.param(["1st", "last"], id="two_items"), + pytest.param(["1st", "2nd", "last"], id="three_items"), + ], +) +async def test_stream_with_heartbeat_should_yield_items_correctly( + expected: list[str], +) -> None: + """Verifies _stream_with_heartbeat reliably delivers all items in correct order. + + Tests three critical stream properties: + 1. Completeness: All source items appear in output (especially the last item) + 2. Uniqueness: Each expected item appears exactly once + 3. Order: Original sequence of items is preserved + + Uses multiple test cases via parametrization and runs 100 concurrent streams + with randomized delays to stress-test the implementation. This specifically + targets race conditions between the drain task and queue consumer that could + cause missing items, duplicates, or reordering. + """ + assert len(set(expected)) == len(expected), "Test requires unique elements" + + class MockAsyncBaseHTTPView: + def encode_multipart_data(self, *_: Any, **__: Any) -> str: + return "" + + view = MockAsyncBaseHTTPView() + + async def stream() -> AsyncGenerator[str, None]: + for elem in expected: + yield elem + + async def collect() -> list[str]: + result = [] + async for item in AsyncBaseHTTPView._stream_with_heartbeat( + cast("AsyncBaseHTTPView", view), stream, "" + )(): + result.append(item) + # Random sleep to promote race conditions between concurrent tasks + await sleep(random() / 1000) # noqa: S311 + return result + + for actual in await asyncio.gather(*(collect() for _ in range(100))): + # Validation 1: Item completeness + count = Counter(actual) + if missing_items := set(expected) - set(count): + assert not missing_items, f"Missing expected items: {list(missing_items)}" + + # Validation 2: No duplicates + for item in expected: + item_count = count[item] + assert item_count == 1, ( + f"Expected item '{item}' appears {item_count} times (should appear exactly once)" + ) + + # Validation 3: Preserved ordering + item_indices = {item: actual.index(item) for item in expected} + for i in range(len(expected) - 1): + curr, next_item = expected[i], expected[i + 1] + assert item_indices[curr] < item_indices[next_item], ( + f"Order incorrect: '{curr}' (at index {item_indices[curr]}) " + f"should appear before '{next_item}' (at index {item_indices[next_item]})" + ) diff --git a/src/tests/http/test_graphql_ide.py b/src/tests/http/test_graphql_ide.py new file mode 100644 index 0000000..39bbd2b --- /dev/null +++ b/src/tests/http/test_graphql_ide.py @@ -0,0 +1,157 @@ +from typing import Union +from typing_extensions import Literal +from urllib.parse import quote + +import pytest + +from .clients.base import HttpClient + + +@pytest.mark.parametrize( + "header_value", + [ + "text/html", + ], +) +@pytest.mark.parametrize( + "graphql_ide_and_title", + [ + ("graphiql", "GraphiQL"), + ("apollo-sandbox", "Apollo Sandbox"), + ("pathfinder", "GraphQL Pathfinder"), + ], +) +async def test_renders_graphql_ide( + header_value: str, + http_client_class: type[HttpClient], + graphql_ide_and_title: tuple[Literal["graphiql"], Literal["GraphiQL"]] + | tuple[Literal["apollo-sandbox"], Literal["Apollo Sandbox"]] + | tuple[Literal["pathfinder"], Literal["GraphQL Pathfinder"]], +): + graphql_ide, title = graphql_ide_and_title + http_client = http_client_class(graphql_ide=graphql_ide) + + response = await http_client.get("/graphql", headers={"Accept": header_value}) + content_type = response.headers.get( + "content-type", response.headers.get("Content-Type", "") + ) + + assert response.status_code == 200 + assert "text/html" in content_type + assert f"{title}" in response.text + + if graphql_ide == "apollo-sandbox": + assert "embeddable-sandbox.cdn.apollographql" in response.text + + if graphql_ide == "pathfinder": + assert "@pathfinder-ide/react" in response.text + + if graphql_ide == "graphiql": + assert "unpkg.com/graphiql" in response.text + + +@pytest.mark.parametrize( + "header_value", + [ + "text/html", + ], +) +async def test_renders_graphql_ide_deprecated( + header_value: str, http_client_class: type[HttpClient] +): + with pytest.deprecated_call( + match=r"The `graphiql` argument is deprecated in favor of `graphql_ide`" + ): + http_client = http_client_class(graphiql=True) + + response = await http_client.get("/graphql", headers={"Accept": header_value}) + + content_type = response.headers.get( + "content-type", response.headers.get("Content-Type", "") + ) + + assert response.status_code == 200 + assert "text/html" in content_type + assert "GraphiQL" in response.text + + assert "https://unpkg.com/graphiql" in response.text + + +async def test_does_not_render_graphiql_if_wrong_accept( + http_client_class: type[HttpClient], +): + http_client = http_client_class() + response = await http_client.get("/graphql", headers={"Accept": "text/xml"}) + + assert response.status_code != 200 + + +@pytest.mark.parametrize("graphql_ide", [False, None]) +async def test_renders_graphiql_disabled( + http_client_class: type[HttpClient], + graphql_ide: Union[bool, None], +): + http_client = http_client_class(graphql_ide=graphql_ide) + response = await http_client.get("/graphql", headers={"Accept": "text/html"}) + + assert response.status_code != 200 + + +async def test_renders_graphiql_disabled_deprecated( + http_client_class: type[HttpClient], +): + with pytest.deprecated_call( + match=r"The `graphiql` argument is deprecated in favor of `graphql_ide`" + ): + http_client = http_client_class(graphiql=False) + response = await http_client.get("/graphql", headers={"Accept": "text/html"}) + + assert response.status_code != 200 + + +@pytest.mark.parametrize( + "header_value", + [ + "text/html", + ], +) +@pytest.mark.parametrize( + "graphql_ide_and_title", + [ + ("graphiql", "GraphiQL"), + # ("apollo-sandbox", "Apollo Sandbox"), + # ("pathfinder", "GraphQL Pathfinder"), + ], +) +async def test_renders_graphql_ide_with_variables( + header_value: str, + http_client_class: type[HttpClient], + graphql_ide_and_title: tuple[Literal["graphiql"], Literal["GraphiQL"]] + | tuple[Literal["apollo-sandbox"], Literal["Apollo Sandbox"]] + | tuple[Literal["pathfinder"], Literal["GraphQL Pathfinder"]], +): + graphql_ide, title = graphql_ide_and_title + http_client = http_client_class(graphql_ide=graphql_ide) + + query = "query { __typename }" + query_encoded = quote(query) + response = await http_client.get( + f"/graphql?query={query_encoded}", headers={"Accept": header_value} + ) + content_type = response.headers.get( + "content-type", response.headers.get("Content-Type", "") + ) + + assert response.status_code == 200 + assert "text/html" in content_type + assert f"{title}" in response.text + assert "__typename" in response.text + + if graphql_ide == "apollo-sandbox": + assert "embeddable-sandbox.cdn.apollographql" in response.text + + if graphql_ide == "pathfinder": + assert "@pathfinder-ide/react" in response.text + + if graphql_ide == "graphiql": + assert "unpkg.com/graphiql" in response.text diff --git a/src/tests/http/test_graphql_over_http_spec.py b/src/tests/http/test_graphql_over_http_spec.py new file mode 100644 index 0000000..1ce50ef --- /dev/null +++ b/src/tests/http/test_graphql_over_http_spec.py @@ -0,0 +1,648 @@ +"""This file essentially mirrors the GraphQL over HTTP audits: +https://github.com/graphql/graphql-http/blob/main/src/audits/server.ts +""" + +import pytest + +try: + from tests.http.clients.chalice import ChaliceHttpClient +except ImportError: + ChaliceHttpClient = type(None) + +try: + from tests.http.clients.django import DjangoHttpClient +except ImportError: + DjangoHttpClient = type(None) + +try: + from tests.http.clients.sanic import SanicHttpClient +except ImportError: + SanicHttpClient = type(None) + + +async def test_22eb(http_client): + """SHOULD accept application/graphql-response+json and match the content-type""" + response = await http_client.query( + method="post", + headers={ + "Content-Type": "application/json", + "Accept": "application/graphql-response+json", + }, + query="{ __typename }", + ) + assert response.status_code == 200 + assert "application/graphql-response+json" in response.headers["content-type"] + + +async def test_4655(http_client): + """MUST accept application/json and match the content-type""" + response = await http_client.query( + method="post", + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + query="{ __typename }", + ) + assert response.status_code == 200 + assert "application/json" in response.headers["content-type"] + + +async def test_47de(http_client): + """SHOULD accept */* and use application/json for the content-type""" + response = await http_client.query( + method="post", + headers={ + "Content-Type": "application/json", + "Accept": "*/*", + }, + query="{ __typename }", + ) + assert response.status_code == 200 + assert "application/json" in response.headers["content-type"] + + +async def test_80d8(http_client): + """SHOULD assume application/json content-type when accept is missing""" + response = await http_client.query( + method="post", + headers={"Content-Type": "application/json"}, + query="{ __typename }", + ) + assert response.status_code == 200 + assert "application/json" in response.headers["content-type"] + + +async def test_82a3(http_client): + """MUST use utf-8 encoding when responding""" + response = await http_client.query( + method="post", + headers={"Content-Type": "application/json"}, + query="{ __typename }", + ) + assert response.status_code == 200 + assert isinstance(response.data, bytes) + + try: + response.data.decode(encoding="utf-8", errors="strict") + except UnicodeDecodeError: + pytest.fail("Response body is not UTF-8 encoded") + + +async def test_bf61(http_client): + """MUST accept utf-8 encoded request""" + response = await http_client.query( + method="post", + headers={"Content-Type": "application/json; charset=utf-8"}, + query='{ __type(name: "Run🏃Swim🏊") { name } }', + ) + assert response.status_code == 200 + + +async def test_78d5(http_client): + """MUST assume utf-8 in request if encoding is unspecified""" + response = await http_client.query( + method="post", + headers={"Content-Type": "application/json"}, + query="{ __typename }", + ) + assert response.status_code == 200 + + +async def test_2c94(http_client): + """MUST accept POST requests""" + response = await http_client.query( + method="post", + headers={"Content-Type": "application/json"}, + query="{ __typename }", + ) + assert response.status_code == 200 + + +async def test_5a70(http_client): + """MAY accept application/x-www-form-urlencoded formatted GET requests""" + response = await http_client.query(method="get", query="{ __typename }") + assert response.status_code == 200 + + +async def test_9c48(http_client): + """MAY NOT allow executing mutations on GET requests""" + response = await http_client.query( + method="get", + headers={"Accept": "application/graphql-response+json"}, + query="mutation { __typename }", + ) + assert 400 <= response.status_code <= 499 + + +async def test_9abe(http_client): + """MAY respond with 4xx status code if content-type is not supplied on POST requests""" + response = await http_client.post( + url="/graphql", + headers={}, + data=b'{"query": "{ __typename }"}', + ) + assert 400 <= response.status_code <= 499 + + +async def test_03d4(http_client): + """MUST accept application/json POST requests""" + response = await http_client.query( + method="post", + headers={"Content-Type": "application/json"}, + query="{ __typename }", + ) + assert response.status_code == 200 + + +async def test_a5bf(http_client): + """MAY use 400 status code when request body is missing on POST""" + response = await http_client.post( + url="/graphql", + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 400 + + +async def test_423l(http_client): + """MAY use 400 status code on missing {query} parameter""" + response = await http_client.post( + url="/graphql", + headers={ + "Content-Type": "application/json", + "Accept": "application/graphql-response+json", + }, + json={"notquery": "{ __typename }"}, + ) + assert response.status_code == 400 + + +@pytest.mark.parametrize( + "invalid", + [{"obj": "ect"}, 0, False, ["array"]], + ids=["LKJ0", "LKJ1", "LKJ2", "LKJ3"], +) +async def test_lkj_(http_client, invalid): + """MAY use 400 status code on invalid {query} parameter""" + response = await http_client.post( + url="/graphql", + headers={"Content-Type": "application/json"}, + json={"query": invalid}, + ) + assert response.status_code == 400 + + +async def test_34a2(http_client): + """SHOULD allow string {query} parameter when accepting application/graphql-response+json""" + response = await http_client.query( + method="post", + headers={ + "Content-Type": "application/json", + "Accept": "application/graphql-response+json", + }, + query="{ __typename }", + ) + assert response.status_code == 200 + + +async def test_13ee(http_client): + """MUST allow string {query} parameter when accepting application/json""" + response = await http_client.query( + method="post", + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + query="{ __typename }", + ) + assert response.status_code == 200 + assert isinstance(response.json, dict) + assert "errors" not in response.json + + +@pytest.mark.parametrize( + "invalid", + [{"obj": "ect"}, 0, False, ["array"]], + ids=["6C00", "6C01", "6C02", "6C03"], +) +async def test_6c0_(http_client, invalid): + """MAY use 400 status code on invalid {operationName} parameter""" + response = await http_client.post( + url="/graphql", + headers={"Content-Type": "application/json"}, + json={ + "operationName": invalid, + "query": "{ __typename }", + }, + ) + assert response.status_code == 400 + + +async def test_8161(http_client): + """SHOULD allow string {operationName} parameter when accepting application/graphql-response+json""" + response = await http_client.post( + url="/graphql", + headers={ + "Content-Type": "application/json", + "Accept": "application/graphql-response+json", + }, + json={ + "operationName": "Query", + "query": "query Query { __typename }", + }, + ) + assert response.status_code == 200 + + +async def test_b8b3(http_client): + """MUST allow string {operationName} parameter when accepting application/json""" + response = await http_client.post( + url="/graphql", + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + json={ + "operationName": "Query", + "query": "query Query { __typename }", + }, + ) + assert response.status_code == 200 + assert isinstance(response.json, dict) + assert "errors" not in response.json + + +@pytest.mark.parametrize( + "parameter", + ["variables", "operationName", "extensions"], + ids=["94B0", "94B1", "94B2"], +) +async def test_94b_(http_client, parameter): + """SHOULD allow null variables/operationName/extensions parameter when accepting application/graphql-response+json""" + response = await http_client.post( + url="/graphql", + headers={ + "Content-Type": "application/json", + "Accept": "application/graphql-response+json", + }, + json={ + "query": "{ __typename }", + parameter: None, + }, + ) + assert response.status_code == 200 + assert "errors" not in response.json + + +@pytest.mark.parametrize( + "parameter", + ["variables", "operationName", "extensions"], + ids=["0220", "0221", "0222"], +) +async def test_022_(http_client, parameter): + """MUST allow null variables/operationName/extensions parameter when accepting application/json""" + response = await http_client.post( + url="/graphql", + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + json={ + "query": "{ __typename }", + parameter: None, + }, + ) + assert response.status_code == 200 + assert "errors" not in response.json + + +@pytest.mark.parametrize( + "invalid", + ["string", 0, False, ["array"]], + ids=["4760", "4761", "4762", "4763"], +) +async def test_476_(http_client, invalid): + """MAY use 400 status code on invalid {variables} parameter""" + response = await http_client.post( + url="/graphql", + headers={"Content-Type": "application/json"}, + json={ + "query": "{ __typename }", + "variables": invalid, + }, + ) + assert response.status_code == 400 + + +async def test_2ea1(http_client): + """SHOULD allow map {variables} parameter when accepting application/graphql-response+json""" + response = await http_client.post( + url="/graphql", + headers={ + "Content-Type": "application/json", + "Accept": "application/graphql-response+json", + }, + json={ + "query": "query Type($name: String!) { __type(name: $name) { name } }", + "variables": {"name": "sometype"}, + }, + ) + assert response.status_code == 200 + + +async def test_28b9(http_client): + """MUST allow map {variables} parameter when accepting application/json""" + response = await http_client.post( + url="/graphql", + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + json={ + "query": "query Type($name: String!) { __type(name: $name) { name } }", + "variables": {"name": "sometype"}, + }, + ) + assert response.status_code == 200 + assert isinstance(response.json, dict) + assert "errors" not in response.json + + +async def test_d6d5(http_client): + """MAY allow URL-encoded JSON string {variables} parameter in GETs when accepting application/graphql-response+json""" + response = await http_client.query( + query="query Type($name: String!) { __type(name: $name) { name } }", + variables={"name": "sometype"}, + method="get", + headers={"Accept": "application/graphql-response+json"}, + ) + assert response.status_code == 200 + + +async def test_6a70(http_client): + """MAY allow URL-encoded JSON string {variables} parameter in GETs when accepting application/json""" + response = await http_client.query( + query="query Type($name: String!) { __type(name: $name) { name } }", + variables={"name": "sometype"}, + method="get", + headers={"Accept": "application/json"}, + ) + assert response.status_code == 200 + assert isinstance(response.json, dict) + assert "errors" not in response.json + + +@pytest.mark.parametrize( + "invalid", + ["string", 0, False, ["array"]], + ids=["58B0", "58B1", "58B2", "58B3"], +) +async def test_58b_(http_client, invalid): + """MAY use 400 status code on invalid {extensions} parameter""" + response = await http_client.post( + url="/graphql", + headers={"Content-Type": "application/json"}, + json={ + "query": "{ __typename }", + "extensions": invalid, + }, + ) + assert response.status_code == 400 + + +async def test_428f(http_client): + """SHOULD allow map {extensions} parameter when accepting application/graphql-response+json""" + response = await http_client.post( + url="/graphql", + headers={ + "Content-Type": "application/json", + "Accept": "application/graphql-response+json", + }, + json={ + "query": "{ __typename }", + "extensions": {"some": "value"}, + }, + ) + assert response.status_code == 200 + + +async def test_1b7a(http_client): + """MUST allow map {extensions} parameter when accepting application/json""" + response = await http_client.post( + url="/graphql", + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + json={ + "query": "{ __typename }", + "extensions": {"some": "value"}, + }, + ) + assert response.status_code == 200 + assert isinstance(response.json, dict) + assert "errors" not in response.json + + +async def test_b6dc(http_client): + """MAY use 4xx or 5xx status codes on JSON parsing failure""" + response = await http_client.post( + url="/graphql", + headers={"Content-Type": "application/json"}, + data=b'{ "not a JSON', + ) + assert 400 <= response.status_code <= 599 + + +async def test_bcf8(http_client): + """MAY use 400 status code on JSON parsing failure""" + response = await http_client.post( + url="/graphql", + headers={"Content-Type": "application/json"}, + data=b'{ "not a JSON', + ) + assert response.status_code == 400 + + +async def test_8764(http_client): + """MAY use 4xx or 5xx status codes if parameters are invalid""" + response = await http_client.post( + url="/graphql", + headers={"Content-Type": "application/json"}, + json={"qeury": "{ __typename }"}, # typo in 'query' + ) + assert 400 <= response.status_code <= 599 + + +async def test_3e3a(http_client): + """MAY use 400 status code if parameters are invalid""" + response = await http_client.post( + url="/graphql", + headers={"Content-Type": "application/json"}, + json={"qeury": "{ __typename }"}, # typo in 'query' + ) + assert response.status_code == 400 + + +async def test_39aa(http_client): + """MUST accept a map for the {extensions} parameter""" + response = await http_client.post( + url="/graphql", + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + json={ + "query": "{ __typename }", + "extensions": {"some": "value"}, + }, + ) + assert response.status_code == 200 + assert isinstance(response.json, dict) + assert "errors" not in response.json + + +async def test_572b(http_client): + """SHOULD use 200 status code on document parsing failure when accepting application/json""" + response = await http_client.post( + url="/graphql", + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + json={"query": "{"}, + ) + assert response.status_code == 200 + + +async def test_dfe2(http_client): + """SHOULD use 200 status code on document validation failure when accepting application/json""" + response = await http_client.post( + url="/graphql", + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + json={ + "query": "{ 8f31403dfe404bccbb0e835f2629c6a7 }" + }, # making sure the field doesn't exist + ) + assert response.status_code == 200 + + +async def test_7b9b(http_client): + """SHOULD use a status code of 200 on variable coercion failure when accepting application/json""" + response = await http_client.post( + url="/graphql", + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + json={ + "query": "query CoerceFailure($id: ID!){ __typename }", + "variables": {"id": None}, + }, + ) + assert response.status_code == 200 + + +async def test_865d(http_client): + """SHOULD use 4xx or 5xx status codes on document parsing failure when accepting application/graphql-response+json""" + response = await http_client.post( + url="/graphql", + headers={ + "Content-Type": "application/json", + "Accept": "application/graphql-response+json", + }, + json={"query": "{"}, + ) + assert 400 <= response.status_code <= 599 + + +async def test_556a(http_client): + """SHOULD use 400 status code on document parsing failure when accepting application/graphql-response+json""" + response = await http_client.post( + url="/graphql", + headers={ + "Content-Type": "application/json", + "Accept": "application/graphql-response+json", + }, + json={"query": "{"}, + ) + assert response.status_code == 400 + + +async def test_d586(http_client): + """SHOULD NOT contain the data entry on document parsing failure when accepting application/graphql-response+json""" + response = await http_client.post( + url="/graphql", + headers={ + "Content-Type": "application/json", + "Accept": "application/graphql-response+json", + }, + json={"query": "{"}, + ) + assert response.status_code == 400 + assert "data" not in response.json + + +async def test_51fe(http_client): + """SHOULD use 4xx or 5xx status codes on document validation failure when accepting application/graphql-response+json""" + response = await http_client.post( + url="/graphql", + headers={ + "Content-Type": "application/json", + "Accept": "application/graphql-response+json", + }, + json={ + "query": "{ 8f31403dfe404bccbb0e835f2629c6a7 }", # making sure the field doesn't exist + }, + ) + assert 400 <= response.status_code <= 599 + + +async def test_74ff(http_client): + """SHOULD use 400 status code on document validation failure when accepting application/graphql-response+json""" + response = await http_client.post( + url="/graphql", + headers={ + "Content-Type": "application/json", + "Accept": "application/graphql-response+json", + }, + json={ + "query": "{ 8f31403dfe404bccbb0e835f2629c6a7 }", # making sure the field doesn't exist + }, + ) + assert response.status_code == 400 + + +async def test_5e5b(http_client): + """SHOULD NOT contain the data entry on document validation failure when accepting application/graphql-response+json""" + response = await http_client.post( + url="/graphql", + headers={ + "Content-Type": "application/json", + "Accept": "application/graphql-response+json", + }, + json={ + "query": "{ 8f31403dfe404bccbb0e835f2629c6a7 }", # making sure the field doesn't exist + }, + ) + assert response.status_code == 400 + assert "data" not in response.json + + +async def test_86ee(http_client): + """SHOULD use a status code of 400 on variable coercion failure when accepting application/graphql-response+json""" + response = await http_client.post( + url="/graphql", + headers={ + "Content-Type": "application/json", + "Accept": "application/graphql-response+json", + }, + json={ + "query": "query CoerceFailure($id: ID!){ __typename }", + "variables": {"id": None}, + }, + ) + assert response.status_code == 400 diff --git a/src/tests/http/test_http.py b/src/tests/http/test_http.py new file mode 100644 index 0000000..60de4ef --- /dev/null +++ b/src/tests/http/test_http.py @@ -0,0 +1,44 @@ +from typing import Literal + +import pytest + +from graphql_server.http.base import BaseView + +from .clients.base import HttpClient + + +@pytest.mark.parametrize("method", ["delete", "head", "put", "patch"]) +async def test_does_only_allow_get_and_post( + method: Literal["delete", "head", "put", "patch"], + http_client: HttpClient, +): + response = await http_client.request(url="/graphql", method=method) + + assert response.status_code == 405 + + +async def test_the_http_handler_uses_the_views_decode_json_method( + http_client: HttpClient, mocker +): + spy = mocker.spy(BaseView, "decode_json") + + response = await http_client.query(query="{ hello }") + assert response.status_code == 200 + + data = response.json["data"] + assert isinstance(data, dict) + assert data["hello"] == "Hello world" + + assert spy.call_count == 1 + + +async def test_does_allow_http_options( + http_client: HttpClient, +): + from .clients.chalice import ChaliceHttpClient + + if isinstance(http_client, ChaliceHttpClient): + pytest.xfail("chalice doesn't support options requests") + + response = await http_client.request(url="/graphql", method="options") + assert response.status_code in (200, 204) diff --git a/src/tests/http/test_multipart_subscription.py b/src/tests/http/test_multipart_subscription.py new file mode 100644 index 0000000..4cd4107 --- /dev/null +++ b/src/tests/http/test_multipart_subscription.py @@ -0,0 +1,112 @@ +import contextlib +from typing_extensions import Literal + +import pytest + +from graphql_server.http.base import BaseView + +from .clients.base import HttpClient + + +@pytest.fixture +def http_client(http_client_class: type[HttpClient]) -> HttpClient: + with contextlib.suppress(ImportError): + import django + + if django.VERSION < (4, 2): + pytest.skip(reason="Django < 4.2 doesn't async streaming responses") + + from .clients.django import DjangoHttpClient + + if http_client_class is DjangoHttpClient: + pytest.skip( + reason="(sync) DjangoHttpClient doesn't support multipart subscriptions" + ) + + with contextlib.suppress(ImportError): + from .clients.channels import SyncChannelsHttpClient + + if http_client_class is SyncChannelsHttpClient: + pytest.skip( + reason="SyncChannelsHttpClient doesn't support multipart subscriptions" + ) + + with contextlib.suppress(ImportError): + from .clients.async_flask import AsyncFlaskHttpClient + from .clients.flask import FlaskHttpClient + + if http_client_class is FlaskHttpClient: + pytest.skip( + reason="FlaskHttpClient doesn't support multipart subscriptions" + ) + + if http_client_class is AsyncFlaskHttpClient: + pytest.xfail( + reason="AsyncFlaskHttpClient doesn't support multipart subscriptions" + ) + + with contextlib.suppress(ImportError): + from .clients.chalice import ChaliceHttpClient + + if http_client_class is ChaliceHttpClient: + pytest.skip( + reason="ChaliceHttpClient doesn't support multipart subscriptions" + ) + + return http_client_class() + + +@pytest.mark.parametrize("method", ["get", "post"]) +async def test_multipart_subscription( + http_client: HttpClient, method: Literal["get", "post"] +): + response = await http_client.query( + method=method, + query='subscription { echo(message: "Hello world", delay: 0.2) }', + headers={ + "accept": "multipart/mixed;boundary=graphql;subscriptionSpec=1.0,application/json", + "content-type": "application/json", + }, + ) + + data = [d async for d in response.streaming_json()] + + assert data == [ + { + "payload": { + "data": {"echo": "Hello world"}, + # "extensions": {"example": "example"}, + } + } + ] + + assert response.status_code == 200 + + +async def test_multipart_subscription_use_the_views_decode_json_method( + http_client: HttpClient, mocker +): + spy = mocker.spy(BaseView, "decode_json") + + response = await http_client.query( + query='subscription { echo(message: "Hello world", delay: 0.2) }', + headers={ + "accept": "multipart/mixed;boundary=graphql;subscriptionSpec=1.0,application/json", + "content-type": "application/json", + }, + ) + + data = [d async for d in response.streaming_json()] + + assert data == [ + { + "payload": { + "data": {"echo": "Hello world"}, + # "extensions": {"example": "example"}, + } + } + ] + + assert response.status_code == 200 + + assert spy.call_count == 1 diff --git a/src/tests/http/test_mutation.py b/src/tests/http/test_mutation.py new file mode 100644 index 0000000..5763ab1 --- /dev/null +++ b/src/tests/http/test_mutation.py @@ -0,0 +1,14 @@ +from .clients.base import HttpClient + + +async def test_mutation(http_client: HttpClient): + response = await http_client.query( + query="mutation { hello }", + headers={ + "Content-Type": "application/json", + }, + ) + data = response.json["data"] + + assert response.status_code == 200 + assert data["hello"] == "teststring" diff --git a/src/tests/http/test_parse_content_type.py b/src/tests/http/test_parse_content_type.py new file mode 100644 index 0000000..1ff010c --- /dev/null +++ b/src/tests/http/test_parse_content_type.py @@ -0,0 +1,47 @@ +import pytest + +from graphql_server.http.parse_content_type import parse_content_type + + +@pytest.mark.parametrize( + ("content_type", "expected"), + [ # type: ignore + ("application/json", ("application/json", {})), + ("", ("", {})), + ("application/json; charset=utf-8", ("application/json", {"charset": "utf-8"})), + ( + "application/json; charset=utf-8; boundary=foobar", + ("application/json", {"charset": "utf-8", "boundary": "foobar"}), + ), + ( + "application/json; boundary=foobar; charset=utf-8", + ("application/json", {"boundary": "foobar", "charset": "utf-8"}), + ), + ( + "application/json; boundary=foobar", + ("application/json", {"boundary": "foobar"}), + ), + ( + "application/json; boundary=foobar; charset=utf-8; foo=bar", + ( + "application/json", + {"boundary": "foobar", "charset": "utf-8", "foo": "bar"}, + ), + ), + ( + 'multipart/mixed; boundary="graphql"; subscriptionSpec=1.0, application/json', + ( + "multipart/mixed", + { + "boundary": "graphql", + "subscriptionspec": "1.0, application/json", + }, + ), + ), + ], +) +async def test_parse_content_type( + content_type: str, + expected: tuple[str, dict[str, str]], +): + assert parse_content_type(content_type) == expected diff --git a/src/tests/http/test_process_result.py b/src/tests/http/test_process_result.py new file mode 100644 index 0000000..6b19766 --- /dev/null +++ b/src/tests/http/test_process_result.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing_extensions import Literal + +import pytest +from graphql import ExecutionResult + +from graphql_server.http import GraphQLHTTPResponse + +from .clients.base import HttpClient + + +def process_result(result: ExecutionResult) -> GraphQLHTTPResponse: + if result.data: + return { + "data": {key.upper(): result for key, result in result.data.items()}, + } + + return {} + + +@pytest.fixture +def http_client(http_client_class) -> HttpClient: + return http_client_class(result_override=process_result) + + +@pytest.mark.parametrize("method", ["get", "post"]) +async def test_custom_process_result( + method: Literal["get", "post"], http_client: HttpClient +): + response = await http_client.query( + method=method, + query="{ hello }", + ) + assert response.json["data"] == {"HELLO": "Hello world"} diff --git a/src/tests/http/test_query.py b/src/tests/http/test_query.py new file mode 100644 index 0000000..949a566 --- /dev/null +++ b/src/tests/http/test_query.py @@ -0,0 +1,319 @@ +from typing_extensions import Literal + +import pytest +from graphql import GraphQLError +from pytest_mock import MockFixture + +from .clients.base import HttpClient + +try: + from .clients.chalice import ChaliceHttpClient +except ImportError: + ChaliceHttpClient = type(None) + + +@pytest.mark.parametrize("method", ["get", "post"]) +async def test_graphql_query(method: Literal["get", "post"], http_client: HttpClient): + response = await http_client.query( + method=method, + query="{ hello }", + ) + data = response.json["data"] + + assert response.status_code == 200 + assert data["hello"] == "Hello world" + + +@pytest.mark.parametrize("method", ["get", "post"]) +async def test_calls_handle_errors( + method: Literal["get", "post"], http_client: HttpClient, mocker: MockFixture +): + sync_mock = mocker.patch( + "graphql_server.http.sync_base_view.SyncBaseHTTPView._handle_errors" + ) + async_mock = mocker.patch( + "graphql_server.http.async_base_view.AsyncBaseHTTPView._handle_errors" + ) + + response = await http_client.query( + method=method, + query="{ hey }", + ) + data = response.json["data"] + + assert response.status_code == 200 + assert data is None + + assert response.json["errors"] == [ + { + "message": "Cannot query field 'hey' on type 'Query'.", + "locations": [{"line": 1, "column": 3}], + } + ] + + error = GraphQLError("Cannot query field 'hey' on type 'Query'.") + + response_data = { + "data": None, + "errors": [ + { + "message": "Cannot query field 'hey' on type 'Query'.", + "locations": [{"line": 1, "column": 3}], + }, + ], + # "extensions": {"example": "example"}, + } + + call_args = async_mock.call_args[0] if async_mock.called else sync_mock.call_args[0] + + assert call_args[0][0].message == error.message + assert call_args[1] == response_data + + +@pytest.mark.parametrize("method", ["get", "post"]) +async def test_graphql_can_pass_variables( + method: Literal["get", "post"], http_client: HttpClient +): + response = await http_client.query( + method=method, + query="query hello($name: String!) { hello(name: $name) }", + variables={"name": "Jake"}, + ) + data = response.json["data"] + + assert response.status_code == 200 + assert data["hello"] == "Hello Jake" + + +@pytest.mark.parametrize("method", ["get", "post"]) +async def test_root_value(method: Literal["get", "post"], http_client: HttpClient): + response = await http_client.query( + method=method, + query="{ rootName }", + ) + data = response.json["data"] + + assert response.status_code == 200 + assert data["rootName"] == "Query" + + +@pytest.mark.parametrize("method", ["get", "post"]) +async def test_passing_invalid_query( + method: Literal["get", "post"], http_client: HttpClient +): + response = await http_client.query( + method=method, + query="{ h", + ) + + assert response.status_code == 200 + assert response.json["errors"] == [ + { + "message": "Syntax Error: Expected Name, found .", + "locations": [{"line": 1, "column": 4}], + } + ] + + +@pytest.mark.parametrize("method", ["get", "post"]) +async def test_returns_errors(method: Literal["get", "post"], http_client: HttpClient): + response = await http_client.query( + method=method, + query="{ maya }", + ) + + assert response.status_code == 200 + assert response.json["errors"] == [ + { + "message": "Cannot query field 'maya' on type 'Query'.", + "locations": [{"line": 1, "column": 3}], + } + ] + + +@pytest.mark.parametrize("method", ["get", "post"]) +async def test_returns_errors_and_data( + method: Literal["get", "post"], http_client: HttpClient +): + response = await http_client.query( + method=method, + query="{ hello, alwaysFail }", + ) + + assert response.status_code == 200 + data = response.json["data"] + errors = response.json["errors"] + + assert errors == [ + { + "locations": [{"column": 10, "line": 1}], + "message": "You are not authorized", + "path": ["alwaysFail"], + } + ] + assert data == {"hello": "Hello world", "alwaysFail": None} + + +async def test_passing_invalid_json_post(http_client: HttpClient): + response = await http_client.post( + url="/graphql", + data=b"{ h", + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code == 400 + assert "Unable to parse request body as JSON" in response.text + + +async def test_passing_invalid_json_get(http_client: HttpClient): + response = await http_client.get( + url="/graphql?query={ hello }&variables='{'", + ) + + assert response.status_code == 400 + assert "Unable to parse request body as JSON" in response.text + + +async def test_query_parameters_are_never_interpreted_as_list(http_client: HttpClient): + response = await http_client.get( + url='/graphql?query=query($name: String!) { hello(name: $name) }&variables={"name": "Jake"}&variables={"name": "Jake"}', + ) + + assert response.status_code == 200 + assert response.json["data"] == {"hello": "Hello Jake"} + + +async def test_missing_query(http_client: HttpClient): + response = await http_client.post( + url="/graphql", + json={}, + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code == 400 + assert "No GraphQL query found in the request" in response.text + + +@pytest.mark.parametrize("method", ["get", "post"]) +async def test_query_context(method: Literal["get", "post"], http_client: HttpClient): + response = await http_client.query( + method=method, + query="{ valueFromContext }", + ) + data = response.json["data"] + + assert response.status_code == 200 + assert data["valueFromContext"] == "a value from context" + + +# @skip_if_gql_32 +# @pytest.mark.parametrize("method", ["get", "post"]) +# async def test_query_extensions( +# method: Literal["get", "post"], http_client: HttpClient +# ): +# response = await http_client.query( +# method=method, +# query='{ valueFromExtensions(key:"test") }', +# extensions={"test": "hello"}, +# ) +# data = response.json["data"] + +# assert response.status_code == 200 +# assert data["valueFromExtensions"] == "hello" + + +@pytest.mark.parametrize("method", ["get", "post"]) +async def test_returning_status_code( + method: Literal["get", "post"], http_client: HttpClient +): + response = await http_client.query( + method=method, + query="{ returns401 }", + ) + + assert response.status_code == 401 + assert response.json["data"] == {"returns401": "hey"} + + +@pytest.mark.parametrize("method", ["get", "post"]) +async def test_updating_headers( + method: Literal["get", "post"], http_client: HttpClient +): + response = await http_client.query( + method=method, + variables={"name": "Jake"}, + query="query ($name: String!) { setHeader(name: $name) }", + ) + + assert response.status_code == 200 + assert response.json["data"] == {"setHeader": "Jake"} + assert response.headers["x-name"] == "Jake" + + +@pytest.mark.parametrize( + ("extra_kwargs", "expected_message"), + [ + # TODO: INCOMPATIBLE WITH OTHER TESTS + # ({}, "Hello Foo"), + # ({"operation_name": None}, "Hello Foo"), + ({"operation_name": "Query1"}, "Hello Foo"), + ({"operation_name": "Query2"}, "Hello Bar"), + ], +) +async def test_operation_selection( + http_client: HttpClient, extra_kwargs, expected_message +): + response = await http_client.query( + query=""" + query Query1 { hello(name: "Foo") } + query Query2 { hello(name: "Bar") } + """, + **extra_kwargs, + ) + + assert response.status_code == 200 + assert response.json["data"] == {"hello": expected_message} + + +@pytest.mark.parametrize( + "operation_name", + ["", "Query3"], +) +async def test_invalid_operation_selection(http_client: HttpClient, operation_name): + response = await http_client.query( + query=""" + query Query1 { hello(name: "Foo") } + query Query2 { hello(name: "Bar") } + """, + operation_name=operation_name, + ) + + assert response.status_code == 400 + + if isinstance(http_client, ChaliceHttpClient): + # Our Chalice integration purposely wraps errors messages with a JSON object + assert response.json == { + "Code": "BadRequestError", + "Message": f'Unknown operation named "{operation_name}".', + } + else: + assert response.data == f'Unknown operation named "{operation_name}".'.encode() + + +async def test_operation_selection_without_operations(http_client: HttpClient): + response = await http_client.query( + query=""" + fragment Fragment1 on Query { __typename } + """, + ) + + assert response.status_code == 400 + + if isinstance(http_client, ChaliceHttpClient): + # Our Chalice integration purposely wraps errors messages with a JSON object + assert response.json == { + "Code": "BadRequestError", + "Message": "Can't get GraphQL operation type", + } + else: + assert response.data == b"Can't get GraphQL operation type" diff --git a/src/tests/http/test_query_via_get.py b/src/tests/http/test_query_via_get.py new file mode 100644 index 0000000..5e7557a --- /dev/null +++ b/src/tests/http/test_query_via_get.py @@ -0,0 +1,44 @@ +from .clients.base import HttpClient + + +async def test_sending_get_with_content_type_passes(http_client_class): + http_client = http_client_class() + + response = await http_client.query( + method="get", + query="query {hello}", + headers={ + "Content-Type": "application/json", + }, + ) + data = response.json["data"] + + assert response.status_code == 200 + assert data["hello"] == "Hello world" + + +async def test_sending_empty_query(http_client_class): + http_client = http_client_class() + + response = await http_client.query( + method="get", query="", variables={"fake": "variable"} + ) + + assert response.status_code == 400 + assert "No GraphQL query found in the request" in response.text + + +async def test_does_not_allow_mutation(http_client: HttpClient): + response = await http_client.query(method="get", query="mutation { hello }") + + assert response.status_code == 400 + assert "mutations are not allowed when using GET" in response.text + + +async def test_fails_if_allow_queries_via_get_false(http_client_class): + http_client = http_client_class(allow_queries_via_get=False) + + response = await http_client.query(method="get", query="{ hello }") + + assert response.status_code == 400 + assert "queries are not allowed when using GET" in response.text diff --git a/src/tests/http/test_upload.py b/src/tests/http/test_upload.py new file mode 100644 index 0000000..1bd2fd1 --- /dev/null +++ b/src/tests/http/test_upload.py @@ -0,0 +1,260 @@ +import contextlib +import json +from io import BytesIO + +import pytest +from urllib3 import encode_multipart_formdata + +from .clients.base import HttpClient + + +@pytest.fixture +def http_client(http_client_class: type[HttpClient]) -> HttpClient: + with contextlib.suppress(ImportError): + from .clients.chalice import ChaliceHttpClient + + if http_client_class is ChaliceHttpClient: + pytest.xfail(reason="Chalice does not support uploads") + + return http_client_class() + + +@pytest.fixture +def enabled_http_client(http_client_class: type[HttpClient]) -> HttpClient: + with contextlib.suppress(ImportError): + from .clients.chalice import ChaliceHttpClient + + if http_client_class is ChaliceHttpClient: + pytest.xfail(reason="Chalice does not support uploads") + + return http_client_class(multipart_uploads_enabled=True) + + +async def test_multipart_uploads_are_disabled_by_default(http_client: HttpClient): + f = BytesIO(b"graphql_server") + + query = """ + mutation($textFile: Upload!) { + readText(textFile: $textFile) + } + """ + + response = await http_client.query( + query, + variables={"textFile": None}, + files={"textFile": f}, + ) + + assert response.status_code == 400 + assert response.data == b"Unsupported content type" + + +async def test_upload(enabled_http_client: HttpClient): + f = BytesIO(b"graphql_server") + + query = """ + mutation($textFile: Upload!) { + readText(textFile: $textFile) + } + """ + + response = await enabled_http_client.query( + query, + variables={"textFile": None}, + files={"textFile": f}, + ) + + assert response.json.get("errors") is None + assert response.json["data"] == {"readText": "graphql_server"} + + +async def test_file_list_upload(enabled_http_client: HttpClient): + query = "mutation($files: [Upload!]!) { readFiles(files: $files) }" + file1 = BytesIO(b"graphql_server1") + file2 = BytesIO(b"graphql_server2") + + response = await enabled_http_client.query( + query=query, + variables={"files": [None, None]}, + files={"file1": file1, "file2": file2}, + ) + + data = response.json["data"] + + assert len(data["readFiles"]) == 2 + assert data["readFiles"][0] == "graphql_server1" + assert data["readFiles"][1] == "graphql_server2" + + +async def test_nested_file_list(enabled_http_client: HttpClient): + query = "mutation($folder: FolderInput!) { readFolder(folder: $folder) }" + file1 = BytesIO(b"graphql_server1") + file2 = BytesIO(b"graphql_server2") + + response = await enabled_http_client.query( + query=query, + variables={"folder": {"files": [None, None]}}, + files={"file1": file1, "file2": file2}, + ) + + data = response.json["data"] + assert len(data["readFolder"]) == 2 + assert data["readFolder"][0] == "graphql_server1" + assert data["readFolder"][1] == "graphql_server2" + + +async def test_upload_single_and_list_file_together(enabled_http_client: HttpClient): + query = """ + mutation($files: [Upload!]!, $textFile: Upload!) { + readFiles(files: $files) + readText(textFile: $textFile) + } + """ + file1 = BytesIO(b"graphql_server1") + file2 = BytesIO(b"graphql_server2") + file3 = BytesIO(b"graphql_server3") + + response = await enabled_http_client.query( + query=query, + variables={"files": [None, None], "textFile": None}, + files={"file1": file1, "file2": file2, "textFile": file3}, + ) + + data = response.json["data"] + assert len(data["readFiles"]) == 2 + assert data["readFiles"][0] == "graphql_server1" + assert data["readFiles"][1] == "graphql_server2" + assert data["readText"] == "graphql_server3" + + +async def test_upload_invalid_query(enabled_http_client: HttpClient): + f = BytesIO(b"graphql_server") + + query = """ + mutation($textFile: Upload!) { + readT + """ + + response = await enabled_http_client.query( + query, + variables={"textFile": None}, + files={"textFile": f}, + ) + + assert response.status_code == 200 + assert response.json["data"] is None + assert response.json["errors"] == [ + { + "locations": [{"column": 5, "line": 4}], + "message": "Syntax Error: Expected Name, found .", + } + ] + + +async def test_upload_missing_file(enabled_http_client: HttpClient): + f = BytesIO(b"graphql_server") + + query = """ + mutation($textFile: Upload!) { + readText(textFile: $textFile) + } + """ + + response = await enabled_http_client.query( + query, + variables={"textFile": None}, + # using the wrong name to simulate a missing file + # this is to make it easier to run tests with our client + files={"a": f}, + ) + + assert response.status_code == 400 + assert "File(s) missing in form data" in response.text + + +class FakeWriter: + def __init__(self): + self.buffer = BytesIO() + + async def write(self, data: bytes): + self.buffer.write(data) + + @property + def value(self) -> bytes: + return self.buffer.getvalue() + + +async def test_extra_form_data_fields_are_ignored(enabled_http_client: HttpClient): + query = """mutation($textFile: Upload!) { + readText(textFile: $textFile) + }""" + + f = BytesIO(b"graphql_server") + operations = json.dumps({"query": query, "variables": {"textFile": None}}) + file_map = json.dumps({"textFile": ["variables.textFile"]}) + extra_field_data = json.dumps({}) + + f = BytesIO(b"graphql_server") + fields = { + "operations": operations, + "map": file_map, + "extra_field": extra_field_data, + "textFile": ("textFile.txt", f.read(), "text/plain"), + } + + data, header = encode_multipart_formdata(fields) + + response = await enabled_http_client.post( + url="/graphql", + data=data, + headers={ + "content-type": header, + "content-length": f"{len(data)}", + }, + ) + + assert response.status_code == 200 + assert response.json["data"] == {"readText": "graphql_server"} + + +async def test_sending_invalid_form_data(enabled_http_client: HttpClient): + headers = {"content-type": "multipart/form-data; boundary=----fake"} + response = await enabled_http_client.post("/graphql", headers=headers) + + assert response.status_code == 400 + # TODO: consolidate this, it seems only AIOHTTP returns the second error + # due to validating the boundary + assert ( + "No GraphQL query found in the request" in response.text + or "Unable to parse the multipart body" in response.text + ) + + +@pytest.mark.aiohttp +async def test_sending_invalid_json_body(enabled_http_client: HttpClient): + f = BytesIO(b"graphql_server") + operations = "}" + file_map = json.dumps({"textFile": ["variables.textFile"]}) + + fields = { + "operations": operations, + "map": file_map, + "textFile": ("textFile.txt", f.read(), "text/plain"), + } + + data, header = encode_multipart_formdata(fields) + + response = await enabled_http_client.post( + "/graphql", + data=data, + headers={ + "content-type": header, + "content-length": f"{len(data)}", + }, + ) + + assert response.status_code == 400 + assert ( + "Unable to parse the multipart body" in response.text + or "Unable to parse request body as JSON" in response.text + ) diff --git a/src/tests/litestar/__init__.py b/src/tests/litestar/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/litestar/app.py b/src/tests/litestar/app.py new file mode 100644 index 0000000..474dd0b --- /dev/null +++ b/src/tests/litestar/app.py @@ -0,0 +1,39 @@ +from typing import Any + +from graphql_server.litestar import make_graphql_controller +from litestar import Litestar, Request +from litestar.config.cors import CORSConfig +from litestar.di import Provide +from tests.views.schema import schema + + +def custom_context_dependency() -> str: + return "Hi!" + + +async def get_root_value(request: Request = None): + return request + + +async def get_context(app_dependency: str, request: Request = None): + return {"custom_value": app_dependency, "request": request} + + +def create_app(schema=schema, **kwargs: Any): + GraphQLController = make_graphql_controller( + schema, + path="/graphql", + context_getter=get_context, + root_value_getter=get_root_value, + **kwargs, + ) + + cors_config = CORSConfig(allow_origins=["*"]) + + return Litestar( + route_handlers=[GraphQLController], + dependencies={ + "app_dependency": Provide(custom_context_dependency, sync_to_thread=True) + }, + cors_config=cors_config, + ) diff --git a/src/tests/litestar/conftest.py b/src/tests/litestar/conftest.py new file mode 100644 index 0000000..45f196d --- /dev/null +++ b/src/tests/litestar/conftest.py @@ -0,0 +1,19 @@ +import pytest + + +@pytest.fixture +def test_client(): + from litestar.testing import TestClient + from tests.litestar.app import create_app + + app = create_app() + return TestClient(app) + + +@pytest.fixture +def test_client_keep_alive(): + from litestar.testing import TestClient + from tests.litestar.app import create_app + + app = create_app(keep_alive=True, keep_alive_interval=0.1) + return TestClient(app) diff --git a/src/tests/litestar/test_context.py b/src/tests/litestar/test_context.py new file mode 100644 index 0000000..45fdb1e --- /dev/null +++ b/src/tests/litestar/test_context.py @@ -0,0 +1,235 @@ +import pytest +from graphql import ( + GraphQLField, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, +) + +from graphql_server.litestar import BaseContext, make_graphql_controller +from litestar import Litestar +from litestar.di import Provide +from litestar.testing import TestClient + + +def test_base_context(): + base_context = BaseContext() + assert base_context.request is None + + +def test_with_class_context_getter(): + class CustomContext(BaseContext): + teststring: str + + def custom_context_dependency() -> CustomContext: + return CustomContext(teststring="rocks") + + async def get_context(custom_context_dependency: CustomContext): + return custom_context_dependency + + def resolve_abc(_root, info): + assert isinstance(info.context, CustomContext) + assert info.context.request is not None + assert info.context.teststring == "rocks" + return "abc" + + Query = GraphQLObjectType( + name="Query", + fields={ + "abc": GraphQLField( + GraphQLString, + resolve=resolve_abc, + ) + }, + ) + + schema = GraphQLSchema(query=Query) + graphql_controller = make_graphql_controller( + path="/graphql", schema=schema, context_getter=get_context + ) + app = Litestar( + route_handlers=[graphql_controller], + dependencies={ + "custom_context_dependency": Provide( + custom_context_dependency, sync_to_thread=True + ) + }, + ) + + test_client = TestClient(app) + response = test_client.post("/graphql", json={"query": "{ abc }"}) + + assert response.status_code == 200 + assert response.json() == {"data": {"abc": "abc"}} + + +def test_with_dict_context_getter(): + def custom_context_dependency() -> str: + return "rocks" + + async def get_context(custom_context_dependency: str) -> dict[str, str]: + return {"teststring": custom_context_dependency} + + def resolve_abc(_root, info): + assert isinstance(info.context, dict) + assert info.context.get("request") is not None + assert info.context.get("teststring") == "rocks" + return "abc" + + Query = GraphQLObjectType( + name="Query", + fields={ + "abc": GraphQLField( + GraphQLString, + resolve=resolve_abc, + ) + }, + ) + + schema = GraphQLSchema(query=Query) + graphql_controller = make_graphql_controller( + path="/graphql", schema=schema, context_getter=get_context + ) + app = Litestar( + route_handlers=[graphql_controller], + dependencies={ + "custom_context_dependency": Provide( + custom_context_dependency, sync_to_thread=True + ) + }, + ) + test_client = TestClient(app) + response = test_client.post("/graphql", json={"query": "{ abc }"}) + + assert response.status_code == 200 + assert response.json() == {"data": {"abc": "abc"}} + + +def test_without_context_getter(): + def resolve_abc(_root, info): + assert isinstance(info.context, dict) + assert info.context.get("request") is not None + assert info.context.get("teststring") is None + return "abc" + + Query = GraphQLObjectType( + name="Query", + fields={ + "abc": GraphQLField( + GraphQLString, + resolve=resolve_abc, + ) + }, + ) + + schema = GraphQLSchema(query=Query) + graphql_controller = make_graphql_controller( + path="/graphql", schema=schema, context_getter=None + ) + app = Litestar(route_handlers=[graphql_controller]) + test_client = TestClient(app) + response = test_client.post("/graphql", json={"query": "{ abc }"}) + + assert response.status_code == 200 + assert response.json() == {"data": {"abc": "abc"}} + + +@pytest.mark.skip(reason="This is no longer supported") +def test_with_invalid_context_getter(): + def custom_context_dependency() -> str: + return "rocks" + + async def get_context(custom_context_dependency: str) -> str: + return custom_context_dependency + + def resolve_abc(_root, info): + assert info.context.get("request") is not None + assert info.context.get("teststring") is None + return "abc" + + Query = GraphQLObjectType( + name="Query", + fields={ + "abc": GraphQLField( + GraphQLString, + resolve=resolve_abc, + ) + }, + ) + + schema = GraphQLSchema(query=Query) + graphql_controller = make_graphql_controller( + path="/graphql", schema=schema, context_getter=get_context + ) + app = Litestar( + route_handlers=[graphql_controller], + dependencies={ + "custom_context_dependency": Provide( + custom_context_dependency, sync_to_thread=True + ) + }, + ) + test_client = TestClient(app, raise_server_exceptions=True) + response = test_client.post("/graphql", json={"query": "{ abc }"}) + assert response.status_code == 500 + assert response.json()["detail"] == "Internal Server Error" + + +def test_custom_context(): + from tests.litestar.app import create_app + + def resolve_custom_context_value(_root, info): + return info.context["custom_value"] + + Query = GraphQLObjectType( + name="Query", + fields={ + "customContextValue": GraphQLField( + GraphQLString, + resolve=resolve_custom_context_value, + ) + }, + ) + + schema = GraphQLSchema(query=Query) + app = create_app(schema=schema) + + test_client = TestClient(app) + response = test_client.post("/graphql", json={"query": "{ customContextValue }"}) + + assert response.status_code == 200 + assert response.json() == {"data": {"customContextValue": "Hi!"}} + + +def test_can_set_background_task(): + from tests.litestar.app import create_app + + task_complete = False + + async def task(): + nonlocal task_complete + task_complete = True + + def resolve_something(_root, info): + response = info.context["response"] + response.background.tasks.append(task) + return "foo" + + Query = GraphQLObjectType( + name="Query", + fields={ + "something": GraphQLField( + GraphQLString, + resolve=resolve_something, + ) + }, + ) + + schema = GraphQLSchema(query=Query) + app = create_app(schema=schema) + + test_client = TestClient(app) + response = test_client.post("/graphql", json={"query": "{ something }"}) + + assert response.json() == {"data": {"something": "foo"}} + assert task_complete diff --git a/src/tests/litestar/test_response_headers.py b/src/tests/litestar/test_response_headers.py new file mode 100644 index 0000000..660a81f --- /dev/null +++ b/src/tests/litestar/test_response_headers.py @@ -0,0 +1,70 @@ +from graphql import GraphQLField, GraphQLObjectType, GraphQLSchema, GraphQLString + +from graphql_server.litestar import make_graphql_controller +from litestar import Litestar +from litestar.testing import TestClient + + +def test_set_response_headers(): + def resolve_abc(_root, info): + assert info.context.get("response") is not None + info.context["response"].headers["X-GraphQL-Server"] = "rocks" + return "abc" + + Query = GraphQLObjectType( + name="Query", + fields={ + "abc": GraphQLField( + GraphQLString, + resolve=resolve_abc, + ) + }, + ) + + schema = GraphQLSchema(query=Query) + graphql_controller = make_graphql_controller(path="/graphql", schema=schema) + app = Litestar(route_handlers=[graphql_controller]) + + test_client = TestClient(app) + response = test_client.post("/graphql", json={"query": "{ abc }"}) + + assert response.status_code == 200 + assert response.json() == {"data": {"abc": "abc"}} + assert response.headers["X-GraphQL-Server"] == "rocks" + + +def test_set_cookie_headers(): + def resolve_abc(_root, info): + assert info.context.get("response") is not None + info.context["response"].set_cookie( + key="teststring", + value="rocks", + ) + info.context["response"].set_cookie( + key="Litestar", + value="rocks", + ) + return "abc" + + Query = GraphQLObjectType( + name="Query", + fields={ + "abc": GraphQLField( + GraphQLString, + resolve=resolve_abc, + ) + }, + ) + + schema = GraphQLSchema(query=Query) + graphql_controller = make_graphql_controller(path="/graphql", schema=schema) + app = Litestar(route_handlers=[graphql_controller]) + + test_client = TestClient(app) + response = test_client.post("/graphql", json={"query": "{ abc }"}) + + assert response.status_code == 200 + assert response.json() == {"data": {"abc": "abc"}} + assert response.headers["set-cookie"] == ( + "teststring=rocks; Path=/; SameSite=lax, Litestar=rocks; Path=/; SameSite=lax" + ) diff --git a/src/tests/litestar/test_response_status.py b/src/tests/litestar/test_response_status.py new file mode 100644 index 0000000..35e1910 --- /dev/null +++ b/src/tests/litestar/test_response_status.py @@ -0,0 +1,55 @@ +from graphql import GraphQLField, GraphQLObjectType, GraphQLSchema, GraphQLString + +from graphql_server.litestar import make_graphql_controller +from litestar import Litestar +from litestar.testing import TestClient + + +def test_set_custom_http_response_status(): + def resolve_abc(_root, info): + assert info.context.get("response") is not None + info.context["response"].status_code = 418 + return "abc" + + Query = GraphQLObjectType( + name="Query", + fields={ + "abc": GraphQLField( + GraphQLString, + resolve=resolve_abc, + ) + }, + ) + + schema = GraphQLSchema(query=Query) + graphql_controller = make_graphql_controller(path="/graphql", schema=schema) + app = Litestar(route_handlers=[graphql_controller]) + + test_client = TestClient(app) + response = test_client.post("/graphql", json={"query": "{ abc }"}) + assert response.status_code == 418 + assert response.json() == {"data": {"abc": "abc"}} + + +def test_set_without_setting_http_response_status(): + def resolve_abc(_root, _info): + return "abc" + + Query = GraphQLObjectType( + name="Query", + fields={ + "abc": GraphQLField( + GraphQLString, + resolve=resolve_abc, + ) + }, + ) + + schema = GraphQLSchema(query=Query) + graphql_controller = make_graphql_controller(path="/graphql", schema=schema) + app = Litestar(route_handlers=[graphql_controller]) + + test_client = TestClient(app) + response = test_client.post("/graphql", json={"query": "{ abc }"}) + assert response.status_code == 200 + assert response.json() == {"data": {"abc": "abc"}} diff --git a/src/tests/sanic/__init__.py b/src/tests/sanic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/sanic/test_file_upload.py b/src/tests/sanic/test_file_upload.py new file mode 100644 index 0000000..abc9fd0 --- /dev/null +++ b/src/tests/sanic/test_file_upload.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from io import BytesIO +from typing import TYPE_CHECKING + +import pytest +from graphql import ( + GraphQLArgument, + GraphQLField, + GraphQLNonNull, + GraphQLObjectType, + GraphQLScalarType, + GraphQLSchema, + GraphQLString, +) + +from graphql_server.sanic import utils +from graphql_server.sanic.views import GraphQLView +from sanic import Sanic +from sanic.request import File + +UploadScalar = GraphQLScalarType( + name="Upload", + description="The `Upload` scalar type represents a file upload.", + serialize=lambda f: f, + parse_value=lambda f: f, + parse_literal=lambda *_: None, +) + + +def resolve_index(_root, _info): + return "Hello there" + + +Query = GraphQLObjectType( + name="Query", + fields={ + "index": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=resolve_index, + ) + }, +) + + +def resolve_file_upload(_root, _info, file): + return file.name + + +Mutation = GraphQLObjectType( + name="Mutation", + fields={ + "fileUpload": GraphQLField( + GraphQLNonNull(GraphQLString), + args={ + "file": GraphQLArgument(GraphQLNonNull(UploadScalar)), + }, + resolve=resolve_file_upload, + ) + }, +) + +if TYPE_CHECKING: + from sanic import Sanic as SanicApp + + +@pytest.fixture +def app() -> SanicApp: + sanic_app = Sanic("sanic_testing") + + schema = GraphQLSchema(query=Query, mutation=Mutation) + + sanic_app.add_route( + GraphQLView.as_view( + schema=schema, + multipart_uploads_enabled=True, + ), + "/graphql", + ) + + return sanic_app + + +def test_file_cast(app: Sanic): + """Tests that the list of files in a sanic Request gets correctly turned into a dictionary""" + file_name = "test.txt" + file_content = b"Hello, there!." + in_memory_file = BytesIO(file_content) + in_memory_file.name = file_name + + form_data = { + "operations": '{ "query": "mutation($file: Upload!){ fileUpload(file: $file) }", "variables": { "file": null } }', + "map": '{ "file": ["variables.file"] }', + } + files = {"file": in_memory_file} + + request, _ = app.test_client.post("/graphql", data=form_data, files=files) + + files_dict = utils.convert_request_to_files_dict(request) # type: ignore + file = files_dict["file"] + + assert isinstance(file, File) + assert file.name == file_name + assert file.body == file_content + + +def test_endpoint(app: Sanic): + """Tests that the graphql api correctly handles file upload and processing""" + file_name = "test.txt" + file_content = b"Hello, there!" + in_memory_file = BytesIO(file_content) + in_memory_file.name = file_name + + form_data = { + "operations": '{ "query": "mutation($file: Upload!){ fileUpload(file: $file) }", "variables": { "file": null } }', + "map": '{ "file": ["variables.file"] }', + } + files = {"file": in_memory_file} + + _, response = app.test_client.post("/graphql", data=form_data, files=files) + + assert response.json["data"]["fileUpload"] == file_name # type: ignore diff --git a/src/tests/test/__init__.py b/src/tests/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/test/conftest.py b/src/tests/test/conftest.py new file mode 100644 index 0000000..c351cc3 --- /dev/null +++ b/src/tests/test/conftest.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING + +import pytest + +from tests.views.schema import schema + +if TYPE_CHECKING: + from graphql_server.test import BaseGraphQLTestClient + + +@asynccontextmanager +async def aiohttp_graphql_client() -> AsyncGenerator[BaseGraphQLTestClient]: + try: + from aiohttp import web + from aiohttp.test_utils import TestClient, TestServer + from graphql_server.aiohttp.test import GraphQLTestClient + from graphql_server.aiohttp.views import GraphQLView + except ImportError: + pytest.skip("Aiohttp not installed") + + view = GraphQLView(schema=schema) + app = web.Application() + app.router.add_route("*", "/graphql/", view) + + async with TestClient(TestServer(app)) as client: + yield GraphQLTestClient(client) + + +@asynccontextmanager +async def asgi_graphql_client() -> AsyncGenerator[BaseGraphQLTestClient]: + try: + from starlette.testclient import TestClient + + from graphql_server.asgi import GraphQL + from graphql_server.asgi.test import GraphQLTestClient + except ImportError: + pytest.skip("Starlette not installed") + + yield GraphQLTestClient(TestClient(GraphQL(schema))) + + +@asynccontextmanager +async def django_graphql_client() -> AsyncGenerator[BaseGraphQLTestClient]: + try: + from django.test.client import Client + + from graphql_server.django.test import GraphQLTestClient + except ImportError: + pytest.skip("Django not installed") + + yield GraphQLTestClient(Client()) + + +@pytest.fixture( + params=[ + pytest.param(aiohttp_graphql_client, marks=[pytest.mark.aiohttp]), + pytest.param(asgi_graphql_client, marks=[pytest.mark.asgi]), + pytest.param(django_graphql_client, marks=[pytest.mark.django]), + ] +) +async def graphql_client(request) -> AsyncGenerator[BaseGraphQLTestClient]: + async with request.param() as graphql_client: + yield graphql_client diff --git a/src/tests/test/test_client.py b/src/tests/test/test_client.py new file mode 100644 index 0000000..1dc9995 --- /dev/null +++ b/src/tests/test/test_client.py @@ -0,0 +1,36 @@ +from contextlib import nullcontext + +import pytest + +from graphql_server.utils.await_maybe import await_maybe + + +@pytest.mark.parametrize("asserts_errors", [True, False]) +async def test_query_asserts_errors_option_is_deprecated( + graphql_client, asserts_errors +): + with pytest.deprecated_call( + match="The `asserts_errors` argument has been renamed to `assert_no_errors`" + ): + await await_maybe( + graphql_client.query("{ hello }", asserts_errors=asserts_errors) + ) + + +@pytest.mark.parametrize( + ("option_name", "expectation1"), + [("asserts_errors", pytest.deprecated_call()), ("assert_no_errors", nullcontext())], +) +@pytest.mark.parametrize( + ("assert_no_errors", "expectation2"), + [(True, pytest.raises(AssertionError)), (False, nullcontext())], +) +async def test_query_with_assert_no_errors_option( + graphql_client, option_name, assert_no_errors, expectation1, expectation2 +): + query = "{ ThisIsNotAValidQuery }" + + with expectation1, expectation2: + await await_maybe( + graphql_client.query(query, **{option_name: assert_no_errors}) + ) diff --git a/src/tests/test_aio.py b/src/tests/test_aio.py new file mode 100644 index 0000000..8cac62b --- /dev/null +++ b/src/tests/test_aio.py @@ -0,0 +1,89 @@ +from graphql_server.utils.aio import ( + aenumerate, + aislice, + asyncgen_to_list, + resolve_awaitable, +) + + +async def test_aenumerate(): + async def gen(): + yield "a" + yield "b" + yield "c" + yield "d" + + res = [(i, v) async for i, v in aenumerate(gen())] + assert res == [(0, "a"), (1, "b"), (2, "c"), (3, "d")] + + +async def test_aslice(): + async def gen(): + yield "a" + yield "b" + raise AssertionError("should never be called") # pragma: no cover + yield "c" # pragma: no cover + + res = [] + async for v in aislice(gen(), 0, 2): + res.append(v) # noqa: PERF401 + + assert res == ["a", "b"] + + +async def test_aislice_empty_generator(): + async def gen(): + if False: # pragma: no cover + yield "should not be returned" + raise AssertionError("should never be called") + + res = [] + async for v in aislice(gen(), 0, 2): + res.append(v) # noqa: PERF401 + + assert res == [] + + +async def test_aislice_empty_slice(): + async def gen(): + if False: # pragma: no cover + yield "should not be returned" + raise AssertionError("should never be called") + + res = [] + async for v in aislice(gen(), 0, 0): + res.append(v) # noqa: PERF401 + + assert res == [] + + +async def test_aislice_with_step(): + async def gen(): + yield "a" + yield "b" + yield "c" + raise AssertionError("should never be called") # pragma: no cover + yield "d" # pragma: no cover + yield "e" # pragma: no cover + + res = [] + async for v in aislice(gen(), 0, 4, 2): + res.append(v) # noqa: PERF401 + + assert res == ["a", "c"] + + +async def test_asyncgen_to_list(): + async def gen(): + yield "a" + yield "b" + yield "c" + + assert await asyncgen_to_list(gen()) == ["a", "b", "c"] + + +async def test_resolve_awaitable(): + async def awaitable(): + return 1 + + assert await resolve_awaitable(awaitable(), lambda v: v + 1) == 2 diff --git a/src/tests/utils/__init__.py b/src/tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/utils/test_get_first_operation.py b/src/tests/utils/test_get_first_operation.py new file mode 100644 index 0000000..58f462f --- /dev/null +++ b/src/tests/utils/test_get_first_operation.py @@ -0,0 +1,43 @@ +from graphql import OperationType, parse + +from graphql_server.utils.operation import get_first_operation + + +def test_document_without_operation_definition_nodes(): + document = parse( + """ + fragment Test on Query { + hello + } + """ + ) + assert get_first_operation(document) is None + + +def test_single_operation_definition_node(): + document = parse( + """ + query Operation1 { + hello + } + """ + ) + node = get_first_operation(document) + assert node is not None + assert node.operation == OperationType.QUERY + + +def test_multiple_operation_definition_nodes(): + document = parse( + """ + mutation Operation1 { + hello + } + query Operation2 { + hello + } + """ + ) + node = get_first_operation(document) + assert node is not None + assert node.operation == OperationType.MUTATION diff --git a/src/tests/utils/test_get_operation_type.py b/src/tests/utils/test_get_operation_type.py new file mode 100644 index 0000000..f50d3fc --- /dev/null +++ b/src/tests/utils/test_get_operation_type.py @@ -0,0 +1,110 @@ +import pytest +from graphql import parse +from graphql.language import OperationType + +from graphql_server.utils.operation import get_operation_type + +mutation_collision = parse(""" +fragment UserAgent on UserAgentType { + id +} + +mutation UserAgent { + setUserAgent { + ...UserAgent + } +} +""") + +query_collision = parse(""" +fragment UserAgent on UserAgentType { + id +} + +query UserAgent { + userAgent { + ...UserAgent + } +} +""") + +subscription_collision = parse(""" +fragment UserAgent on UserAgentType { + id +} + +subscription UserAgent { + userAgent { + ...UserAgent + } +} +""") + +mutation_no_collision = parse(""" +fragment UserAgentFragment on UserAgentType { + id +} + +mutation UserAgent { + setUserAgent { + ...UserAgentFragment + } +} +""") + +query_no_collision = parse(""" +fragment UserAgentFragment on UserAgentType { + id +} + +query UserAgent { + userAgent { + ...UserAgentFragment + } +} +""") + +subscription_no_collision = parse(""" +fragment UserAgentFragment on UserAgentType { + id +} + +subscription UserAgent { + userAgent { + ...UserAgentFragment + } +} +""") + + +@pytest.mark.parametrize( + ("document", "operation", "expectation"), + [ + (query_collision, "UserAgent", OperationType.QUERY), + (query_no_collision, "UserAgent", OperationType.QUERY), + (mutation_collision, "UserAgent", OperationType.MUTATION), + (mutation_no_collision, "UserAgent", OperationType.MUTATION), + (subscription_collision, "UserAgent", OperationType.SUBSCRIPTION), + (subscription_no_collision, "UserAgent", OperationType.SUBSCRIPTION), + (query_collision, None, OperationType.QUERY), + (mutation_collision, None, OperationType.MUTATION), + (subscription_collision, None, OperationType.SUBSCRIPTION), + ], +) +def test_get_operation_type_with_fragment_name_collision( + document, operation, expectation +): + assert get_operation_type(document, operation) == expectation + + +def test_get_operation_type_only_fragments(): + only_fragments = parse(""" + fragment Foo on Bar { + id + } + """) + + with pytest.raises(RuntimeError) as excinfo: + get_operation_type(only_fragments) + + assert "Can't get GraphQL operation type" in str(excinfo.value) diff --git a/src/tests/utils/test_logging.py b/src/tests/utils/test_logging.py new file mode 100644 index 0000000..55422ee --- /dev/null +++ b/src/tests/utils/test_logging.py @@ -0,0 +1,16 @@ +import logging + +from graphql.error import GraphQLError + +from graphql_server.utils.logs import GraphQLServerLogger + + +def test_graphql_server_logger_error(caplog): + caplog.set_level(logging.ERROR, logger="graphql_server.execution") + + exc = GraphQLError("test exception") + GraphQLServerLogger.error(exc) + + assert caplog.record_tuples == [ + ("graphql_server.execution", logging.ERROR, "test exception") + ] diff --git a/src/tests/views/__init__.py b/src/tests/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/views/schema.py b/src/tests/views/schema.py new file mode 100644 index 0000000..11cdff0 --- /dev/null +++ b/src/tests/views/schema.py @@ -0,0 +1,468 @@ +import asyncio +import contextlib + +from graphql import ( + GraphQLArgument, + GraphQLBoolean, + GraphQLEnumType, + GraphQLEnumValue, + GraphQLError, + GraphQLField, + GraphQLFloat, + GraphQLInputField, + GraphQLInputObjectType, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLScalarType, + GraphQLSchema, + GraphQLString, +) +from graphql.language import ast + +from graphql_server.file_uploads import Upload as UploadValue + + +def _read_file(text_file: UploadValue) -> str: + with contextlib.suppress(ModuleNotFoundError): + from starlette.datastructures import UploadFile as StarletteUploadFile + + if isinstance(text_file, StarletteUploadFile): + text_file = text_file.file._file # type: ignore + + with contextlib.suppress(ModuleNotFoundError): + from litestar.datastructures import UploadFile as LitestarUploadFile + + if isinstance(text_file, LitestarUploadFile): + text_file = text_file.file # type: ignore + + with contextlib.suppress(ModuleNotFoundError): + from sanic.request import File as SanicUploadFile + + if isinstance(text_file, SanicUploadFile): + return text_file.body.decode() + + return text_file.read().decode() + + +UploadScalar = GraphQLScalarType( + name="Upload", + description="The `Upload` scalar type represents a file upload.", + serialize=lambda f: f, + parse_value=lambda f: f, + parse_literal=lambda node, vars=None: None, +) + + +def _parse_json_literal(node): + if isinstance(node, ast.StringValue): + return node.value + if isinstance(node, ast.IntValue): + return int(node.value) + if isinstance(node, ast.FloatValue): + return float(node.value) + if isinstance(node, ast.BooleanValue): + return node.value + if isinstance(node, ast.NullValue): + return None + if isinstance(node, ast.ListValue): + return [_parse_json_literal(v) for v in node.values] + if isinstance(node, ast.ObjectValue): + return { + field.name.value: _parse_json_literal(field.value) for field in node.fields + } + return None + + +JSONScalar = GraphQLScalarType( + name="JSON", + description="Arbitrary JSON value", + serialize=lambda v: v, + parse_value=lambda v: v, + parse_literal=_parse_json_literal, +) + +FlavorEnum = GraphQLEnumType( + name="Flavor", + values={ + "VANILLA": GraphQLEnumValue("vanilla"), + "STRAWBERRY": GraphQLEnumValue("strawberry"), + "CHOCOLATE": GraphQLEnumValue("chocolate"), + }, +) + +FolderInputType = GraphQLInputObjectType( + name="FolderInput", + fields={ + "files": GraphQLInputField( + GraphQLNonNull(GraphQLList(GraphQLNonNull(UploadScalar))) + ), + }, +) + +DebugInfoType = GraphQLObjectType( + name="DebugInfo", + fields=lambda: { + "numActiveResultHandlers": GraphQLField(GraphQLNonNull(GraphQLInt)), + "isConnectionInitTimeoutTaskDone": GraphQLField(GraphQLBoolean), + }, +) + + +def resolve_greetings(_root, _info): + return "hello" + + +def resolve_hello(_root, _info, name=None): + return f"Hello {name or 'world'}" + + +async def resolve_async_hello(_root, _info, name=None, delay=0): + await asyncio.sleep(delay) + return f"Hello {name or 'world'}" + + +def resolve_always_fail(_root, _info): + raise GraphQLError("You are not authorized") + + +def resolve_teapot(_root, info): + info.context["response"].status_code = 418 + return "🫖" + + +def resolve_root_name(root, _info): + return type(root).__name__ + + +def resolve_value_from_context(_root, info): + return info.context["custom_value"] + + +def resolve_value_from_extensions(_root, info, key): + # raise NotImplementedError("Not implemented") + return None + # return info.input_extensions[key] + + +def resolve_returns401(_root, info): + resp = info.context["response"] + if hasattr(resp, "set_status"): + resp.set_status(401) + else: + resp.status_code = 401 + return "hey" + + +def resolve_set_header(_root, info, name): + info.context["response"].headers["X-Name"] = name + return name + + +class Query: + pass + + +QueryType = GraphQLObjectType( + name="Query", + fields={ + "greetings": GraphQLField( + GraphQLNonNull(GraphQLString), resolve=resolve_greetings + ), + "hello": GraphQLField( + GraphQLNonNull(GraphQLString), + args={"name": GraphQLArgument(GraphQLString)}, + resolve=resolve_hello, + ), + "asyncHello": GraphQLField( + GraphQLNonNull(GraphQLString), + args={ + "name": GraphQLArgument(GraphQLString), + "delay": GraphQLArgument(GraphQLFloat), + }, + resolve=resolve_async_hello, + ), + "alwaysFail": GraphQLField(GraphQLString, resolve=resolve_always_fail), + "teapot": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_teapot), + "rootName": GraphQLField( + GraphQLNonNull(GraphQLString), resolve=resolve_root_name + ), + "valueFromContext": GraphQLField( + GraphQLNonNull(GraphQLString), resolve=resolve_value_from_context + ), + "valueFromExtensions": GraphQLField( + GraphQLNonNull(GraphQLString), + args={"key": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + resolve=resolve_value_from_extensions, + ), + "returns401": GraphQLField( + GraphQLNonNull(GraphQLString), resolve=resolve_returns401 + ), + "setHeader": GraphQLField( + GraphQLNonNull(GraphQLString), + args={"name": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + resolve=resolve_set_header, + ), + }, +) + + +def resolve_echo(_root, _info, stringToEcho): + return stringToEcho + + +def resolve_hello_mut(_root, _info): + return "teststring" + + +def resolve_read_text(_root, _info, textFile): + return _read_file(textFile) + + +def resolve_read_files(_root, _info, files): + return [_read_file(f) for f in files] + + +def resolve_read_folder(_root, _info, folder): + return [_read_file(f) for f in folder["files"]] + + +def resolve_match_text(_root, _info, textFile, pattern): + text = textFile.read().decode() + return pattern if pattern in text else "" + + +MutationType = GraphQLObjectType( + name="Mutation", + fields={ + "echo": GraphQLField( + GraphQLNonNull(GraphQLString), + args={"stringToEcho": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + resolve=resolve_echo, + ), + "hello": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_hello_mut), + "readText": GraphQLField( + GraphQLNonNull(GraphQLString), + args={"textFile": GraphQLArgument(GraphQLNonNull(UploadScalar))}, + resolve=resolve_read_text, + ), + "readFiles": GraphQLField( + GraphQLNonNull(GraphQLList(GraphQLNonNull(GraphQLString))), + args={ + "files": GraphQLArgument( + GraphQLNonNull(GraphQLList(GraphQLNonNull(UploadScalar))) + ) + }, + resolve=resolve_read_files, + ), + "readFolder": GraphQLField( + GraphQLNonNull(GraphQLList(GraphQLNonNull(GraphQLString))), + args={"folder": GraphQLArgument(GraphQLNonNull(FolderInputType))}, + resolve=resolve_read_folder, + ), + "matchText": GraphQLField( + GraphQLNonNull(GraphQLString), + args={ + "textFile": GraphQLArgument(GraphQLNonNull(UploadScalar)), + "pattern": GraphQLArgument(GraphQLNonNull(GraphQLString)), + }, + resolve=resolve_match_text, + ), + }, +) + + +async def subscribe_echo(root, info, message, delay=0): + await asyncio.sleep(delay) + yield message + + +async def subscribe_request_ping(_root, info): + from graphql_server.subscriptions.protocols.graphql_transport_ws.types import ( + PingMessage, + ) + + ws = info.context["ws"] + await ws.send_json(PingMessage({"type": "ping"})) + yield True + + +async def subscribe_infinity(_root, info, message): + Subscription.active_infinity_subscriptions += 1 + try: + while True: + yield message + await asyncio.sleep(1) + finally: + Subscription.active_infinity_subscriptions -= 1 + + +async def subscribe_context(_root, info): + yield info.context["custom_value"] + + +async def subscribe_error_sub(_root, info, message): + yield GraphQLError(message) + + +async def subscribe_exception(_root, _info, message): + raise ValueError(message) + yield + + +async def subscribe_flavors(_root, _info): + yield "vanilla" + yield "strawberry" + yield "chocolate" + + +async def subscribe_flavors_invalid(_root, _info): + yield "vanilla" + yield "invalid type" + yield "chocolate" + + +async def subscribe_debug(_root, info): + active = [t for t in info.context["get_tasks"]() if not t.done()] + timeout_task = info.context.get("connectionInitTimeoutTask") + done = timeout_task.done() if timeout_task else None + yield { + "numActiveResultHandlers": len(active), + "isConnectionInitTimeoutTaskDone": done, + } + + +async def subscribe_listener(_root, info, timeout=None, group=None): + yield info.context["request"].channel_name + async with info.context["request"].listen_to_channel( + type="test.message", + timeout=timeout, + groups=[group] if group is not None else [], + ) as cm: + async for msg in cm: + yield msg["text"] + + +async def subscribe_listener_with_confirmation(_root, info, timeout=None, group=None): + async with info.context["request"].listen_to_channel( + type="test.message", + timeout=timeout, + groups=[group] if group is not None else [], + ) as cm: + yield None + yield info.context["request"].channel_name + async for msg in cm: + yield msg["text"] + + +async def subscribe_connection_params(_root, info): + yield info.context["connection_params"] + + +async def subscribe_long_finalizer(_root, _info, delay=0): + try: + for _ in range(100): + yield "hello" + await asyncio.sleep(0.01) + finally: + await asyncio.sleep(delay) + + +SubscriptionType = GraphQLObjectType( + name="Subscription", + fields={ + "echo": GraphQLField( + GraphQLString, + args={ + "message": GraphQLArgument(GraphQLNonNull(GraphQLString)), + "delay": GraphQLArgument(GraphQLFloat), + }, + subscribe=subscribe_echo, + resolve=lambda payload, *args, **kwargs: payload, + ), + "requestPing": GraphQLField( + GraphQLNonNull(GraphQLBoolean), + subscribe=subscribe_request_ping, + resolve=lambda payload, _info: payload, + ), + "infinity": GraphQLField( + GraphQLString, + args={"message": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + subscribe=subscribe_infinity, + resolve=lambda payload, *args, **kwargs: payload, + ), + "context": GraphQLField( + GraphQLString, + subscribe=subscribe_context, + resolve=lambda payload, _info: payload, + ), + "error": GraphQLField( + GraphQLString, + args={"message": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + subscribe=subscribe_error_sub, + resolve=lambda payload, *args, **kwargs: payload, + ), + "exception": GraphQLField( + GraphQLString, + args={"message": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + subscribe=subscribe_exception, + resolve=lambda payload, *args, **kwargs: payload, + ), + "flavors": GraphQLField( + FlavorEnum, + subscribe=subscribe_flavors, + resolve=lambda payload, _info: payload, + ), + "flavorsInvalid": GraphQLField( + FlavorEnum, + subscribe=subscribe_flavors_invalid, + resolve=lambda payload, _info: payload, + ), + "debug": GraphQLField( + DebugInfoType, + subscribe=subscribe_debug, + resolve=lambda payload, _info: payload, + ), + "listener": GraphQLField( + GraphQLString, + args={ + "timeout": GraphQLArgument(GraphQLFloat), + "group": GraphQLArgument(GraphQLString), + }, + subscribe=subscribe_listener, + resolve=lambda payload, *args, **kwargs: payload, + ), + "listenerWithConfirmation": GraphQLField( + GraphQLString, + args={ + "timeout": GraphQLArgument(GraphQLFloat), + "group": GraphQLArgument(GraphQLString), + }, + subscribe=subscribe_listener_with_confirmation, + resolve=lambda payload, *args, **kwargs: payload, + ), + "connectionParams": GraphQLField( + JSONScalar, + subscribe=subscribe_connection_params, + resolve=lambda payload, _info: payload, + ), + "longFinalizer": GraphQLField( + GraphQLString, + args={"delay": GraphQLArgument(GraphQLFloat)}, + subscribe=subscribe_long_finalizer, + resolve=lambda payload, *args, **kwargs: payload, + ), + }, +) + + +class Subscription: + active_infinity_subscriptions: int = 0 + + +schema = GraphQLSchema( + query=QueryType, + mutation=MutationType, + subscription=SubscriptionType, +) diff --git a/src/tests/websockets/__init__.py b/src/tests/websockets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/websockets/conftest.py b/src/tests/websockets/conftest.py new file mode 100644 index 0000000..9fd5631 --- /dev/null +++ b/src/tests/websockets/conftest.py @@ -0,0 +1,44 @@ +import importlib +from collections.abc import Generator +from typing import Any + +import pytest + +from tests.http.clients.base import HttpClient + + +def _get_http_client_classes() -> Generator[Any, None, None]: + for client, module, marks in [ + ("AioHttpClient", "aiohttp", [pytest.mark.aiohttp]), + ("AsgiHttpClient", "asgi", [pytest.mark.asgi]), + ("ChannelsHttpClient", "channels", [pytest.mark.channels]), + ("FastAPIHttpClient", "fastapi", [pytest.mark.fastapi]), + ("LitestarHttpClient", "litestar", [pytest.mark.litestar]), + ("QuartHttpClient", "quart", [pytest.mark.quart]), + ]: + try: + client_class = getattr( + importlib.import_module(f"tests.http.clients.{module}"), client + ) + except ImportError: + client_class = None + + yield pytest.param( + client_class, + marks=[ + *marks, + pytest.mark.skipif( + client_class is None, reason=f"Client {client} not found" + ), + ], + ) + + +@pytest.fixture(params=_get_http_client_classes()) +def http_client_class(request: Any) -> type[HttpClient]: + return request.param + + +@pytest.fixture +def http_client(http_client_class: type[HttpClient]) -> HttpClient: + return http_client_class() diff --git a/src/tests/websockets/test_graphql_transport_ws.py b/src/tests/websockets/test_graphql_transport_ws.py new file mode 100644 index 0000000..dbfaa3b --- /dev/null +++ b/src/tests/websockets/test_graphql_transport_ws.py @@ -0,0 +1,1218 @@ +from __future__ import annotations + +import asyncio +import json +import time +from collections.abc import AsyncGenerator +from datetime import timedelta +from typing import TYPE_CHECKING, Optional, Union +from unittest.mock import AsyncMock, Mock, patch + +import pytest +import pytest_asyncio +from pytest_mock import MockerFixture + +from graphql_server import runtime as graphql_server_runtime +from graphql_server.subscriptions import GRAPHQL_TRANSPORT_WS_PROTOCOL +from graphql_server.subscriptions.protocols.graphql_transport_ws.types import ( + CompleteMessage, + ConnectionAckMessage, + ConnectionInitMessage, + ErrorMessage, + NextMessage, + PingMessage, + PongMessage, + SubscribeMessage, +) +from tests.http.clients.base import DebuggableGraphQLTransportWSHandler +from tests.views.schema import Subscription + +if TYPE_CHECKING: + from tests.http.clients.base import HttpClient, WebSocketClient + + +@pytest_asyncio.fixture +async def ws_raw(http_client: HttpClient) -> AsyncGenerator[WebSocketClient, None]: + async with http_client.ws_connect( + "/graphql", protocols=[GRAPHQL_TRANSPORT_WS_PROTOCOL] + ) as ws: + yield ws + await ws.close() + assert ws.closed + + +@pytest_asyncio.fixture +async def ws(ws_raw: WebSocketClient) -> WebSocketClient: + await ws_raw.send_message({"type": "connection_init"}) + connection_ack_message: ConnectionAckMessage = await ws_raw.receive_json() + assert connection_ack_message == {"type": "connection_ack"} + return ws_raw + + +def assert_next( + next_message: NextMessage, + id: str, + data: dict[str, object], + extensions: Optional[dict[str, object]] = None, +): + """Assert that the NextMessage payload contains the provided data. + If extensions is provided, it will also assert that the + extensions are present + """ + assert next_message["type"] == "next" + assert next_message["id"] == id + assert set(next_message["payload"].keys()) <= {"data", "errors", "extensions"} + assert "data" in next_message["payload"] + assert next_message["payload"]["data"] == data + if extensions is not None: + assert "extensions" in next_message["payload"] + assert next_message["payload"]["extensions"] == extensions + + +async def test_unknown_message_type(ws_raw: WebSocketClient): + ws = ws_raw + + await ws.send_json({"type": "NOT_A_MESSAGE_TYPE"}) + + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4400 + assert ws.close_reason == "Unknown message type: NOT_A_MESSAGE_TYPE" + + +async def test_missing_message_type(ws_raw: WebSocketClient): + ws = ws_raw + + await ws.send_json({"notType": None}) + + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4400 + assert ws.close_reason == "Failed to parse message" + + +async def test_parsing_an_invalid_message(ws: WebSocketClient): + await ws.send_json({"type": "subscribe", "notPayload": None}) + + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4400 + assert ws.close_reason == "Failed to parse message" + + +async def test_non_text_ws_messages_result_in_socket_closure(ws_raw: WebSocketClient): + ws = ws_raw + + await ws.send_bytes( + json.dumps(ConnectionInitMessage({"type": "connection_init"})).encode() + ) + + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4400 + assert ws.close_reason == "WebSocket message type must be text" + + +async def test_non_json_ws_messages_result_in_socket_closure(ws_raw: WebSocketClient): + ws = ws_raw + + await ws.send_text("not valid json") + + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4400 + assert ws.close_reason == "WebSocket message must be valid JSON" + + +async def test_ws_message_frame_types_cannot_be_mixed(ws_raw: WebSocketClient): + ws = ws_raw + + await ws.send_message({"type": "connection_init"}) + + ack_message: ConnectionAckMessage = await ws.receive_json() + assert ack_message == {"type": "connection_ack"} + + await ws.send_bytes( + json.dumps( + SubscribeMessage( + { + "id": "sub1", + "type": "subscribe", + "payload": { + "query": "subscription { debug { isConnectionInitTimeoutTaskDone } }" + }, + } + ) + ).encode() + ) + + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4400 + assert ws.close_reason == "WebSocket message type must be text" + + +async def test_connection_init_timeout(http_client_class: type[HttpClient]): + test_client = http_client_class(connection_init_wait_timeout=timedelta(seconds=0)) + + async with test_client.ws_connect( + "/graphql", protocols=[GRAPHQL_TRANSPORT_WS_PROTOCOL] + ) as ws: + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4408 + assert ws.close_reason == "Connection initialisation timeout" + + +@pytest.mark.flaky +async def test_connection_init_timeout_cancellation( + ws_raw: WebSocketClient, +): + # Verify that the timeout task is cancelled after the connection Init + # message is received + ws = ws_raw + await ws.send_message({"type": "connection_init"}) + + connection_ack_message: ConnectionAckMessage = await ws.receive_json() + assert connection_ack_message == {"type": "connection_ack"} + + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": { + "query": "subscription { debug { isConnectionInitTimeoutTaskDone } }" + }, + } + ) + + next_message: NextMessage = await ws.receive_json() + assert_next( + next_message, "sub1", {"debug": {"isConnectionInitTimeoutTaskDone": True}} + ) + + +async def test_close_twice(mocker: MockerFixture, http_client_class: type[HttpClient]): + test_client = http_client_class( + connection_init_wait_timeout=timedelta(seconds=0.25) + ) + + async with test_client.ws_connect( + "/graphql", protocols=[GRAPHQL_TRANSPORT_WS_PROTOCOL] + ) as ws: + transport_close = mocker.patch.object(ws, "close") + + # We set payload is set to "invalid value" to force a invalid payload error + # which will close the connection + await ws.send_json({"type": "connection_init", "payload": "invalid value"}) + + # Yield control so that ._close can be called + await asyncio.sleep(0) + + for t in asyncio.all_tasks(): + if ( + t.get_coro().__qualname__ + == "BaseGraphQLTransportWSHandler.handle_connection_init_timeout" + ): + # The init timeout task should be cancelled + with pytest.raises(asyncio.CancelledError): + await t + + await ws.receive(timeout=0.5) + assert ws.closed + assert ws.close_code == 4400 + assert ws.close_reason == "Invalid connection init payload" + transport_close.assert_not_called() + + +async def test_too_many_initialisation_requests(ws: WebSocketClient): + await ws.send_message({"type": "connection_init"}) + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4429 + assert ws.close_reason == "Too many initialisation requests" + + +async def test_connections_are_accepted_by_default(ws_raw: WebSocketClient): + await ws_raw.send_message({"type": "connection_init"}) + connection_ack_message: ConnectionAckMessage = await ws_raw.receive_json() + assert connection_ack_message == {"type": "connection_ack"} + + await ws_raw.close() + assert ws_raw.closed + + +@pytest.mark.parametrize("payload", [None, {"token": "secret"}]) +async def test_setting_a_connection_ack_payload(ws_raw: WebSocketClient, payload): + await ws_raw.send_message( + { + "type": "connection_init", + "payload": {"test-accept": True, "ack-payload": payload}, + } + ) + + connection_ack_message: ConnectionAckMessage = await ws_raw.receive_json() + assert connection_ack_message == {"type": "connection_ack", "payload": payload} + + await ws_raw.close() + assert ws_raw.closed + + +async def test_connection_ack_payload_may_be_unset(ws_raw: WebSocketClient): + await ws_raw.send_message( + { + "type": "connection_init", + "payload": {"test-accept": True}, + } + ) + + connection_ack_message: ConnectionAckMessage = await ws_raw.receive_json() + assert connection_ack_message == {"type": "connection_ack"} + + await ws_raw.close() + assert ws_raw.closed + + +async def test_rejecting_connection_closes_socket_with_expected_code_and_message( + ws_raw: WebSocketClient, +): + await ws_raw.send_message( + {"type": "connection_init", "payload": {"test-reject": True}} + ) + + await ws_raw.receive(timeout=2) + assert ws_raw.closed + assert ws_raw.close_code == 4403 + assert ws_raw.close_reason == "Forbidden" + + +async def test_context_can_be_modified_from_within_on_ws_connect( + ws_raw: WebSocketClient, +): + await ws_raw.send_message( + { + "type": "connection_init", + "payload": {"test-modify": True}, + } + ) + + connection_ack_message: ConnectionAckMessage = await ws_raw.receive_json() + assert connection_ack_message == {"type": "connection_ack"} + + await ws_raw.send_message( + { + "type": "subscribe", + "id": "demo", + "payload": { + "query": "subscription { connectionParams }", + }, + } + ) + + next_message: NextMessage = await ws_raw.receive_json() + assert next_message["type"] == "next" + assert next_message["id"] == "demo" + assert "data" in next_message["payload"] + assert next_message["payload"]["data"] == { + "connectionParams": {"test-modify": True, "modified": True} + } + + await ws_raw.close() + assert ws_raw.closed + + +async def test_ping_pong(ws: WebSocketClient): + await ws.send_message({"type": "ping"}) + pong_message: PongMessage = await ws.receive_json() + assert pong_message == {"type": "pong"} + + +async def test_can_send_payload_with_additional_things(ws_raw: WebSocketClient): + ws = ws_raw + + # send init + + await ws.send_message({"type": "connection_init"}) + + await ws.receive(timeout=2) + + await ws.send_message( + { + "type": "subscribe", + "payload": { + "query": 'subscription { echo(message: "Hi") }', + # "extensions": { + # "some": "other thing", + # }, + }, + "id": "1", + } + ) + + next_message: NextMessage = await ws.receive_json(timeout=2) + + assert next_message == { + "type": "next", + "id": "1", + "payload": { + "data": {"echo": "Hi"}, + # "extensions": {"example": "example"} + }, + } + + +async def test_server_sent_ping(ws: WebSocketClient): + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": {"query": "subscription { requestPing }"}, + } + ) + + ping_message: PingMessage = await ws.receive_json() + assert ping_message == {"type": "ping"} + + await ws.send_message({"type": "pong"}) + + next_message: NextMessage = await ws.receive_json() + assert_next(next_message, "sub1", {"requestPing": True}) + + complete_message: CompleteMessage = await ws.receive_json() + assert complete_message == {"id": "sub1", "type": "complete"} + + +async def test_unauthorized_subscriptions(ws_raw: WebSocketClient): + ws = ws_raw + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": {"query": 'subscription { echo(message: "Hi") }'}, + } + ) + + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4401 + assert ws.close_reason == "Unauthorized" + + +async def test_duplicated_operation_ids(ws: WebSocketClient): + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": {"query": 'subscription { echo(message: "Hi", delay: 5) }'}, + } + ) + + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": {"query": 'subscription { echo(message: "Hi", delay: 5) }'}, + } + ) + + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4409 + assert ws.close_reason == "Subscriber for sub1 already exists" + + +async def test_reused_operation_ids(ws: WebSocketClient): + """Test that an operation id can be re-used after it has been + previously used for a completed operation. + """ + # Use sub1 as an id for an operation + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": {"query": 'subscription { echo(message: "Hi") }'}, + } + ) + + next_message1: NextMessage = await ws.receive_json() + assert_next(next_message1, "sub1", {"echo": "Hi"}) + + complete_message: CompleteMessage = await ws.receive_json() + assert complete_message == {"id": "sub1", "type": "complete"} + + # operation is now complete. Create a new operation using + # the same ID + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": {"query": 'subscription { echo(message: "Hi") }'}, + } + ) + + next_message2: NextMessage = await ws.receive_json() + assert_next(next_message2, "sub1", {"echo": "Hi"}) + + +async def test_simple_subscription(ws: WebSocketClient): + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": {"query": 'subscription { echo(message: "Hi") }'}, + } + ) + + next_message: NextMessage = await ws.receive_json() + assert_next(next_message, "sub1", {"echo": "Hi"}) + await ws.send_message({"id": "sub1", "type": "complete"}) + + +@pytest.mark.parametrize( + ("extra_payload", "expected_message"), + [ + # TODO: INCOMPATIBLE WITH OTHER TESTS + # ({}, "Hi1"), + # ({"operationName": None}, "Hi1"), + ({"operationName": "Subscription1"}, "Hi1"), + ({"operationName": "Subscription2"}, "Hi2"), + ], +) +async def test_operation_selection( + ws: WebSocketClient, extra_payload, expected_message +): + await ws.send_json( + { + "type": "subscribe", + "id": "sub1", + "payload": { + "query": """ + subscription Subscription1 { echo(message: "Hi1") } + subscription Subscription2 { echo(message: "Hi2") } + """, + **extra_payload, + }, + } + ) + + next_message: NextMessage = await ws.receive_json() + assert_next(next_message, "sub1", {"echo": expected_message}) + await ws.send_message({"id": "sub1", "type": "complete"}) + + +@pytest.mark.parametrize( + ("operation_name"), + ["", "Subscription2"], +) +async def test_invalid_operation_selection(ws: WebSocketClient, operation_name): + await ws.send_message( + { + "type": "subscribe", + "id": "sub1", + "payload": { + "query": """ + subscription Subscription1 { echo(message: "Hi1") } + """, + "operationName": f"{operation_name}", + }, + } + ) + + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4400 + assert ws.close_reason == f'Unknown operation named "{operation_name}".' + + +async def test_operation_selection_without_operations(ws: WebSocketClient): + await ws.send_message( + { + "type": "subscribe", + "id": "sub1", + "payload": { + "query": """ + fragment Fragment1 on Query { __typename } + """, + }, + } + ) + + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4400 + assert ws.close_reason == "Can't get GraphQL operation type" + + +async def test_subscription_syntax_error(ws: WebSocketClient): + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": {"query": "subscription { INVALID_SYNTAX "}, + } + ) + + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4400 + assert ws.close_reason == "Syntax Error: Expected Name, found ." + + +async def test_subscription_field_errors(ws: WebSocketClient): + process_errors = Mock() + with patch.object(graphql_server_runtime, "process_errors", process_errors): + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": { + "query": "subscription { notASubscriptionField }", + }, + } + ) + + error_message: ErrorMessage = await ws.receive_json() + assert error_message["type"] == "error" + assert error_message["id"] == "sub1" + assert len(error_message["payload"]) == 1 + + assert "locations" in error_message["payload"][0] + assert error_message["payload"][0]["locations"] == [{"line": 1, "column": 16}] + + assert "message" in error_message["payload"][0] + assert ( + error_message["payload"][0]["message"] + == "Cannot query field 'notASubscriptionField' on type 'Subscription'." + ) + + process_errors.assert_called_once() + + +async def test_subscription_cancellation(ws: WebSocketClient): + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": {"query": 'subscription { echo(message: "Hi", delay: 99) }'}, + } + ) + + await ws.send_message( + { + "id": "sub2", + "type": "subscribe", + "payload": { + "query": "subscription { debug { numActiveResultHandlers } }", + }, + } + ) + + next_message: NextMessage = await ws.receive_json() + assert_next(next_message, "sub2", {"debug": {"numActiveResultHandlers": 2}}) + + complete_message: CompleteMessage = await ws.receive_json() + assert complete_message == {"id": "sub2", "type": "complete"} + + await ws.send_message({"id": "sub1", "type": "complete"}) + + await ws.send_message( + { + "id": "sub3", + "type": "subscribe", + "payload": { + "query": "subscription { debug { numActiveResultHandlers } }", + }, + } + ) + + next_message: NextMessage = await ws.receive_json() + assert_next(next_message, "sub3", {"debug": {"numActiveResultHandlers": 1}}) + + complete_message: CompleteMessage = await ws.receive_json() + assert complete_message == {"id": "sub3", "type": "complete"} + + +async def test_subscription_errors(ws: WebSocketClient): + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": { + "query": 'subscription { error(message: "TEST ERR") }', + }, + } + ) + + next_message: NextMessage = await ws.receive_json() + assert next_message["type"] == "next" + assert next_message["id"] == "sub1" + + assert "errors" in next_message["payload"] + payload_errors = next_message["payload"]["errors"] + assert payload_errors is not None + assert len(payload_errors) == 1 + + assert "path" in payload_errors[0] + assert payload_errors[0]["path"] == ["error"] + + assert "message" in payload_errors[0] + assert payload_errors[0]["message"] == "TEST ERR" + + +async def test_operation_error_no_complete(ws: WebSocketClient): + """Test that an "error" message is not followed by "complete".""" + # Since we don't include the operation variables, + # the subscription will fail immediately. + # see https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md#error + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": { + "query": "subscription Foo($bar: String!){ exception(message: $bar) }", + }, + } + ) + + error_message: ErrorMessage = await ws.receive_json() + assert error_message["type"] == "error" + assert error_message["id"] == "sub1" + + # after an "error" message, there should be nothing more + # sent regarding "sub1", not even a "complete". + await ws.send_message({"type": "ping"}) + + pong_message: PongMessage = await ws.receive_json(timeout=1) + assert pong_message == {"type": "pong"} + + +async def test_subscription_exceptions(ws: WebSocketClient): + process_errors = Mock() + with patch.object(graphql_server_runtime, "process_errors", process_errors): + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": { + "query": 'subscription { exception(message: "TEST EXC") }', + }, + } + ) + + next_message: NextMessage = await ws.receive_json() + assert next_message["type"] == "next" + assert next_message["id"] == "sub1" + assert "errors" in next_message["payload"] + assert next_message["payload"]["errors"] == [{"message": "TEST EXC"}] + process_errors.assert_called_once() + + +async def test_single_result_query_operation(ws: WebSocketClient): + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": {"query": "query { hello }"}, + } + ) + + next_message: NextMessage = await ws.receive_json() + assert_next(next_message, "sub1", {"hello": "Hello world"}) + + complete_message: CompleteMessage = await ws.receive_json() + assert complete_message == {"id": "sub1", "type": "complete"} + + +async def test_single_result_query_operation_async(ws: WebSocketClient): + """Test a single result query operation on an + `async` method in the schema, including an artificial + async delay. + """ + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": {"query": 'query { asyncHello(name: "Dolly", delay:0.01)}'}, + } + ) + + next_message: NextMessage = await ws.receive_json() + assert_next(next_message, "sub1", {"asyncHello": "Hello Dolly"}) + + complete_message: CompleteMessage = await ws.receive_json() + assert complete_message == {"id": "sub1", "type": "complete"} + + +async def test_single_result_query_operation_overlapped(ws: WebSocketClient): + """Test that two single result queries can be in flight at the same time, + just like regular queries. Start two queries with separate ids. The + first query has a delay, so we expect the message to the second + query to be delivered first. + """ + # first query + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": {"query": 'query { asyncHello(name: "Dolly", delay:1)}'}, + } + ) + # second query + await ws.send_message( + { + "id": "sub2", + "type": "subscribe", + "payload": {"query": 'query { asyncHello(name: "Dolly", delay:0)}'}, + } + ) + + # we expect the message to the second query to arrive first + next_message: NextMessage = await ws.receive_json() + assert_next(next_message, "sub2", {"asyncHello": "Hello Dolly"}) + + complete_message: CompleteMessage = await ws.receive_json() + assert complete_message == {"id": "sub2", "type": "complete"} + + +async def test_single_result_mutation_operation(ws: WebSocketClient): + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": {"query": "mutation { hello }"}, + } + ) + + next_message: NextMessage = await ws.receive_json() + assert_next(next_message, "sub1", {"hello": "teststring"}) + + complete_message: CompleteMessage = await ws.receive_json() + assert complete_message == {"id": "sub1", "type": "complete"} + + +@pytest.mark.parametrize( + ("extra_payload", "expected_message"), + [ + # TODO: INCOMPATIBLE WITH GRAPHQL-CORE + # ({}, "Hello graphql-server1"), + # ({"operationName": None}, "Hello graphql-server1"), + ({"operationName": "Query1"}, "Hello graphql-server1"), + ({"operationName": "Query2"}, "Hello graphql-server2"), + ], +) +async def test_single_result_operation_selection( + ws: WebSocketClient, extra_payload, expected_message +): + query = """ + query Query1 { + hello(name: "graphql-server1") + } + query Query2 { + hello(name: "graphql-server2") + } + """ + + await ws.send_json( + { + "id": "sub1", + "type": "subscribe", + "payload": {"query": query, **extra_payload}, + } + ) + + next_message: NextMessage = await ws.receive_json() + assert_next(next_message, "sub1", {"hello": expected_message}) + + complete_message: CompleteMessage = await ws.receive_json() + assert complete_message == {"id": "sub1", "type": "complete"} + + +@pytest.mark.parametrize( + "operation_name", + ["", "Query2"], +) +async def test_single_result_invalid_operation_selection( + ws: WebSocketClient, operation_name +): + query = """ + query Query1 { + hello + } + """ + + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": {"query": query, "operationName": operation_name}, + } + ) + + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4400 + assert ws.close_reason == f'Unknown operation named "{operation_name}".' + + +async def test_single_result_operation_selection_without_operations( + ws: WebSocketClient, +): + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": { + "query": """ + fragment Fragment1 on Query { __typename } + """, + }, + } + ) + + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4400 + assert ws.close_reason == "Can't get GraphQL operation type" + + +async def test_single_result_execution_error(ws: WebSocketClient): + process_errors = Mock() + with patch.object(graphql_server_runtime, "process_errors", process_errors): + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": { + "query": "query { alwaysFail }", + }, + } + ) + + next_message: NextMessage = await ws.receive_json() + assert next_message["type"] == "next" + assert next_message["id"] == "sub1" + + assert "errors" in next_message["payload"] + payload_errors = next_message["payload"]["errors"] + assert payload_errors is not None + assert len(payload_errors) == 1 + + assert "path" in payload_errors[0] + assert payload_errors[0]["path"] == ["alwaysFail"] + + assert "message" in payload_errors[0] + assert payload_errors[0]["message"] == "You are not authorized" + + process_errors.assert_called_once() + + +async def test_single_result_pre_execution_error(ws: WebSocketClient): + """Test that single-result-operations which raise exceptions + behave in the same way as streaming operations. + """ + process_errors = Mock() + with patch.object(graphql_server_runtime, "process_errors", process_errors): + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": { + "query": "query { IDontExist }", + }, + } + ) + + error_message: ErrorMessage = await ws.receive_json() + print(error_message) + assert error_message["type"] == "error" + assert error_message["id"] == "sub1" + assert len(error_message["payload"]) == 1 + assert "message" in error_message["payload"][0] + assert ( + error_message["payload"][0]["message"] + == "Cannot query field 'IDontExist' on type 'Query'." + ) + process_errors.assert_called_once() + + +async def test_single_result_duplicate_ids_sub(ws: WebSocketClient): + """Test that single-result-operations and streaming operations + share the same ID namespace. Start a regular subscription, + then issue a single-result operation with same ID and expect an + error due to already existing ID + """ + # regular subscription + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": {"query": 'subscription { echo(message: "Hi", delay: 5) }'}, + } + ) + # single result subscription with duplicate id + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": { + "query": "query { hello }", + }, + } + ) + + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4409 + assert ws.close_reason == "Subscriber for sub1 already exists" + + +async def test_single_result_duplicate_ids_query(ws: WebSocketClient): + """Test that single-result-operations don't allow duplicate + IDs for two asynchronous queries. Issue one async query + with delay, then another with same id. Expect error. + """ + # single result subscription 1 + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": {"query": 'query { asyncHello(name: "Hi", delay: 5) }'}, + } + ) + # single result subscription with duplicate id + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": { + "query": "query { hello }", + }, + } + ) + + # We expect the remote to close the socket due to duplicate ID in use + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4409 + assert ws.close_reason == "Subscriber for sub1 already exists" + + +async def test_injects_connection_params(ws_raw: WebSocketClient): + ws = ws_raw + await ws.send_message( + {"type": "connection_init", "payload": {"graphql_server": "rocks"}} + ) + + connection_ack_message: ConnectionAckMessage = await ws.receive_json() + assert connection_ack_message == {"type": "connection_ack"} + + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": {"query": "subscription { connectionParams }"}, + } + ) + + next_message: NextMessage = await ws.receive_json() + assert_next(next_message, "sub1", {"connectionParams": {"graphql_server": "rocks"}}) + + await ws.send_message({"id": "sub1", "type": "complete"}) + + +async def test_rejects_connection_params_not_dict(ws_raw: WebSocketClient): + ws = ws_raw + await ws.send_json({"type": "connection_init", "payload": "gonna fail"}) + + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4400 + assert ws.close_reason == "Invalid connection init payload" + + +@pytest.mark.parametrize( + "payload", + [[], "invalid value", 1], +) +async def test_rejects_connection_params_with_wrong_type( + payload: object, ws_raw: WebSocketClient +): + ws = ws_raw + await ws.send_json({"type": "connection_init", "payload": payload}) + + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4400 + assert ws.close_reason == "Invalid connection init payload" + + +async def test_subsciption_cancel_finalization_delay(ws: WebSocketClient): + # Test that when we cancel a subscription, the websocket isn't blocked + # while some complex finalization takes place. + delay = 0.1 + + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": {"query": f"subscription {{ longFinalizer(delay: {delay}) }}"}, + } + ) + + next_message: NextMessage = await ws.receive_json() + assert_next(next_message, "sub1", {"longFinalizer": "hello"}) + + # now cancel the stubscription and send a new query. We expect the message + # to the new query to arrive immediately, without waiting for the finalizer + start = time.time() + await ws.send_message({"id": "sub1", "type": "complete"}) + await ws.send_message( + { + "id": "sub2", + "type": "subscribe", + "payload": {"query": "query { hello }"}, + } + ) + + while True: + next_or_complete_message: Union[ + NextMessage, CompleteMessage + ] = await ws.receive_json() + + assert next_or_complete_message["type"] in ("next", "complete") + + if next_or_complete_message["id"] == "sub2": + break + + end = time.time() + elapsed = end - start + assert elapsed < delay * 2 # adds a 100% to make sure it runs well in CI + + +async def test_error_handler_for_timeout(http_client: HttpClient): + """Test that the error handler is called when the timeout + task encounters an error. + """ + # with contextlib.suppress(ImportError): + # from tests.http.clients.channels import ChannelsHttpClient + + # if isinstance(http_client, ChannelsHttpClient): + # pytest.skip("Can't patch on_init for this client") + + # if not AsyncMock: + # pytest.skip("Don't have AsyncMock") + + ws = ws_raw + handler = None + errorhandler = AsyncMock() + + def on_init(_handler): + nonlocal handler + if handler: + return + handler = _handler + # patch the object + handler.handle_task_exception = errorhandler + # cause an attribute error in the timeout task + handler.connection_init_wait_timeout = None + + with patch.object(DebuggableGraphQLTransportWSHandler, "on_init", on_init): + async with http_client.ws_connect( + "/graphql", protocols=[GRAPHQL_TRANSPORT_WS_PROTOCOL] + ) as ws: + await asyncio.sleep(0.01) # wait for the timeout task to start + await ws.send_message({"type": "connection_init"}) + connection_ack_message: ConnectionAckMessage = await ws.receive_json() + assert connection_ack_message == {"type": "connection_ack"} + await ws.close() + + # the error hander should have been called + assert handler + errorhandler.assert_called_once() + args = errorhandler.call_args + assert isinstance(args[0][0], AttributeError) + assert "total_seconds" in str(args[0][0]) + + +async def test_subscription_errors_continue(ws: WebSocketClient): + """Verify that an ExecutionResult with errors during subscription does not terminate + the subscription. + """ + process_errors = Mock() + with patch.object(graphql_server_runtime, "process_errors", process_errors): + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": { + "query": "subscription { flavorsInvalid }", + }, + } + ) + + next_message1: NextMessage = await ws.receive_json() + assert next_message1["type"] == "next" + assert next_message1["id"] == "sub1" + assert "data" in next_message1["payload"] + assert next_message1["payload"]["data"] == {"flavorsInvalid": "VANILLA"} + + next_message2: NextMessage = await ws.receive_json() + assert next_message2["type"] == "next" + assert next_message2["id"] == "sub1" + assert "data" in next_message2["payload"] + # TODO: INCOMPATIBLE WITH OTHER TESTS + assert next_message2["payload"]["data"] == {"flavorsInvalid": None} + assert "errors" in next_message2["payload"] + assert "cannot represent value" in str(next_message2["payload"]["errors"]) + process_errors.assert_called_once() + + next_message3: NextMessage = await ws.receive_json() + assert next_message3["type"] == "next" + assert next_message3["id"] == "sub1" + assert "data" in next_message3["payload"] + assert next_message3["payload"]["data"] == {"flavorsInvalid": "CHOCOLATE"} + + complete_message: CompleteMessage = await ws.receive_json() + assert complete_message["type"] == "complete" + assert complete_message["id"] == "sub1" + + +# @patch.object(MyExtension, MyExtension.get_results.__name__, return_value={}) +# async def test_no_extensions_results_wont_send_extensions_in_payload( +# mock: Mock, ws: WebSocketClient +# ): +# await ws.send_message( +# { +# "id": "sub1", +# "type": "subscribe", +# "payload": {"query": 'subscription { echo(message: "Hi") }'}, +# } +# ) + +# next_message: NextMessage = await ws.receive_json() +# mock.assert_called_once() +# assert_next(next_message, "sub1", {"echo": "Hi"}) +# assert "extensions" not in next_message["payload"] + + +async def test_unexpected_client_disconnects_are_gracefully_handled( + ws: WebSocketClient, +): + process_errors = Mock() + + with patch.object(graphql_server_runtime, "process_errors", process_errors): + await ws.send_message( + { + "id": "sub1", + "type": "subscribe", + "payload": {"query": 'subscription { infinity(message: "Hi") }'}, + } + ) + await ws.receive(timeout=1) + assert Subscription.active_infinity_subscriptions == 1 + + await ws.close() + await asyncio.sleep(0.5) + + assert not process_errors.called + assert Subscription.active_infinity_subscriptions == 0 diff --git a/src/tests/websockets/test_graphql_ws.py b/src/tests/websockets/test_graphql_ws.py new file mode 100644 index 0000000..4ae4438 --- /dev/null +++ b/src/tests/websockets/test_graphql_ws.py @@ -0,0 +1,869 @@ +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncGenerator +from typing import TYPE_CHECKING, Union +from unittest import mock + +import pytest +import pytest_asyncio + +import graphql_server +from graphql_server.subscriptions import GRAPHQL_WS_PROTOCOL +from graphql_server.subscriptions.protocols.graphql_ws.types import ( + CompleteMessage, + ConnectionAckMessage, + ConnectionErrorMessage, + ConnectionInitMessage, + ConnectionKeepAliveMessage, + DataMessage, + ErrorMessage, + StartMessage, +) +from tests.views.schema import Subscription + +if TYPE_CHECKING: + from tests.http.clients.base import HttpClient, WebSocketClient + + +@pytest_asyncio.fixture +async def ws_raw(http_client: HttpClient) -> AsyncGenerator[WebSocketClient, None]: + async with http_client.ws_connect( + "/graphql", protocols=[GRAPHQL_WS_PROTOCOL] + ) as ws: + yield ws + await ws.close() + assert ws.closed + + +@pytest_asyncio.fixture +async def ws(ws_raw: WebSocketClient) -> AsyncGenerator[WebSocketClient, None]: + ws = ws_raw + + await ws.send_legacy_message({"type": "connection_init"}) + response: ConnectionAckMessage = await ws.receive_json() + assert response["type"] == "connection_ack" + + yield ws + + await ws.send_legacy_message({"type": "connection_terminate"}) + # make sure the WebSocket is disconnected now + await ws.receive(timeout=1) # receive close + assert ws.closed + + +async def test_simple_subscription(ws: WebSocketClient): + await ws.send_legacy_message( + { + "type": "start", + "id": "demo", + "payload": { + "query": 'subscription { echo(message: "Hi") }', + }, + } + ) + + data_message: DataMessage = await ws.receive_json() + assert data_message["type"] == "data" + assert data_message["id"] == "demo" + assert data_message["payload"]["data"] == {"echo": "Hi"} + + await ws.send_legacy_message({"type": "stop", "id": "demo"}) + + complete_message: CompleteMessage = await ws.receive_json() + assert complete_message["type"] == "complete" + assert complete_message["id"] == "demo" + + +@pytest.mark.parametrize( + ("extra_payload", "expected_message"), + [ + # TODO: INCOMPATIBLE WITH OTHER TESTS + # ({}, "Hi1"), + # ({"operationName": None}, "Hi1"), + ({"operationName": "Subscription1"}, "Hi1"), + ({"operationName": "Subscription2"}, "Hi2"), + ], +) +async def test_operation_selection( + ws: WebSocketClient, extra_payload, expected_message +): + await ws.send_json( + { + "type": "start", + "id": "demo", + "payload": { + "query": """ + subscription Subscription1 { echo(message: "Hi1") } + subscription Subscription2 { echo(message: "Hi2") } + """, + **extra_payload, + }, + } + ) + + data_message: DataMessage = await ws.receive_json() + assert data_message["type"] == "data" + assert data_message["id"] == "demo" + assert data_message["payload"]["data"] == {"echo": expected_message} + + await ws.send_legacy_message({"type": "stop", "id": "demo"}) + + complete_message: CompleteMessage = await ws.receive_json() + assert complete_message["type"] == "complete" + assert complete_message["id"] == "demo" + + +@pytest.mark.parametrize( + ("operation_name"), + ["", "Subscription2"], +) +async def test_invalid_operation_selection(ws: WebSocketClient, operation_name): + await ws.send_legacy_message( + { + "type": "start", + "id": "demo", + "payload": { + "query": """ + subscription Subscription1 { echo(message: "Hi1") } + """, + "operationName": operation_name, + }, + } + ) + + error_message: ErrorMessage = await ws.receive_json() + assert error_message["type"] == "error" + assert error_message["id"] == "demo" + assert error_message["payload"] == { + "message": f'Unknown operation named "{operation_name}".' + } + + +async def test_operation_selection_without_operations(ws: WebSocketClient): + await ws.send_legacy_message( + { + "type": "start", + "id": "demo", + "payload": { + "query": """ + fragment Fragment1 on Query { __typename } + """, + }, + } + ) + + error_message: ErrorMessage = await ws.receive_json() + assert error_message["type"] == "error" + assert error_message["id"] == "demo" + assert error_message["payload"] == {"message": "Can't get GraphQL operation type"} + + +async def test_connections_are_accepted_by_default(ws_raw: WebSocketClient): + await ws_raw.send_legacy_message({"type": "connection_init"}) + connection_ack_message: ConnectionAckMessage = await ws_raw.receive_json() + assert connection_ack_message == {"type": "connection_ack"} + + await ws_raw.close() + assert ws_raw.closed + + +async def test_setting_a_connection_ack_payload(ws_raw: WebSocketClient): + await ws_raw.send_legacy_message( + { + "type": "connection_init", + "payload": {"test-accept": True, "ack-payload": {"token": "secret"}}, + } + ) + + connection_ack_message: ConnectionAckMessage = await ws_raw.receive_json() + assert connection_ack_message == { + "type": "connection_ack", + "payload": {"token": "secret"}, + } + + await ws_raw.close() + assert ws_raw.closed + + +async def test_connection_ack_payload_may_be_unset(ws_raw: WebSocketClient): + await ws_raw.send_legacy_message( + { + "type": "connection_init", + "payload": {"test-accept": True}, + } + ) + + connection_ack_message: ConnectionAckMessage = await ws_raw.receive_json() + assert connection_ack_message == {"type": "connection_ack"} + + await ws_raw.close() + assert ws_raw.closed + + +async def test_a_connection_ack_payload_of_none_is_treated_as_unset( + ws_raw: WebSocketClient, +): + await ws_raw.send_legacy_message( + { + "type": "connection_init", + "payload": {"test-accept": True, "ack-payload": None}, + } + ) + + connection_ack_message: ConnectionAckMessage = await ws_raw.receive_json() + assert connection_ack_message == {"type": "connection_ack"} + + await ws_raw.close() + assert ws_raw.closed + + +async def test_rejecting_connection_results_in_error_message_and_socket_closure( + ws_raw: WebSocketClient, +): + await ws_raw.send_legacy_message( + {"type": "connection_init", "payload": {"test-reject": True}} + ) + + connection_error_message: ConnectionErrorMessage = await ws_raw.receive_json() + assert connection_error_message == {"type": "connection_error", "payload": {}} + + await ws_raw.receive(timeout=2) + assert ws_raw.closed + assert ws_raw.close_code == 1011 + assert not ws_raw.close_reason + + +async def test_rejecting_connection_with_custom_connection_error_payload( + ws_raw: WebSocketClient, +): + await ws_raw.send_legacy_message( + { + "type": "connection_init", + "payload": {"test-reject": True, "err-payload": {"custom": "error"}}, + } + ) + + connection_error_message: ConnectionErrorMessage = await ws_raw.receive_json() + assert connection_error_message == { + "type": "connection_error", + "payload": {"custom": "error"}, + } + + await ws_raw.receive(timeout=2) + assert ws_raw.closed + assert ws_raw.close_code == 1011 + assert not ws_raw.close_reason + + +async def test_context_can_be_modified_from_within_on_ws_connect( + ws_raw: WebSocketClient, +): + await ws_raw.send_legacy_message( + { + "type": "connection_init", + "payload": {"test-modify": True}, + } + ) + + connection_ack_message: ConnectionAckMessage = await ws_raw.receive_json() + assert connection_ack_message == {"type": "connection_ack"} + + await ws_raw.send_legacy_message( + { + "type": "start", + "id": "demo", + "payload": { + "query": "subscription { connectionParams }", + }, + } + ) + + data_message: DataMessage = await ws_raw.receive_json() + assert data_message["type"] == "data" + assert data_message["id"] == "demo" + assert data_message["payload"]["data"] == { + "connectionParams": {"test-modify": True, "modified": True} + } + + await ws_raw.close() + assert ws_raw.closed + + +async def test_sends_keep_alive(http_client_class: type[HttpClient]): + http_client = http_client_class(keep_alive=True, keep_alive_interval=0.1) + + async with http_client.ws_connect( + "/graphql", protocols=[GRAPHQL_WS_PROTOCOL] + ) as ws: + await ws.send_legacy_message({"type": "connection_init"}) + await ws.send_legacy_message( + { + "type": "start", + "id": "demo", + "payload": { + "query": 'subscription { echo(message: "Hi", delay: 0.15) }', + }, + } + ) + + ack_message: ConnectionAckMessage = await ws.receive_json() + assert ack_message["type"] == "connection_ack" + + # we can't be sure how many keep-alives exactly we + # get but they should be more than one. + keepalive_count = 0 + while True: + ka_or_data_message: Union[ + ConnectionKeepAliveMessage, DataMessage + ] = await ws.receive_json() + if ka_or_data_message["type"] == "ka": + keepalive_count += 1 + else: + break + assert keepalive_count >= 1 + + assert ka_or_data_message["type"] == "data" + assert ka_or_data_message["id"] == "demo" + assert ka_or_data_message["payload"]["data"] == {"echo": "Hi"} + + complete_message: CompleteMessage = await ws.receive_json() + assert complete_message["type"] == "complete" + assert complete_message["id"] == "demo" + + await ws.send_legacy_message({"type": "connection_terminate"}) + + +async def test_subscription_cancellation(ws: WebSocketClient): + await ws.send_legacy_message( + { + "type": "start", + "id": "demo", + "payload": {"query": 'subscription { echo(message: "Hi", delay: 99) }'}, + } + ) + + await ws.send_legacy_message( + { + "type": "start", + "id": "debug1", + "payload": { + "query": "subscription { debug { numActiveResultHandlers } }", + }, + } + ) + + data_message: DataMessage = await ws.receive_json() + assert data_message["type"] == "data" + assert data_message["id"] == "debug1" + assert data_message["payload"]["data"] == {"debug": {"numActiveResultHandlers": 2}} + + complete_message1 = await ws.receive_json() + assert complete_message1["type"] == "complete" + assert complete_message1["id"] == "debug1" + + await ws.send_legacy_message({"type": "stop", "id": "demo"}) + + complete_message2 = await ws.receive_json() + assert complete_message2["type"] == "complete" + assert complete_message2["id"] == "demo" + + await ws.send_legacy_message( + { + "type": "start", + "id": "debug2", + "payload": { + "query": "subscription { debug { numActiveResultHandlers} }", + }, + } + ) + + data_message2 = await ws.receive_json() + assert data_message2["type"] == "data" + assert data_message2["id"] == "debug2" + assert data_message2["payload"]["data"] == {"debug": {"numActiveResultHandlers": 1}} + + complete_message3: CompleteMessage = await ws.receive_json() + assert complete_message3["type"] == "complete" + assert complete_message3["id"] == "debug2" + + +async def test_subscription_errors(ws: WebSocketClient): + await ws.send_legacy_message( + { + "type": "start", + "id": "demo", + "payload": {"query": 'subscription { error(message: "TEST ERR") }'}, + } + ) + + data_message: DataMessage = await ws.receive_json() + assert data_message["type"] == "data" + assert data_message["id"] == "demo" + assert data_message["payload"]["data"]["error"] is None + + assert "errors" in data_message["payload"] + assert data_message["payload"]["errors"] is not None + assert len(data_message["payload"]["errors"]) == 1 + + assert "path" in data_message["payload"]["errors"][0] + assert data_message["payload"]["errors"][0]["path"] == ["error"] + + assert "message" in data_message["payload"]["errors"][0] + assert data_message["payload"]["errors"][0]["message"] == "TEST ERR" + + complete_message: CompleteMessage = await ws.receive_json() + assert complete_message["type"] == "complete" + assert complete_message["id"] == "demo" + + +async def test_subscription_exceptions(ws: WebSocketClient): + await ws.send_legacy_message( + { + "type": "start", + "id": "demo", + "payload": {"query": 'subscription { exception(message: "TEST EXC") }'}, + } + ) + + data_message: DataMessage = await ws.receive_json() + assert data_message["type"] == "data" + assert data_message["id"] == "demo" + assert data_message["payload"]["data"] is None + + assert "errors" in data_message["payload"] + assert data_message["payload"]["errors"] is not None + assert data_message["payload"]["errors"] == [{"message": "TEST EXC"}] + + await ws.send_legacy_message({"type": "stop", "id": "demo"}) + complete_message = await ws.receive_json() + assert complete_message["type"] == "complete" + assert complete_message["id"] == "demo" + + +async def test_subscription_field_error(ws: WebSocketClient): + await ws.send_legacy_message( + { + "type": "start", + "id": "invalid-field", + "payload": {"query": "subscription { notASubscriptionField }"}, + } + ) + + error_message: ErrorMessage = await ws.receive_json() + assert error_message["type"] == "error" + assert error_message["id"] == "invalid-field" + assert error_message["payload"] == { + "locations": [{"line": 1, "column": 16}], + "message": ( + "Cannot query field 'notASubscriptionField' on type 'Subscription'." + ), + } + + +async def test_subscription_syntax_error(ws: WebSocketClient): + await ws.send_legacy_message( + { + "type": "start", + "id": "syntax-error", + "payload": {"query": "subscription { example "}, + } + ) + + error_message: ErrorMessage = await ws.receive_json() + assert error_message["type"] == "error" + assert error_message["id"] == "syntax-error" + assert error_message["payload"] == { + "locations": [{"line": 1, "column": 24}], + "message": "Syntax Error: Expected Name, found .", + } + + +async def test_non_text_ws_messages_result_in_socket_closure(ws_raw: WebSocketClient): + ws = ws_raw + + await ws.send_bytes( + json.dumps(ConnectionInitMessage({"type": "connection_init"})).encode() + ) + + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 1002 + assert ws.close_reason == "WebSocket message type must be text" + + +async def test_non_json_ws_messages_are_ignored(ws_raw: WebSocketClient): + ws = ws_raw + + await ws.send_text("NOT VALID JSON") + await ws.send_legacy_message({"type": "connection_init"}) + + connection_ack_message: ConnectionAckMessage = await ws.receive_json() + assert connection_ack_message["type"] == "connection_ack" + + await ws.send_text("NOT VALID JSON") + await ws.send_legacy_message( + { + "type": "start", + "id": "demo", + "payload": { + "query": 'subscription { echo(message: "Hi") }', + }, + } + ) + + data_message = await ws.receive_json() + assert data_message["type"] == "data" + assert data_message["id"] == "demo" + assert data_message["payload"]["data"] == {"echo": "Hi"} + + await ws.send_text("NOT VALID JSON") + await ws.send_legacy_message({"type": "stop", "id": "demo"}) + + complete_message: CompleteMessage = await ws.receive_json() + assert complete_message["type"] == "complete" + assert complete_message["id"] == "demo" + + await ws.send_text("NOT VALID JSON") + await ws.send_legacy_message({"type": "connection_terminate"}) + await ws.receive(timeout=2) # receive close + assert ws.closed + + +async def test_ws_message_frame_types_cannot_be_mixed(ws_raw: WebSocketClient): + ws = ws_raw + + await ws.send_legacy_message({"type": "connection_init"}) + + connection_ack_message: ConnectionAckMessage = await ws.receive_json() + assert connection_ack_message["type"] == "connection_ack" + + await ws.send_bytes( + json.dumps( + StartMessage( + { + "type": "start", + "id": "demo", + "payload": { + "query": 'subscription { echo(message: "Hi") }', + }, + } + ) + ).encode() + ) + + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 1002 + assert ws.close_reason == "WebSocket message type must be text" + + +async def test_unknown_protocol_messages_are_ignored(ws_raw: WebSocketClient): + ws = ws_raw + await ws.send_json({"type": "NotAProtocolMessage"}) + await ws.send_legacy_message({"type": "connection_init"}) + + await ws.send_json({"type": "NotAProtocolMessage"}) + await ws.send_legacy_message( + { + "type": "start", + "id": "demo", + "payload": { + "query": 'subscription { echo(message: "Hi") }', + }, + } + ) + + connection_ack_message: ConnectionAckMessage = await ws.receive_json() + assert connection_ack_message["type"] == "connection_ack" + + data_message = await ws.receive_json() + assert data_message["type"] == "data" + assert data_message["id"] == "demo" + assert data_message["payload"]["data"] == {"echo": "Hi"} + + await ws.send_json({"type": "NotAProtocolMessage"}) + await ws.send_legacy_message({"type": "stop", "id": "demo"}) + + complete_message: CompleteMessage = await ws.receive_json() + assert complete_message["type"] == "complete" + assert complete_message["id"] == "demo" + + await ws.send_json({"type": "NotAProtocolMessage"}) + await ws.send_legacy_message({"type": "connection_terminate"}) + + # make sure the WebSocket is disconnected now + await ws.receive(timeout=2) # receive close + assert ws.closed + + +async def test_custom_context(ws: WebSocketClient): + await ws.send_legacy_message( + { + "type": "start", + "id": "demo", + "payload": { + "query": "subscription { context }", + }, + } + ) + + data_message: DataMessage = await ws.receive_json() + assert data_message["type"] == "data" + assert data_message["id"] == "demo" + assert data_message["payload"]["data"] == {"context": "a value from context"} + + await ws.send_legacy_message({"type": "stop", "id": "demo"}) + + complete_message: CompleteMessage = await ws.receive_json() + assert complete_message["type"] == "complete" + assert complete_message["id"] == "demo" + + +async def test_resolving_enums(ws: WebSocketClient): + await ws.send_legacy_message( + { + "type": "start", + "id": "demo", + "payload": { + "query": "subscription { flavors }", + }, + } + ) + + data_message1: DataMessage = await ws.receive_json() + assert data_message1["type"] == "data" + assert data_message1["id"] == "demo" + assert data_message1["payload"]["data"] == {"flavors": "VANILLA"} + + data_message2: DataMessage = await ws.receive_json() + assert data_message2["type"] == "data" + assert data_message2["id"] == "demo" + assert data_message2["payload"]["data"] == {"flavors": "STRAWBERRY"} + + data_message3: DataMessage = await ws.receive_json() + assert data_message3["type"] == "data" + assert data_message3["id"] == "demo" + assert data_message3["payload"]["data"] == {"flavors": "CHOCOLATE"} + + await ws.send_legacy_message({"type": "stop", "id": "demo"}) + + complete_message: CompleteMessage = await ws.receive_json() + assert complete_message["type"] == "complete" + assert complete_message["id"] == "demo" + + +# @pytest.mark.xfail(reason="flaky test") +async def test_task_cancellation_separation(http_client: HttpClient): + # Note Python 3.7 does not support Task.get_name/get_coro so we have to use + # repr(Task) to check whether expected tasks are running. + # This only works for aiohttp, where we are using the same event loop + # on the client side and server. + try: + from tests.http.clients.aiohttp import AioHttpClient + + aio = http_client == AioHttpClient # type: ignore + except ImportError: + aio = False + + def get_result_handler_tasks(): + return [ + task + for task in asyncio.all_tasks() + if "BaseGraphQLWSHandler.handle_async_results" in repr(task) + ] + + connection1 = http_client.ws_connect("/graphql", protocols=[GRAPHQL_WS_PROTOCOL]) + connection2 = http_client.ws_connect("/graphql", protocols=[GRAPHQL_WS_PROTOCOL]) + + async with connection1 as ws1, connection2 as ws2: + start_message: StartMessage = { + "type": "start", + "id": "demo", + "payload": {"query": 'subscription { infinity(message: "Hi") }'}, + } + + # 0 active result handler tasks + if aio: + assert len(get_result_handler_tasks()) == 0 + + await ws1.send_legacy_message({"type": "connection_init"}) + await ws1.send_legacy_message(start_message) + await ws1.receive_json() # ack + await ws1.receive_json() # data + + # 1 active result handler tasks + if aio: + assert len(get_result_handler_tasks()) == 1 + + await ws2.send_legacy_message({"type": "connection_init"}) + await ws2.send_legacy_message(start_message) + await ws2.receive_json() + await ws2.receive_json() + + # 2 active result handler tasks + if aio: + assert len(get_result_handler_tasks()) == 2 + + await ws1.send_legacy_message({"type": "stop", "id": "demo"}) + await ws1.receive_json() # complete + + # 1 active result handler tasks + if aio: + assert len(get_result_handler_tasks()) == 1 + + await ws2.send_legacy_message({"type": "stop", "id": "demo"}) + await ws2.receive_json() # complete + + # 0 active result handler tasks + if aio: + assert len(get_result_handler_tasks()) == 0 + + await ws1.send_legacy_message( + { + "type": "start", + "id": "debug1", + "payload": { + "query": "subscription { debug { numActiveResultHandlers } }", + }, + } + ) + + data_message: DataMessage = await ws1.receive_json() + assert data_message["type"] == "data" + assert data_message["id"] == "debug1" + + # The one active result handler is the one for this debug subscription + assert data_message["payload"]["data"] == { + "debug": {"numActiveResultHandlers": 1} + } + + complete_message: CompleteMessage = await ws1.receive_json() + assert complete_message["type"] == "complete" + assert complete_message["id"] == "debug1" + + +async def test_injects_connection_params(http_client: HttpClient): + async with http_client.ws_connect( + "/graphql", protocols=[GRAPHQL_WS_PROTOCOL] + ) as ws: + await ws.send_legacy_message( + { + "type": "connection_init", + "payload": {"graphql_server": "rocks"}, + } + ) + await ws.send_legacy_message( + { + "type": "start", + "id": "demo", + "payload": { + "query": "subscription { connectionParams }", + }, + } + ) + + connection_ack_message: ConnectionAckMessage = await ws.receive_json() + assert connection_ack_message["type"] == "connection_ack" + + data_message: DataMessage = await ws.receive_json() + assert data_message["type"] == "data" + assert data_message["id"] == "demo" + assert data_message["payload"]["data"] == { + "connectionParams": {"graphql_server": "rocks"} + } + + await ws.send_legacy_message({"type": "stop", "id": "demo"}) + + complete_message: CompleteMessage = await ws.receive_json() + assert complete_message["type"] == "complete" + assert complete_message["id"] == "demo" + + await ws.send_legacy_message({"type": "connection_terminate"}) + + # make sure the WebSocket is disconnected now + await ws.receive(timeout=2) # receive close + assert ws.closed + + +async def test_rejects_connection_params(http_client: HttpClient): + async with http_client.ws_connect( + "/graphql", protocols=[GRAPHQL_WS_PROTOCOL] + ) as ws: + await ws.send_json( + { + "type": "connection_init", + "id": "demo", + "payload": "gonna fail", + } + ) + + connection_error_message: ConnectionErrorMessage = await ws.receive_json() + assert connection_error_message["type"] == "connection_error" + + # make sure the WebSocket is disconnected now + await ws.receive(timeout=2) # receive close + assert ws.closed + + +# @mock.patch.object(MyExtension, MyExtension.get_results.__name__, return_value={}) +# async def test_no_extensions_results_wont_send_extensions_in_payload( +# mock: mock.MagicMock, http_client: HttpClient +# ): +# async with http_client.ws_connect( +# "/graphql", protocols=[GRAPHQL_WS_PROTOCOL] +# ) as ws: +# await ws.send_legacy_message({"type": "connection_init"}) +# await ws.send_legacy_message( +# { +# "type": "start", +# "id": "demo", +# "payload": { +# "query": 'subscription { echo(message: "Hi") }', +# }, +# } +# ) + +# connection_ack_message = await ws.receive_json() +# assert connection_ack_message["type"] == "connection_ack" + +# data_message: DataMessage = await ws.receive_json() +# mock.assert_called_once() +# assert data_message["type"] == "data" +# assert data_message["id"] == "demo" +# assert "extensions" not in data_message["payload"] + +# await ws.send_legacy_message({"type": "stop", "id": "demo"}) +# await ws.receive_json() + + +async def test_unexpected_client_disconnects_are_gracefully_handled( + ws_raw: WebSocketClient, +): + ws = ws_raw + process_errors = mock.Mock() + + with mock.patch.object(graphql_server, "process_errors", process_errors): + await ws.send_legacy_message({"type": "connection_init"}) + + connection_ack_message: ConnectionAckMessage = await ws.receive_json() + assert connection_ack_message["type"] == "connection_ack" + + await ws.send_legacy_message( + { + "type": "start", + "id": "sub1", + "payload": { + "query": 'subscription { infinity(message: "Hi") }', + }, + } + ) + await ws.receive_json() + assert Subscription.active_infinity_subscriptions == 1 + + await ws.close() + await asyncio.sleep(0.5) + + assert not process_errors.called + assert Subscription.active_infinity_subscriptions == 0 diff --git a/src/tests/websockets/test_websockets.py b/src/tests/websockets/test_websockets.py new file mode 100644 index 0000000..0b17fa4 --- /dev/null +++ b/src/tests/websockets/test_websockets.py @@ -0,0 +1,122 @@ +from graphql_server.http.async_base_view import AsyncBaseHTTPView +from graphql_server.subscriptions import ( + GRAPHQL_TRANSPORT_WS_PROTOCOL, + GRAPHQL_WS_PROTOCOL, +) +from graphql_server.subscriptions.protocols.graphql_transport_ws.types import ( + ConnectionAckMessage, +) +from tests.http.clients.base import HttpClient + + +async def test_turning_off_graphql_ws(http_client_class: type[HttpClient]): + http_client = http_client_class( + subscription_protocols=[GRAPHQL_TRANSPORT_WS_PROTOCOL] + ) + + async with http_client.ws_connect( + "/graphql", protocols=[GRAPHQL_WS_PROTOCOL] + ) as ws: + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4406 + assert ws.close_reason == "Subprotocol not acceptable" + + +async def test_turning_off_graphql_transport_ws(http_client_class: type[HttpClient]): + http_client = http_client_class(subscription_protocols=[GRAPHQL_WS_PROTOCOL]) + + async with http_client.ws_connect( + "/graphql", protocols=[GRAPHQL_TRANSPORT_WS_PROTOCOL] + ) as ws: + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4406 + assert ws.close_reason == "Subprotocol not acceptable" + + +async def test_turning_off_all_subprotocols(http_client_class: type[HttpClient]): + http_client = http_client_class(subscription_protocols=[]) + + async with http_client.ws_connect( + "/graphql", protocols=[GRAPHQL_TRANSPORT_WS_PROTOCOL] + ) as ws: + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4406 + assert ws.close_reason == "Subprotocol not acceptable" + + async with http_client.ws_connect( + "/graphql", protocols=[GRAPHQL_WS_PROTOCOL] + ) as ws: + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4406 + assert ws.close_reason == "Subprotocol not acceptable" + + +async def test_generally_unsupported_subprotocols_are_rejected(http_client: HttpClient): + async with http_client.ws_connect( + "/graphql", protocols=["imaginary-protocol"] + ) as ws: + await ws.receive(timeout=2) + assert ws.closed + assert ws.close_code == 4406 + assert ws.close_reason == "Subprotocol not acceptable" + + +async def test_clients_can_prefer_subprotocols(http_client_class: type[HttpClient]): + http_client = http_client_class( + subscription_protocols=[GRAPHQL_WS_PROTOCOL, GRAPHQL_TRANSPORT_WS_PROTOCOL] + ) + + async with http_client.ws_connect( + "/graphql", protocols=[GRAPHQL_TRANSPORT_WS_PROTOCOL, GRAPHQL_WS_PROTOCOL] + ) as ws: + assert ws.accepted_subprotocol == GRAPHQL_TRANSPORT_WS_PROTOCOL + await ws.close() + assert ws.closed + + async with http_client.ws_connect( + "/graphql", protocols=[GRAPHQL_WS_PROTOCOL, GRAPHQL_TRANSPORT_WS_PROTOCOL] + ) as ws: + assert ws.accepted_subprotocol == GRAPHQL_WS_PROTOCOL + await ws.close() + assert ws.closed + + +async def test_handlers_use_the_views_encode_json_method( + http_client: HttpClient, mocker +): + spy = mocker.spy(AsyncBaseHTTPView, "encode_json") + + async with http_client.ws_connect( + "/graphql", protocols=[GRAPHQL_TRANSPORT_WS_PROTOCOL] + ) as ws: + await ws.send_json({"type": "connection_init"}) + connection_ack_message: ConnectionAckMessage = await ws.receive_json() + assert connection_ack_message == {"type": "connection_ack"} + + await ws.close() + assert ws.closed + + assert spy.call_count == 1 + + +async def test_handlers_use_the_views_decode_json_method( + http_client: HttpClient, mocker +): + spy = mocker.spy(AsyncBaseHTTPView, "decode_json") + + async with http_client.ws_connect( + "/graphql", protocols=[GRAPHQL_TRANSPORT_WS_PROTOCOL] + ) as ws: + await ws.send_message({"type": "connection_init"}) + + connection_ack_message: ConnectionAckMessage = await ws.receive_json() + assert connection_ack_message == {"type": "connection_ack"} + + await ws.close() + assert ws.closed + + assert spy.call_count == 1 diff --git a/src/tests/websockets/views.py b/src/tests/websockets/views.py new file mode 100644 index 0000000..f68c5db --- /dev/null +++ b/src/tests/websockets/views.py @@ -0,0 +1,46 @@ +from typing import Union + +from graphql_server.exceptions import ConnectionRejectionError +from graphql_server.http.async_base_view import AsyncBaseHTTPView +from graphql_server.http.typevars import ( + Request, + Response, + SubResponse, + WebSocketRequest, + WebSocketResponse, +) +from graphql_server.types.unset import UNSET, UnsetType + + +class OnWSConnectMixin( + AsyncBaseHTTPView[ + Request, + Response, + SubResponse, + WebSocketRequest, + WebSocketResponse, + dict[str, object], + object, + ] +): + async def on_ws_connect( + self, context: dict[str, object] + ) -> Union[UnsetType, None, dict[str, object]]: + connection_params = context["connection_params"] + + if isinstance(connection_params, dict): + if connection_params.get("test-reject"): + if "err-payload" in connection_params: + raise ConnectionRejectionError(connection_params["err-payload"]) + raise ConnectionRejectionError + + if connection_params.get("test-accept"): + if "ack-payload" in connection_params: + return connection_params["ack-payload"] + return UNSET + + if connection_params.get("test-modify"): + connection_params["modified"] = True + return UNSET + + return await super().on_ws_connect(context) diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index 2a8fe60..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""GraphQL-Server-Core Tests""" diff --git a/tests/schema.py b/tests/schema.py deleted file mode 100644 index c60b0ed..0000000 --- a/tests/schema.py +++ /dev/null @@ -1,52 +0,0 @@ -from graphql.type.definition import ( - GraphQLArgument, - GraphQLField, - GraphQLNonNull, - GraphQLObjectType, -) -from graphql.type.scalars import GraphQLString -from graphql.type.schema import GraphQLSchema - - -def resolve_error(*_args): - raise ValueError("Throws!") - - -def resolve_request(_obj, info): - return info.context.get("q") - - -def resolve_context(_obj, info): - return str(info.context) - - -def resolve_test(_obj, _info, who="World"): - return "Hello {}".format(who) - - -NonNullString = GraphQLNonNull(GraphQLString) - -QueryRootType = GraphQLObjectType( - name="QueryRoot", - fields={ - "error": GraphQLField(NonNullString, resolver=resolve_error), - "request": GraphQLField(NonNullString, resolver=resolve_request), - "context": GraphQLField(NonNullString, resolver=resolve_context), - "test": GraphQLField( - GraphQLString, - {"who": GraphQLArgument(GraphQLString)}, - resolver=resolve_test, - ), - }, -) - -MutationRootType = GraphQLObjectType( - name="MutationRoot", - fields={ - "writeTest": GraphQLField( - type=QueryRootType, resolver=lambda *_args: QueryRootType - ) - }, -) - -schema = GraphQLSchema(QueryRootType, MutationRootType) diff --git a/tests/test_error.py b/tests/test_error.py deleted file mode 100644 index a0f7017..0000000 --- a/tests/test_error.py +++ /dev/null @@ -1,28 +0,0 @@ -from graphql_server import HttpQueryError - - -def test_create_http_query_error(): - - error = HttpQueryError(420, "Some message", headers={"SomeHeader": "SomeValue"}) - assert error.status_code == 420 - assert error.message == "Some message" - assert error.headers == {"SomeHeader": "SomeValue"} - - -def test_compare_http_query_errors(): - - error = HttpQueryError(400, "Message", headers={"Header": "Value"}) - assert error == HttpQueryError(400, "Message", headers={"Header": "Value"}) - assert error != HttpQueryError(420, "Message", headers={"Header": "Value"}) - assert error != HttpQueryError(400, "Other Message", headers={"Header": "Value"}) - assert error != HttpQueryError(400, "Message", headers={"Header": "OtherValue"}) - - -def test_hash_http_query_errors(): - - error = HttpQueryError(400, "Foo", headers={"Bar": "Baz"}) - - assert hash(error) == hash(HttpQueryError(400, "Foo", headers={"Bar": "Baz"})) - assert hash(error) != hash(HttpQueryError(420, "Foo", headers={"Bar": "Baz"})) - assert hash(error) != hash(HttpQueryError(400, "Boo", headers={"Bar": "Baz"})) - assert hash(error) != hash(HttpQueryError(400, "Foo", headers={"Bar": "Faz"})) diff --git a/tests/test_helpers.py b/tests/test_helpers.py deleted file mode 100644 index 9f99669..0000000 --- a/tests/test_helpers.py +++ /dev/null @@ -1,316 +0,0 @@ -import json - -from graphql.error import GraphQLError -from graphql.execution import ExecutionResult -from graphql.language.location import SourceLocation - -from graphql_server import ( - HttpQueryError, - ServerResponse, - encode_execution_results, - json_encode, - json_encode_pretty, - load_json_body, -) -from pytest import raises - - -def test_json_encode(): - result = json_encode({"query": "{test}"}) - assert result == '{"query":"{test}"}' - - -def test_json_encode_pretty(): - result = json_encode_pretty({"query": "{test}"}) - assert result == '{\n "query": "{test}"\n}' - - -def test_load_json_body_as_dict(): - result = load_json_body('{"query": "{test}"}') - assert result == {"query": "{test}"} - - -def test_load_json_body_with_variables(): - result = load_json_body( - """ - { - "query": "query helloWho($who: String){ test(who: $who) }", - "variables": {"who": "Dolly"} - } - """ - ) - - assert result["variables"] == {"who": "Dolly"} - - -def test_load_json_body_as_list(): - result = load_json_body('[{"query": "{test}"}]') - assert result == [{"query": "{test}"}] - - -def test_load_invalid_json_body(): - with raises(HttpQueryError) as exc_info: - load_json_body('{"query":') - assert exc_info.value == HttpQueryError(400, "POST body sent invalid JSON.") - - -def test_graphql_server_response(): - assert issubclass(ServerResponse, tuple) - # noinspection PyUnresolvedReferences - assert ServerResponse._fields == ("body", "status_code") - - -def test_encode_execution_results_without_error(): - execution_results = [ - ExecutionResult({"result": 1}, None), - ExecutionResult({"result": 2}, None), - ExecutionResult({"result": 3}, None), - ] - - output = encode_execution_results(execution_results) - assert isinstance(output, ServerResponse) - assert isinstance(output.body, str) - assert isinstance(output.status_code, int) - assert json.loads(output.body) == {"data": {"result": 1}} - assert output.status_code == 200 - - -def test_encode_execution_results_with_error(): - execution_results = [ - ExecutionResult( - None, - [ - GraphQLError( - "Some error", locations=[SourceLocation(1, 2)], path=["somePath"] - ) - ], - ), - ExecutionResult({"result": 42}, None), - ] - - output = encode_execution_results(execution_results) - assert isinstance(output, ServerResponse) - assert isinstance(output.body, str) - assert isinstance(output.status_code, int) - assert json.loads(output.body) == { - "data": None, - "errors": [ - { - "message": "Some error", - "locations": [{"line": 1, "column": 2}], - "path": ["somePath"], - } - ], - } - assert output.status_code == 200 - - -def test_encode_execution_results_with_invalid(): - execution_results = [ - ExecutionResult( - None, - [GraphQLError("SyntaxError", locations=[SourceLocation(1, 2)])], - invalid=True, - ), - ExecutionResult({"result": 42}, None), - ] - - output = encode_execution_results(execution_results) - assert isinstance(output, ServerResponse) - assert isinstance(output.body, str) - assert isinstance(output.status_code, int) - assert json.loads(output.body) == { - "errors": [{"message": "SyntaxError", "locations": [{"line": 1, "column": 2}]}] - } - assert output.status_code == 400 - - -def test_encode_execution_results_with_empty_result(): - execution_results = [None] - - output = encode_execution_results(execution_results) - assert isinstance(output, ServerResponse) - assert isinstance(output.body, str) - assert isinstance(output.status_code, int) - assert output.body == "null" - assert output.status_code == 200 - - -def test_encode_execution_results_with_format_error(): - execution_results = [ - ExecutionResult( - None, - [ - GraphQLError( - "Some msg", locations=[SourceLocation(1, 2)], path=["some", "path"] - ) - ], - ) - ] - - def format_error(error): - return { - "msg": str(error), - "loc": "{}:{}".format(error.locations[0].line, error.locations[0].column), - "pth": "/".join(error.path), - } - - output = encode_execution_results(execution_results, format_error=format_error) - assert isinstance(output, ServerResponse) - assert isinstance(output.body, str) - assert isinstance(output.status_code, int) - assert json.loads(output.body) == { - "data": None, - "errors": [{"msg": "Some msg", "loc": "1:2", "pth": "some/path"}], - } - assert output.status_code == 200 - - -def test_encode_execution_results_with_batch(): - execution_results = [ - ExecutionResult({"result": 1}, None), - ExecutionResult({"result": 2}, None), - ExecutionResult({"result": 3}, None), - ] - - output = encode_execution_results(execution_results, is_batch=True) - assert isinstance(output, ServerResponse) - assert isinstance(output.body, str) - assert isinstance(output.status_code, int) - assert json.loads(output.body) == [ - {"data": {"result": 1}}, - {"data": {"result": 2}}, - {"data": {"result": 3}}, - ] - assert output.status_code == 200 - - -def test_encode_execution_results_with_batch_and_empty_result(): - execution_results = [ - ExecutionResult({"result": 1}, None), - None, - ExecutionResult({"result": 3}, None), - ] - - output = encode_execution_results(execution_results, is_batch=True) - assert isinstance(output, ServerResponse) - assert isinstance(output.body, str) - assert isinstance(output.status_code, int) - assert json.loads(output.body) == [ - {"data": {"result": 1}}, - None, - {"data": {"result": 3}}, - ] - assert output.status_code == 200 - - -def test_encode_execution_results_with_batch_and_error(): - execution_results = [ - ExecutionResult({"result": 1}, None), - ExecutionResult( - None, - [ - GraphQLError( - "No data here", locations=[SourceLocation(1, 2)], path=["somePath"] - ) - ], - ), - ExecutionResult({"result": 3}, None), - ] - - output = encode_execution_results(execution_results, is_batch=True) - assert isinstance(output, ServerResponse) - assert isinstance(output.body, str) - assert isinstance(output.status_code, int) - assert json.loads(output.body) == [ - {"data": {"result": 1}}, - { - "data": None, - "errors": [ - { - "message": "No data here", - "locations": [{"line": 1, "column": 2}], - "path": ["somePath"], - } - ], - }, - {"data": {"result": 3}}, - ] - assert output.status_code == 200 - - -def test_encode_execution_results_with_batch_and_invalid(): - execution_results = [ - ExecutionResult({"result": 1}, None), - ExecutionResult( - None, - [ - GraphQLError( - "No data here", locations=[SourceLocation(1, 2)], path=["somePath"] - ) - ], - ), - ExecutionResult({"result": 3}, None), - ExecutionResult( - None, - [GraphQLError("SyntaxError", locations=[SourceLocation(1, 2)])], - invalid=True, - ), - ExecutionResult({"result": 5}, None), - ] - - output = encode_execution_results(execution_results, is_batch=True) - assert isinstance(output, ServerResponse) - assert isinstance(output.body, str) - assert isinstance(output.status_code, int) - assert json.loads(output.body) == [ - {"data": {"result": 1}}, - { - "data": None, - "errors": [ - { - "message": "No data here", - "locations": [{"line": 1, "column": 2}], - "path": ["somePath"], - } - ], - }, - {"data": {"result": 3}}, - { - "errors": [ - {"message": "SyntaxError", "locations": [{"line": 1, "column": 2}]} - ] - }, - {"data": {"result": 5}}, - ] - assert output.status_code == 400 - - -def test_encode_execution_results_with_encode(): - execution_results = [ExecutionResult({"result": None}, None)] - - def encode(result): - return repr(dict(result)) - - output = encode_execution_results(execution_results, encode=encode) - assert isinstance(output, ServerResponse) - assert isinstance(output.body, str) - assert isinstance(output.status_code, int) - assert output.body == "{'data': {'result': None}}" - assert output.status_code == 200 - - -def test_encode_execution_results_with_pretty(): - execution_results = [ExecutionResult({"test": "Hello World"}, None)] - - output = encode_execution_results(execution_results, encode=json_encode_pretty) - body = output.body - assert body == "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" - - -def test_encode_execution_results_not_pretty_by_default(): - execution_results = [ExecutionResult({"test": "Hello World"}, None)] - # execution_results = [ExecutionResult({"result": None}, None)] - - output = encode_execution_results(execution_results) - assert output.body == '{"data":{"test":"Hello World"}}' diff --git a/tests/test_query.py b/tests/test_query.py deleted file mode 100644 index 07c9e57..0000000 --- a/tests/test_query.py +++ /dev/null @@ -1,532 +0,0 @@ -import json - -from graphql.error import GraphQLError - -from graphql_server import ( - HttpQueryError, - RequestParams, - ServerResults, - encode_execution_results, - json_encode, - json_encode_pretty, - load_json_body, - run_http_query, -) -from pytest import raises - -from .schema import schema - - -def as_dicts(results): - """Convert execution results to a list of tuples of dicts for better comparison.""" - return [result.to_dict(dict_class=dict) for result in results] - - -def test_request_params(): - assert issubclass(RequestParams, tuple) - # noinspection PyUnresolvedReferences - assert RequestParams._fields == ("query", "variables", "operation_name") - - -def test_server_results(): - assert issubclass(ServerResults, tuple) - # noinspection PyUnresolvedReferences - assert ServerResults._fields == ("results", "params") - - -def test_allows_get_with_query_param(): - query = "{test}" - results, params = run_http_query(schema, "get", {}, dict(query=query)) - - assert as_dicts(results) == [{"data": {"test": "Hello World"}}] - assert params == [RequestParams(query=query, variables=None, operation_name=None)] - - -def test_allows_get_with_variable_values(): - results, params = run_http_query( - schema, - "get", - {}, - dict( - query="query helloWho($who: String){ test(who: $who) }", - variables=json.dumps({"who": "Dolly"}), - ), - ) - - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] - - -def test_allows_get_with_operation_name(): - results, params = run_http_query( - schema, - "get", - {}, - query_data=dict( - query=""" - query helloYou { test(who: "You"), ...shared } - query helloWorld { test(who: "World"), ...shared } - query helloDolly { test(who: "Dolly"), ...shared } - fragment shared on QueryRoot { - shared: test(who: "Everyone") - } - """, - operationName="helloWorld", - ), - ) - - assert as_dicts(results) == [ - {"data": {"test": "Hello World", "shared": "Hello Everyone"}} - ] - - -def test_reports_validation_errors(): - results, params = run_http_query( - schema, "get", {}, query_data=dict(query="{ test, unknownOne, unknownTwo }") - ) - - assert as_dicts(results) == [ - { - "errors": [ - { - "message": 'Cannot query field "unknownOne" on type "QueryRoot".', - "locations": [{"line": 1, "column": 9}], - }, - { - "message": 'Cannot query field "unknownTwo" on type "QueryRoot".', - "locations": [{"line": 1, "column": 21}], - }, - ] - } - ] - - -def test_non_dict_params_in_non_batch_query(): - with raises(HttpQueryError) as exc_info: - # noinspection PyTypeChecker - run_http_query(schema, "get", "not a dict") # type: ignore - - assert exc_info.value == HttpQueryError( - 400, "GraphQL params should be a dict. Received 'not a dict'." - ) - - -def test_empty_batch_in_batch_query(): - with raises(HttpQueryError) as exc_info: - run_http_query(schema, "get", [], batch_enabled=True) - - assert exc_info.value == HttpQueryError( - 400, "Received an empty list in the batch request." - ) - - -def test_errors_when_missing_operation_name(): - results, params = run_http_query( - schema, - "get", - {}, - query_data=dict( - query=""" - query TestQuery { test } - mutation TestMutation { writeTest { test } } - """ - ), - ) - - assert as_dicts(results) == [ - { - "errors": [ - { - "message": ( - "Must provide operation name" - " if query contains multiple operations." - ) - } - ] - } - ] - assert isinstance(results[0].errors[0], GraphQLError) - - -def test_errors_when_sending_a_mutation_via_get(): - with raises(HttpQueryError) as exc_info: - run_http_query( - schema, - "get", - {}, - query_data=dict( - query=""" - mutation TestMutation { writeTest { test } } - """ - ), - ) - - assert exc_info.value == HttpQueryError( - 405, - "Can only perform a mutation operation from a POST request.", - headers={"Allow": "POST"}, - ) - - -def test_catching_errors_when_sending_a_mutation_via_get(): - results, params = run_http_query( - schema, - "get", - {}, - query_data=dict( - query=""" - mutation TestMutation { writeTest { test } } - """ - ), - catch=True, - ) - - assert results == [None] - - -def test_errors_when_selecting_a_mutation_within_a_get(): - with raises(HttpQueryError) as exc_info: - run_http_query( - schema, - "get", - {}, - query_data=dict( - query=""" - query TestQuery { test } - mutation TestMutation { writeTest { test } } - """, - operationName="TestMutation", - ), - ) - - assert exc_info.value == HttpQueryError( - 405, - "Can only perform a mutation operation from a POST request.", - headers={"Allow": "POST"}, - ) - - -def test_allows_mutation_to_exist_within_a_get(): - results, params = run_http_query( - schema, - "get", - {}, - query_data=dict( - query=""" - query TestQuery { test } - mutation TestMutation { writeTest { test } } - """, - operationName="TestQuery", - ), - ) - - assert as_dicts(results) == [{"data": {"test": "Hello World"}}] - - -def test_allows_sending_a_mutation_via_post(): - results, params = run_http_query( - schema, - "post", - {}, - query_data=dict(query="mutation TestMutation { writeTest { test } }"), - ) - - assert as_dicts(results) == [{"data": {"writeTest": {"test": "Hello World"}}}] - - -def test_allows_post_with_url_encoding(): - results, params = run_http_query( - schema, "post", {}, query_data=dict(query="{test}") - ) - - assert as_dicts(results) == [{"data": {"test": "Hello World"}}] - - -def test_supports_post_json_query_with_string_variables(): - results, params = run_http_query( - schema, - "post", - {}, - query_data=dict( - query="query helloWho($who: String){ test(who: $who) }", - variables='{"who": "Dolly"}', - ), - ) - - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] - - -def test_supports_post_url_encoded_query_with_string_variables(): - results, params = run_http_query( - schema, - "post", - {}, - query_data=dict( - query="query helloWho($who: String){ test(who: $who) }", - variables='{"who": "Dolly"}', - ), - ) - - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] - - -def test_supports_post_json_query_with_get_variable_values(): - results, params = run_http_query( - schema, - "post", - data=dict(query="query helloWho($who: String){ test(who: $who) }"), - query_data=dict(variables={"who": "Dolly"}), - ) - - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] - - -def test_post_url_encoded_query_with_get_variable_values(): - results, params = run_http_query( - schema, - "get", - data=dict(query="query helloWho($who: String){ test(who: $who) }"), - query_data=dict(variables='{"who": "Dolly"}'), - ) - - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] - - -def test_supports_post_raw_text_query_with_get_variable_values(): - results, params = run_http_query( - schema, - "get", - data=dict(query="query helloWho($who: String){ test(who: $who) }"), - query_data=dict(variables='{"who": "Dolly"}'), - ) - - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] - - -def test_allows_post_with_operation_name(): - results, params = run_http_query( - schema, - "get", - data=dict( - query=""" - query helloYou { test(who: "You"), ...shared } - query helloWorld { test(who: "World"), ...shared } - query helloDolly { test(who: "Dolly"), ...shared } - fragment shared on QueryRoot { - shared: test(who: "Everyone") - } - """, - operationName="helloWorld", - ), - ) - - assert as_dicts(results) == [ - {"data": {"test": "Hello World", "shared": "Hello Everyone"}} - ] - - -def test_allows_post_with_get_operation_name(): - results, params = run_http_query( - schema, - "get", - data=dict( - query=""" - query helloYou { test(who: "You"), ...shared } - query helloWorld { test(who: "World"), ...shared } - query helloDolly { test(who: "Dolly"), ...shared } - fragment shared on QueryRoot { - shared: test(who: "Everyone") - } - """ - ), - query_data=dict(operationName="helloWorld"), - ) - - assert as_dicts(results) == [ - {"data": {"test": "Hello World", "shared": "Hello Everyone"}} - ] - - -def test_supports_pretty_printing_data(): - results, params = run_http_query(schema, "get", dict(query="{test}")) - body = encode_execution_results(results, encode=json_encode_pretty).body - - assert body == "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" - - -def test_not_pretty_data_by_default(): - results, params = run_http_query(schema, "get", dict(query="{test}")) - body = encode_execution_results(results).body - - assert body == '{"data":{"test":"Hello World"}}' - - -def test_handles_field_errors_caught_by_graphql(): - results, params = run_http_query(schema, "get", dict(query="{error}")) - - assert as_dicts(results) == [ - { - "data": None, - "errors": [ - { - "message": "Throws!", - "locations": [{"line": 1, "column": 2}], - "path": ["error"], - } - ], - } - ] - - -def test_handles_syntax_errors_caught_by_graphql(): - results, params = run_http_query(schema, "get", dict(query="syntaxerror")) - - assert as_dicts(results) == [ - { - "errors": [ - { - "locations": [{"line": 1, "column": 1}], - "message": "Syntax Error GraphQL (1:1)" - ' Unexpected Name "syntaxerror"\n\n1: syntaxerror\n ^\n', - } - ] - } - ] - - -def test_handles_errors_caused_by_a_lack_of_query(): - with raises(HttpQueryError) as exc_info: - run_http_query(schema, "get", {}) - - assert exc_info.value == HttpQueryError(400, "Must provide query string.") - - -def test_handles_errors_caused_by_invalid_query_type(): - results, params = run_http_query(schema, "get", dict(query=42)) - - assert as_dicts(results) == [ - {"errors": [{"message": "The query must be a string"}]} - ] - - -def test_handles_batch_correctly_if_is_disabled(): - with raises(HttpQueryError) as exc_info: - run_http_query(schema, "post", []) - - assert exc_info.value == HttpQueryError( - 400, "Batch GraphQL requests are not enabled." - ) - - -def test_handles_incomplete_json_bodies(): - with raises(HttpQueryError) as exc_info: - run_http_query(schema, "post", load_json_body('{"query":')) - - assert exc_info.value == HttpQueryError(400, "POST body sent invalid JSON.") - - -def test_handles_plain_post_text(): - with raises(HttpQueryError) as exc_info: - run_http_query(schema, "post", {}) - - assert exc_info.value == HttpQueryError(400, "Must provide query string.") - - -def test_handles_poorly_formed_variables(): - with raises(HttpQueryError) as exc_info: - run_http_query( - schema, - "get", - {}, - dict( - query="query helloWho($who: String){ test(who: $who) }", - variables="who:You", - ), - ) - - assert exc_info.value == HttpQueryError(400, "Variables are invalid JSON.") - - -def test_handles_bad_schema(): - with raises(TypeError) as exc_info: - # noinspection PyTypeChecker - run_http_query("not a schema", "get", {"query": "{error}"}) # type: ignore - - msg = str(exc_info.value) - assert msg == "Expected a GraphQL schema, but received 'not a schema'." - - -def test_handles_unsupported_http_methods(): - with raises(HttpQueryError) as exc_info: - run_http_query(schema, "put", {}) - - assert exc_info.value == HttpQueryError( - 405, - "GraphQL only supports GET and POST requests.", - headers={"Allow": "GET, POST"}, - ) - - -def test_passes_request_into_request_context(): - results, params = run_http_query( - schema, "get", {}, dict(query="{request}"), context_value={"q": "testing"} - ) - - assert as_dicts(results) == [{"data": {"request": "testing"}}] - - -def test_supports_pretty_printing_context(): - class Context: - def __str__(self): - return "CUSTOM CONTEXT" - - results, params = run_http_query( - schema, "get", {}, dict(query="{context}"), context_value=Context() - ) - - assert as_dicts(results) == [{"data": {"context": "CUSTOM CONTEXT"}}] - - -def test_post_multipart_data(): - query = "mutation TestMutation { writeTest { test } }" - results, params = run_http_query(schema, "post", {}, query_data=dict(query=query)) - - assert as_dicts(results) == [{"data": {"writeTest": {"test": "Hello World"}}}] - - -def test_batch_allows_post_with_json_encoding(): - data = load_json_body('[{"query": "{test}"}]') - results, params = run_http_query(schema, "post", data, batch_enabled=True) - - assert as_dicts(results) == [{"data": {"test": "Hello World"}}] - - -def test_batch_supports_post_json_query_with_json_variables(): - data = load_json_body( - '[{"query":"query helloWho($who: String){ test(who: $who) }",' - '"variables":{"who":"Dolly"}}]' - ) - results, params = run_http_query(schema, "post", data, batch_enabled=True) - - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] - - -def test_batch_allows_post_with_operation_name(): - data = [ - dict( - query=""" - query helloYou { test(who: "You"), ...shared } - query helloWorld { test(who: "World"), ...shared } - query helloDolly { test(who: "Dolly"), ...shared } - fragment shared on QueryRoot { - shared: test(who: "Everyone") - } - """, - operationName="helloWorld", - ) - ] - data = load_json_body(json_encode(data)) - results, params = run_http_query(schema, "post", data, batch_enabled=True) - - assert as_dicts(results) == [ - {"data": {"test": "Hello World", "shared": "Hello Everyone"}} - ] diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 6560fc2..0000000 --- a/tox.ini +++ /dev/null @@ -1,40 +0,0 @@ -[tox] -envlist = black,flake8,mypy,py37,py36,py35,py34,py33,py27,pypy3,pypy -skipsdist = true - -[testenv] -setenv = - PYTHONPATH = {toxinidir} -deps = - pytest>=3.0,<4 - graphql-core>=2.1,<3 - pytest-cov>=2.7 -commands = - py{py,27,33,34,35,36,37}: py.test tests {posargs} - -[testenv:black] -basepython=python3.7 -deps = black -commands = - black --check graphql_server tests - -[testenv:flake8] -basepython=python3.7 -deps = flake8 -commands = - flake8 graphql_server tests - -[testenv:isort] -basepython=python3.7 -deps = - isort - graphql-core>=2.1 -commands = - isort -rc graphql_server/ tests/ - -[testenv:mypy] -basepython=python3.7 -deps = mypy -commands = - mypy graphql_server tests --ignore-missing-imports - diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..c2cfff5 --- /dev/null +++ b/uv.lock @@ -0,0 +1,4670 @@ +version = 1 +revision = 2 +requires-python = ">=3.9, <4.0" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version >= '3.10' and python_full_version < '3.13'", + "python_full_version < '3.10'", +] + +[[package]] +name = "aiofiles" +version = "24.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" }, +] + +[[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.12.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/6e/ab88e7cb2a4058bed2f7870276454f85a7c56cd6da79349eb314fc7bbcaa/aiohttp-3.12.13.tar.gz", hash = "sha256:47e2da578528264a12e4e3dd8dd72a7289e5f812758fe086473fab037a10fcce", size = 7819160, upload-time = "2025-06-14T15:15:41.354Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/2d/27e4347660723738b01daa3f5769d56170f232bf4695dd4613340da135bb/aiohttp-3.12.13-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5421af8f22a98f640261ee48aae3a37f0c41371e99412d55eaf2f8a46d5dad29", size = 702090, upload-time = "2025-06-14T15:12:58.938Z" }, + { url = "https://files.pythonhosted.org/packages/10/0b/4a8e0468ee8f2b9aff3c05f2c3a6be1dfc40b03f68a91b31041d798a9510/aiohttp-3.12.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fcda86f6cb318ba36ed8f1396a6a4a3fd8f856f84d426584392083d10da4de0", size = 478440, upload-time = "2025-06-14T15:13:02.981Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/2086df2f9a842b13feb92d071edf756be89250f404f10966b7bc28317f17/aiohttp-3.12.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cd71c9fb92aceb5a23c4c39d8ecc80389c178eba9feab77f19274843eb9412d", size = 466215, upload-time = "2025-06-14T15:13:04.817Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3d/d23e5bd978bc8012a65853959b13bd3b55c6e5afc172d89c26ad6624c52b/aiohttp-3.12.13-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34ebf1aca12845066c963016655dac897651e1544f22a34c9b461ac3b4b1d3aa", size = 1648271, upload-time = "2025-06-14T15:13:06.532Z" }, + { url = "https://files.pythonhosted.org/packages/31/31/e00122447bb137591c202786062f26dd383574c9f5157144127077d5733e/aiohttp-3.12.13-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:893a4639694c5b7edd4bdd8141be296042b6806e27cc1d794e585c43010cc294", size = 1622329, upload-time = "2025-06-14T15:13:08.394Z" }, + { url = "https://files.pythonhosted.org/packages/04/01/caef70be3ac38986969045f21f5fb802ce517b3f371f0615206bf8aa6423/aiohttp-3.12.13-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:663d8ee3ffb3494502ebcccb49078faddbb84c1d870f9c1dd5a29e85d1f747ce", size = 1694734, upload-time = "2025-06-14T15:13:09.979Z" }, + { url = "https://files.pythonhosted.org/packages/3f/15/328b71fedecf69a9fd2306549b11c8966e420648a3938d75d3ed5bcb47f6/aiohttp-3.12.13-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0f8f6a85a0006ae2709aa4ce05749ba2cdcb4b43d6c21a16c8517c16593aabe", size = 1737049, upload-time = "2025-06-14T15:13:11.672Z" }, + { url = "https://files.pythonhosted.org/packages/e6/7a/d85866a642158e1147c7da5f93ad66b07e5452a84ec4258e5f06b9071e92/aiohttp-3.12.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1582745eb63df267c92d8b61ca655a0ce62105ef62542c00a74590f306be8cb5", size = 1641715, upload-time = "2025-06-14T15:13:13.548Z" }, + { url = "https://files.pythonhosted.org/packages/14/57/3588800d5d2f5f3e1cb6e7a72747d1abc1e67ba5048e8b845183259c2e9b/aiohttp-3.12.13-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d59227776ee2aa64226f7e086638baa645f4b044f2947dbf85c76ab11dcba073", size = 1581836, upload-time = "2025-06-14T15:13:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/2f/55/c913332899a916d85781aa74572f60fd98127449b156ad9c19e23135b0e4/aiohttp-3.12.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06b07c418bde1c8e737d8fa67741072bd3f5b0fb66cf8c0655172188c17e5fa6", size = 1625685, upload-time = "2025-06-14T15:13:17.163Z" }, + { url = "https://files.pythonhosted.org/packages/4c/34/26cded195f3bff128d6a6d58d7a0be2ae7d001ea029e0fe9008dcdc6a009/aiohttp-3.12.13-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:9445c1842680efac0f81d272fd8db7163acfcc2b1436e3f420f4c9a9c5a50795", size = 1636471, upload-time = "2025-06-14T15:13:19.086Z" }, + { url = "https://files.pythonhosted.org/packages/19/21/70629ca006820fccbcec07f3cd5966cbd966e2d853d6da55339af85555b9/aiohttp-3.12.13-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:09c4767af0b0b98c724f5d47f2bf33395c8986995b0a9dab0575ca81a554a8c0", size = 1611923, upload-time = "2025-06-14T15:13:20.997Z" }, + { url = "https://files.pythonhosted.org/packages/31/80/7fa3f3bebf533aa6ae6508b51ac0de9965e88f9654fa679cc1a29d335a79/aiohttp-3.12.13-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f3854fbde7a465318ad8d3fc5bef8f059e6d0a87e71a0d3360bb56c0bf87b18a", size = 1691511, upload-time = "2025-06-14T15:13:22.54Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7a/359974653a3cdd3e9cee8ca10072a662c3c0eb46a359c6a1f667b0296e2f/aiohttp-3.12.13-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2332b4c361c05ecd381edb99e2a33733f3db906739a83a483974b3df70a51b40", size = 1714751, upload-time = "2025-06-14T15:13:24.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/24/0aa03d522171ce19064347afeefadb008be31ace0bbb7d44ceb055700a14/aiohttp-3.12.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1561db63fa1b658cd94325d303933553ea7d89ae09ff21cc3bcd41b8521fbbb6", size = 1643090, upload-time = "2025-06-14T15:13:26.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/7d4b0026a41e4b467e143221c51b279083b7044a4b104054f5c6464082ff/aiohttp-3.12.13-cp310-cp310-win32.whl", hash = "sha256:a0be857f0b35177ba09d7c472825d1b711d11c6d0e8a2052804e3b93166de1ad", size = 427526, upload-time = "2025-06-14T15:13:27.988Z" }, + { url = "https://files.pythonhosted.org/packages/17/de/34d998da1e7f0de86382160d039131e9b0af1962eebfe53dda2b61d250e7/aiohttp-3.12.13-cp310-cp310-win_amd64.whl", hash = "sha256:fcc30ad4fb5cb41a33953292d45f54ef4066746d625992aeac33b8c681173178", size = 450734, upload-time = "2025-06-14T15:13:29.394Z" }, + { url = "https://files.pythonhosted.org/packages/6a/65/5566b49553bf20ffed6041c665a5504fb047cefdef1b701407b8ce1a47c4/aiohttp-3.12.13-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7c229b1437aa2576b99384e4be668af1db84b31a45305d02f61f5497cfa6f60c", size = 709401, upload-time = "2025-06-14T15:13:30.774Z" }, + { url = "https://files.pythonhosted.org/packages/14/b5/48e4cc61b54850bdfafa8fe0b641ab35ad53d8e5a65ab22b310e0902fa42/aiohttp-3.12.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04076d8c63471e51e3689c93940775dc3d12d855c0c80d18ac5a1c68f0904358", size = 481669, upload-time = "2025-06-14T15:13:32.316Z" }, + { url = "https://files.pythonhosted.org/packages/04/4f/e3f95c8b2a20a0437d51d41d5ccc4a02970d8ad59352efb43ea2841bd08e/aiohttp-3.12.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55683615813ce3601640cfaa1041174dc956d28ba0511c8cbd75273eb0587014", size = 469933, upload-time = "2025-06-14T15:13:34.104Z" }, + { url = "https://files.pythonhosted.org/packages/41/c9/c5269f3b6453b1cfbd2cfbb6a777d718c5f086a3727f576c51a468b03ae2/aiohttp-3.12.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:921bc91e602d7506d37643e77819cb0b840d4ebb5f8d6408423af3d3bf79a7b7", size = 1740128, upload-time = "2025-06-14T15:13:35.604Z" }, + { url = "https://files.pythonhosted.org/packages/6f/49/a3f76caa62773d33d0cfaa842bdf5789a78749dbfe697df38ab1badff369/aiohttp-3.12.13-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e72d17fe0974ddeae8ed86db297e23dba39c7ac36d84acdbb53df2e18505a013", size = 1688796, upload-time = "2025-06-14T15:13:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e4/556fccc4576dc22bf18554b64cc873b1a3e5429a5bdb7bbef7f5d0bc7664/aiohttp-3.12.13-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0653d15587909a52e024a261943cf1c5bdc69acb71f411b0dd5966d065a51a47", size = 1787589, upload-time = "2025-06-14T15:13:38.745Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3d/d81b13ed48e1a46734f848e26d55a7391708421a80336e341d2aef3b6db2/aiohttp-3.12.13-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a77b48997c66722c65e157c06c74332cdf9c7ad00494b85ec43f324e5c5a9b9a", size = 1826635, upload-time = "2025-06-14T15:13:40.733Z" }, + { url = "https://files.pythonhosted.org/packages/75/a5/472e25f347da88459188cdaadd1f108f6292f8a25e62d226e63f860486d1/aiohttp-3.12.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6946bae55fd36cfb8e4092c921075cde029c71c7cb571d72f1079d1e4e013bc", size = 1729095, upload-time = "2025-06-14T15:13:42.312Z" }, + { url = "https://files.pythonhosted.org/packages/b9/fe/322a78b9ac1725bfc59dfc301a5342e73d817592828e4445bd8f4ff83489/aiohttp-3.12.13-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f95db8c8b219bcf294a53742c7bda49b80ceb9d577c8e7aa075612b7f39ffb7", size = 1666170, upload-time = "2025-06-14T15:13:44.884Z" }, + { url = "https://files.pythonhosted.org/packages/7a/77/ec80912270e231d5e3839dbd6c065472b9920a159ec8a1895cf868c2708e/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:03d5eb3cfb4949ab4c74822fb3326cd9655c2b9fe22e4257e2100d44215b2e2b", size = 1714444, upload-time = "2025-06-14T15:13:46.401Z" }, + { url = "https://files.pythonhosted.org/packages/21/b2/fb5aedbcb2b58d4180e58500e7c23ff8593258c27c089abfbcc7db65bd40/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6383dd0ffa15515283c26cbf41ac8e6705aab54b4cbb77bdb8935a713a89bee9", size = 1709604, upload-time = "2025-06-14T15:13:48.377Z" }, + { url = "https://files.pythonhosted.org/packages/e3/15/a94c05f7c4dc8904f80b6001ad6e07e035c58a8ebfcc15e6b5d58500c858/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6548a411bc8219b45ba2577716493aa63b12803d1e5dc70508c539d0db8dbf5a", size = 1689786, upload-time = "2025-06-14T15:13:50.401Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fd/0d2e618388f7a7a4441eed578b626bda9ec6b5361cd2954cfc5ab39aa170/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:81b0fcbfe59a4ca41dc8f635c2a4a71e63f75168cc91026c61be665945739e2d", size = 1783389, upload-time = "2025-06-14T15:13:51.945Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6b/6986d0c75996ef7e64ff7619b9b7449b1d1cbbe05c6755e65d92f1784fe9/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6a83797a0174e7995e5edce9dcecc517c642eb43bc3cba296d4512edf346eee2", size = 1803853, upload-time = "2025-06-14T15:13:53.533Z" }, + { url = "https://files.pythonhosted.org/packages/21/65/cd37b38f6655d95dd07d496b6d2f3924f579c43fd64b0e32b547b9c24df5/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5734d8469a5633a4e9ffdf9983ff7cdb512524645c7a3d4bc8a3de45b935ac3", size = 1716909, upload-time = "2025-06-14T15:13:55.148Z" }, + { url = "https://files.pythonhosted.org/packages/fd/20/2de7012427dc116714c38ca564467f6143aec3d5eca3768848d62aa43e62/aiohttp-3.12.13-cp311-cp311-win32.whl", hash = "sha256:fef8d50dfa482925bb6b4c208b40d8e9fa54cecba923dc65b825a72eed9a5dbd", size = 427036, upload-time = "2025-06-14T15:13:57.076Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b6/98518bcc615ef998a64bef371178b9afc98ee25895b4f476c428fade2220/aiohttp-3.12.13-cp311-cp311-win_amd64.whl", hash = "sha256:9a27da9c3b5ed9d04c36ad2df65b38a96a37e9cfba6f1381b842d05d98e6afe9", size = 451427, upload-time = "2025-06-14T15:13:58.505Z" }, + { url = "https://files.pythonhosted.org/packages/b4/6a/ce40e329788013cd190b1d62bbabb2b6a9673ecb6d836298635b939562ef/aiohttp-3.12.13-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0aa580cf80558557285b49452151b9c69f2fa3ad94c5c9e76e684719a8791b73", size = 700491, upload-time = "2025-06-14T15:14:00.048Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/7150d5cf9163e05081f1c5c64a0cdf3c32d2f56e2ac95db2a28fe90eca69/aiohttp-3.12.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b103a7e414b57e6939cc4dece8e282cfb22043efd0c7298044f6594cf83ab347", size = 475104, upload-time = "2025-06-14T15:14:01.691Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/d42ba4aed039ce6e449b3e2db694328756c152a79804e64e3da5bc19dffc/aiohttp-3.12.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f64e748e9e741d2eccff9597d09fb3cd962210e5b5716047cbb646dc8fe06f", size = 467948, upload-time = "2025-06-14T15:14:03.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/3b/06f0a632775946981d7c4e5a865cddb6e8dfdbaed2f56f9ade7bb4a1039b/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c955989bf4c696d2ededc6b0ccb85a73623ae6e112439398935362bacfaaf6", size = 1714742, upload-time = "2025-06-14T15:14:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/92/a6/2552eebad9ec5e3581a89256276009e6a974dc0793632796af144df8b740/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d640191016763fab76072c87d8854a19e8e65d7a6fcfcbf017926bdbbb30a7e5", size = 1697393, upload-time = "2025-06-14T15:14:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/d8/9f/bd08fdde114b3fec7a021381b537b21920cdd2aa29ad48c5dffd8ee314f1/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4dc507481266b410dede95dd9f26c8d6f5a14315372cc48a6e43eac652237d9b", size = 1752486, upload-time = "2025-06-14T15:14:08.808Z" }, + { url = "https://files.pythonhosted.org/packages/f7/e1/affdea8723aec5bd0959171b5490dccd9a91fcc505c8c26c9f1dca73474d/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8a94daa873465d518db073bd95d75f14302e0208a08e8c942b2f3f1c07288a75", size = 1798643, upload-time = "2025-06-14T15:14:10.767Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9d/666d856cc3af3a62ae86393baa3074cc1d591a47d89dc3bf16f6eb2c8d32/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f52420cde4ce0bb9425a375d95577fe082cb5721ecb61da3049b55189e4e6", size = 1718082, upload-time = "2025-06-14T15:14:12.38Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ce/3c185293843d17be063dada45efd2712bb6bf6370b37104b4eda908ffdbd/aiohttp-3.12.13-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f7df1f620ec40f1a7fbcb99ea17d7326ea6996715e78f71a1c9a021e31b96b8", size = 1633884, upload-time = "2025-06-14T15:14:14.415Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5b/f3413f4b238113be35dfd6794e65029250d4b93caa0974ca572217745bdb/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3062d4ad53b36e17796dce1c0d6da0ad27a015c321e663657ba1cc7659cfc710", size = 1694943, upload-time = "2025-06-14T15:14:16.48Z" }, + { url = "https://files.pythonhosted.org/packages/82/c8/0e56e8bf12081faca85d14a6929ad5c1263c146149cd66caa7bc12255b6d/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8605e22d2a86b8e51ffb5253d9045ea73683d92d47c0b1438e11a359bdb94462", size = 1716398, upload-time = "2025-06-14T15:14:18.589Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f3/33192b4761f7f9b2f7f4281365d925d663629cfaea093a64b658b94fc8e1/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:54fbbe6beafc2820de71ece2198458a711e224e116efefa01b7969f3e2b3ddae", size = 1657051, upload-time = "2025-06-14T15:14:20.223Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0b/26ddd91ca8f84c48452431cb4c5dd9523b13bc0c9766bda468e072ac9e29/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:050bd277dfc3768b606fd4eae79dd58ceda67d8b0b3c565656a89ae34525d15e", size = 1736611, upload-time = "2025-06-14T15:14:21.988Z" }, + { url = "https://files.pythonhosted.org/packages/c3/8d/e04569aae853302648e2c138a680a6a2f02e374c5b6711732b29f1e129cc/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2637a60910b58f50f22379b6797466c3aa6ae28a6ab6404e09175ce4955b4e6a", size = 1764586, upload-time = "2025-06-14T15:14:23.979Z" }, + { url = "https://files.pythonhosted.org/packages/ac/98/c193c1d1198571d988454e4ed75adc21c55af247a9fda08236602921c8c8/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e986067357550d1aaa21cfe9897fa19e680110551518a5a7cf44e6c5638cb8b5", size = 1724197, upload-time = "2025-06-14T15:14:25.692Z" }, + { url = "https://files.pythonhosted.org/packages/e7/9e/07bb8aa11eec762c6b1ff61575eeeb2657df11ab3d3abfa528d95f3e9337/aiohttp-3.12.13-cp312-cp312-win32.whl", hash = "sha256:ac941a80aeea2aaae2875c9500861a3ba356f9ff17b9cb2dbfb5cbf91baaf5bf", size = 421771, upload-time = "2025-06-14T15:14:27.364Z" }, + { url = "https://files.pythonhosted.org/packages/52/66/3ce877e56ec0813069cdc9607cd979575859c597b6fb9b4182c6d5f31886/aiohttp-3.12.13-cp312-cp312-win_amd64.whl", hash = "sha256:671f41e6146a749b6c81cb7fd07f5a8356d46febdaaaf07b0e774ff04830461e", size = 447869, upload-time = "2025-06-14T15:14:29.05Z" }, + { url = "https://files.pythonhosted.org/packages/11/0f/db19abdf2d86aa1deec3c1e0e5ea46a587b97c07a16516b6438428b3a3f8/aiohttp-3.12.13-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d4a18e61f271127465bdb0e8ff36e8f02ac4a32a80d8927aa52371e93cd87938", size = 694910, upload-time = "2025-06-14T15:14:30.604Z" }, + { url = "https://files.pythonhosted.org/packages/d5/81/0ab551e1b5d7f1339e2d6eb482456ccbe9025605b28eed2b1c0203aaaade/aiohttp-3.12.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:532542cb48691179455fab429cdb0d558b5e5290b033b87478f2aa6af5d20ace", size = 472566, upload-time = "2025-06-14T15:14:32.275Z" }, + { url = "https://files.pythonhosted.org/packages/34/3f/6b7d336663337672d29b1f82d1f252ec1a040fe2d548f709d3f90fa2218a/aiohttp-3.12.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d7eea18b52f23c050ae9db5d01f3d264ab08f09e7356d6f68e3f3ac2de9dfabb", size = 464856, upload-time = "2025-06-14T15:14:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/26/7f/32ca0f170496aa2ab9b812630fac0c2372c531b797e1deb3deb4cea904bd/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad7c8e5c25f2a26842a7c239de3f7b6bfb92304593ef997c04ac49fb703ff4d7", size = 1703683, upload-time = "2025-06-14T15:14:36.034Z" }, + { url = "https://files.pythonhosted.org/packages/ec/53/d5513624b33a811c0abea8461e30a732294112318276ce3dbf047dbd9d8b/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6af355b483e3fe9d7336d84539fef460120c2f6e50e06c658fe2907c69262d6b", size = 1684946, upload-time = "2025-06-14T15:14:38Z" }, + { url = "https://files.pythonhosted.org/packages/37/72/4c237dd127827b0247dc138d3ebd49c2ded6114c6991bbe969058575f25f/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a95cf9f097498f35c88e3609f55bb47b28a5ef67f6888f4390b3d73e2bac6177", size = 1737017, upload-time = "2025-06-14T15:14:39.951Z" }, + { url = "https://files.pythonhosted.org/packages/0d/67/8a7eb3afa01e9d0acc26e1ef847c1a9111f8b42b82955fcd9faeb84edeb4/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8ed8c38a1c584fe99a475a8f60eefc0b682ea413a84c6ce769bb19a7ff1c5ef", size = 1786390, upload-time = "2025-06-14T15:14:42.151Z" }, + { url = "https://files.pythonhosted.org/packages/48/19/0377df97dd0176ad23cd8cad4fd4232cfeadcec6c1b7f036315305c98e3f/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a0b9170d5d800126b5bc89d3053a2363406d6e327afb6afaeda2d19ee8bb103", size = 1708719, upload-time = "2025-06-14T15:14:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/61/97/ade1982a5c642b45f3622255173e40c3eed289c169f89d00eeac29a89906/aiohttp-3.12.13-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:372feeace612ef8eb41f05ae014a92121a512bd5067db8f25101dd88a8db11da", size = 1622424, upload-time = "2025-06-14T15:14:45.945Z" }, + { url = "https://files.pythonhosted.org/packages/99/ab/00ad3eea004e1d07ccc406e44cfe2b8da5acb72f8c66aeeb11a096798868/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a946d3702f7965d81f7af7ea8fb03bb33fe53d311df48a46eeca17e9e0beed2d", size = 1675447, upload-time = "2025-06-14T15:14:47.911Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fe/74e5ce8b2ccaba445fe0087abc201bfd7259431d92ae608f684fcac5d143/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a0c4725fae86555bbb1d4082129e21de7264f4ab14baf735278c974785cd2041", size = 1707110, upload-time = "2025-06-14T15:14:50.334Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c4/39af17807f694f7a267bd8ab1fbacf16ad66740862192a6c8abac2bff813/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b28ea2f708234f0a5c44eb6c7d9eb63a148ce3252ba0140d050b091b6e842d1", size = 1649706, upload-time = "2025-06-14T15:14:52.378Z" }, + { url = "https://files.pythonhosted.org/packages/38/e8/f5a0a5f44f19f171d8477059aa5f28a158d7d57fe1a46c553e231f698435/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d4f5becd2a5791829f79608c6f3dc745388162376f310eb9c142c985f9441cc1", size = 1725839, upload-time = "2025-06-14T15:14:54.617Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ac/81acc594c7f529ef4419d3866913f628cd4fa9cab17f7bf410a5c3c04c53/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:60f2ce6b944e97649051d5f5cc0f439360690b73909230e107fd45a359d3e911", size = 1759311, upload-time = "2025-06-14T15:14:56.597Z" }, + { url = "https://files.pythonhosted.org/packages/38/0d/aabe636bd25c6ab7b18825e5a97d40024da75152bec39aa6ac8b7a677630/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69fc1909857401b67bf599c793f2183fbc4804717388b0b888f27f9929aa41f3", size = 1708202, upload-time = "2025-06-14T15:14:58.598Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/561ef2d8a223261683fb95a6283ad0d36cb66c87503f3a7dde7afe208bb2/aiohttp-3.12.13-cp313-cp313-win32.whl", hash = "sha256:7d7e68787a2046b0e44ba5587aa723ce05d711e3a3665b6b7545328ac8e3c0dd", size = 420794, upload-time = "2025-06-14T15:15:00.939Z" }, + { url = "https://files.pythonhosted.org/packages/9d/47/b11d0089875a23bff0abd3edb5516bcd454db3fefab8604f5e4b07bd6210/aiohttp-3.12.13-cp313-cp313-win_amd64.whl", hash = "sha256:5a178390ca90419bfd41419a809688c368e63c86bd725e1186dd97f6b89c2706", size = 446735, upload-time = "2025-06-14T15:15:02.858Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/0f6b2b4797ac364b6ecc9176bb2dd24d4a9aeaa77ecb093c7f87e44dfbd6/aiohttp-3.12.13-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:36f6c973e003dc9b0bb4e8492a643641ea8ef0e97ff7aaa5c0f53d68839357b4", size = 704988, upload-time = "2025-06-14T15:15:04.705Z" }, + { url = "https://files.pythonhosted.org/packages/52/38/d51ea984c777b203959030895c1c8b1f9aac754f8e919e4942edce05958e/aiohttp-3.12.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6cbfc73179bd67c229eb171e2e3745d2afd5c711ccd1e40a68b90427f282eab1", size = 479967, upload-time = "2025-06-14T15:15:06.575Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0a/62f1c2914840eb2184939e773b65e1e5d6b651b78134798263467f0d2467/aiohttp-3.12.13-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1e8b27b2d414f7e3205aa23bb4a692e935ef877e3a71f40d1884f6e04fd7fa74", size = 467373, upload-time = "2025-06-14T15:15:08.788Z" }, + { url = "https://files.pythonhosted.org/packages/7b/4e/327a4b56bb940afb03ee45d5fd1ef7dae5ed6617889d61ed8abf0548310b/aiohttp-3.12.13-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eabded0c2b2ef56243289112c48556c395d70150ce4220d9008e6b4b3dd15690", size = 1642326, upload-time = "2025-06-14T15:15:10.74Z" }, + { url = "https://files.pythonhosted.org/packages/55/5d/f0277aad4d85a56cd6102335d5111c7c6d1f98cb760aa485e4fe11a24f52/aiohttp-3.12.13-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:003038e83f1a3ff97409999995ec02fe3008a1d675478949643281141f54751d", size = 1616820, upload-time = "2025-06-14T15:15:12.77Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ff/909193459a6d32ee806d9f7ae2342c940ee97d2c1416140c5aec3bd6bfc0/aiohttp-3.12.13-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b6f46613031dbc92bdcaad9c4c22c7209236ec501f9c0c5f5f0b6a689bf50f3", size = 1690448, upload-time = "2025-06-14T15:15:14.754Z" }, + { url = "https://files.pythonhosted.org/packages/45/e7/14d09183849e9bd69d8d5bf7df0ab7603996b83b00540e0890eeefa20e1e/aiohttp-3.12.13-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c332c6bb04650d59fb94ed96491f43812549a3ba6e7a16a218e612f99f04145e", size = 1729763, upload-time = "2025-06-14T15:15:16.783Z" }, + { url = "https://files.pythonhosted.org/packages/55/01/07b980d6226574cc2d157fa4978a3d77270a4e860193a579630a81b30e30/aiohttp-3.12.13-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3fea41a2c931fb582cb15dc86a3037329e7b941df52b487a9f8b5aa960153cbd", size = 1636002, upload-time = "2025-06-14T15:15:18.871Z" }, + { url = "https://files.pythonhosted.org/packages/73/cf/20a1f75ca3d8e48065412e80b79bb1c349e26a4fa51d660be186a9c0c1e3/aiohttp-3.12.13-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:846104f45d18fb390efd9b422b27d8f3cf8853f1218c537f36e71a385758c896", size = 1571003, upload-time = "2025-06-14T15:15:20.95Z" }, + { url = "https://files.pythonhosted.org/packages/e1/99/09520d83e5964d6267074be9c66698e2003dfe8c66465813f57b029dec8c/aiohttp-3.12.13-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d6c85ac7dd350f8da2520bac8205ce99df4435b399fa7f4dc4a70407073e390", size = 1618964, upload-time = "2025-06-14T15:15:23.155Z" }, + { url = "https://files.pythonhosted.org/packages/3a/01/c68f2c7632441fbbfc4a835e003e61eb1d63531857b0a2b73c9698846fa8/aiohttp-3.12.13-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5a1ecce0ed281bec7da8550da052a6b89552db14d0a0a45554156f085a912f48", size = 1629103, upload-time = "2025-06-14T15:15:25.209Z" }, + { url = "https://files.pythonhosted.org/packages/fb/fe/f9540bf12fa443d8870ecab70260c02140ed8b4c37884a2e1050bdd689a2/aiohttp-3.12.13-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:5304d74867028cca8f64f1cc1215eb365388033c5a691ea7aa6b0dc47412f495", size = 1605745, upload-time = "2025-06-14T15:15:27.604Z" }, + { url = "https://files.pythonhosted.org/packages/91/d7/526f1d16ca01e0c995887097b31e39c2e350dc20c1071e9b2dcf63a86fcd/aiohttp-3.12.13-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:64d1f24ee95a2d1e094a4cd7a9b7d34d08db1bbcb8aa9fb717046b0a884ac294", size = 1693348, upload-time = "2025-06-14T15:15:30.151Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0a/c103fdaab6fbde7c5f10450b5671dca32cea99800b1303ee8194a799bbb9/aiohttp-3.12.13-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:119c79922a7001ca6a9e253228eb39b793ea994fd2eccb79481c64b5f9d2a055", size = 1709023, upload-time = "2025-06-14T15:15:32.881Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bc/b8d14e754b5e0bf9ecf6df4b930f2cbd6eaaafcdc1b2f9271968747fb6e3/aiohttp-3.12.13-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:bb18f00396d22e2f10cd8825d671d9f9a3ba968d708a559c02a627536b36d91c", size = 1638691, upload-time = "2025-06-14T15:15:35.033Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7b/44b77bf4c48d95d81af5c57e79337d0d51350a85a84e9997a99a6205c441/aiohttp-3.12.13-cp39-cp39-win32.whl", hash = "sha256:0022de47ef63fd06b065d430ac79c6b0bd24cdae7feaf0e8c6bac23b805a23a8", size = 428365, upload-time = "2025-06-14T15:15:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/e5/cb/aaa022eb993e7d51928dc22d743ed17addb40142250e829701c5e6679615/aiohttp-3.12.13-cp39-cp39-win_amd64.whl", hash = "sha256:29e08111ccf81b2734ae03f1ad1cb03b9615e7d8f616764f22f71209c094f122", size = 451652, upload-time = "2025-06-14T15:15:39.079Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424, upload-time = "2024-12-13T17:10:40.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597, upload-time = "2024-12-13T17:10:38.469Z" }, +] + +[[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 = "ansicon" +version = "1.89.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/e2/1c866404ddbd280efedff4a9f15abfe943cb83cde6e895022370f3a61f85/ansicon-1.89.0.tar.gz", hash = "sha256:e4d039def5768a47e4afec8e89e83ec3ae5a26bf00ad851f914d1240b444d2b1", size = 67312, upload-time = "2019-04-29T20:23:57.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/f9/f1c10e223c7b56a38109a3f2eb4e7fe9a757ea3ed3a166754fb30f65e466/ansicon-1.89.0-py2.py3-none-any.whl", hash = "sha256:f1def52d17f65c2c9682cf8370c03f541f410c1752d6a14029f97318e4b9dfec", size = 63675, upload-time = "2019-04-29T20:23:53.83Z" }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, +] + +[[package]] +name = "argcomplete" +version = "3.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/0f/861e168fc813c56a78b35f3c30d91c6757d1fd185af1110f1aec784b35d0/argcomplete-3.6.2.tar.gz", hash = "sha256:d0519b1bc867f5f4f4713c41ad0aba73a4a5f007449716b16f385f2166dc6adf", size = 73403, upload-time = "2025-04-03T04:57:03.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/da/e42d7a9d8dd33fa775f467e4028a47936da2f01e4b0e561f9ba0d74cb0ca/argcomplete-3.6.2-py3-none-any.whl", hash = "sha256:65b3133a29ad53fb42c48cf5114752c7ab66c1c38544fdf6460f450c09b42591", size = 43708, upload-time = "2025-04-03T04:57:01.591Z" }, +] + +[[package]] +name = "asgiref" +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186, upload-time = "2024-03-22T14:39:36.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978, upload-time = "2024-11-30T04:30:14.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + +[[package]] +name = "autobahn" +version = "24.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "hyperlink" }, + { name = "setuptools" }, + { name = "txaio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/f2/8dffb3b709383ba5b47628b0cc4e43e8d12d59eecbddb62cfccac2e7cf6a/autobahn-24.4.2.tar.gz", hash = "sha256:a2d71ef1b0cf780b6d11f8b205fd2c7749765e65795f2ea7d823796642ee92c9", size = 482700, upload-time = "2024-08-02T09:26:48.241Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/ee/a6475f39ef6c6f41c33da6b193e0ffd2c6048f52e1698be6253c59301b72/autobahn-24.4.2-py2.py3-none-any.whl", hash = "sha256:c56a2abe7ac78abbfb778c02892d673a4de58fd004d088cd7ab297db25918e81", size = 666965, upload-time = "2024-08-02T09:26:44.274Z" }, +] + +[[package]] +name = "automat" +version = "25.4.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/0f/d40bbe294bbf004d436a8bcbcfaadca8b5140d39ad0ad3d73d1a8ba15f14/automat-25.4.16.tar.gz", hash = "sha256:0017591a5477066e90d26b0e696ddc143baafd87b588cfac8100bc6be9634de0", size = 129977, upload-time = "2025-04-16T20:12:16.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/ff/1175b0b7371e46244032d43a56862d0af455823b5280a50c63d99cc50f18/automat-25.4.16-py3-none-any.whl", hash = "sha256:04e9bce696a8d5671ee698005af6e5a9fa15354140a87f4870744604dcdd3ba1", size = 42842, upload-time = "2025-04-16T20:12:14.447Z" }, +] + +[[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 = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "black" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449, upload-time = "2025-01-29T04:15:40.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/3b/4ba3f93ac8d90410423fdd31d7541ada9bcee1df32fb90d26de41ed40e1d/black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32", size = 1629419, upload-time = "2025-01-29T05:37:06.642Z" }, + { url = "https://files.pythonhosted.org/packages/b4/02/0bde0485146a8a5e694daed47561785e8b77a0466ccc1f3e485d5ef2925e/black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da", size = 1461080, upload-time = "2025-01-29T05:37:09.321Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/abdf75183c830eaca7589144ff96d49bce73d7ec6ad12ef62185cc0f79a2/black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7", size = 1766886, upload-time = "2025-01-29T04:18:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a6/97d8bb65b1d8a41f8a6736222ba0a334db7b7b77b8023ab4568288f23973/black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9", size = 1419404, upload-time = "2025-01-29T04:19:04.296Z" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/87f596aca05c3ce5b94b8663dbfe242a12843caaa82dd3f85f1ffdc3f177/black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", size = 1614372, upload-time = "2025-01-29T05:37:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d0/2c34c36190b741c59c901e56ab7f6e54dad8df05a6272a9747ecef7c6036/black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", size = 1442865, upload-time = "2025-01-29T05:37:14.309Z" }, + { url = "https://files.pythonhosted.org/packages/21/d4/7518c72262468430ead45cf22bd86c883a6448b9eb43672765d69a8f1248/black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", size = 1749699, upload-time = "2025-01-29T04:18:17.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/db/4f5beb989b547f79096e035c4981ceb36ac2b552d0ac5f2620e941501c99/black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", size = 1428028, upload-time = "2025-01-29T04:18:51.711Z" }, + { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988, upload-time = "2025-01-29T05:37:16.707Z" }, + { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985, upload-time = "2025-01-29T05:37:18.273Z" }, + { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816, upload-time = "2025-01-29T04:18:33.823Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860, upload-time = "2025-01-29T04:19:12.944Z" }, + { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673, upload-time = "2025-01-29T05:37:20.574Z" }, + { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190, upload-time = "2025-01-29T05:37:22.106Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926, upload-time = "2025-01-29T04:18:58.564Z" }, + { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613, upload-time = "2025-01-29T04:19:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b6/ae7507470a4830dbbfe875c701e84a4a5fb9183d1497834871a715716a92/black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0", size = 1628593, upload-time = "2025-01-29T05:37:23.672Z" }, + { url = "https://files.pythonhosted.org/packages/24/c1/ae36fa59a59f9363017ed397750a0cd79a470490860bc7713967d89cdd31/black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f", size = 1460000, upload-time = "2025-01-29T05:37:25.829Z" }, + { url = "https://files.pythonhosted.org/packages/ac/b6/98f832e7a6c49aa3a464760c67c7856363aa644f2f3c74cf7d624168607e/black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e", size = 1765963, upload-time = "2025-01-29T04:18:38.116Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e9/2cb0a017eb7024f70e0d2e9bdb8c5a5b078c5740c7f8816065d06f04c557/black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355", size = 1419419, upload-time = "2025-01-29T04:18:30.191Z" }, + { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload-time = "2025-01-29T04:15:38.082Z" }, +] + +[[package]] +name = "blessed" +version = "1.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinxed", marker = "sys_platform == 'win32'" }, + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/5e/3cada2f7514ee2a76bb8168c71f9b65d056840ebb711962e1ec08eeaa7b0/blessed-1.21.0.tar.gz", hash = "sha256:ece8bbc4758ab9176452f4e3a719d70088eb5739798cd5582c9e05f2a28337ec", size = 6660011, upload-time = "2025-04-26T21:56:58.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/8e/0a37e44878fd76fac9eff5355a1bf760701f53cb5c38cdcd59a8fd9ab2a2/blessed-1.21.0-py2.py3-none-any.whl", hash = "sha256:f831e847396f5a2eac6c106f4dfadedf46c4f804733574b15fe86d2ed45a9588", size = 84727, upload-time = "2025-04-26T16:58:29.919Z" }, +] + +[[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 = "botocore" +version = "1.38.41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/46/cb33f5a0b00086a97c4eebbc4e0211fe85d66d45e53a9545b33805f25b31/botocore-1.38.41.tar.gz", hash = "sha256:98e3fed636ebb519320c4b2d078db6fa6099b052b4bb9b5c66632a5a7fe72507", size = 14031081, upload-time = "2025-06-20T19:26:31.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/b7/37d9f1a633e72250408cb7d53d8915561ac6108b5c3a1973eb8f53ce2990/botocore-1.38.41-py3-none-any.whl", hash = "sha256:06069a06f1352accb1f6c9505d6e323753627112be80a9d2e057c6d9c9779ffd", size = 13690225, upload-time = "2025-06-20T19:26:26.014Z" }, +] + +[[package]] +name = "build" +version = "1.2.2.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10.2'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/46/aeab111f8e06793e4f0e421fcad593d547fb8313b50990f31681ee2fb1ad/build-1.2.2.post1.tar.gz", hash = "sha256:b36993e92ca9375a219c99e606a122ff365a760a2d4bba0caa09bd5278b608b7", size = 46701, upload-time = "2024-10-06T17:22:25.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/c2/80633736cd183ee4a62107413def345f7e6e3c01563dbca1417363cf957e/build-1.2.2.post1-py3-none-any.whl", hash = "sha256:1d61c0887fa860c01971625baae8bdd338e517b836a2f70dd1f7aa3a6b2fc5b5", size = 22950, upload-time = "2024-10-06T17:22:23.299Z" }, +] + +[[package]] +name = "cachecontrol" +version = "0.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/3a/0cbeb04ea57d2493f3ec5a069a117ab467f85e4a10017c6d854ddcbff104/cachecontrol-0.14.3.tar.gz", hash = "sha256:73e7efec4b06b20d9267b441c1f733664f989fb8688391b670ca812d70795d11", size = 28985, upload-time = "2025-04-30T16:45:06.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/4c/800b0607b00b3fd20f1087f80ab53d6b4d005515b0f773e4831e37cfa83f/cachecontrol-0.14.3-py3-none-any.whl", hash = "sha256:b35e44a3113f17d2a31c1e6b27b9de6d4405f84ae51baa8c1d3cc5b633010cae", size = 21802, upload-time = "2025-04-30T16:45:03.863Z" }, +] + +[package.optional-dependencies] +filecache = [ + { name = "filelock" }, +] + +[[package]] +name = "cattrs" +version = "25.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/2b/561d78f488dcc303da4639e02021311728fb7fda8006dd2835550cddd9ed/cattrs-25.1.1.tar.gz", hash = "sha256:c914b734e0f2d59e5b720d145ee010f1fd9a13ee93900922a2f3f9d593b8382c", size = 435016, upload-time = "2025-06-04T20:27:15.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/b0/215274ef0d835bbc1056392a367646648b6084e39d489099959aefcca2af/cattrs-25.1.1-py3-none-any.whl", hash = "sha256:1b40b2d3402af7be79a7e7e097a9b4cd16d4c06e6d526644b0b26a063a1cc064", size = 69386, upload-time = "2025-06-04T20:27:13.969Z" }, +] + +[[package]] +name = "certifi" +version = "2025.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753, upload-time = "2025-06-15T02:45:51.329Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload-time = "2025-06-15T02:45:49.977Z" }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191, upload-time = "2024-09-04T20:43:30.027Z" }, + { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592, upload-time = "2024-09-04T20:43:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024, upload-time = "2024-09-04T20:43:34.186Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188, upload-time = "2024-09-04T20:43:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571, upload-time = "2024-09-04T20:43:38.586Z" }, + { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687, upload-time = "2024-09-04T20:43:40.084Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211, upload-time = "2024-09-04T20:43:41.526Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325, upload-time = "2024-09-04T20:43:43.117Z" }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784, upload-time = "2024-09-04T20:43:45.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564, upload-time = "2024-09-04T20:43:46.779Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804, upload-time = "2024-09-04T20:43:48.186Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299, upload-time = "2024-09-04T20:43:49.812Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload-time = "2024-09-04T20:43:51.124Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload-time = "2024-09-04T20:43:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload-time = "2024-09-04T20:44:00.18Z" }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload-time = "2024-09-04T20:44:01.585Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload-time = "2024-09-04T20:44:03.467Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727, upload-time = "2024-09-04T20:44:09.481Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400, upload-time = "2024-09-04T20:44:10.873Z" }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ea/8bb50596b8ffbc49ddd7a1ad305035daa770202a6b782fc164647c2673ad/cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", size = 182220, upload-time = "2024-09-04T20:45:01.577Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/e77c8cd24f58285a82c23af484cf5b124a376b32644e445960d1a4654c3a/cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", size = 178605, upload-time = "2024-09-04T20:45:03.837Z" }, + { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910, upload-time = "2024-09-04T20:45:05.315Z" }, + { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200, upload-time = "2024-09-04T20:45:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565, upload-time = "2024-09-04T20:45:08.975Z" }, + { url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635, upload-time = "2024-09-04T20:45:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218, upload-time = "2024-09-04T20:45:12.366Z" }, + { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486, upload-time = "2024-09-04T20:45:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911, upload-time = "2024-09-04T20:45:15.696Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632, upload-time = "2024-09-04T20:45:17.284Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b5/fd9f8b5a84010ca169ee49f4e4ad6f8c05f4e3545b72ee041dbbcb159882/cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", size = 171820, upload-time = "2024-09-04T20:45:18.762Z" }, + { url = "https://files.pythonhosted.org/packages/8c/52/b08750ce0bce45c143e1b5d7357ee8c55341b52bdef4b0f081af1eb248c2/cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", size = 181290, upload-time = "2024-09-04T20:45:20.226Z" }, +] + +[[package]] +name = "chalice" +version = "1.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "inquirer" }, + { name = "jmespath" }, + { name = "pip" }, + { name = "pyyaml" }, + { name = "setuptools" }, + { name = "six" }, + { name = "wheel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/aa/ea9b4394c160dbbbbbd6b923b0e1ac5f6f5aad0361b0a74353f8b1fcdf62/chalice-1.32.0.tar.gz", hash = "sha256:c1d469316747ef8850b4b286c60bcf8c53da3bab1a2042d7551284aa8be06af2", size = 256997, upload-time = "2025-05-29T16:19:03.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/89/f3756545a0df8792f79921a0a08786db8039ae753c0da7ca0d0475df9701/chalice-1.32.0-py3-none-any.whl", hash = "sha256:671fdf45b8fe9315a29acb63a0accfdff60dfc582ea4faf54f0d463323930542", size = 265483, upload-time = "2025-05-29T16:19:00.88Z" }, +] + +[[package]] +name = "channels" +version = "4.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django", version = "4.2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "django", version = "5.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/d6/049f93c3c96a88265a52f85da91d2635279261bbd4a924b45caa43b8822e/channels-4.2.2.tar.gz", hash = "sha256:8d7208e48ab8fdb972aaeae8311ce920637d97656ffc7ae5eca4f93f84bcd9a0", size = 26647, upload-time = "2025-03-30T14:59:20.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/bf/4799809715225d19928147d59fda0d3a4129da055b59a9b3e35aa6223f52/channels-4.2.2-py3-none-any.whl", hash = "sha256:ff36a6e1576cacf40bcdc615fa7aece7a709fc4fdd2dc87f2971f4061ffdaa81", size = 31048, upload-time = "2025-03-30T14:59:18.969Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/28/9901804da60055b406e1a1c5ba7aac1276fb77f1dde635aabfc7fd84b8ab/charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", size = 201818, upload-time = "2025-05-02T08:31:46.725Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9b/892a8c8af9110935e5adcbb06d9c6fe741b6bb02608c6513983048ba1a18/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd", size = 144649, upload-time = "2025-05-02T08:31:48.889Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a5/4179abd063ff6414223575e008593861d62abfc22455b5d1a44995b7c101/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6", size = 155045, upload-time = "2025-05-02T08:31:50.757Z" }, + { url = "https://files.pythonhosted.org/packages/3b/95/bc08c7dfeddd26b4be8c8287b9bb055716f31077c8b0ea1cd09553794665/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d", size = 147356, upload-time = "2025-05-02T08:31:52.634Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/7a5b635aa65284bf3eab7653e8b4151ab420ecbae918d3e359d1947b4d61/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86", size = 149471, upload-time = "2025-05-02T08:31:56.207Z" }, + { url = "https://files.pythonhosted.org/packages/ae/38/51fc6ac74251fd331a8cfdb7ec57beba8c23fd5493f1050f71c87ef77ed0/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c", size = 151317, upload-time = "2025-05-02T08:31:57.613Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/edee1e32215ee6e9e46c3e482645b46575a44a2d72c7dfd49e49f60ce6bf/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0", size = 146368, upload-time = "2025-05-02T08:31:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/26/2c/ea3e66f2b5f21fd00b2825c94cafb8c326ea6240cd80a91eb09e4a285830/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef", size = 154491, upload-time = "2025-05-02T08:32:01.219Z" }, + { url = "https://files.pythonhosted.org/packages/52/47/7be7fa972422ad062e909fd62460d45c3ef4c141805b7078dbab15904ff7/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6", size = 157695, upload-time = "2025-05-02T08:32:03.045Z" }, + { url = "https://files.pythonhosted.org/packages/2f/42/9f02c194da282b2b340f28e5fb60762de1151387a36842a92b533685c61e/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366", size = 154849, upload-time = "2025-05-02T08:32:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/67/44/89cacd6628f31fb0b63201a618049be4be2a7435a31b55b5eb1c3674547a/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db", size = 150091, upload-time = "2025-05-02T08:32:06.719Z" }, + { url = "https://files.pythonhosted.org/packages/1f/79/4b8da9f712bc079c0f16b6d67b099b0b8d808c2292c937f267d816ec5ecc/charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a", size = 98445, upload-time = "2025-05-02T08:32:08.66Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d7/96970afb4fb66497a40761cdf7bd4f6fca0fc7bafde3a84f836c1f57a926/charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509", size = 105782, upload-time = "2025-05-02T08:32:10.46Z" }, + { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794, upload-time = "2025-05-02T08:32:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846, upload-time = "2025-05-02T08:32:13.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350, upload-time = "2025-05-02T08:32:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657, upload-time = "2025-05-02T08:32:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260, upload-time = "2025-05-02T08:32:18.807Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164, upload-time = "2025-05-02T08:32:20.333Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571, upload-time = "2025-05-02T08:32:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952, upload-time = "2025-05-02T08:32:23.434Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959, upload-time = "2025-05-02T08:32:24.993Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030, upload-time = "2025-05-02T08:32:26.435Z" }, + { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015, upload-time = "2025-05-02T08:32:28.376Z" }, + { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106, upload-time = "2025-05-02T08:32:30.281Z" }, + { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402, upload-time = "2025-05-02T08:32:32.191Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" }, + { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" }, + { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" }, + { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" }, + { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" }, + { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/28/f8/dfb01ff6cc9af38552c69c9027501ff5a5117c4cc18dcd27cb5259fa1888/charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4", size = 201671, upload-time = "2025-05-02T08:34:12.696Z" }, + { url = "https://files.pythonhosted.org/packages/32/fb/74e26ee556a9dbfe3bd264289b67be1e6d616329403036f6507bb9f3f29c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7", size = 144744, upload-time = "2025-05-02T08:34:14.665Z" }, + { url = "https://files.pythonhosted.org/packages/ad/06/8499ee5aa7addc6f6d72e068691826ff093329fe59891e83b092ae4c851c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836", size = 154993, upload-time = "2025-05-02T08:34:17.134Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a2/5e4c187680728219254ef107a6949c60ee0e9a916a5dadb148c7ae82459c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597", size = 147382, upload-time = "2025-05-02T08:34:19.081Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/56aca740dda674f0cc1ba1418c4d84534be51f639b5f98f538b332dc9a95/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7", size = 149536, upload-time = "2025-05-02T08:34:21.073Z" }, + { url = "https://files.pythonhosted.org/packages/53/13/db2e7779f892386b589173dd689c1b1e304621c5792046edd8a978cbf9e0/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f", size = 151349, upload-time = "2025-05-02T08:34:23.193Z" }, + { url = "https://files.pythonhosted.org/packages/69/35/e52ab9a276186f729bce7a0638585d2982f50402046e4b0faa5d2c3ef2da/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba", size = 146365, upload-time = "2025-05-02T08:34:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d8/af7333f732fc2e7635867d56cb7c349c28c7094910c72267586947561b4b/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12", size = 154499, upload-time = "2025-05-02T08:34:27.359Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/a5b2e48acef264d71e036ff30bcc49e51bde80219bb628ba3e00cf59baac/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518", size = 157735, upload-time = "2025-05-02T08:34:29.798Z" }, + { url = "https://files.pythonhosted.org/packages/85/d8/23e2c112532a29f3eef374375a8684a4f3b8e784f62b01da931186f43494/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5", size = 154786, upload-time = "2025-05-02T08:34:31.858Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/93e0169f08ecc20fe82d12254a200dfaceddc1c12a4077bf454ecc597e33/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3", size = 150203, upload-time = "2025-05-02T08:34:33.88Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9d/9bf2b005138e7e060d7ebdec7503d0ef3240141587651f4b445bdf7286c2/charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471", size = 98436, upload-time = "2025-05-02T08:34:35.907Z" }, + { url = "https://files.pythonhosted.org/packages/6d/24/5849d46cf4311bbf21b424c443b09b459f5b436b1558c04e45dbb7cc478b/charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e", size = 105772, upload-time = "2025-05-02T08:34:37.935Z" }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, +] + +[[package]] +name = "cleo" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "crashtest" }, + { name = "rapidfuzz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/30/f7960ed7041b158301c46774f87620352d50a9028d111b4211187af13783/cleo-2.1.0.tar.gz", hash = "sha256:0b2c880b5d13660a7ea651001fb4acb527696c01f15c9ee650f377aa543fd523", size = 79957, upload-time = "2023-10-30T18:54:12.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/f5/6bbead8b880620e5a99e0e4bb9e22e67cca16ff48d54105302a3e7821096/cleo-2.1.0-py3-none-any.whl", hash = "sha256:4a31bd4dd45695a64ee3c4758f583f134267c2bc518d8ae9a29cf237d009b07e", size = 78711, upload-time = "2023-10-30T18:54:08.557Z" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version >= '3.10' and python_full_version < '3.13'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[package]] +name = "codeflash" +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "coverage" }, + { name = "crosshair-tool" }, + { name = "dill" }, + { name = "gitpython" }, + { name = "humanize" }, + { name = "inquirer" }, + { name = "isort" }, + { name = "jedi" }, + { name = "junitparser" }, + { name = "libcst" }, + { name = "line-profiler" }, + { name = "lxml" }, + { name = "parameterized" }, + { name = "platformdirs" }, + { name = "posthog" }, + { name = "pydantic" }, + { name = "pytest" }, + { name = "pytest-timeout" }, + { name = "rich" }, + { name = "sentry-sdk" }, + { name = "timeout-decorator" }, + { name = "tomlkit" }, + { name = "unidiff" }, + { name = "unittest-xml-reporting" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/3e/b62bd959c4dfc759a226f41d00ab442caa21f5f0f40bf8bcb902c17ffa6a/codeflash-0.14.4.tar.gz", hash = "sha256:389d34ac96da35246a1743295339b043aa2fa70be61d4f02621b17c305ce5061", size = 172136, upload-time = "2025-06-19T01:01:19.386Z" } + +[[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.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/7a/359f4d5df2353f26172b3cc39ea32daa39af8de522205f512f458923e677/colorlog-6.9.0.tar.gz", hash = "sha256:bfba54a1b93b94f54e1f4fe48395725a3d92fd2a4af702f6bd70946bdc0c6ac2", size = 16624, upload-time = "2024-10-29T18:34:51.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl", hash = "sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff", size = 11424, upload-time = "2024-10-29T18:34:49.815Z" }, +] + +[[package]] +name = "constantly" +version = "23.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/6f/cb2a94494ff74aa9528a36c5b1422756330a75a8367bf20bd63171fc324d/constantly-23.10.4.tar.gz", hash = "sha256:aa92b70a33e2ac0bb33cd745eb61776594dc48764b06c35e0efd050b7f1c7cbd", size = 13300, upload-time = "2023-10-28T23:18:24.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/40/c199d095151addf69efdb4b9ca3a4f20f70e20508d6222bffb9b76f58573/constantly-23.10.4-py3-none-any.whl", hash = "sha256:3fd9b4d1c3dc1ec9757f3c52aef7e53ad9323dbe39f51dfd4c43853b68dfa3f9", size = 13547, upload-time = "2023-10-28T23:18:23.038Z" }, +] + +[[package]] +name = "coverage" +version = "7.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/e0/98670a80884f64578f0c22cd70c5e81a6e07b08167721c7487b4d70a7ca0/coverage-7.9.1.tar.gz", hash = "sha256:6cf43c78c4282708a28e466316935ec7489a9c487518a77fa68f716c67909cec", size = 813650, upload-time = "2025-06-13T13:02:28.627Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/78/1c1c5ec58f16817c09cbacb39783c3655d54a221b6552f47ff5ac9297603/coverage-7.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc94d7c5e8423920787c33d811c0be67b7be83c705f001f7180c7b186dcf10ca", size = 212028, upload-time = "2025-06-13T13:00:29.293Z" }, + { url = "https://files.pythonhosted.org/packages/98/db/e91b9076f3a888e3b4ad7972ea3842297a52cc52e73fd1e529856e473510/coverage-7.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:16aa0830d0c08a2c40c264cef801db8bc4fc0e1892782e45bcacbd5889270509", size = 212420, upload-time = "2025-06-13T13:00:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d0/2b3733412954576b0aea0a16c3b6b8fbe95eb975d8bfa10b07359ead4252/coverage-7.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf95981b126f23db63e9dbe4cf65bd71f9a6305696fa5e2262693bc4e2183f5b", size = 241529, upload-time = "2025-06-13T13:00:35.786Z" }, + { url = "https://files.pythonhosted.org/packages/b3/00/5e2e5ae2e750a872226a68e984d4d3f3563cb01d1afb449a17aa819bc2c4/coverage-7.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f05031cf21699785cd47cb7485f67df619e7bcdae38e0fde40d23d3d0210d3c3", size = 239403, upload-time = "2025-06-13T13:00:37.399Z" }, + { url = "https://files.pythonhosted.org/packages/37/3b/a2c27736035156b0a7c20683afe7df498480c0dfdf503b8c878a21b6d7fb/coverage-7.9.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb4fbcab8764dc072cb651a4bcda4d11fb5658a1d8d68842a862a6610bd8cfa3", size = 240548, upload-time = "2025-06-13T13:00:39.647Z" }, + { url = "https://files.pythonhosted.org/packages/98/f5/13d5fc074c3c0e0dc80422d9535814abf190f1254d7c3451590dc4f8b18c/coverage-7.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0f16649a7330ec307942ed27d06ee7e7a38417144620bb3d6e9a18ded8a2d3e5", size = 240459, upload-time = "2025-06-13T13:00:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/36/24/24b9676ea06102df824c4a56ffd13dc9da7904478db519efa877d16527d5/coverage-7.9.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cea0a27a89e6432705fffc178064503508e3c0184b4f061700e771a09de58187", size = 239128, upload-time = "2025-06-13T13:00:42.343Z" }, + { url = "https://files.pythonhosted.org/packages/be/05/242b7a7d491b369ac5fee7908a6e5ba42b3030450f3ad62c645b40c23e0e/coverage-7.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e980b53a959fa53b6f05343afbd1e6f44a23ed6c23c4b4c56c6662bbb40c82ce", size = 239402, upload-time = "2025-06-13T13:00:43.634Z" }, + { url = "https://files.pythonhosted.org/packages/73/e0/4de7f87192fa65c9c8fbaeb75507e124f82396b71de1797da5602898be32/coverage-7.9.1-cp310-cp310-win32.whl", hash = "sha256:70760b4c5560be6ca70d11f8988ee6542b003f982b32f83d5ac0b72476607b70", size = 214518, upload-time = "2025-06-13T13:00:45.622Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ab/5e4e2fe458907d2a65fab62c773671cfc5ac704f1e7a9ddd91996f66e3c2/coverage-7.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a66e8f628b71f78c0e0342003d53b53101ba4e00ea8dabb799d9dba0abbbcebe", size = 215436, upload-time = "2025-06-13T13:00:47.245Z" }, + { url = "https://files.pythonhosted.org/packages/60/34/fa69372a07d0903a78ac103422ad34db72281c9fc625eba94ac1185da66f/coverage-7.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:95c765060e65c692da2d2f51a9499c5e9f5cf5453aeaf1420e3fc847cc060582", size = 212146, upload-time = "2025-06-13T13:00:48.496Z" }, + { url = "https://files.pythonhosted.org/packages/27/f0/da1894915d2767f093f081c42afeba18e760f12fdd7a2f4acbe00564d767/coverage-7.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ba383dc6afd5ec5b7a0d0c23d38895db0e15bcba7fb0fa8901f245267ac30d86", size = 212536, upload-time = "2025-06-13T13:00:51.535Z" }, + { url = "https://files.pythonhosted.org/packages/10/d5/3fc33b06e41e390f88eef111226a24e4504d216ab8e5d1a7089aa5a3c87a/coverage-7.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37ae0383f13cbdcf1e5e7014489b0d71cc0106458878ccde52e8a12ced4298ed", size = 245092, upload-time = "2025-06-13T13:00:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/0a/39/7aa901c14977aba637b78e95800edf77f29f5a380d29768c5b66f258305b/coverage-7.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69aa417a030bf11ec46149636314c24c8d60fadb12fc0ee8f10fda0d918c879d", size = 242806, upload-time = "2025-06-13T13:00:54.571Z" }, + { url = "https://files.pythonhosted.org/packages/43/fc/30e5cfeaf560b1fc1989227adedc11019ce4bb7cce59d65db34fe0c2d963/coverage-7.9.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a4be2a28656afe279b34d4f91c3e26eccf2f85500d4a4ff0b1f8b54bf807338", size = 244610, upload-time = "2025-06-13T13:00:56.932Z" }, + { url = "https://files.pythonhosted.org/packages/bf/15/cca62b13f39650bc87b2b92bb03bce7f0e79dd0bf2c7529e9fc7393e4d60/coverage-7.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:382e7ddd5289f140259b610e5f5c58f713d025cb2f66d0eb17e68d0a94278875", size = 244257, upload-time = "2025-06-13T13:00:58.545Z" }, + { url = "https://files.pythonhosted.org/packages/cd/1a/c0f2abe92c29e1464dbd0ff9d56cb6c88ae2b9e21becdb38bea31fcb2f6c/coverage-7.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e5532482344186c543c37bfad0ee6069e8ae4fc38d073b8bc836fc8f03c9e250", size = 242309, upload-time = "2025-06-13T13:00:59.836Z" }, + { url = "https://files.pythonhosted.org/packages/57/8d/c6fd70848bd9bf88fa90df2af5636589a8126d2170f3aade21ed53f2b67a/coverage-7.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a39d18b3f50cc121d0ce3838d32d58bd1d15dab89c910358ebefc3665712256c", size = 242898, upload-time = "2025-06-13T13:01:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9e/6ca46c7bff4675f09a66fe2797cd1ad6a24f14c9c7c3b3ebe0470a6e30b8/coverage-7.9.1-cp311-cp311-win32.whl", hash = "sha256:dd24bd8d77c98557880def750782df77ab2b6885a18483dc8588792247174b32", size = 214561, upload-time = "2025-06-13T13:01:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/a1/30/166978c6302010742dabcdc425fa0f938fa5a800908e39aff37a7a876a13/coverage-7.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:6b55ad10a35a21b8015eabddc9ba31eb590f54adc9cd39bcf09ff5349fd52125", size = 215493, upload-time = "2025-06-13T13:01:05.702Z" }, + { url = "https://files.pythonhosted.org/packages/60/07/a6d2342cd80a5be9f0eeab115bc5ebb3917b4a64c2953534273cf9bc7ae6/coverage-7.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ad935f0016be24c0e97fc8c40c465f9c4b85cbbe6eac48934c0dc4d2568321e", size = 213869, upload-time = "2025-06-13T13:01:09.345Z" }, + { url = "https://files.pythonhosted.org/packages/68/d9/7f66eb0a8f2fce222de7bdc2046ec41cb31fe33fb55a330037833fb88afc/coverage-7.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8de12b4b87c20de895f10567639c0797b621b22897b0af3ce4b4e204a743626", size = 212336, upload-time = "2025-06-13T13:01:10.909Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/e07cb920ef3addf20f052ee3d54906e57407b6aeee3227a9c91eea38a665/coverage-7.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5add197315a054e92cee1b5f686a2bcba60c4c3e66ee3de77ace6c867bdee7cb", size = 212571, upload-time = "2025-06-13T13:01:12.518Z" }, + { url = "https://files.pythonhosted.org/packages/78/f8/96f155de7e9e248ca9c8ff1a40a521d944ba48bec65352da9be2463745bf/coverage-7.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600a1d4106fe66f41e5d0136dfbc68fe7200a5cbe85610ddf094f8f22e1b0300", size = 246377, upload-time = "2025-06-13T13:01:14.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cf/1d783bd05b7bca5c10ded5f946068909372e94615a4416afadfe3f63492d/coverage-7.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a876e4c3e5a2a1715a6608906aa5a2e0475b9c0f68343c2ada98110512ab1d8", size = 243394, upload-time = "2025-06-13T13:01:16.23Z" }, + { url = "https://files.pythonhosted.org/packages/02/dd/e7b20afd35b0a1abea09fb3998e1abc9f9bd953bee548f235aebd2b11401/coverage-7.9.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81f34346dd63010453922c8e628a52ea2d2ccd73cb2487f7700ac531b247c8a5", size = 245586, upload-time = "2025-06-13T13:01:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/4e/38/b30b0006fea9d617d1cb8e43b1bc9a96af11eff42b87eb8c716cf4d37469/coverage-7.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:888f8eee13f2377ce86d44f338968eedec3291876b0b8a7289247ba52cb984cd", size = 245396, upload-time = "2025-06-13T13:01:19.164Z" }, + { url = "https://files.pythonhosted.org/packages/31/e4/4d8ec1dc826e16791f3daf1b50943e8e7e1eb70e8efa7abb03936ff48418/coverage-7.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9969ef1e69b8c8e1e70d591f91bbc37fc9a3621e447525d1602801a24ceda898", size = 243577, upload-time = "2025-06-13T13:01:22.433Z" }, + { url = "https://files.pythonhosted.org/packages/25/f4/b0e96c5c38e6e40ef465c4bc7f138863e2909c00e54a331da335faf0d81a/coverage-7.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60c458224331ee3f1a5b472773e4a085cc27a86a0b48205409d364272d67140d", size = 244809, upload-time = "2025-06-13T13:01:24.143Z" }, + { url = "https://files.pythonhosted.org/packages/8a/65/27e0a1fa5e2e5079bdca4521be2f5dabf516f94e29a0defed35ac2382eb2/coverage-7.9.1-cp312-cp312-win32.whl", hash = "sha256:5f646a99a8c2b3ff4c6a6e081f78fad0dde275cd59f8f49dc4eab2e394332e74", size = 214724, upload-time = "2025-06-13T13:01:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a8/d5b128633fd1a5e0401a4160d02fa15986209a9e47717174f99dc2f7166d/coverage-7.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:30f445f85c353090b83e552dcbbdad3ec84c7967e108c3ae54556ca69955563e", size = 215535, upload-time = "2025-06-13T13:01:27.861Z" }, + { url = "https://files.pythonhosted.org/packages/a3/37/84bba9d2afabc3611f3e4325ee2c6a47cd449b580d4a606b240ce5a6f9bf/coverage-7.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:af41da5dca398d3474129c58cb2b106a5d93bbb196be0d307ac82311ca234342", size = 213904, upload-time = "2025-06-13T13:01:29.202Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a7/a027970c991ca90f24e968999f7d509332daf6b8c3533d68633930aaebac/coverage-7.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:31324f18d5969feef7344a932c32428a2d1a3e50b15a6404e97cba1cc9b2c631", size = 212358, upload-time = "2025-06-13T13:01:30.909Z" }, + { url = "https://files.pythonhosted.org/packages/f2/48/6aaed3651ae83b231556750280682528fea8ac7f1232834573472d83e459/coverage-7.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c804506d624e8a20fb3108764c52e0eef664e29d21692afa375e0dd98dc384f", size = 212620, upload-time = "2025-06-13T13:01:32.256Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/f4b613f3b44d8b9f144847c89151992b2b6b79cbc506dee89ad0c35f209d/coverage-7.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef64c27bc40189f36fcc50c3fb8f16ccda73b6a0b80d9bd6e6ce4cffcd810bbd", size = 245788, upload-time = "2025-06-13T13:01:33.948Z" }, + { url = "https://files.pythonhosted.org/packages/04/d2/de4fdc03af5e4e035ef420ed26a703c6ad3d7a07aff2e959eb84e3b19ca8/coverage-7.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4fe2348cc6ec372e25adec0219ee2334a68d2f5222e0cba9c0d613394e12d86", size = 243001, upload-time = "2025-06-13T13:01:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e8/eed18aa5583b0423ab7f04e34659e51101135c41cd1dcb33ac1d7013a6d6/coverage-7.9.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34ed2186fe52fcc24d4561041979a0dec69adae7bce2ae8d1c49eace13e55c43", size = 244985, upload-time = "2025-06-13T13:01:36.712Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/ae9e5cce8885728c934eaa58ebfa8281d488ef2afa81c3dbc8ee9e6d80db/coverage-7.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25308bd3d00d5eedd5ae7d4357161f4df743e3c0240fa773ee1b0f75e6c7c0f1", size = 245152, upload-time = "2025-06-13T13:01:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c8/272c01ae792bb3af9b30fac14d71d63371db227980682836ec388e2c57c0/coverage-7.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73e9439310f65d55a5a1e0564b48e34f5369bee943d72c88378f2d576f5a5751", size = 243123, upload-time = "2025-06-13T13:01:40.727Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d0/2819a1e3086143c094ab446e3bdf07138527a7b88cb235c488e78150ba7a/coverage-7.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ab6be0859141b53aa89412a82454b482c81cf750de4f29223d52268a86de67", size = 244506, upload-time = "2025-06-13T13:01:42.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4e/9f6117b89152df7b6112f65c7a4ed1f2f5ec8e60c4be8f351d91e7acc848/coverage-7.9.1-cp313-cp313-win32.whl", hash = "sha256:64bdd969456e2d02a8b08aa047a92d269c7ac1f47e0c977675d550c9a0863643", size = 214766, upload-time = "2025-06-13T13:01:44.482Z" }, + { url = "https://files.pythonhosted.org/packages/27/0f/4b59f7c93b52c2c4ce7387c5a4e135e49891bb3b7408dcc98fe44033bbe0/coverage-7.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:be9e3f68ca9edb897c2184ad0eee815c635565dbe7a0e7e814dc1f7cbab92c0a", size = 215568, upload-time = "2025-06-13T13:01:45.772Z" }, + { url = "https://files.pythonhosted.org/packages/09/1e/9679826336f8c67b9c39a359352882b24a8a7aee48d4c9cad08d38d7510f/coverage-7.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:1c503289ffef1d5105d91bbb4d62cbe4b14bec4d13ca225f9c73cde9bb46207d", size = 213939, upload-time = "2025-06-13T13:01:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/bb/5b/5c6b4e7a407359a2e3b27bf9c8a7b658127975def62077d441b93a30dbe8/coverage-7.9.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0b3496922cb5f4215bf5caaef4cf12364a26b0be82e9ed6d050f3352cf2d7ef0", size = 213079, upload-time = "2025-06-13T13:01:48.554Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/1e2e07279fd2fd97ae26c01cc2186e2258850e9ec125ae87184225662e89/coverage-7.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9565c3ab1c93310569ec0d86b017f128f027cab0b622b7af288696d7ed43a16d", size = 213299, upload-time = "2025-06-13T13:01:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/4c5125a4b69d66b8c85986d3321520f628756cf524af810baab0790c7647/coverage-7.9.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2241ad5dbf79ae1d9c08fe52b36d03ca122fb9ac6bca0f34439e99f8327ac89f", size = 256535, upload-time = "2025-06-13T13:01:51.314Z" }, + { url = "https://files.pythonhosted.org/packages/81/8b/e36a04889dda9960be4263e95e777e7b46f1bb4fc32202612c130a20c4da/coverage-7.9.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bb5838701ca68b10ebc0937dbd0eb81974bac54447c55cd58dea5bca8451029", size = 252756, upload-time = "2025-06-13T13:01:54.403Z" }, + { url = "https://files.pythonhosted.org/packages/98/82/be04eff8083a09a4622ecd0e1f31a2c563dbea3ed848069e7b0445043a70/coverage-7.9.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30a25f814591a8c0c5372c11ac8967f669b97444c47fd794926e175c4047ece", size = 254912, upload-time = "2025-06-13T13:01:56.769Z" }, + { url = "https://files.pythonhosted.org/packages/0f/25/c26610a2c7f018508a5ab958e5b3202d900422cf7cdca7670b6b8ca4e8df/coverage-7.9.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2d04b16a6062516df97969f1ae7efd0de9c31eb6ebdceaa0d213b21c0ca1a683", size = 256144, upload-time = "2025-06-13T13:01:58.19Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/fb9425c4684066c79e863f1e6e7ecebb49e3a64d9f7f7860ef1688c56f4a/coverage-7.9.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7931b9e249edefb07cd6ae10c702788546341d5fe44db5b6108a25da4dca513f", size = 254257, upload-time = "2025-06-13T13:01:59.645Z" }, + { url = "https://files.pythonhosted.org/packages/93/df/27b882f54157fc1131e0e215b0da3b8d608d9b8ef79a045280118a8f98fe/coverage-7.9.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52e92b01041151bf607ee858e5a56c62d4b70f4dac85b8c8cb7fb8a351ab2c10", size = 255094, upload-time = "2025-06-13T13:02:01.37Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/cad1c3dbed8b3ee9e16fa832afe365b4e3eeab1fb6edb65ebbf745eabc92/coverage-7.9.1-cp313-cp313t-win32.whl", hash = "sha256:684e2110ed84fd1ca5f40e89aa44adf1729dc85444004111aa01866507adf363", size = 215437, upload-time = "2025-06-13T13:02:02.905Z" }, + { url = "https://files.pythonhosted.org/packages/99/4d/fad293bf081c0e43331ca745ff63673badc20afea2104b431cdd8c278b4c/coverage-7.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:437c576979e4db840539674e68c84b3cda82bc824dd138d56bead1435f1cb5d7", size = 216605, upload-time = "2025-06-13T13:02:05.638Z" }, + { url = "https://files.pythonhosted.org/packages/1f/56/4ee027d5965fc7fc126d7ec1187529cc30cc7d740846e1ecb5e92d31b224/coverage-7.9.1-cp313-cp313t-win_arm64.whl", hash = "sha256:18a0912944d70aaf5f399e350445738a1a20b50fbea788f640751c2ed9208b6c", size = 214392, upload-time = "2025-06-13T13:02:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d6/c41dd9b02bf16ec001aaf1cbef665537606899a3db1094e78f5ae17540ca/coverage-7.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6f424507f57878e424d9a95dc4ead3fbdd72fd201e404e861e465f28ea469951", size = 212029, upload-time = "2025-06-13T13:02:09.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c0/40420d81d731f84c3916dcdf0506b3e6c6570817bff2576b83f780914ae6/coverage-7.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:535fde4001b2783ac80865d90e7cc7798b6b126f4cd8a8c54acfe76804e54e58", size = 212407, upload-time = "2025-06-13T13:02:11.151Z" }, + { url = "https://files.pythonhosted.org/packages/9b/87/f0db7d62d0e09f14d6d2f6ae8c7274a2f09edf74895a34b412a0601e375a/coverage-7.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02532fd3290bb8fa6bec876520842428e2a6ed6c27014eca81b031c2d30e3f71", size = 241160, upload-time = "2025-06-13T13:02:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b7/3337c064f058a5d7696c4867159651a5b5fb01a5202bcf37362f0c51400e/coverage-7.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56f5eb308b17bca3bbff810f55ee26d51926d9f89ba92707ee41d3c061257e55", size = 239027, upload-time = "2025-06-13T13:02:14.294Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/5898a283f66d1bd413c32c2e0e05408196fd4f37e206e2b06c6e0c626e0e/coverage-7.9.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfa447506c1a52271f1b0de3f42ea0fa14676052549095e378d5bff1c505ff7b", size = 240145, upload-time = "2025-06-13T13:02:15.745Z" }, + { url = "https://files.pythonhosted.org/packages/e0/33/d96e3350078a3c423c549cb5b2ba970de24c5257954d3e4066e2b2152d30/coverage-7.9.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9ca8e220006966b4a7b68e8984a6aee645a0384b0769e829ba60281fe61ec4f7", size = 239871, upload-time = "2025-06-13T13:02:17.344Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6e/6fb946072455f71a820cac144d49d11747a0f1a21038060a68d2d0200499/coverage-7.9.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:49f1d0788ba5b7ba65933f3a18864117c6506619f5ca80326b478f72acf3f385", size = 238122, upload-time = "2025-06-13T13:02:18.849Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5c/bc43f25c8586840ce25a796a8111acf6a2b5f0909ba89a10d41ccff3920d/coverage-7.9.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:68cd53aec6f45b8e4724c0950ce86eacb775c6be01ce6e3669fe4f3a21e768ed", size = 239058, upload-time = "2025-06-13T13:02:21.423Z" }, + { url = "https://files.pythonhosted.org/packages/11/d8/ce2007418dd7fd00ff8c8b898bb150bb4bac2d6a86df05d7b88a07ff595f/coverage-7.9.1-cp39-cp39-win32.whl", hash = "sha256:95335095b6c7b1cc14c3f3f17d5452ce677e8490d101698562b2ffcacc304c8d", size = 214532, upload-time = "2025-06-13T13:02:22.857Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/334e76fa246e92e6d69cab217f7c8a70ae0cc8f01438bd0544103f29528e/coverage-7.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:e1b5191d1648acc439b24721caab2fd0c86679d8549ed2c84d5a7ec1bedcc244", size = 215439, upload-time = "2025-06-13T13:02:24.268Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e5/c723545c3fd3204ebde3b4cc4b927dce709d3b6dc577754bb57f63ca4a4a/coverage-7.9.1-pp39.pp310.pp311-none-any.whl", hash = "sha256:db0f04118d1db74db6c9e1cb1898532c7dcc220f1d2718f058601f7c3f499514", size = 204009, upload-time = "2025-06-13T13:02:25.787Z" }, + { url = "https://files.pythonhosted.org/packages/08/b8/7ddd1e8ba9701dea08ce22029917140e6f66a859427406579fd8d0ca7274/coverage-7.9.1-py3-none-any.whl", hash = "sha256:66b974b145aa189516b6bf2d8423e888b742517d37872f6ee4c5be0073bd9a3c", size = 204000, upload-time = "2025-06-13T13:02:27.173Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "crashtest" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/5d/d79f51058e75948d6c9e7a3d679080a47be61c84d3cc8f71ee31255eb22b/crashtest-0.4.1.tar.gz", hash = "sha256:80d7b1f316ebfbd429f648076d6275c877ba30ba48979de4191714a75266f0ce", size = 4708, upload-time = "2022-11-02T21:15:13.722Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/5c/3ba7d12e7a79566f97b8f954400926d7b6eb33bcdccc1315a857f200f1f1/crashtest-0.4.1-py3-none-any.whl", hash = "sha256:8d23eac5fa660409f57472e3851dab7ac18aba459a8d19cbbba86d3d5aecd2a5", size = 7558, upload-time = "2022-11-02T21:15:12.437Z" }, +] + +[[package]] +name = "crosshair-tool" +version = "0.0.93" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "packaging" }, + { name = "pygls" }, + { name = "typeshed-client" }, + { name = "typing-extensions" }, + { name = "typing-inspect" }, + { name = "z3-solver" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/b9/43c645afe0f82038a3b6129fca3913fab486ae5a462ab4697c64def55d07/crosshair_tool-0.0.93.tar.gz", hash = "sha256:f9fbdffb9f1b7d1bc9adfe383093237cc2a0a4721bfcd92e7634dcf3ad4701b8", size = 468407, upload-time = "2025-06-13T19:20:22.855Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/3c/2a992b360a8a61c3192b51bc5adb2b733171478af54fbe9ee1c33365a2e1/crosshair_tool-0.0.93-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:07d20d6a126c9c8b122daee7a7f02b7be5d1f9a685985e550c0beb2f36d1eb79", size = 530713, upload-time = "2025-06-13T19:19:31.308Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c8/8390ff3153c5c65f779ae7bf9fe575365674998f14f35afc838f389e2ed4/crosshair_tool-0.0.93-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2755cb95a4e28190232b391d26c907610a354e4716a524fa5f1fd6ba8f0be680", size = 522798, upload-time = "2025-06-13T19:19:33.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/99/762bbe19aa095dde3baf99741e16b182e80218277a4e77017070258aebf9/crosshair_tool-0.0.93-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6054dc87c2dc024dada927513bfe9d4887267214b8c78917445832244673aacd", size = 523574, upload-time = "2025-06-13T19:19:34.193Z" }, + { url = "https://files.pythonhosted.org/packages/99/aa/ccc859bb484ce92f381c0581fb9e05f586437adbd0317ccebec1c7254253/crosshair_tool-0.0.93-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:649649dbf1b6aede8ea3151b0d5fa1028260f3ce6695c6de8852333acb1da46b", size = 547265, upload-time = "2025-06-13T19:19:35.298Z" }, + { url = "https://files.pythonhosted.org/packages/73/6e/35124029e39c888e4fcaf4d6ffae7b291667ab1679ec72610864122a0e30/crosshair_tool-0.0.93-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e3d1976d25fef5ce19008217bc3aff0c873c17689ca68d76179467ffac241ee1", size = 546052, upload-time = "2025-06-13T19:19:36.808Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/eb09d80c71d394f0915031e62b2497cccda83eca7750c5cb9212e829e048/crosshair_tool-0.0.93-cp310-cp310-win32.whl", hash = "sha256:6698be289f91c03d42e08145a04549936ffab724773d58be2b2d8050e649956a", size = 525731, upload-time = "2025-06-13T19:19:37.974Z" }, + { url = "https://files.pythonhosted.org/packages/a4/30/59bc5f33298841b92cad3a464ee52d2f3b6aebcbdd482966136d8ace8dc3/crosshair_tool-0.0.93-cp310-cp310-win_amd64.whl", hash = "sha256:bdb9a8590905eb263e88528550795458322169e0ab9004495fa39b835faed9ae", size = 526754, upload-time = "2025-06-13T19:19:39.041Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/38219a2bf5fdcfb6655d50e4f1c03a85d71209ecbba5c30c87ed044b10f8/crosshair_tool-0.0.93-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ef4c28fa690c67a1461a9be4ee309aec230821bb1f2b434b3835c3ed2d941f5e", size = 530799, upload-time = "2025-06-13T19:19:40.46Z" }, + { url = "https://files.pythonhosted.org/packages/af/3f/bc74e0c44e19ed9328672114b0bdb298785044222ca18bb71c1319512388/crosshair_tool-0.0.93-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b7862d41f1dd0603ecc71193e4206461333e802f1c682957e11945401ffea5d1", size = 522849, upload-time = "2025-06-13T19:19:41.661Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/01c7444891b8660730d8ced1be82866bcdc9da5a4b623235d1b9bbba1a6e/crosshair_tool-0.0.93-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:526e82b554456d138df302ed739f33de1214529de93e24dc6b39a5d8bcd9a646", size = 523615, upload-time = "2025-06-13T19:19:43.033Z" }, + { url = "https://files.pythonhosted.org/packages/25/c6/26fb42f4bc0fed35c9ab054e39c64d2c5e8307ed12549bff63386241543b/crosshair_tool-0.0.93-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18086425427f0ea970ee76a93be92541f8f1e9568648dae6993ebbd3efd77920", size = 547506, upload-time = "2025-06-13T19:19:44.078Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6d/f4785200c0205321f56c098da302e9f15e9e78dbf956be907ef2511f6269/crosshair_tool-0.0.93-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e2d71aafb49b7f3fd58f8d94bafd1ad8eeca375242b16b544dc2faa9ad96a827", size = 546430, upload-time = "2025-06-13T19:19:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4c/79/bac7bb1465a8551c21161b68e50be9291e3481a4af548f7adb9e26358a32/crosshair_tool-0.0.93-cp311-cp311-win32.whl", hash = "sha256:c2aed5ea2eeaf9061bdfcb4c916e01feee9ca837cca184cab67e779612796a57", size = 525769, upload-time = "2025-06-13T19:19:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/0b/61/9daf99ccbada871688ece7109d8b8b670765807c2d495561811737308640/crosshair_tool-0.0.93-cp311-cp311-win_amd64.whl", hash = "sha256:c7542273e0b4e28c14d4f04e3044d998afcbca626729c7dced848a4661977edd", size = 526792, upload-time = "2025-06-13T19:19:47.637Z" }, + { url = "https://files.pythonhosted.org/packages/e5/96/4c34435b9c564b6ea6da5fe241aaffc1e4069432b3fdcc2a6a2052fbded7/crosshair_tool-0.0.93-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dcb24cdb031b47fb9a14141230088f0a73d48d8eaec4ca8ee8a8708b13cb0a8f", size = 534691, upload-time = "2025-06-13T19:19:49.733Z" }, + { url = "https://files.pythonhosted.org/packages/0e/3e/b0354a95189b3c4e4fa1e439ca653d5d78ca2fd3132ff5724975767fcfe8/crosshair_tool-0.0.93-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8cc632233bccfb11cf590f4c25a79c2abb490c55b9a811d17919c59315d2fdaf", size = 525273, upload-time = "2025-06-13T19:19:51.186Z" }, + { url = "https://files.pythonhosted.org/packages/b0/0f/7eb68201405237691964c35670a7c3b0e6e30ee2168794194832a74d3e5b/crosshair_tool-0.0.93-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffe166b41eee56aceb7dd311fc628e86a45c6b814677753c31e634f629405351", size = 525900, upload-time = "2025-06-13T19:19:52.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/9a/740a9f571bb90d52b7959269c57480d703189c05ca835ae0c2133306b474/crosshair_tool-0.0.93-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d0462a658c710d71626025781014626002194400c691975cbba335c5d2d816b", size = 556492, upload-time = "2025-06-13T19:19:53.233Z" }, + { url = "https://files.pythonhosted.org/packages/5e/96/64c99f77383633e1ee6a827a2850c7df14c1f228a5c7870923565f50ddea/crosshair_tool-0.0.93-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8fe1a63d8f8f3bce2dc8c05e432439d9417048f8f75648685912ca3e9dba26d8", size = 555382, upload-time = "2025-06-13T19:19:54.33Z" }, + { url = "https://files.pythonhosted.org/packages/6d/86/5ea449f43eb0682c2495eaab176776c0379b2be1116c08a8c03c61cbb233/crosshair_tool-0.0.93-cp312-cp312-win32.whl", hash = "sha256:2b196ebd6fcec055404447062a01024ae6af47c6bd4b2b8034c86d8151a77d62", size = 527434, upload-time = "2025-06-13T19:19:55.404Z" }, + { url = "https://files.pythonhosted.org/packages/b3/60/290d3d9a66a7250c737b521b9af7cf0f1fefcb9e93f83f9e725d2df5420e/crosshair_tool-0.0.93-cp312-cp312-win_amd64.whl", hash = "sha256:6a32aa2435343fc84e183ab5ca0a2c354a9443db80fc61d688b75331dd6b9c64", size = 528603, upload-time = "2025-06-13T19:19:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/4b/68/1e249e1e6f3c72679d5817d858cae741eab476ffe2797b4e57f641dee46d/crosshair_tool-0.0.93-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d52a3503fef53915e7e25cfb02fa3f14cf29207f2377344f6eaf2f778a228e94", size = 543340, upload-time = "2025-06-13T19:19:58.271Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8f/52d7093d4ed113a6d386467f025ab262d9bc94d7290b6867e5685f838c62/crosshair_tool-0.0.93-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9f30c48905f806b7c6d4bd0e99805d24f4708ee2660fbd62f0a3494df87b505f", size = 529049, upload-time = "2025-06-13T19:19:59.378Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f2/d17ec57f1a0401e4d01e63fa9fa8db2ec6d173db273c2cee6dbd4b602bb0/crosshair_tool-0.0.93-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df4ad89717c173b7c2c2e78f66b5a55d7fe162d14061f907e69d8605faa4d3c1", size = 529730, upload-time = "2025-06-13T19:20:00.613Z" }, + { url = "https://files.pythonhosted.org/packages/63/f1/144c5769492061c0522926e15e52ad943c07737071ecf76ac333b219f8a2/crosshair_tool-0.0.93-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce5f08e1cf786c8d5ebd9bd3c9f140110bec6e2b87dbca81e60a86af8651762", size = 562703, upload-time = "2025-06-13T19:20:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b8/5a552ad08e3934084c1e7ecbeb0423036b25208f3c5f46f9ca3d82ca0808/crosshair_tool-0.0.93-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:551f2887a5b7da93eeba3046df02eb9d00de8d8d343bd82a79c19ce918f0b364", size = 562066, upload-time = "2025-06-13T19:20:03.068Z" }, + { url = "https://files.pythonhosted.org/packages/03/e2/4b1d6300166c960e3972d95b7a392f0f0156d7deb23b07707920edbc265b/crosshair_tool-0.0.93-cp313-cp313-win32.whl", hash = "sha256:d382a761d643533b1379728841652ce5f4ce62d0e5d1027570268ed5207b55ec", size = 527460, upload-time = "2025-06-13T19:20:04.119Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/7e5cf34c081272dec7efb461110af334d31577b8e2df879d98e8b08ba426/crosshair_tool-0.0.93-cp313-cp313-win_amd64.whl", hash = "sha256:47583d671ce7251e146af8675343ac59da2ba572f97430f552c962971b649d80", size = 528643, upload-time = "2025-06-13T19:20:05.248Z" }, + { url = "https://files.pythonhosted.org/packages/0d/32/b12838d595970631e48219dd57e564f346e5ed32107d21a428354d14d6cc/crosshair_tool-0.0.93-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:dc5bcdf09b12ba314891816864e7b2d5fc130b4b5d645541961da4cf70ef335b", size = 530619, upload-time = "2025-06-13T19:20:14.2Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/2ffc9dd27917c91454537d9d150bdf174efc14c8fbff780a78362b20d459/crosshair_tool-0.0.93-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7e1248f0eba8cf696a7c206355fb2220054db809d06d8951dac01bf9a0b5818b", size = 522742, upload-time = "2025-06-13T19:20:15.565Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ac/e493ef85b47868bd65720c9c10c8ed66cd0549e22e46285a0296b188e1e9/crosshair_tool-0.0.93-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:465405f9e4828c1259bd6dc2b0b4f312e4fdef3fdfd1537dfe5fb19c3e2399fb", size = 523539, upload-time = "2025-06-13T19:20:16.942Z" }, + { url = "https://files.pythonhosted.org/packages/7a/2c/a74c938928b1b4711a417adab79f74004818dc13ca347ac178333d491dbe/crosshair_tool-0.0.93-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97b3e397b3b487e8f1ab5db0291bc49b29a30586ef3e911355dc344d6e4769ac", size = 546512, upload-time = "2025-06-13T19:20:18.295Z" }, + { url = "https://files.pythonhosted.org/packages/45/10/e7ceb350299c5a61f5bb0b009734ea1b3af0b6cbaca3852678cec6f867c0/crosshair_tool-0.0.93-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e6ce216159a632992a12d8cf475582f86265f22267e1be0c6839e362109c5570", size = 545363, upload-time = "2025-06-13T19:20:19.464Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0c/c3f591acd4fb9313258e071dccc23704c567e8088524f5aaf000959d020e/crosshair_tool-0.0.93-cp39-cp39-win32.whl", hash = "sha256:8a0c7753c43252c0612f2c6914281ad4f7db9038ebb978082c000b38e8b1c221", size = 525724, upload-time = "2025-06-13T19:20:20.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/ce/f7ef4fac3399ebc4fb50a0897e6ab47bd2ba3863a2b8f5b53ae3de718a7a/crosshair_tool-0.0.93-cp39-cp39-win_amd64.whl", hash = "sha256:7e297a2a0c257b2b23a5a0a7a2f0b9e5b8f5a3f012b048837ca452c49fc8d9c0", size = 526774, upload-time = "2025-06-13T19:20:21.702Z" }, +] + +[[package]] +name = "cryptography" +version = "45.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/c8/a2a376a8711c1e11708b9c9972e0c3223f5fc682552c82d8db844393d6ce/cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57", size = 744890, upload-time = "2025-06-10T00:03:51.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/1c/92637793de053832523b410dbe016d3f5c11b41d0cf6eef8787aabb51d41/cryptography-45.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:425a9a6ac2823ee6e46a76a21a4e8342d8fa5c01e08b823c1f19a8b74f096069", size = 7055712, upload-time = "2025-06-10T00:02:38.826Z" }, + { url = "https://files.pythonhosted.org/packages/ba/14/93b69f2af9ba832ad6618a03f8a034a5851dc9a3314336a3d71c252467e1/cryptography-45.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d", size = 4205335, upload-time = "2025-06-10T00:02:41.64Z" }, + { url = "https://files.pythonhosted.org/packages/67/30/fae1000228634bf0b647fca80403db5ca9e3933b91dd060570689f0bd0f7/cryptography-45.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036", size = 4431487, upload-time = "2025-06-10T00:02:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5a/7dffcf8cdf0cb3c2430de7404b327e3db64735747d641fc492539978caeb/cryptography-45.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e", size = 4208922, upload-time = "2025-06-10T00:02:45.334Z" }, + { url = "https://files.pythonhosted.org/packages/c6/f3/528729726eb6c3060fa3637253430547fbaaea95ab0535ea41baa4a6fbd8/cryptography-45.0.4-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2", size = 3900433, upload-time = "2025-06-10T00:02:47.359Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4a/67ba2e40f619e04d83c32f7e1d484c1538c0800a17c56a22ff07d092ccc1/cryptography-45.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b", size = 4464163, upload-time = "2025-06-10T00:02:49.412Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9a/b4d5aa83661483ac372464809c4b49b5022dbfe36b12fe9e323ca8512420/cryptography-45.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1", size = 4208687, upload-time = "2025-06-10T00:02:50.976Z" }, + { url = "https://files.pythonhosted.org/packages/db/b7/a84bdcd19d9c02ec5807f2ec2d1456fd8451592c5ee353816c09250e3561/cryptography-45.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999", size = 4463623, upload-time = "2025-06-10T00:02:52.542Z" }, + { url = "https://files.pythonhosted.org/packages/d8/84/69707d502d4d905021cac3fb59a316344e9f078b1da7fb43ecde5e10840a/cryptography-45.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750", size = 4332447, upload-time = "2025-06-10T00:02:54.63Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ee/d4f2ab688e057e90ded24384e34838086a9b09963389a5ba6854b5876598/cryptography-45.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2", size = 4572830, upload-time = "2025-06-10T00:02:56.689Z" }, + { url = "https://files.pythonhosted.org/packages/70/d4/994773a261d7ff98034f72c0e8251fe2755eac45e2265db4c866c1c6829c/cryptography-45.0.4-cp311-abi3-win32.whl", hash = "sha256:e00a6c10a5c53979d6242f123c0a97cff9f3abed7f064fc412c36dc521b5f257", size = 2932769, upload-time = "2025-06-10T00:02:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/5a/42/c80bd0b67e9b769b364963b5252b17778a397cefdd36fa9aa4a5f34c599a/cryptography-45.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:817ee05c6c9f7a69a16200f0c90ab26d23a87701e2a284bd15156783e46dbcc8", size = 3410441, upload-time = "2025-06-10T00:03:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0b/2488c89f3a30bc821c9d96eeacfcab6ff3accc08a9601ba03339c0fd05e5/cryptography-45.0.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:964bcc28d867e0f5491a564b7debb3ffdd8717928d315d12e0d7defa9e43b723", size = 7031836, upload-time = "2025-06-10T00:03:01.726Z" }, + { url = "https://files.pythonhosted.org/packages/fe/51/8c584ed426093aac257462ae62d26ad61ef1cbf5b58d8b67e6e13c39960e/cryptography-45.0.4-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637", size = 4195746, upload-time = "2025-06-10T00:03:03.94Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7d/4b0ca4d7af95a704eef2f8f80a8199ed236aaf185d55385ae1d1610c03c2/cryptography-45.0.4-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d", size = 4424456, upload-time = "2025-06-10T00:03:05.589Z" }, + { url = "https://files.pythonhosted.org/packages/1d/45/5fabacbc6e76ff056f84d9f60eeac18819badf0cefc1b6612ee03d4ab678/cryptography-45.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee", size = 4198495, upload-time = "2025-06-10T00:03:09.172Z" }, + { url = "https://files.pythonhosted.org/packages/55/b7/ffc9945b290eb0a5d4dab9b7636706e3b5b92f14ee5d9d4449409d010d54/cryptography-45.0.4-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff", size = 3885540, upload-time = "2025-06-10T00:03:10.835Z" }, + { url = "https://files.pythonhosted.org/packages/7f/e3/57b010282346980475e77d414080acdcb3dab9a0be63071efc2041a2c6bd/cryptography-45.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6", size = 4452052, upload-time = "2025-06-10T00:03:12.448Z" }, + { url = "https://files.pythonhosted.org/packages/37/e6/ddc4ac2558bf2ef517a358df26f45bc774a99bf4653e7ee34b5e749c03e3/cryptography-45.0.4-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad", size = 4198024, upload-time = "2025-06-10T00:03:13.976Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c0/85fa358ddb063ec588aed4a6ea1df57dc3e3bc1712d87c8fa162d02a65fc/cryptography-45.0.4-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6", size = 4451442, upload-time = "2025-06-10T00:03:16.248Z" }, + { url = "https://files.pythonhosted.org/packages/33/67/362d6ec1492596e73da24e669a7fbbaeb1c428d6bf49a29f7a12acffd5dc/cryptography-45.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872", size = 4325038, upload-time = "2025-06-10T00:03:18.4Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/82a14bf047a96a1b13ebb47fb9811c4f73096cfa2e2b17c86879687f9027/cryptography-45.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4", size = 4560964, upload-time = "2025-06-10T00:03:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/cd/37/1a3cba4c5a468ebf9b95523a5ef5651244693dc712001e276682c278fc00/cryptography-45.0.4-cp37-abi3-win32.whl", hash = "sha256:c22fe01e53dc65edd1945a2e6f0015e887f84ced233acecb64b4daadb32f5c97", size = 2924557, upload-time = "2025-06-10T00:03:22.563Z" }, + { url = "https://files.pythonhosted.org/packages/2a/4b/3256759723b7e66380397d958ca07c59cfc3fb5c794fb5516758afd05d41/cryptography-45.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:627ba1bc94f6adf0b0a2e35d87020285ead22d9f648c7e75bb64f367375f3b22", size = 3395508, upload-time = "2025-06-10T00:03:24.586Z" }, + { url = "https://files.pythonhosted.org/packages/16/33/b38e9d372afde56906a23839302c19abdac1c505bfb4776c1e4b07c3e145/cryptography-45.0.4-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a77c6fb8d76e9c9f99f2f3437c1a4ac287b34eaf40997cfab1e9bd2be175ac39", size = 3580103, upload-time = "2025-06-10T00:03:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b9/357f18064ec09d4807800d05a48f92f3b369056a12f995ff79549fbb31f1/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7aad98a25ed8ac917fdd8a9c1e706e5a0956e06c498be1f713b61734333a4507", size = 4143732, upload-time = "2025-06-10T00:03:27.896Z" }, + { url = "https://files.pythonhosted.org/packages/c4/9c/7f7263b03d5db329093617648b9bd55c953de0b245e64e866e560f9aac07/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3530382a43a0e524bc931f187fc69ef4c42828cf7d7f592f7f249f602b5a4ab0", size = 4385424, upload-time = "2025-06-10T00:03:29.992Z" }, + { url = "https://files.pythonhosted.org/packages/a6/5a/6aa9d8d5073d5acc0e04e95b2860ef2684b2bd2899d8795fc443013e263b/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:6b613164cb8425e2f8db5849ffb84892e523bf6d26deb8f9bb76ae86181fa12b", size = 4142438, upload-time = "2025-06-10T00:03:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/42/1c/71c638420f2cdd96d9c2b287fec515faf48679b33a2b583d0f1eda3a3375/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:96d4819e25bf3b685199b304a0029ce4a3caf98947ce8a066c9137cc78ad2c58", size = 4384622, upload-time = "2025-06-10T00:03:33.491Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ab/e3a055c34e97deadbf0d846e189237d3385dca99e1a7e27384c3b2292041/cryptography-45.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b97737a3ffbea79eebb062eb0d67d72307195035332501722a9ca86bab9e3ab2", size = 3328911, upload-time = "2025-06-10T00:03:35.035Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ba/cf442ae99ef363855ed84b39e0fb3c106ac66b7a7703f3c9c9cfe05412cb/cryptography-45.0.4-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4828190fb6c4bcb6ebc6331f01fe66ae838bb3bd58e753b59d4b22eb444b996c", size = 3590512, upload-time = "2025-06-10T00:03:36.982Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a7d5bb87d149eb99a5abdc69a41e4e47b8001d767e5f403f78bfaafc7aa7/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:03dbff8411206713185b8cebe31bc5c0eb544799a50c09035733716b386e61a4", size = 4146899, upload-time = "2025-06-10T00:03:38.659Z" }, + { url = "https://files.pythonhosted.org/packages/17/11/9361c2c71c42cc5c465cf294c8030e72fb0c87752bacbd7a3675245e3db3/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51dfbd4d26172d31150d84c19bbe06c68ea4b7f11bbc7b3a5e146b367c311349", size = 4388900, upload-time = "2025-06-10T00:03:40.233Z" }, + { url = "https://files.pythonhosted.org/packages/c0/76/f95b83359012ee0e670da3e41c164a0c256aeedd81886f878911581d852f/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:0339a692de47084969500ee455e42c58e449461e0ec845a34a6a9b9bf7df7fb8", size = 4146422, upload-time = "2025-06-10T00:03:41.827Z" }, + { url = "https://files.pythonhosted.org/packages/09/ad/5429fcc4def93e577a5407988f89cf15305e64920203d4ac14601a9dc876/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:0cf13c77d710131d33e63626bd55ae7c0efb701ebdc2b3a7952b9b23a0412862", size = 4388475, upload-time = "2025-06-10T00:03:43.493Z" }, + { url = "https://files.pythonhosted.org/packages/99/49/0ab9774f64555a1b50102757811508f5ace451cf5dc0a2d074a4b9deca6a/cryptography-45.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bbc505d1dc469ac12a0a064214879eac6294038d6b24ae9f71faae1448a9608d", size = 3337594, upload-time = "2025-06-10T00:03:45.523Z" }, +] + +[[package]] +name = "daphne" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "autobahn" }, + { name = "twisted", extra = ["tls"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/fa/88208e036d1a000cc99eed11a5ddae2397a39d89c44ac22a3c35a58eb951/daphne-4.2.0.tar.gz", hash = "sha256:c055de9e685cab7aa369e25e16731baa9b310b9db1a76886dbdde0b4456fb056", size = 45302, upload-time = "2025-05-16T14:46:48.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/ad/b00755d09a1ec080ad5fe0d5a6e609f9e4441755204a16c17cc7a07f80f6/daphne-4.2.0-py3-none-any.whl", hash = "sha256:ccc7a476c498272237e27758a02aff11c76ab777c4e20b9b6c141729db599d5d", size = 28493, upload-time = "2025-05-16T14:46:46.859Z" }, +] + +[[package]] +name = "dependency-groups" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/55/f054de99871e7beb81935dea8a10b90cd5ce42122b1c3081d5282fdb3621/dependency_groups-1.3.1.tar.gz", hash = "sha256:78078301090517fd938c19f64a53ce98c32834dfe0dee6b88004a569a6adfefd", size = 10093, upload-time = "2025-05-02T00:34:29.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/c7/d1ec24fb280caa5a79b6b950db565dab30210a66259d17d5bb2b3a9f878d/dependency_groups-1.3.1-py3-none-any.whl", hash = "sha256:51aeaa0dfad72430fcfb7bcdbefbd75f3792e5919563077f30bc0d73f4493030", size = 8664, upload-time = "2025-05-02T00:34:27.085Z" }, +] + +[[package]] +name = "dill" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976, upload-time = "2025-04-16T00:41:48.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" }, +] + +[[package]] +name = "distlib" +version = "0.3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923, upload-time = "2024-10-09T18:35:47.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "django" +version = "4.2.23" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version < '3.10'" }, + { name = "sqlparse", marker = "python_full_version < '3.10'" }, + { name = "tzdata", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/20/02242739714eb4e53933d6c0fe2c57f41feb449955b0aa39fc2da82b8f3c/django-4.2.23.tar.gz", hash = "sha256:42fdeaba6e6449d88d4f66de47871015097dc6f1b87910db00a91946295cfae4", size = 10448384, upload-time = "2025-06-10T10:06:34.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/44/314e8e4612bd122dd0424c88b44730af68eafbee88cc887a86586b7a1f2a/django-4.2.23-py3-none-any.whl", hash = "sha256:dafbfaf52c2f289bd65f4ab935791cb4fb9a198f2a5ba9faf35d7338a77e9803", size = 7993904, upload-time = "2025-06-10T10:06:28.092Z" }, +] + +[[package]] +name = "django" +version = "5.2.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version >= '3.10' and python_full_version < '3.13'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version >= '3.10'" }, + { name = "sqlparse", marker = "python_full_version >= '3.10'" }, + { name = "tzdata", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/af/77b403926025dc6f7fd7b31256394d643469418965eb528eab45d0505358/django-5.2.3.tar.gz", hash = "sha256:335213277666ab2c5cac44a792a6d2f3d58eb79a80c14b6b160cd4afc3b75684", size = 10850303, upload-time = "2025-06-10T10:14:05.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/11/7aff961db37e1ea501a2bb663d27a8ce97f3683b9e5b83d3bfead8b86fa4/django-5.2.3-py3-none-any.whl", hash = "sha256:c517a6334e0fd940066aa9467b29401b93c37cec2e61365d663b80922542069d", size = 8301935, upload-time = "2025-06-10T10:13:58.993Z" }, +] + +[[package]] +name = "dulwich" +version = "0.22.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/8b/0f2de00c0c0d5881dc39be147ec2918725fb3628deeeb1f27d1c6cf6d9f4/dulwich-0.22.8.tar.gz", hash = "sha256:701547310415de300269331abe29cb5717aa1ea377af826bf513d0adfb1c209b", size = 466542, upload-time = "2025-03-02T23:08:10.375Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/4d/0bfc8a96456d033428875003b5104da2c32407363b5b829da5e27553b403/dulwich-0.22.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:546176d18b8cc0a492b0f23f07411e38686024cffa7e9d097ae20512a2e57127", size = 925150, upload-time = "2025-03-02T23:06:45.982Z" }, + { url = "https://files.pythonhosted.org/packages/99/71/0dd97cf5a7a09aee93f8266421898d705eba737ca904720450584f471bd3/dulwich-0.22.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d2434dd72b2ae09b653c9cfe6764a03c25cfbd99fbbb7c426f0478f6fb1100f", size = 994973, upload-time = "2025-03-02T23:06:48.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/40/831bed622eeacfa21f47d1fd75fc0c33a70a2cf1c091ae955be63e94144c/dulwich-0.22.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe8318bc0921d42e3e69f03716f983a301b5ee4c8dc23c7f2c5bbb28581257a9", size = 1002875, upload-time = "2025-03-02T23:06:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9e/5255b3927f355c95f6779debf11d551b7bb427a80a11564a1e1b78f0acf6/dulwich-0.22.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7a0f96a2a87f3b4f7feae79d2ac6b94107d6b7d827ac08f2f331b88c8f597a1", size = 1046048, upload-time = "2025-03-02T23:06:53.173Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f9/d3041cea8cbaaffbd4bf95343c5c16d64608200fc5fa26418bee00ebff23/dulwich-0.22.8-cp310-cp310-win32.whl", hash = "sha256:432a37b25733202897b8d67cdd641688444d980167c356ef4e4dd15a17a39a24", size = 592790, upload-time = "2025-03-02T23:06:55.319Z" }, + { url = "https://files.pythonhosted.org/packages/94/95/e90a292fb00ffae4f3fbb53b199574eedfaf57b72b67a8ddb835536fc66b/dulwich-0.22.8-cp310-cp310-win_amd64.whl", hash = "sha256:f3a15e58dac8b8a76073ddca34e014f66f3672a5540a99d49ef6a9c09ab21285", size = 609197, upload-time = "2025-03-02T23:06:57.439Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6e/de1a1c35960d0e399f71725cfcd4dfdb3c391b22c0e5059d991f7ade3488/dulwich-0.22.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0852edc51cff4f4f62976bdaa1d82f6ef248356c681c764c0feb699bc17d5782", size = 925222, upload-time = "2025-03-02T23:06:59.595Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/b65953b4e9c39268c67038bb8d88516885b720beb25b0f6a0ae95ea3f6b2/dulwich-0.22.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:826aae8b64ac1a12321d6b272fc13934d8f62804fda2bc6ae46f93f4380798eb", size = 994572, upload-time = "2025-03-02T23:07:00.971Z" }, + { url = "https://files.pythonhosted.org/packages/13/eb/07e3974964bfe05888457f7764cfe53b6b95082313c2be06fbbb72116372/dulwich-0.22.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7ae726f923057d36cdbb9f4fb7da0d0903751435934648b13f1b851f0e38ea1", size = 1002530, upload-time = "2025-03-02T23:07:02.927Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b3/69aebfda4dd4b05ae11af803e4df2d8d350356a30b3b6b6fc662fa1ff729/dulwich-0.22.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6987d753227f55cf75ba29a8dab69d1d83308ce483d7a8c6d223086f7a42e125", size = 1046084, upload-time = "2025-03-02T23:07:04.901Z" }, + { url = "https://files.pythonhosted.org/packages/d4/88/ea0f473d726e117f9fcd7c7a95d97f9ba0e0ee9d9005d745a38809d33352/dulwich-0.22.8-cp311-cp311-win32.whl", hash = "sha256:7757b4a2aad64c6f1920082fc1fccf4da25c3923a0ae7b242c08d06861dae6e1", size = 593130, upload-time = "2025-03-02T23:07:07.336Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/ed23a435d6922ba7d9601404f473e49acdcb5768a35d89a5bc5fa51d882b/dulwich-0.22.8-cp311-cp311-win_amd64.whl", hash = "sha256:12b243b7e912011c7225dc67480c313ac8d2990744789b876016fb593f6f3e19", size = 609118, upload-time = "2025-03-02T23:07:11.171Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f2/53c5a22a4a9c0033e10f35c293bc533d64fe3e0c4ff4421128a97d6feda9/dulwich-0.22.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d81697f74f50f008bb221ab5045595f8a3b87c0de2c86aa55be42ba97421f3cd", size = 915677, upload-time = "2025-03-02T23:07:13.292Z" }, + { url = "https://files.pythonhosted.org/packages/02/57/7163ed06a2d9bf1f34d89dcc7c5881119beeed287022c997b0a706edcfbe/dulwich-0.22.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bff1da8e2e6a607c3cb45f5c2e652739589fe891245e1d5b770330cdecbde41", size = 991955, upload-time = "2025-03-02T23:07:14.633Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/50ddf1f3ad592c2526cb34287f45b07ee6320b850efddda2917cc81ac651/dulwich-0.22.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9969099e15b939d3936f8bee8459eaef7ef5a86cd6173393a17fe28ca3d38aff", size = 1000045, upload-time = "2025-03-02T23:07:16.807Z" }, + { url = "https://files.pythonhosted.org/packages/70/6b/1153b2793bfc34253589badb5fc22ed476cf741dab7854919e6e51cb0441/dulwich-0.22.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:017152c51b9a613f0698db28c67cf3e0a89392d28050dbf4f4ac3f657ea4c0dc", size = 1044291, upload-time = "2025-03-02T23:07:18.912Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e3/6b013b98254d7f508f21456832e757b17a9116752979e8b923f89f8c8989/dulwich-0.22.8-cp312-cp312-win32.whl", hash = "sha256:ee70e8bb8798b503f81b53f7a103cb869c8e89141db9005909f79ab1506e26e9", size = 591258, upload-time = "2025-03-02T23:07:21.038Z" }, + { url = "https://files.pythonhosted.org/packages/81/20/b149f68557d42607b5dcc6f57c1650f2136049be617f3e68092c25861275/dulwich-0.22.8-cp312-cp312-win_amd64.whl", hash = "sha256:dc89c6f14dcdcbfee200b0557c59ae243835e42720be143526d834d0e53ed3af", size = 608693, upload-time = "2025-03-02T23:07:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b7/78116bfe8860edca277d00ac243749c8b94714dc3b4608f0c23fa7f4b78e/dulwich-0.22.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbade3342376be1cd2409539fe1b901d2d57a531106bbae204da921ef4456a74", size = 915617, upload-time = "2025-03-02T23:07:25.18Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/28c317a83d6ae9ca93a8decfaa50f09b25a73134f5087a98f51fa5a2d784/dulwich-0.22.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71420ffb6deebc59b2ce875e63d814509f9c1dc89c76db962d547aebf15670c7", size = 991271, upload-time = "2025-03-02T23:07:26.554Z" }, + { url = "https://files.pythonhosted.org/packages/84/a0/64a0376f79c7fb87ec6e6d9a0e2157f3196d1f5f75618c402645ac5ccf19/dulwich-0.22.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a626adbfac44646a125618266a24133763bdc992bf8bd0702910d67e6b994443", size = 999791, upload-time = "2025-03-02T23:07:28.068Z" }, + { url = "https://files.pythonhosted.org/packages/63/c3/260f060ededcdf5f13a7e63a36329c95225bf8e8c3f50aeca6820850b56a/dulwich-0.22.8-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f1476c9c4e4ede95714d06c4831883a26680e37b040b8b6230f506e5ba39f51", size = 1043970, upload-time = "2025-03-02T23:07:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/11/47/2bc02dd1c25eb13cb3cd20cd5a55dd9d7b9fa6af95ed574dd913dd67a0fb/dulwich-0.22.8-cp313-cp313-win32.whl", hash = "sha256:b2b31913932bb5bd41658dd398b33b1a2d4d34825123ad54e40912cfdfe60003", size = 590548, upload-time = "2025-03-02T23:07:31.518Z" }, + { url = "https://files.pythonhosted.org/packages/f3/17/66368fa9d4cffd52663d20354a74aa42d3a6d998f1a462e30aff38c99d25/dulwich-0.22.8-cp313-cp313-win_amd64.whl", hash = "sha256:7a44e5a61a7989aca1e301d39cfb62ad2f8853368682f524d6e878b4115d823d", size = 608200, upload-time = "2025-03-02T23:07:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c5/c67e7742c5fa7d70a01eb8689b3c2014e5151169fc5d19186ec81899001b/dulwich-0.22.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f9cd0c67fb44a38358b9fcabee948bf11044ef6ce7a129e50962f54c176d084e", size = 926618, upload-time = "2025-03-02T23:07:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/3a/92/7bd8fc43b02d6f3f997a5a201af6effed0d026359877092f84d50ac5f327/dulwich-0.22.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b79b94726c3f4a9e5a830c649376fd0963236e73142a4290bac6bc9fc9cb120", size = 995038, upload-time = "2025-03-02T23:07:35.979Z" }, + { url = "https://files.pythonhosted.org/packages/96/f3/8f96461752375bc0b81cab941d58824a1359b84d43a49311b5213a9699d0/dulwich-0.22.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16bbe483d663944972e22d64e1f191201123c3b5580fbdaac6a4f66bfaa4fc11", size = 1003876, upload-time = "2025-03-02T23:07:37.497Z" }, + { url = "https://files.pythonhosted.org/packages/d5/34/5d3b5b1ace0c2ab964f0a724f57523e07cf02eafa45df39328cd4bcf2e99/dulwich-0.22.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e02d403af23d93dc1f96eb2408e25efd50046e38590a88c86fa4002adc9849b0", size = 1048552, upload-time = "2025-03-02T23:07:39.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d9/16fcd2c973aa2c1ec3e880c43c95f5afced1abb3f655f5a3fd1911abf02b/dulwich-0.22.8-cp39-cp39-win32.whl", hash = "sha256:8bdd9543a77fb01be704377f5e634b71f955fec64caa4a493dc3bfb98e3a986e", size = 594500, upload-time = "2025-03-02T23:07:41.683Z" }, + { url = "https://files.pythonhosted.org/packages/ef/9b/e7f3d9a5b7ceed1c1051237abd48b5fa1c1a3ab716a4f9c56a1a2f5e839a/dulwich-0.22.8-cp39-cp39-win_amd64.whl", hash = "sha256:3b6757c6b3ba98212b854a766a4157b9cb79a06f4e1b06b46dec4bd834945b8e", size = 610275, upload-time = "2025-03-02T23:07:43.105Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a3/7f88ba8ed56eaed6206a7d9b35244964a32eb08635be33f2af60819e6431/dulwich-0.22.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7bb18fa09daa1586c1040b3e2777d38d4212a5cdbe47d384ba66a1ac336fcc4c", size = 947436, upload-time = "2025-03-02T23:07:44.398Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d0/664a38f03cf4264a4ab9112067eb4998d14ffbf3af4cff9fb2d1447f11bc/dulwich-0.22.8-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b2fda8e87907ed304d4a5962aea0338366144df0df60f950b8f7f125871707f", size = 998380, upload-time = "2025-03-02T23:07:45.935Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e4/3595a23375b797a8602a2ca8f6b8207b4ebdf2e3a1ccba306f7b90d74c3f/dulwich-0.22.8-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1748cd573a0aee4d530bc223a23ccb8bb5b319645931a37bd1cfb68933b720c1", size = 1006758, upload-time = "2025-03-02T23:07:47.503Z" }, + { url = "https://files.pythonhosted.org/packages/20/d1/32d89d37da8e2ae947558db0401940594efdda9fa5bb1c55c2b46c43f244/dulwich-0.22.8-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a631b2309feb9a9631eabd896612ba36532e3ffedccace57f183bb868d7afc06", size = 1050947, upload-time = "2025-03-02T23:07:49.208Z" }, + { url = "https://files.pythonhosted.org/packages/f5/dc/b9448b82de3e244400dc35813f31db9f4952605c7d4e3041fd94878613c9/dulwich-0.22.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:00e7d9a3d324f9e0a1b27880eec0e8e276ff76519621b66c1a429ca9eb3f5a8d", size = 612479, upload-time = "2025-03-02T23:07:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/e2/20/d855d603ea49ce437d2a015fad9dbb22409e23520340aef3d3dca8b299bb/dulwich-0.22.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f8aa3de93201f9e3e40198725389aa9554a4ee3318a865f96a8e9bc9080f0b25", size = 947073, upload-time = "2025-03-02T23:07:52.082Z" }, + { url = "https://files.pythonhosted.org/packages/30/06/390a3a9ce2f4d5b20af0e64f0e9bcefb4a87ad30ef53ee122887f5444076/dulwich-0.22.8-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e8da9dd8135884975f5be0563ede02179240250e11f11942801ae31ac293f37", size = 997873, upload-time = "2025-03-02T23:07:54.399Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cd/3c5731784bac200e41b5e66b1440f9f30f92781d3eeefb9f90147c3d392e/dulwich-0.22.8-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fc5ce2435fb3abdf76f1acabe48f2e4b3f7428232cadaef9daaf50ea7fa30ee", size = 1006609, upload-time = "2025-03-02T23:07:56.091Z" }, + { url = "https://files.pythonhosted.org/packages/19/cf/01180599b0028e2175da4c0878fbe050d1f197825529be19718f65c5a475/dulwich-0.22.8-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:982b21cc3100d959232cadb3da0a478bd549814dd937104ea50f43694ec27153", size = 1051004, upload-time = "2025-03-02T23:07:58.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/7b/df95faaf8746cce65704f1631a6626e5bb4604a499a0f63fc9103669deba/dulwich-0.22.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6bde2b13a05cc0ec2ecd4597a99896663544c40af1466121f4d046119b874ce3", size = 612529, upload-time = "2025-03-02T23:07:59.731Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/f9736e4a94f2d13220169c3293167e5d154508a6038613fcda8cc2515c55/dulwich-0.22.8-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6d446cb7d272a151934ad4b48ba691f32486d5267cf2de04ee3b5e05fc865326", size = 947961, upload-time = "2025-03-02T23:08:01.842Z" }, + { url = "https://files.pythonhosted.org/packages/3b/20/7d7a38b8409514365bd0bc046ced20f011daf363dba55434643a9cfbb484/dulwich-0.22.8-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f6338e6cf95cd76a0191b3637dc3caed1f988ae84d8e75f876d5cd75a8dd81a", size = 998944, upload-time = "2025-03-02T23:08:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/4f/a95c197882dd93c5e3997f64d5e53cd70ceec4dcc8ff9eb8fc1eb0cab34f/dulwich-0.22.8-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e004fc532ea262f2d5f375068101ca4792becb9d4aa663b050f5ac31fda0bb5c", size = 1007748, upload-time = "2025-03-02T23:08:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/79/45/d29a9fca7960d8ef9eb7e2cc8a8049add3a2e831e48a56f07a5ae886ace6/dulwich-0.22.8-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6bfdbc6fa477dee00d04e22d43a51571cd820cfaaaa886f0f155b8e29b3e3d45", size = 1053398, upload-time = "2025-03-02T23:08:06.29Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3a/2fdc2e85d9eea6324617a566138f60ffc2b3fdf89cd058aae0c4edb72a22/dulwich-0.22.8-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ae900c8e573f79d714c1d22b02cdadd50b64286dd7203028f0200f82089e4950", size = 613736, upload-time = "2025-03-02T23:08:07.662Z" }, + { url = "https://files.pythonhosted.org/packages/37/56/395c6d82d4d9eb7a7ab62939c99db5b746995b0f3ad3b31f43c15e3e07a0/dulwich-0.22.8-py3-none-any.whl", hash = "sha256:ffc7a02e62b72884de58baaa3b898b7f6427893e79b1289ffa075092efe59181", size = 273071, upload-time = "2025-03-02T23:08:09.013Z" }, +] + +[[package]] +name = "editor" +version = "1.6.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "runs" }, + { name = "xmod" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/92/734a4ab345914259cb6146fd36512608ea42be16195375c379046f33283d/editor-1.6.6.tar.gz", hash = "sha256:bb6989e872638cd119db9a4fce284cd8e13c553886a1c044c6b8d8a160c871f8", size = 3197, upload-time = "2024-01-25T10:44:59.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/c2/4bc8cd09b14e28ce3f406a8b05761bed0d785d1ca8c2a5c6684d884c66a2/editor-1.6.6-py3-none-any.whl", hash = "sha256:e818e6913f26c2a81eadef503a2741d7cca7f235d20e217274a009ecd5a74abf", size = 4017, upload-time = "2024-01-25T10:44:58.66Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524, upload-time = "2024-04-08T09:04:19.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612, upload-time = "2024-04-08T09:04:17.414Z" }, +] + +[[package]] +name = "executing" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693, upload-time = "2025-01-22T15:41:29.403Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702, upload-time = "2025-01-22T15:41:25.929Z" }, +] + +[[package]] +name = "faker" +version = "37.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/f9/66af4019ee952fc84b8fe5b523fceb7f9e631ed8484417b6f1e3092f8290/faker-37.4.0.tar.gz", hash = "sha256:7f69d579588c23d5ce671f3fa872654ede0e67047820255f43a4aa1925b89780", size = 1901976, upload-time = "2025-06-11T17:59:30.818Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/5e/c8c3c5ea0896ab747db2e2889bf5a6f618ed291606de6513df56ad8670a8/faker-37.4.0-py3-none-any.whl", hash = "sha256:cb81c09ebe06c32a10971d1bbdb264bb0e22b59af59548f011ac4809556ce533", size = 1942992, upload-time = "2025-06-11T17:59:28.698Z" }, +] + +[[package]] +name = "fastapi" +version = "0.115.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/64/ec0788201b5554e2a87c49af26b77a4d132f807a0fa9675257ac92c6aa0e/fastapi-0.115.13.tar.gz", hash = "sha256:55d1d25c2e1e0a0a50aceb1c8705cd932def273c102bff0b1c1da88b3c6eb307", size = 295680, upload-time = "2025-06-17T11:49:45.575Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/4a/e17764385382062b0edbb35a26b7cf76d71e27e456546277a42ba6545c6e/fastapi-0.115.13-py3-none-any.whl", hash = "sha256:0a0cab59afa7bab22f5eb347f8c9864b681558c278395e94035a741fc10cd865", size = 95315, upload-time = "2025-06-17T11:49:44.106Z" }, +] + +[[package]] +name = "fastjsonschema" +version = "2.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/50/4b769ce1ac4071a1ef6d86b1a3fb56cdc3a37615e8c5519e1af96cdac366/fastjsonschema-2.21.1.tar.gz", hash = "sha256:794d4f0a58f848961ba16af7b9c85a3e88cd360df008c59aac6fc5ae9323b5d4", size = 373939, upload-time = "2024-12-02T10:55:15.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/2b/0817a2b257fe88725c25589d89aec060581aabf668707a8d03b2e9e0cb2a/fastjsonschema-2.21.1-py3-none-any.whl", hash = "sha256:c9e5b7e908310918cf494a434eeb31384dd84a98b57a30bcb1f535015b554667", size = 23924, upload-time = "2024-12-02T10:55:07.599Z" }, +] + +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, +] + +[[package]] +name = "findpython" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/73/ab2c4fb7972145c1595c07837cffc1456c1510a908f5c8bda9745930ee60/findpython-0.6.3.tar.gz", hash = "sha256:5863ea55556d8aadc693481a14ac4f3624952719efc1c5591abb0b4a9e965c94", size = 17827, upload-time = "2025-03-10T02:21:20.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/cc/10e4ec45585eba7784a6e86f21990e97b828b8d8927d28ae639b06d50c59/findpython-0.6.3-py3-none-any.whl", hash = "sha256:a85bb589b559cdf1b87227cc233736eb7cad894b9e68021ee498850611939ebc", size = 20564, upload-time = "2025-03-10T02:21:19.624Z" }, +] + +[[package]] +name = "flask" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/de/e47735752347f4128bcf354e0da07ef311a78244eba9e3dc1d4a5ab21a98/flask-3.1.1.tar.gz", hash = "sha256:284c7b8f2f58cb737f0cf1c30fd7eaf0ccfcde196099d24ecede3fc2005aa59e", size = 753440, upload-time = "2025-05-13T15:01:17.447Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/68/9d4508e893976286d2ead7f8f571314af6c2037af34853a30fd769c02e9d/flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c", size = 103305, upload-time = "2025-05-13T15:01:15.591Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/36/0da0a49409f6b47cc2d060dc8c9040b897b5902a8a4e37d9bc1deb11f680/frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a", size = 81304, upload-time = "2025-06-09T22:59:46.226Z" }, + { url = "https://files.pythonhosted.org/packages/77/f0/77c11d13d39513b298e267b22eb6cb559c103d56f155aa9a49097221f0b6/frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61", size = 47735, upload-time = "2025-06-09T22:59:48.133Z" }, + { url = "https://files.pythonhosted.org/packages/37/12/9d07fa18971a44150593de56b2f2947c46604819976784bcf6ea0d5db43b/frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d", size = 46775, upload-time = "2025-06-09T22:59:49.564Z" }, + { url = "https://files.pythonhosted.org/packages/70/34/f73539227e06288fcd1f8a76853e755b2b48bca6747e99e283111c18bcd4/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e", size = 224644, upload-time = "2025-06-09T22:59:51.35Z" }, + { url = "https://files.pythonhosted.org/packages/fb/68/c1d9c2f4a6e438e14613bad0f2973567586610cc22dcb1e1241da71de9d3/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9", size = 222125, upload-time = "2025-06-09T22:59:52.884Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d0/98e8f9a515228d708344d7c6986752be3e3192d1795f748c24bcf154ad99/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c", size = 233455, upload-time = "2025-06-09T22:59:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/79/df/8a11bcec5600557f40338407d3e5bea80376ed1c01a6c0910fcfdc4b8993/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981", size = 227339, upload-time = "2025-06-09T22:59:56.187Z" }, + { url = "https://files.pythonhosted.org/packages/50/82/41cb97d9c9a5ff94438c63cc343eb7980dac4187eb625a51bdfdb7707314/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615", size = 212969, upload-time = "2025-06-09T22:59:57.604Z" }, + { url = "https://files.pythonhosted.org/packages/13/47/f9179ee5ee4f55629e4f28c660b3fdf2775c8bfde8f9c53f2de2d93f52a9/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50", size = 222862, upload-time = "2025-06-09T22:59:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/df81e41ec6b953902c8b7e3a83bee48b195cb0e5ec2eabae5d8330c78038/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa", size = 222492, upload-time = "2025-06-09T23:00:01.026Z" }, + { url = "https://files.pythonhosted.org/packages/84/17/30d6ea87fa95a9408245a948604b82c1a4b8b3e153cea596421a2aef2754/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577", size = 238250, upload-time = "2025-06-09T23:00:03.401Z" }, + { url = "https://files.pythonhosted.org/packages/8f/00/ecbeb51669e3c3df76cf2ddd66ae3e48345ec213a55e3887d216eb4fbab3/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59", size = 218720, upload-time = "2025-06-09T23:00:05.282Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c0/c224ce0e0eb31cc57f67742071bb470ba8246623c1823a7530be0e76164c/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e", size = 232585, upload-time = "2025-06-09T23:00:07.962Z" }, + { url = "https://files.pythonhosted.org/packages/55/3c/34cb694abf532f31f365106deebdeac9e45c19304d83cf7d51ebbb4ca4d1/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd", size = 234248, upload-time = "2025-06-09T23:00:09.428Z" }, + { url = "https://files.pythonhosted.org/packages/98/c0/2052d8b6cecda2e70bd81299e3512fa332abb6dcd2969b9c80dfcdddbf75/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718", size = 221621, upload-time = "2025-06-09T23:00:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bf/7dcebae315436903b1d98ffb791a09d674c88480c158aa171958a3ac07f0/frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e", size = 39578, upload-time = "2025-06-09T23:00:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/8f/5f/f69818f017fa9a3d24d1ae39763e29b7f60a59e46d5f91b9c6b21622f4cd/frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464", size = 43830, upload-time = "2025-06-09T23:00:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/34/7e/803dde33760128acd393a27eb002f2020ddb8d99d30a44bfbaab31c5f08a/frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a", size = 82251, upload-time = "2025-06-09T23:00:16.279Z" }, + { url = "https://files.pythonhosted.org/packages/75/a9/9c2c5760b6ba45eae11334db454c189d43d34a4c0b489feb2175e5e64277/frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750", size = 48183, upload-time = "2025-06-09T23:00:17.698Z" }, + { url = "https://files.pythonhosted.org/packages/47/be/4038e2d869f8a2da165f35a6befb9158c259819be22eeaf9c9a8f6a87771/frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd", size = 47107, upload-time = "2025-06-09T23:00:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/79/26/85314b8a83187c76a37183ceed886381a5f992975786f883472fcb6dc5f2/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2", size = 237333, upload-time = "2025-06-09T23:00:20.275Z" }, + { url = "https://files.pythonhosted.org/packages/1f/fd/e5b64f7d2c92a41639ffb2ad44a6a82f347787abc0c7df5f49057cf11770/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f", size = 231724, upload-time = "2025-06-09T23:00:21.705Z" }, + { url = "https://files.pythonhosted.org/packages/20/fb/03395c0a43a5976af4bf7534759d214405fbbb4c114683f434dfdd3128ef/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30", size = 245842, upload-time = "2025-06-09T23:00:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/d0/15/c01c8e1dffdac5d9803507d824f27aed2ba76b6ed0026fab4d9866e82f1f/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98", size = 239767, upload-time = "2025-06-09T23:00:25.103Z" }, + { url = "https://files.pythonhosted.org/packages/14/99/3f4c6fe882c1f5514b6848aa0a69b20cb5e5d8e8f51a339d48c0e9305ed0/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86", size = 224130, upload-time = "2025-06-09T23:00:27.061Z" }, + { url = "https://files.pythonhosted.org/packages/4d/83/220a374bd7b2aeba9d0725130665afe11de347d95c3620b9b82cc2fcab97/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae", size = 235301, upload-time = "2025-06-09T23:00:29.02Z" }, + { url = "https://files.pythonhosted.org/packages/03/3c/3e3390d75334a063181625343e8daab61b77e1b8214802cc4e8a1bb678fc/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8", size = 234606, upload-time = "2025-06-09T23:00:30.514Z" }, + { url = "https://files.pythonhosted.org/packages/23/1e/58232c19608b7a549d72d9903005e2d82488f12554a32de2d5fb59b9b1ba/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31", size = 248372, upload-time = "2025-06-09T23:00:31.966Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/e4a567e01702a88a74ce8a324691e62a629bf47d4f8607f24bf1c7216e7f/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7", size = 229860, upload-time = "2025-06-09T23:00:33.375Z" }, + { url = "https://files.pythonhosted.org/packages/73/a6/63b3374f7d22268b41a9db73d68a8233afa30ed164c46107b33c4d18ecdd/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5", size = 245893, upload-time = "2025-06-09T23:00:35.002Z" }, + { url = "https://files.pythonhosted.org/packages/6d/eb/d18b3f6e64799a79673c4ba0b45e4cfbe49c240edfd03a68be20002eaeaa/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898", size = 246323, upload-time = "2025-06-09T23:00:36.468Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f5/720f3812e3d06cd89a1d5db9ff6450088b8f5c449dae8ffb2971a44da506/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56", size = 233149, upload-time = "2025-06-09T23:00:37.963Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/03efbf545e217d5db8446acfd4c447c15b7c8cf4dbd4a58403111df9322d/frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7", size = 39565, upload-time = "2025-06-09T23:00:39.753Z" }, + { url = "https://files.pythonhosted.org/packages/58/17/fe61124c5c333ae87f09bb67186d65038834a47d974fc10a5fadb4cc5ae1/frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d", size = 44019, upload-time = "2025-06-09T23:00:40.988Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" }, + { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084, upload-time = "2025-06-09T23:00:46.125Z" }, + { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524, upload-time = "2025-06-09T23:00:47.73Z" }, + { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493, upload-time = "2025-06-09T23:00:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116, upload-time = "2025-06-09T23:00:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557, upload-time = "2025-06-09T23:00:52.855Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820, upload-time = "2025-06-09T23:00:54.43Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542, upload-time = "2025-06-09T23:00:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350, upload-time = "2025-06-09T23:00:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093, upload-time = "2025-06-09T23:01:00.015Z" }, + { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482, upload-time = "2025-06-09T23:01:01.474Z" }, + { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590, upload-time = "2025-06-09T23:01:02.961Z" }, + { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785, upload-time = "2025-06-09T23:01:05.095Z" }, + { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload-time = "2025-06-09T23:01:06.54Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload-time = "2025-06-09T23:01:07.752Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, + { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, + { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, + { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" }, + { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, + { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, + { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, + { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, + { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, + { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, + { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, + { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/ee59496f51cd244039330015d60f13ce5a54a0f2bd8d79e4a4a375ab7469/frozenlist-1.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cea3dbd15aea1341ea2de490574a4a37ca080b2ae24e4b4f4b51b9057b4c3630", size = 82434, upload-time = "2025-06-09T23:02:05.195Z" }, + { url = "https://files.pythonhosted.org/packages/75/e1/d518391ce36a6279b3fa5bc14327dde80bcb646bb50d059c6ca0756b8d05/frozenlist-1.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7d536ee086b23fecc36c2073c371572374ff50ef4db515e4e503925361c24f71", size = 48232, upload-time = "2025-06-09T23:02:07.728Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8d/a0d04f28b6e821a9685c22e67b5fb798a5a7b68752f104bfbc2dccf080c4/frozenlist-1.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dfcebf56f703cb2e346315431699f00db126d158455e513bd14089d992101e44", size = 47186, upload-time = "2025-06-09T23:02:09.243Z" }, + { url = "https://files.pythonhosted.org/packages/93/3a/a5334c0535c8b7c78eeabda1579179e44fe3d644e07118e59a2276dedaf1/frozenlist-1.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:974c5336e61d6e7eb1ea5b929cb645e882aadab0095c5a6974a111e6479f8878", size = 226617, upload-time = "2025-06-09T23:02:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/8258d971f519dc3f278c55069a775096cda6610a267b53f6248152b72b2f/frozenlist-1.7.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c70db4a0ab5ab20878432c40563573229a7ed9241506181bba12f6b7d0dc41cb", size = 224179, upload-time = "2025-06-09T23:02:12.603Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/8225905bf889b97c6d935dd3aeb45668461e59d415cb019619383a8a7c3b/frozenlist-1.7.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1137b78384eebaf70560a36b7b229f752fb64d463d38d1304939984d5cb887b6", size = 235783, upload-time = "2025-06-09T23:02:14.678Z" }, + { url = "https://files.pythonhosted.org/packages/54/6e/ef52375aa93d4bc510d061df06205fa6dcfd94cd631dd22956b09128f0d4/frozenlist-1.7.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e793a9f01b3e8b5c0bc646fb59140ce0efcc580d22a3468d70766091beb81b35", size = 229210, upload-time = "2025-06-09T23:02:16.313Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/62c87d1a6547bfbcd645df10432c129100c5bd0fd92a384de6e3378b07c1/frozenlist-1.7.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74739ba8e4e38221d2c5c03d90a7e542cb8ad681915f4ca8f68d04f810ee0a87", size = 215994, upload-time = "2025-06-09T23:02:17.9Z" }, + { url = "https://files.pythonhosted.org/packages/45/d2/263fea1f658b8ad648c7d94d18a87bca7e8c67bd6a1bbf5445b1bd5b158c/frozenlist-1.7.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e63344c4e929b1a01e29bc184bbb5fd82954869033765bfe8d65d09e336a677", size = 225122, upload-time = "2025-06-09T23:02:19.479Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/7145e35d12fb368d92124f679bea87309495e2e9ddf14c6533990cb69218/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2ea2a7369eb76de2217a842f22087913cdf75f63cf1307b9024ab82dfb525938", size = 224019, upload-time = "2025-06-09T23:02:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/44/1e/7dae8c54301beb87bcafc6144b9a103bfd2c8f38078c7902984c9a0c4e5b/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:836b42f472a0e006e02499cef9352ce8097f33df43baaba3e0a28a964c26c7d2", size = 239925, upload-time = "2025-06-09T23:02:22.466Z" }, + { url = "https://files.pythonhosted.org/packages/4b/1e/99c93e54aa382e949a98976a73b9b20c3aae6d9d893f31bbe4991f64e3a8/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e22b9a99741294b2571667c07d9f8cceec07cb92aae5ccda39ea1b6052ed4319", size = 220881, upload-time = "2025-06-09T23:02:24.521Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9c/ca5105fa7fb5abdfa8837581be790447ae051da75d32f25c8f81082ffc45/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:9a19e85cc503d958abe5218953df722748d87172f71b73cf3c9257a91b999890", size = 234046, upload-time = "2025-06-09T23:02:26.206Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4d/e99014756093b4ddbb67fb8f0df11fe7a415760d69ace98e2ac6d5d43402/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f22dac33bb3ee8fe3e013aa7b91dc12f60d61d05b7fe32191ffa84c3aafe77bd", size = 235756, upload-time = "2025-06-09T23:02:27.79Z" }, + { url = "https://files.pythonhosted.org/packages/8b/72/a19a40bcdaa28a51add2aaa3a1a294ec357f36f27bd836a012e070c5e8a5/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9ccec739a99e4ccf664ea0775149f2749b8a6418eb5b8384b4dc0a7d15d304cb", size = 222894, upload-time = "2025-06-09T23:02:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/08/49/0042469993e023a758af81db68c76907cd29e847d772334d4d201cbe9a42/frozenlist-1.7.0-cp39-cp39-win32.whl", hash = "sha256:b3950f11058310008a87757f3eee16a8e1ca97979833239439586857bc25482e", size = 39848, upload-time = "2025-06-09T23:02:31.413Z" }, + { url = "https://files.pythonhosted.org/packages/5a/45/827d86ee475c877f5f766fbc23fb6acb6fada9e52f1c9720e2ba3eae32da/frozenlist-1.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:43a82fce6769c70f2f5a06248b614a7d268080a9d20f7457ef10ecee5af82b63", size = 44102, upload-time = "2025-06-09T23:02:32.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.44" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/89/37df0b71473153574a5cdef8f242de422a0f5d26d7a9e231e6f169b4ad14/gitpython-3.1.44.tar.gz", hash = "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269", size = 214196, upload-time = "2025-01-02T07:32:43.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/9a/4114a9057db2f1462d5c8f8390ab7383925fe1ac012eaa42402ad65c2963/GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110", size = 207599, upload-time = "2025-01-02T07:32:40.731Z" }, +] + +[[package]] +name = "graphql-core" +version = "3.3.0a9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/50/54c02cb0781df65fd998f8f499dfebb40cf1146ddd63c9e356748ad2a234/graphql_core-3.3.0a9.tar.gz", hash = "sha256:f442b34311c815281ba7a984c4389a1e74cda89bd3af291b98aae0b06fb338ef", size = 588475, upload-time = "2025-06-18T21:37:19.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/48/98ccc5fde6e4f7060cbce766d58e3364425af1374ea7379244b2c34cf635/graphql_core-3.3.0a9-py3-none-any.whl", hash = "sha256:73eeb24b8d0256cea62b70f0a7caa8bfc290e2e87769e0ccfdf4e9e4f06911cc", size = 227535, upload-time = "2025-06-18T21:37:17.579Z" }, +] + +[[package]] +name = "graphql-server" +version = "3.0.0b8" +source = { editable = "." } +dependencies = [ + { name = "graphql-core" }, +] + +[package.optional-dependencies] +aiohttp = [ + { name = "aiohttp" }, +] +asgi = [ + { name = "python-multipart" }, + { name = "starlette" }, +] +chalice = [ + { name = "chalice" }, +] +channels = [ + { name = "asgiref" }, + { name = "channels" }, +] +debug = [ + { name = "libcst" }, + { name = "rich" }, +] +debug-server = [ + { name = "libcst" }, + { name = "pygments" }, + { name = "python-multipart" }, + { name = "rich" }, + { name = "starlette" }, + { name = "typer" }, + { name = "uvicorn" }, + { name = "websockets" }, +] +django = [ + { name = "asgiref" }, + { name = "django", version = "4.2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "django", version = "5.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +fastapi = [ + { name = "fastapi" }, + { name = "python-multipart" }, +] +flask = [ + { name = "flask" }, +] +litestar = [ + { name = "litestar", marker = "python_full_version >= '3.10'" }, +] +opentelemetry = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, +] +pyinstrument = [ + { name = "pyinstrument" }, +] +quart = [ + { name = "quart" }, +] +sanic = [ + { name = "sanic" }, +] + +[package.dev-dependencies] +dev = [ + { name = "asgiref" }, + { name = "codeflash" }, + { name = "inline-snapshot" }, + { name = "mypy" }, + { name = "nox" }, + { name = "poetry-plugin-export" }, + { name = "pygments" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-codspeed" }, + { name = "pytest-cov" }, + { name = "pytest-emoji" }, + { name = "pytest-mock" }, + { name = "pytest-snapshot" }, + { name = "pytest-xdist", extra = ["psutil"] }, + { name = "python-multipart" }, + { name = "ruff" }, + { name = "sanic-testing" }, + { name = "types-deprecated" }, + { name = "types-six" }, + { name = "urllib3" }, +] +integrations = [ + { name = "aiohttp" }, + { name = "chalice" }, + { name = "channels" }, + { name = "daphne" }, + { name = "django", version = "4.2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "django", version = "5.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "fastapi" }, + { name = "flask" }, + { name = "litestar", marker = "python_full_version >= '3.10'" }, + { name = "pydantic" }, + { name = "pytest-aiohttp" }, + { name = "pytest-django" }, + { name = "quart" }, + { name = "sanic" }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", marker = "extra == 'aiohttp'", specifier = ">=3.7.4.post0,<4" }, + { name = "asgiref", marker = "extra == 'channels'", specifier = "~=3.2" }, + { name = "asgiref", marker = "extra == 'django'", specifier = "~=3.2" }, + { name = "chalice", marker = "extra == 'chalice'", specifier = "~=1.22" }, + { name = "channels", marker = "extra == 'channels'", specifier = ">=3.0.5" }, + { name = "django", marker = "extra == 'django'", specifier = ">=3.2" }, + { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.65.2" }, + { name = "flask", marker = "extra == 'flask'", specifier = ">=1.1" }, + { name = "graphql-core", specifier = ">=3.2.0,<3.4.0" }, + { name = "libcst", marker = "extra == 'debug'" }, + { name = "libcst", marker = "extra == 'debug-server'" }, + { name = "litestar", marker = "python_full_version >= '3.10' and python_full_version < '4' and extra == 'litestar'", specifier = ">=2" }, + { name = "opentelemetry-api", marker = "extra == 'opentelemetry'", specifier = "<2" }, + { name = "opentelemetry-sdk", marker = "extra == 'opentelemetry'", specifier = "<2" }, + { name = "pygments", marker = "extra == 'debug-server'", specifier = "~=2.3" }, + { name = "pyinstrument", marker = "extra == 'pyinstrument'", specifier = ">=4.0.0" }, + { name = "python-multipart", marker = "extra == 'asgi'", specifier = ">=0.0.7" }, + { name = "python-multipart", marker = "extra == 'debug-server'", specifier = ">=0.0.7" }, + { name = "python-multipart", marker = "extra == 'fastapi'", specifier = ">=0.0.7" }, + { name = "quart", marker = "extra == 'quart'", specifier = ">=0.19.3" }, + { name = "rich", marker = "extra == 'debug'", specifier = ">=12.0.0" }, + { name = "rich", marker = "extra == 'debug-server'", specifier = ">=12.0.0" }, + { name = "sanic", marker = "extra == 'sanic'", specifier = ">=20.12.2" }, + { name = "starlette", marker = "extra == 'asgi'", specifier = ">=0.18.0" }, + { name = "starlette", marker = "extra == 'debug-server'", specifier = ">=0.18.0" }, + { name = "typer", marker = "extra == 'debug-server'", specifier = ">=0.7.0" }, + { name = "uvicorn", marker = "extra == 'debug-server'", specifier = ">=0.11.6" }, + { name = "websockets", marker = "extra == 'debug-server'", specifier = ">=15.0.1,<16" }, +] +provides-extras = ["aiohttp", "asgi", "debug", "debug-server", "django", "channels", "flask", "quart", "opentelemetry", "sanic", "fastapi", "chalice", "litestar", "pyinstrument"] + +[package.metadata.requires-dev] +dev = [ + { name = "asgiref", specifier = ">=3.2,<4.0" }, + { name = "codeflash", specifier = ">=0.9.2" }, + { name = "inline-snapshot", specifier = ">=0.10.1,<0.11" }, + { name = "mypy", specifier = ">=1.15.0,<2.0" }, + { name = "nox", specifier = ">=2025.5.1" }, + { name = "poetry-plugin-export", marker = "python_full_version < '4'", specifier = ">=1.6.0,<2.0" }, + { name = "pygments", specifier = ">=2.3,<3.0" }, + { name = "pyright", specifier = "==1.1.401" }, + { name = "pytest", specifier = ">=7.2,<8.0" }, + { name = "pytest-asyncio", specifier = ">=0.20.3" }, + { name = "pytest-codspeed", marker = "python_full_version >= '3.9'", specifier = ">=3.0.0" }, + { name = "pytest-cov", specifier = ">=4.0.0,<5.0" }, + { name = "pytest-emoji", specifier = ">=0.2.0,<0.3" }, + { name = "pytest-mock", specifier = ">=3.10,<4.0" }, + { name = "pytest-snapshot", specifier = ">=0.9.0,<1.0" }, + { name = "pytest-xdist", extras = ["psutil"], specifier = ">=3.1.0,<4.0" }, + { name = "python-multipart", specifier = ">=0.0.7" }, + { name = "ruff", specifier = ">=0.11.4,<0.12" }, + { name = "sanic-testing", specifier = ">=22.9,<24.0" }, + { name = "types-deprecated", specifier = ">=1.2.15.20241117,<2.0" }, + { name = "types-six", specifier = ">=1.17.0.20250403,<2.0" }, + { name = "urllib3", specifier = "<2" }, +] +integrations = [ + { name = "aiohttp", specifier = ">=3.7.4.post0,<4.0" }, + { name = "chalice", specifier = ">=1.22,<2.0" }, + { name = "channels", specifier = ">=3.0.5,<5.0.0" }, + { name = "daphne", specifier = ">=4.0.0,<5.0" }, + { name = "django", specifier = ">=3.2" }, + { name = "fastapi", specifier = ">=0.65.0" }, + { name = "flask", specifier = ">=1.1" }, + { name = "litestar", marker = "python_full_version >= '3.10' and python_full_version < '4'", specifier = ">=2" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pytest-aiohttp", specifier = ">=1.0.3,<2.0" }, + { name = "pytest-django", specifier = ">=4.5,<5.0" }, + { name = "quart", specifier = ">=0.19.3" }, + { name = "sanic", specifier = ">=20.12.2" }, + { name = "starlette", specifier = ">=0.13.6" }, + { name = "uvicorn", specifier = ">=0.11.6" }, +] + +[[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 = "h2" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/38/d7f80fd13e6582fb8e0df8c9a653dcc02b03ca34f4d72f34869298c5baf8/h2-4.2.0.tar.gz", hash = "sha256:c8a52129695e88b1a0578d8d2cc6842bbd79128ac685463b887ee278126ad01f", size = 2150682, upload-time = "2025-02-02T07:43:51.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/9e/984486f2d0a0bd2b024bf4bc1c62688fcafa9e61991f041fb0e2def4a982/h2-4.2.0-py3-none-any.whl", hash = "sha256:479a53ad425bb29af087f3458a61d30780bc818e4ebcf01f0b536ba916462ed0", size = 60957, upload-time = "2025-02-01T11:02:26.481Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + +[[package]] +name = "html5tagger" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/02/2ae5f46d517a2c1d4a17f2b1e4834c2c7cc0fb3a69c92389172fa16ab389/html5tagger-1.3.0.tar.gz", hash = "sha256:84fa3dfb49e5c83b79bbd856ab7b1de8e2311c3bb46a8be925f119e3880a8da9", size = 14196, upload-time = "2023-03-28T05:59:34.642Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/12/2f5d43ee912ea14a6baba4b3db6d309b02d932e3b7074c3339b4aded98ff/html5tagger-1.3.0-py3-none-any.whl", hash = "sha256:ce14313515edffec8ed8a36c5890d023922641171b4e6e5774ad1a74998f5351", size = 10956, upload-time = "2023-03-28T05:59:32.524Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload-time = "2024-10-16T19:45:08.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/6f/972f8eb0ea7d98a1c6be436e2142d51ad2a64ee18e02b0e7ff1f62171ab1/httptools-0.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3c73ce323711a6ffb0d247dcd5a550b8babf0f757e86a52558fe5b86d6fefcc0", size = 198780, upload-time = "2024-10-16T19:44:06.882Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/17c672b4bc5c7ba7f201eada4e96c71d0a59fbc185e60e42580093a86f21/httptools-0.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:345c288418f0944a6fe67be8e6afa9262b18c7626c3ef3c28adc5eabc06a68da", size = 103297, upload-time = "2024-10-16T19:44:08.129Z" }, + { url = "https://files.pythonhosted.org/packages/92/5e/b4a826fe91971a0b68e8c2bd4e7db3e7519882f5a8ccdb1194be2b3ab98f/httptools-0.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deee0e3343f98ee8047e9f4c5bc7cedbf69f5734454a94c38ee829fb2d5fa3c1", size = 443130, upload-time = "2024-10-16T19:44:09.45Z" }, + { url = "https://files.pythonhosted.org/packages/b0/51/ce61e531e40289a681a463e1258fa1e05e0be54540e40d91d065a264cd8f/httptools-0.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca80b7485c76f768a3bc83ea58373f8db7b015551117375e4918e2aa77ea9b50", size = 442148, upload-time = "2024-10-16T19:44:11.539Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/270b7d767849b0c96f275c695d27ca76c30671f8eb8cc1bab6ced5c5e1d0/httptools-0.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:90d96a385fa941283ebd231464045187a31ad932ebfa541be8edf5b3c2328959", size = 415949, upload-time = "2024-10-16T19:44:13.388Z" }, + { url = "https://files.pythonhosted.org/packages/81/86/ced96e3179c48c6f656354e106934e65c8963d48b69be78f355797f0e1b3/httptools-0.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:59e724f8b332319e2875efd360e61ac07f33b492889284a3e05e6d13746876f4", size = 417591, upload-time = "2024-10-16T19:44:15.258Z" }, + { url = "https://files.pythonhosted.org/packages/75/73/187a3f620ed3175364ddb56847d7a608a6fc42d551e133197098c0143eca/httptools-0.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:c26f313951f6e26147833fc923f78f95604bbec812a43e5ee37f26dc9e5a686c", size = 88344, upload-time = "2024-10-16T19:44:16.54Z" }, + { url = "https://files.pythonhosted.org/packages/7b/26/bb526d4d14c2774fe07113ca1db7255737ffbb119315839af2065abfdac3/httptools-0.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f47f8ed67cc0ff862b84a1189831d1d33c963fb3ce1ee0c65d3b0cbe7b711069", size = 199029, upload-time = "2024-10-16T19:44:18.427Z" }, + { url = "https://files.pythonhosted.org/packages/a6/17/3e0d3e9b901c732987a45f4f94d4e2c62b89a041d93db89eafb262afd8d5/httptools-0.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0614154d5454c21b6410fdf5262b4a3ddb0f53f1e1721cfd59d55f32138c578a", size = 103492, upload-time = "2024-10-16T19:44:19.515Z" }, + { url = "https://files.pythonhosted.org/packages/b7/24/0fe235d7b69c42423c7698d086d4db96475f9b50b6ad26a718ef27a0bce6/httptools-0.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8787367fbdfccae38e35abf7641dafc5310310a5987b689f4c32cc8cc3ee975", size = 462891, upload-time = "2024-10-16T19:44:21.067Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2f/205d1f2a190b72da6ffb5f41a3736c26d6fa7871101212b15e9b5cd8f61d/httptools-0.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b0f7fe4fd38e6a507bdb751db0379df1e99120c65fbdc8ee6c1d044897a636", size = 459788, upload-time = "2024-10-16T19:44:22.958Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4c/d09ce0eff09057a206a74575ae8f1e1e2f0364d20e2442224f9e6612c8b9/httptools-0.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40a5ec98d3f49904b9fe36827dcf1aadfef3b89e2bd05b0e35e94f97c2b14721", size = 433214, upload-time = "2024-10-16T19:44:24.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/84c9e23edbccc4a4c6f96a1b8d99dfd2350289e94f00e9ccc7aadde26fb5/httptools-0.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dacdd3d10ea1b4ca9df97a0a303cbacafc04b5cd375fa98732678151643d4988", size = 434120, upload-time = "2024-10-16T19:44:26.295Z" }, + { url = "https://files.pythonhosted.org/packages/d0/46/4d8e7ba9581416de1c425b8264e2cadd201eb709ec1584c381f3e98f51c1/httptools-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:288cd628406cc53f9a541cfaf06041b4c71d751856bab45e3702191f931ccd17", size = 88565, upload-time = "2024-10-16T19:44:29.188Z" }, + { url = "https://files.pythonhosted.org/packages/bb/0e/d0b71465c66b9185f90a091ab36389a7352985fe857e352801c39d6127c8/httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2", size = 200683, upload-time = "2024-10-16T19:44:30.175Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b8/412a9bb28d0a8988de3296e01efa0bd62068b33856cdda47fe1b5e890954/httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44", size = 104337, upload-time = "2024-10-16T19:44:31.786Z" }, + { url = "https://files.pythonhosted.org/packages/9b/01/6fb20be3196ffdc8eeec4e653bc2a275eca7f36634c86302242c4fbb2760/httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1", size = 508796, upload-time = "2024-10-16T19:44:32.825Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/b644c44acc1368938317d76ac991c9bba1166311880bcc0ac297cb9d6bd7/httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2", size = 510837, upload-time = "2024-10-16T19:44:33.974Z" }, + { url = "https://files.pythonhosted.org/packages/52/d8/254d16a31d543073a0e57f1c329ca7378d8924e7e292eda72d0064987486/httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81", size = 485289, upload-time = "2024-10-16T19:44:35.111Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3c/4aee161b4b7a971660b8be71a92c24d6c64372c1ab3ae7f366b3680df20f/httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f", size = 489779, upload-time = "2024-10-16T19:44:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/12/b7/5cae71a8868e555f3f67a50ee7f673ce36eac970f029c0c5e9d584352961/httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970", size = 88634, upload-time = "2024-10-16T19:44:37.357Z" }, + { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214, upload-time = "2024-10-16T19:44:38.738Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431, upload-time = "2024-10-16T19:44:39.818Z" }, + { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121, upload-time = "2024-10-16T19:44:41.189Z" }, + { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805, upload-time = "2024-10-16T19:44:42.384Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858, upload-time = "2024-10-16T19:44:43.959Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042, upload-time = "2024-10-16T19:44:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682, upload-time = "2024-10-16T19:44:46.46Z" }, + { url = "https://files.pythonhosted.org/packages/51/b1/4fc6f52afdf93b7c4304e21f6add9e981e4f857c2fa622a55dfe21b6059e/httptools-0.6.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:85797e37e8eeaa5439d33e556662cc370e474445d5fab24dcadc65a8ffb04003", size = 201123, upload-time = "2024-10-16T19:44:59.13Z" }, + { url = "https://files.pythonhosted.org/packages/c2/01/e6ecb40ac8fdfb76607c7d3b74a41b464458d5c8710534d8f163b0c15f29/httptools-0.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:db353d22843cf1028f43c3651581e4bb49374d85692a85f95f7b9a130e1b2cab", size = 104507, upload-time = "2024-10-16T19:45:00.254Z" }, + { url = "https://files.pythonhosted.org/packages/dc/24/c70c34119d209bf08199d938dc9c69164f585ed3029237b4bdb90f673cb9/httptools-0.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1ffd262a73d7c28424252381a5b854c19d9de5f56f075445d33919a637e3547", size = 449615, upload-time = "2024-10-16T19:45:01.351Z" }, + { url = "https://files.pythonhosted.org/packages/2b/62/e7f317fed3703bd81053840cacba4e40bcf424b870e4197f94bd1cf9fe7a/httptools-0.6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:703c346571fa50d2e9856a37d7cd9435a25e7fd15e236c397bf224afaa355fe9", size = 448819, upload-time = "2024-10-16T19:45:02.652Z" }, + { url = "https://files.pythonhosted.org/packages/2a/13/68337d3be6b023260139434c49d7aa466aaa98f9aee7ed29270ac7dde6a2/httptools-0.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aafe0f1918ed07b67c1e838f950b1c1fabc683030477e60b335649b8020e1076", size = 422093, upload-time = "2024-10-16T19:45:03.765Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b3/3a1bc45be03dda7a60c7858e55b6cd0489a81613c1908fb81cf21d34ae50/httptools-0.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0e563e54979e97b6d13f1bbc05a96109923e76b901f786a5eae36e99c01237bd", size = 423898, upload-time = "2024-10-16T19:45:05.683Z" }, + { url = "https://files.pythonhosted.org/packages/05/72/2ddc2ae5f7ace986f7e68a326215b2e7c32e32fd40e6428fa8f1d8065c7e/httptools-0.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:b799de31416ecc589ad79dd85a0b2657a8fe39327944998dea368c1d4c9e55e6", size = 89552, upload-time = "2024-10-16T19:45:07.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "humanize" +version = "4.12.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/d1/bbc4d251187a43f69844f7fd8941426549bbe4723e8ff0a7441796b0789f/humanize-4.12.3.tar.gz", hash = "sha256:8430be3a615106fdfceb0b2c1b41c4c98c6b0fc5cc59663a5539b111dd325fb0", size = 80514, upload-time = "2025-04-30T11:51:07.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/1e/62a2ec3104394a2975a2629eec89276ede9dbe717092f6966fcf963e1bf0/humanize-4.12.3-py3-none-any.whl", hash = "sha256:2cbf6370af06568fa6d2da77c86edb7886f3160ecd19ee1ffef07979efc597f6", size = 128487, upload-time = "2025-04-30T11:51:06.468Z" }, +] + +[[package]] +name = "hypercorn" +version = "0.17.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "h11" }, + { name = "h2" }, + { name = "priority" }, + { name = "taskgroup", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/3a/df6c27642e0dcb7aff688ca4be982f0fb5d89f2afd3096dc75347c16140f/hypercorn-0.17.3.tar.gz", hash = "sha256:1b37802ee3ac52d2d85270700d565787ab16cf19e1462ccfa9f089ca17574165", size = 44409, upload-time = "2024-05-28T20:55:53.06Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/3b/dfa13a8d96aa24e40ea74a975a9906cfdc2ab2f4e3b498862a57052f04eb/hypercorn-0.17.3-py3-none-any.whl", hash = "sha256:059215dec34537f9d40a69258d323f56344805efb462959e727152b0aa504547", size = 61742, upload-time = "2024-05-28T20:55:48.829Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "hyperlink" +version = "21.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/51/1947bd81d75af87e3bb9e34593a4cf118115a8feb451ce7a69044ef1412e/hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", size = 140743, upload-time = "2021-01-08T05:51:20.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4", size = 74638, upload-time = "2021-01-08T05:51:22.906Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + +[[package]] +name = "importlib-resources" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, +] + +[[package]] +name = "incremental" +version = "24.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/87/156b374ff6578062965afe30cc57627d35234369b3336cf244b240c8d8e6/incremental-24.7.2.tar.gz", hash = "sha256:fb4f1d47ee60efe87d4f6f0ebb5f70b9760db2b2574c59c8e8912be4ebd464c9", size = 28157, upload-time = "2024-07-29T20:03:55.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/38/221e5b2ae676a3938c2c1919131410c342b6efc2baffeda395dd66eeca8f/incremental-24.7.2-py3-none-any.whl", hash = "sha256:8cb2c3431530bec48ad70513931a760f446ad6c25e8333ca5d95e24b0ed7b8fe", size = 20516, upload-time = "2024-07-29T20:03:53.677Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "inline-snapshot" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "black" }, + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "executing" }, + { name = "rich" }, + { name = "toml" }, + { name = "types-toml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/5e/46006dade79f0cc2af5bc2b87215359e505fa19615060856498ecf13ba5e/inline_snapshot-0.10.2.tar.gz", hash = "sha256:fb3c1410a08c9700ca838a269f70117760b024d99d6193661a8b47f8302b09cd", size = 21172, upload-time = "2024-05-28T06:55:46.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/c0/f7e31cf9cbdadf8e723681fca9d803c27a51f054390e1ffe47232b31c04a/inline_snapshot-0.10.2-py3-none-any.whl", hash = "sha256:f61d42f0d4bddd2a3efae041f5b168e94ac2df566cbf2c67a26d03d5f090835a", size = 23011, upload-time = "2024-05-28T06:55:45.062Z" }, +] + +[[package]] +name = "inquirer" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blessed" }, + { name = "editor" }, + { name = "readchar" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/06/ef91eb8f3feafb736aa33dcb278fc9555d17861aa571b684715d095db24d/inquirer-3.4.0.tar.gz", hash = "sha256:8edc99c076386ee2d2204e5e3653c2488244e82cb197b2d498b3c1b5ffb25d0b", size = 14472, upload-time = "2024-08-12T12:03:43.83Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/b2/be907c8c0f8303bc4b10089f5470014c3bf3521e9b8d3decf3037fd94725/inquirer-3.4.0-py3-none-any.whl", hash = "sha256:bb0ec93c833e4ce7b51b98b1644b0a4d2bb39755c39787f6a504e4fee7a11b60", size = 18077, upload-time = "2024-08-12T12:03:41.589Z" }, +] + +[[package]] +name = "installer" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/18/ceeb4e3ab3aa54495775775b38ae42b10a92f42ce42dfa44da684289b8c8/installer-0.7.0.tar.gz", hash = "sha256:a26d3e3116289bb08216e0d0f7d925fcef0b0194eedfa0c944bcaaa106c4b631", size = 474349, upload-time = "2023-03-17T20:39:38.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ca/1172b6638d52f2d6caa2dd262ec4c811ba59eee96d54a7701930726bce18/installer-0.7.0-py3-none-any.whl", hash = "sha256:05d1933f0a5ba7d8d6296bb6d5018e7c94fa473ceb10cf198a92ccea19c27b53", size = 453838, upload-time = "2023-03-17T20:39:36.219Z" }, +] + +[[package]] +name = "isort" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/21/1e2a441f74a653a144224d7d21afe8f4169e6c7c20bb13aec3a2dc3815e0/isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450", size = 821955, upload-time = "2025-02-26T21:13:16.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/11/114d0a5f4dabbdcedc1125dee0888514c3c3b16d3e9facad87ed96fad97c/isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615", size = 94186, upload-time = "2025-02-26T21:13:14.911Z" }, +] + +[[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 = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/23/9894b3df5d0a6eb44611c36aec777823fc2e07740dabbd0b810e19594013/jaraco_functools-4.1.0.tar.gz", hash = "sha256:70f7e0e2ae076498e212562325e805204fc092d7b4c17e0e86c959e249701a9d", size = 19159, upload-time = "2024-09-27T19:47:09.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/4f/24b319316142c44283d7540e76c7b5a6dbd5db623abd86bb7b3491c21018/jaraco.functools-4.1.0-py3-none-any.whl", hash = "sha256:ad159f13428bc4acbf5541ad6dec511f91573b90fba04df61dafa2a1231cf649", size = 10187, upload-time = "2024-09-27T19:47:07.14Z" }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[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 = "jinxed" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ansicon", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/d0/59b2b80e7a52d255f9e0ad040d2e826342d05580c4b1d7d7747cfb8db731/jinxed-1.3.0.tar.gz", hash = "sha256:1593124b18a41b7a3da3b078471442e51dbad3d77b4d4f2b0c26ab6f7d660dbf", size = 80981, upload-time = "2024-07-31T22:39:18.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/e3/0e0014d6ab159d48189e92044ace13b1e1fe9aa3024ba9f4e8cf172aa7c2/jinxed-1.3.0-py2.py3-none-any.whl", hash = "sha256:b993189f39dc2d7504d802152671535b06d380b26d78070559551cbf92df4fc5", size = 33085, upload-time = "2024-07-31T22:39:17.426Z" }, +] + +[[package]] +name = "jmespath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843, upload-time = "2022-06-17T18:00:12.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload-time = "2022-06-17T18:00:10.251Z" }, +] + +[[package]] +name = "junitparser" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/88/6a268028a297751ed73be8e291f12aa727caf22adbc218e8dfbafcc974af/junitparser-3.2.0.tar.gz", hash = "sha256:b05e89c27e7b74b3c563a078d6e055d95cf397444f8f689b0ca616ebda0b3c65", size = 20073, upload-time = "2024-09-01T04:07:42.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/f9/321d566c9f2af81fdb4bb3d5900214116b47be9e26b82219da8b818d9da9/junitparser-3.2.0-py2.py3-none-any.whl", hash = "sha256:e14fdc0a999edfc15889b637390e8ef6ca09a49532416d3bd562857d42d4b96d", size = 13394, upload-time = "2024-09-01T04:07:40.541Z" }, +] + +[[package]] +name = "keyring" +version = "25.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750, upload-time = "2024-12-25T15:26:45.782Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085, upload-time = "2024-12-25T15:26:44.377Z" }, +] + +[[package]] +name = "libcst" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml", marker = "python_full_version < '3.13'" }, + { name = "pyyaml-ft", marker = "python_full_version >= '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/aa/b52d195b167958fe1bd106a260f64cc80ec384f6ac2a9cda874d8803df06/libcst-1.8.2.tar.gz", hash = "sha256:66e82cedba95a6176194a817be4232c720312f8be6d2c8f3847f3317d95a0c7f", size = 881534, upload-time = "2025-06-13T20:56:37.915Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/2e/1d7f67d2ef6f875e9e8798c024f7cb3af3fe861e417bff485c69b655ac96/libcst-1.8.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:67d9720d91f507c87b3e5f070627ad640a00bc6cfdf5635f8c6ee9f2964cf71c", size = 2195106, upload-time = "2025-06-13T20:54:49.166Z" }, + { url = "https://files.pythonhosted.org/packages/82/d0/3d94fee2685f263fd8d85a83e2537fcc78b644eae450738bf2c72604f0df/libcst-1.8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:94b7c032b72566077614a02baab1929739fd0af0cc1d46deaba4408b870faef2", size = 2080577, upload-time = "2025-06-13T20:54:51.518Z" }, + { url = "https://files.pythonhosted.org/packages/14/87/c9b49bebb9a930fdcb59bf841f1c45719d2a4a39c3eb7efacfd30a2bfb0a/libcst-1.8.2-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:11ea148902e3e1688afa392087c728ac3a843e54a87d334d1464d2097d3debb7", size = 2404076, upload-time = "2025-06-13T20:54:53.303Z" }, + { url = "https://files.pythonhosted.org/packages/49/fa/9ca145aa9033f9a8362a5663ceb28dfb67082574de8118424b6b8e445e7a/libcst-1.8.2-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:22c9473a2cc53faabcc95a0ac6ca4e52d127017bf34ba9bc0f8e472e44f7b38e", size = 2219813, upload-time = "2025-06-13T20:54:55.351Z" }, + { url = "https://files.pythonhosted.org/packages/0c/25/496a025c09e96116437a57fd34abefe84c041d930f832c6e42d84d9e028c/libcst-1.8.2-cp310-cp310-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b5269b96367e65793a7714608f6d906418eb056d59eaac9bba980486aabddbed", size = 2189782, upload-time = "2025-06-13T20:54:57.013Z" }, + { url = "https://files.pythonhosted.org/packages/b3/75/826b5772192826d70480efe93bab3e4f0b4a24d31031f45547257ad5f9a8/libcst-1.8.2-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:d20e932ddd9a389da57b060c26e84a24118c96ff6fc5dcc7b784da24e823b694", size = 2312403, upload-time = "2025-06-13T20:54:58.996Z" }, + { url = "https://files.pythonhosted.org/packages/93/f4/316fa14ea6c61ea8755672d60e012558f0216300b3819e72bebc7864a507/libcst-1.8.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a553d452004e44b841788f6faa7231a02157527ddecc89dbbe5b689b74822226", size = 2280566, upload-time = "2025-06-13T20:55:00.707Z" }, + { url = "https://files.pythonhosted.org/packages/fc/52/74b69350db379b1646739288b88ffab2981b2ad48407faf03df3768d7d2f/libcst-1.8.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7fe762c4c390039b79b818cbc725d8663586b25351dc18a2704b0e357d69b924", size = 2388508, upload-time = "2025-06-13T20:55:02.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c6/fa92699b537ed65e93c2869144e23bdf156ec81ae7b84b4f34cbc20d6048/libcst-1.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:5c513e64eff0f7bf2a908e2d987a98653eb33e1062ce2afd3a84af58159a24f9", size = 2093260, upload-time = "2025-06-13T20:55:04.771Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ac/4ec4ae9da311f72cd97e930c325bb605e9ad0baaafcafadb0588e1dc5c4e/libcst-1.8.2-cp310-cp310-win_arm64.whl", hash = "sha256:41613fe08e647213546c7c59a5a1fc5484666e7d4cab6e80260c612acbb20e8c", size = 1985236, upload-time = "2025-06-13T20:55:06.317Z" }, + { url = "https://files.pythonhosted.org/packages/c5/73/f0a4d807bff6931e3d8c3180472cf43d63a121aa60be895425fba2ed4f3a/libcst-1.8.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:688a03bac4dfb9afc5078ec01d53c21556381282bdf1a804dd0dbafb5056de2a", size = 2195040, upload-time = "2025-06-13T20:55:08.117Z" }, + { url = "https://files.pythonhosted.org/packages/e5/fa/ede0cfc410e498e1279eb489603f31077d2ca112d84e1327b04b508c0cbe/libcst-1.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c34060ff2991707c710250463ae9f415ebb21653f2f5b013c61c9c376ff9b715", size = 2080304, upload-time = "2025-06-13T20:55:09.729Z" }, + { url = "https://files.pythonhosted.org/packages/39/8d/59f7c488dbedf96454c07038dea72ee2a38de13d52b4f796a875a1dc45a6/libcst-1.8.2-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f54f5c4176d60e7cd6b0880e18fb3fa8501ae046069151721cab457c7c538a3d", size = 2403816, upload-time = "2025-06-13T20:55:11.527Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c2/af8d6cc0c6dcd1a5d0ed5cf846be242354513139a9358e005c63252c6ab7/libcst-1.8.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:d11992561de0ad29ec2800230fbdcbef9efaa02805d5c633a73ab3cf2ba51bf1", size = 2219415, upload-time = "2025-06-13T20:55:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b8/1638698d6c33bdb4397ee6f60e534e7504ef2cd1447b24104df65623dedb/libcst-1.8.2-cp311-cp311-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fa3b807c2d2b34397c135d19ad6abb20c47a2ddb7bf65d90455f2040f7797e1e", size = 2189568, upload-time = "2025-06-13T20:55:15.119Z" }, + { url = "https://files.pythonhosted.org/packages/05/16/51c1015dada47b8464c5fa0cbf70fecc5fce0facd07d05a5cb6e7eb68b88/libcst-1.8.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b0110140738be1287e3724080a101e7cec6ae708008b7650c9d8a1c1788ec03a", size = 2312018, upload-time = "2025-06-13T20:55:16.831Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/8d24158f345ea2921d0d7ff49a6bf86fd4a08b0f05735f14a84ea9e28fa9/libcst-1.8.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a50618f4819a97ef897e055ac7aaf1cad5df84c206f33be35b0759d671574197", size = 2279875, upload-time = "2025-06-13T20:55:18.418Z" }, + { url = "https://files.pythonhosted.org/packages/73/fd/0441cc1bcf188300aaa41ca5d473919a00939cc7f4934b3b08b23c8740c1/libcst-1.8.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e9bb599c175dc34a4511f0e26d5b5374fbcc91ea338871701a519e95d52f3c28", size = 2388060, upload-time = "2025-06-13T20:55:20.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fc/28f6380eefd58543f80589b77cab81eb038e7cc86f7c34a815a287dba82f/libcst-1.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:96e2363e1f6e44bd7256bbbf3a53140743f821b5133046e6185491e0d9183447", size = 2093117, upload-time = "2025-06-13T20:55:21.977Z" }, + { url = "https://files.pythonhosted.org/packages/ef/db/cdbd1531bca276c44bc485e40c3156e770e01020f8c1a737282bf884d69f/libcst-1.8.2-cp311-cp311-win_arm64.whl", hash = "sha256:f5391d71bd7e9e6c73dcb3ee8d8c63b09efc14ce6e4dad31568d4838afc9aae0", size = 1985285, upload-time = "2025-06-13T20:55:24.438Z" }, + { url = "https://files.pythonhosted.org/packages/31/2d/8726bf8ea8252e8fd1e48980753eef5449622c5f6cf731102bc43dcdc2c6/libcst-1.8.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2e8c1dfa854e700fcf6cd79b2796aa37d55697a74646daf5ea47c7c764bac31c", size = 2185942, upload-time = "2025-06-13T20:55:26.105Z" }, + { url = "https://files.pythonhosted.org/packages/99/b3/565d24db8daed66eae7653c1fc1bc97793d49d5d3bcef530450ee8da882c/libcst-1.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b5c57a3c1976c365678eb0730bcb140d40510990cb77df9a91bb5c41d587ba6", size = 2072622, upload-time = "2025-06-13T20:55:27.548Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d6/5a433e8a58eeb5c5d46635cfe958d0605f598d87977d4560484e3662d438/libcst-1.8.2-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:0f23409add2aaebbb6d8e881babab43c2d979f051b8bd8aed5fe779ea180a4e8", size = 2402738, upload-time = "2025-06-13T20:55:29.539Z" }, + { url = "https://files.pythonhosted.org/packages/85/e4/0dd752c1880b570118fa91ac127589e6cf577ddcb2eef1aaf8b81ecc3f79/libcst-1.8.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:b88e9104c456590ad0ef0e82851d4fc03e9aa9d621fa8fdd4cd0907152a825ae", size = 2219932, upload-time = "2025-06-13T20:55:31.17Z" }, + { url = "https://files.pythonhosted.org/packages/42/bc/fceae243c6a329477ac6d4edb887bcaa2ae7a3686158d8d9b9abb3089c37/libcst-1.8.2-cp312-cp312-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5ba3ea570c8fb6fc44f71aa329edc7c668e2909311913123d0d7ab8c65fc357", size = 2191891, upload-time = "2025-06-13T20:55:33.066Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7d/eb341bdc11f1147e7edeccffd0f2f785eff014e72134f5e46067472012b0/libcst-1.8.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:460fcf3562f078781e1504983cb11909eb27a1d46eaa99e65c4b0fafdc298298", size = 2311927, upload-time = "2025-06-13T20:55:34.614Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/78bfc7aa5a542574d2ab0768210d084901dec5fc373103ca119905408cf2/libcst-1.8.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1381ddbd1066d543e05d580c15beacf671e1469a0b2adb6dba58fec311f4eed", size = 2281098, upload-time = "2025-06-13T20:55:36.089Z" }, + { url = "https://files.pythonhosted.org/packages/83/37/a41788a72dc06ed3566606f7cf50349c9918cee846eeae45d1bac03d54c2/libcst-1.8.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a70e40ce7600e1b32e293bb9157e9de3b69170e2318ccb219102f1abb826c94a", size = 2387649, upload-time = "2025-06-13T20:55:37.797Z" }, + { url = "https://files.pythonhosted.org/packages/bb/df/7a49576c9fd55cdfd8bcfb725273aa4ee7dc41e87609f3451a4901d68057/libcst-1.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:3ece08ba778b6eeea74d9c705e9af2d1b4e915e9bc6de67ad173b962e575fcc0", size = 2094574, upload-time = "2025-06-13T20:55:39.833Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/27381e194d2af08bfd0fed090c905b2732907b69da48d97d86c056d70790/libcst-1.8.2-cp312-cp312-win_arm64.whl", hash = "sha256:5efd1bf6ee5840d1b0b82ec8e0b9c64f182fa5a7c8aad680fbd918c4fa3826e0", size = 1984568, upload-time = "2025-06-13T20:55:41.511Z" }, + { url = "https://files.pythonhosted.org/packages/11/9c/e3d4c7f1eb5c23907f905f84a4da271b60cd15b746ac794d42ea18bb105e/libcst-1.8.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08e9dca4ab6f8551794ce7ec146f86def6a82da41750cbed2c07551345fa10d3", size = 2185848, upload-time = "2025-06-13T20:55:43.653Z" }, + { url = "https://files.pythonhosted.org/packages/59/e0/635cbb205d42fd296c01ab5cd1ba485b0aee92bffe061de587890c81f1bf/libcst-1.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8310521f2ccb79b5c4345750d475b88afa37bad930ab5554735f85ad5e3add30", size = 2072510, upload-time = "2025-06-13T20:55:45.287Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/8911cfe9413fd690a024a1ff2c8975f060dd721160178679d3f6a21f939e/libcst-1.8.2-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:da2d8b008aff72acd5a4a588491abdda1b446f17508e700f26df9be80d8442ae", size = 2403226, upload-time = "2025-06-13T20:55:46.927Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/819d2b1b1fd870ad34ce4f34ec68704ca69bf48ef2d7665483115f267ec4/libcst-1.8.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:be821d874ce8b26cbadd7277fa251a9b37f6d2326f8b5682b6fc8966b50a3a59", size = 2220669, upload-time = "2025-06-13T20:55:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/d4/2f/2c4742bf834f88a9803095915c4f41cafefb7b04bde66ea86f74668b4b7b/libcst-1.8.2-cp313-cp313-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f74b0bc7378ad5afcf25ac9d0367b4dbba50f6f6468faa41f5dfddcf8bf9c0f8", size = 2191919, upload-time = "2025-06-13T20:55:50.092Z" }, + { url = "https://files.pythonhosted.org/packages/64/f4/107e13815f1ee5aad642d4eb4671c0273ee737f3832e3dbca9603b39f8d9/libcst-1.8.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:b68ea4a6018abfea1f68d50f74de7d399172684c264eb09809023e2c8696fc23", size = 2311965, upload-time = "2025-06-13T20:55:51.974Z" }, + { url = "https://files.pythonhosted.org/packages/03/63/2948b6e4be367ad375d273a8ad00df573029cffe5ac8f6c09398c250de5b/libcst-1.8.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e264307ec49b2c72480422abafe80457f90b4e6e693b7ddf8a23d24b5c24001", size = 2281704, upload-time = "2025-06-13T20:55:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d3/590cde9c8c386d5f4f05fdef3394c437ea51060478a5141ff4a1f289e747/libcst-1.8.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5d5519962ce7c72d81888fb0c09e58e308ba4c376e76bcd853b48151063d6a8", size = 2387511, upload-time = "2025-06-13T20:55:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/96/3d/ba5e36c663028043fc607dc33e5c390c7f73136fb15a890fb3710ee9d158/libcst-1.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:b62aa11d6b74ed5545e58ac613d3f63095e5fd0254b3e0d1168fda991b9a6b41", size = 2094526, upload-time = "2025-06-13T20:55:57.486Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/530ca3b972dddad562f266c81190bea29376f8ba70054ea7b45b114504cd/libcst-1.8.2-cp313-cp313-win_arm64.whl", hash = "sha256:9c2bd4ac288a9cdb7ffc3229a9ce8027a66a3fd3f2ab9e13da60f5fbfe91f3b2", size = 1984627, upload-time = "2025-06-13T20:55:59.017Z" }, + { url = "https://files.pythonhosted.org/packages/19/9f/491f7b8d9d93444cd9bf711156ee1f122c38d25b903599e363d669acc8ab/libcst-1.8.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:08a8c7d9922ca6eed24e2c13a3c552b3c186af8fc78e5d4820b58487d780ec19", size = 2175415, upload-time = "2025-06-13T20:56:01.157Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fe/4d13437f453f92687246aa7c5138e102ee5186fe96609ee4c598bb9f9ecb/libcst-1.8.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:bba7c2b5063e8ada5a5477f9fa0c01710645426b5a8628ec50d558542a0a292e", size = 2063719, upload-time = "2025-06-13T20:56:02.787Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/758ae142c6607f275269021362b731e0f22ff5c9aa7cc67b0ed3a6bc930f/libcst-1.8.2-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:d97c9fe13aacfbefded6861f5200dcb8e837da7391a9bdeb44ccb133705990af", size = 2380624, upload-time = "2025-06-13T20:56:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c5/31d214a0bcb3523243a9b5643b597ff653d6ec9e1f3326cfcc16bcbf185d/libcst-1.8.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d2194ae959630aae4176a4b75bd320b3274c20bef2a5ca6b8d6fc96d3c608edf", size = 2208801, upload-time = "2025-06-13T20:56:06.983Z" }, + { url = "https://files.pythonhosted.org/packages/70/16/a53f852322b266c63b492836a5c4968f192ee70fb52795a79feb4924e9ed/libcst-1.8.2-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0be639f5b2e1999a4b4a82a0f4633969f97336f052d0c131627983589af52f56", size = 2179557, upload-time = "2025-06-13T20:56:09.09Z" }, + { url = "https://files.pythonhosted.org/packages/fa/49/12a5664c73107187ba3af14869d3878fca1fd4c37f6fbb9adb943cb7a791/libcst-1.8.2-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6753e50904e05c27915933da41518ecd7a8ca4dd3602112ba44920c6e353a455", size = 2302499, upload-time = "2025-06-13T20:56:10.751Z" }, + { url = "https://files.pythonhosted.org/packages/e9/46/2d62552a9346a040c045d6619b645d59bb707a586318121f099abd0cd5c4/libcst-1.8.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:706d07106af91c343150be86caeae1ea3851b74aa0730fcbbf8cd089e817f818", size = 2271070, upload-time = "2025-06-13T20:56:12.445Z" }, + { url = "https://files.pythonhosted.org/packages/af/67/b625fd6ae22575255aade0a24f45e1d430b7e7279729c9c51d4faac982d2/libcst-1.8.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd4310ea8ddc49cc8872e083737cf806299b17f93159a1f354d59aa08993e876", size = 2380767, upload-time = "2025-06-13T20:56:13.995Z" }, + { url = "https://files.pythonhosted.org/packages/e6/84/fb88f2ffdb045ff7323a6c05dd3d243a9eb3cb3517a6269dee43fbfb9990/libcst-1.8.2-cp313-cp313t-win_amd64.whl", hash = "sha256:51bbafdd847529e8a16d1965814ed17831af61452ee31943c414cb23451de926", size = 2083403, upload-time = "2025-06-13T20:56:15.959Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8f/da755d6d517eb8ec9664afae967b00a9b8dd567bbbb350e261359c1b47fc/libcst-1.8.2-cp313-cp313t-win_arm64.whl", hash = "sha256:4f14f5045766646ed9e8826b959c6d07194788babed1e0ba08c94ea4f39517e3", size = 1974355, upload-time = "2025-06-13T20:56:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/2e/55/7c223ffc44fa623cc4c6c45e932d8e0724e31c8daede8a66d6a53ccd49a1/libcst-1.8.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:f69582e24667715e3860d80d663f1caeb2398110077e23cc0a1e0066a851f5ab", size = 2195291, upload-time = "2025-06-13T20:56:20.114Z" }, + { url = "https://files.pythonhosted.org/packages/77/3a/dced5455963238f1ebedd28cf48bfd5e5d84c847132846a2567f5beaf7fc/libcst-1.8.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1ba85f9e6a7f37ef998168aa3fd28d263d7f83016bd306a4508a2394e5e793b4", size = 2080544, upload-time = "2025-06-13T20:56:22.096Z" }, + { url = "https://files.pythonhosted.org/packages/da/ec/2bce80fb362961191e3ac67a38619780f9bd5203732ad95962458a3b71c0/libcst-1.8.2-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:43ccaa6c54daa1749cec53710c70d47150965574d4c6d4c4f2e3f87b9bf9f591", size = 2404396, upload-time = "2025-06-13T20:56:24.215Z" }, + { url = "https://files.pythonhosted.org/packages/6a/33/dd10a5ad783f3c1edc55fe97f5cbfe3924f6a7ce3556464538640a348e04/libcst-1.8.2-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:8a81d816c2088d2055112af5ecd82fdfbe8ff277600e94255e2639b07de10234", size = 2219446, upload-time = "2025-06-13T20:56:25.84Z" }, + { url = "https://files.pythonhosted.org/packages/dd/66/e7a208e5208bbd37b5be989e22b7abd117c40866b7880e7c447f4fb8ee46/libcst-1.8.2-cp39-cp39-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:449f9ff8a5025dcd5c8d4ad28f6c291de5de89e4c044b0bda96b45bef8999b75", size = 2189946, upload-time = "2025-06-13T20:56:27.472Z" }, + { url = "https://files.pythonhosted.org/packages/08/6f/5ef938f947e7cdd83bdffb6929697e7f27b0ae4a6f84a7f30e044690ba1c/libcst-1.8.2-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:36d5ab95f39f855521585b0e819dc2d4d1b2a4080bad04c2f3de1e387a5d2233", size = 2312416, upload-time = "2025-06-13T20:56:29.49Z" }, + { url = "https://files.pythonhosted.org/packages/04/5b/2f965ae65ef12bc0800a35c5668df3eda26437f6a8bcc0f5520b02f3c3a5/libcst-1.8.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:207575dec2dae722acf6ab39b4b361151c65f8f895fd37edf9d384f5541562e1", size = 2280429, upload-time = "2025-06-13T20:56:30.995Z" }, + { url = "https://files.pythonhosted.org/packages/35/1d/f67e6cb1146c0b546f095baf0d6ff6fa561bd61c1e1a5357e9557a16d501/libcst-1.8.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52a1067cf31d9e9e4be514b253bea6276f1531dd7de6ab0917df8ce5b468a820", size = 2388615, upload-time = "2025-06-13T20:56:32.655Z" }, + { url = "https://files.pythonhosted.org/packages/b7/83/b4d659782e88f46c073ea5cbd9a4e99bf7ea17883632371795f91121b220/libcst-1.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:59e8f611c977206eba294c296c2d29a1c1b1b88206cb97cd0d4847c1a3d923e7", size = 2093194, upload-time = "2025-06-13T20:56:34.348Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/3614b732cb25a3bba93ffde84b9e006007c687a9c84d22e64add56dee5fd/libcst-1.8.2-cp39-cp39-win_arm64.whl", hash = "sha256:ae22376633cfa3db21c4eed2870d1c36b5419289975a41a45f34a085b2d9e6ea", size = 1985259, upload-time = "2025-06-13T20:56:36.337Z" }, +] + +[[package]] +name = "line-profiler" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/55/3f/f0659eb67f76022b5f7722cdc71a6059536e11f20c9dcc5a96a2f923923d/line_profiler-4.2.0.tar.gz", hash = "sha256:09e10f25f876514380b3faee6de93fb0c228abba85820ba1a591ddb3eb451a96", size = 199037, upload-time = "2024-12-03T17:12:20.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/24/a7f141527f126965d141733140c648710b39daf00417afe9c459ebbb89e0/line_profiler-4.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:70e2503f52ee6464ac908b578d73ad6dae21d689c95f2252fee97d7aa8426693", size = 221762, upload-time = "2024-12-03T17:10:58.782Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9c/3a215f70f4d1946eb3afb9a07def86242f108d138ae250eb23b70f56ceb1/line_profiler-4.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b6047c8748d7a2453522eaea3edc8d9febc658b57f2ea189c03fe3d5e34595b5", size = 141549, upload-time = "2024-12-03T17:11:01.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/187ba46030274c29d898d4b47eeac53a833450037634e87e6aa78be9cb8f/line_profiler-4.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0048360a2afbd92c0b423f8207af1f6581d85c064c0340b0d02c63c8e0c8292c", size = 134961, upload-time = "2024-12-03T17:11:03.049Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f8/efe6b3be4f0b15ca977da4bf54e40a27d4210fda11e82fe8ad802f259cc8/line_profiler-4.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e71fa1c85f21e3de575c7c617fd4eb607b052cc7b4354035fecc18f3f2a4317", size = 700997, upload-time = "2024-12-03T17:11:04.879Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e3/3a3206285f8df202d00da7aa67664a3892a0ed607a15f59a64516c112266/line_profiler-4.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5ec99d48cffdf36efbcd7297e81cc12bf2c0a7e0627a567f3ab0347e607b242", size = 718256, upload-time = "2024-12-03T17:11:07.29Z" }, + { url = "https://files.pythonhosted.org/packages/83/19/ada8573aff98a7893f4c960e51e37abccc8a758855d6f0af55a3c002af5f/line_profiler-4.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:bfc9582f19a64283434fc6a3fd41a3a51d59e3cce2dc7adc5fe859fcae67e746", size = 1801932, upload-time = "2024-12-03T17:11:08.745Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9c/91c22b6ef3275c0eefb0d72da7a50114c20ef595086982679c6ae2dfbf20/line_profiler-4.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2b5dcfb3205e18c98c94388065f1604dc9d709df4dd62300ff8c5bbbd9bd163f", size = 1706908, upload-time = "2024-12-03T17:11:11.436Z" }, + { url = "https://files.pythonhosted.org/packages/bc/af/a71d69019639313a7d9c5e86fdc819cdce8b0745356d20daf05050070463/line_profiler-4.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:4999eb1db5d52cb34a5293941986eea4357fb9fe3305a160694e5f13c9ec4008", size = 128018, upload-time = "2024-12-03T17:11:12.862Z" }, + { url = "https://files.pythonhosted.org/packages/2f/8b/cd2a2ad1b80a92f3a5c707945c839fec7170b6e3790b2d86f275e6dee5fe/line_profiler-4.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:402406f200401a496fb93e1788387bf2d87c921d7f8f7e5f88324ac9efb672ac", size = 221775, upload-time = "2024-12-03T17:11:14.1Z" }, + { url = "https://files.pythonhosted.org/packages/8a/43/916491dc01aa4bfa08c0e1868af6c7f14bef3c7b4ed652fd4df7e1c2e8e7/line_profiler-4.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d9a0b5696f1ad42bb31e90706e5d57845833483d1d07f092b66b4799847a2f76", size = 141769, upload-time = "2024-12-03T17:11:16.41Z" }, + { url = "https://files.pythonhosted.org/packages/40/51/cbeab2995b18c74db1bfdf0ac07910661be1fc2afa7425c899d940001097/line_profiler-4.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2f950fa19f797a9ab55c8d7b33a7cdd95c396cf124c3adbc1cf93a1978d2767", size = 134789, upload-time = "2024-12-03T17:11:17.642Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c8/e94b4ef5854515e0f3baad48e9ebc335d8bd4f9f05336167c6c65446b79a/line_profiler-4.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d09fd8f580716da5a0b9a7f544a306b468f38eee28ba2465c56e0aa5d7d1822", size = 728859, upload-time = "2024-12-03T17:11:19.614Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ae/b92c4cfa52a84d794907e7ce6e206fa3ea4e4a6d7b950c525b8d118988fc/line_profiler-4.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:628f585960c6538873a9760d112db20b76b6035d3eaad7711a8bd80fa909d7ea", size = 750156, upload-time = "2024-12-03T17:11:21.066Z" }, + { url = "https://files.pythonhosted.org/packages/60/9f/c18cf5b17d79e5b420b35c73cb9fad299f779cf78a4812c97266962dfd55/line_profiler-4.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:63ed929c7d41e230cc1c4838c25bbee165d7f2fa974ca28d730ea69e501fc44d", size = 1828250, upload-time = "2024-12-03T17:11:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/d2/dc/14daab09eb1e30772d42b23140e5716034fbeb04224e6903c208212b9e97/line_profiler-4.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6bda74fc206ba375396068526e9e7b5466a24c7e54cbd6ee1c98c1e0d1f0fd99", size = 1739326, upload-time = "2024-12-03T17:11:24.12Z" }, + { url = "https://files.pythonhosted.org/packages/79/4b/8acfbc5413ed87ebaaa1fc2844e59da3136661885d8be2797e0d20d0ac25/line_profiler-4.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:eaf6eb827c202c07b8b8d82363bb039a6747fbf84ca04279495a91b7da3b773f", size = 128882, upload-time = "2024-12-03T17:11:25.623Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/f8330f4533434a90daa240ea9a3296e704a5d644339352316e20102add6f/line_profiler-4.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:82d29887f1226938a86db30ca3a125b1bde89913768a2a486fa14d0d3f8c0d91", size = 221536, upload-time = "2024-12-03T17:11:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/0f6fba16a9f67e083a277242a24344c0a482263a47462b4ce50c6cc7a5dc/line_profiler-4.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bf60706467203db0a872b93775a5e5902a02b11d79f8f75a8f8ef381b75789e1", size = 141581, upload-time = "2024-12-03T17:11:29.202Z" }, + { url = "https://files.pythonhosted.org/packages/5c/2b/a3a76c5879a3540b44eacdd0276e566a9c7fc381978fc527b6fc8e67a513/line_profiler-4.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:934fd964eed9bed87e3c01e8871ee6bdc54d10edf7bf14d20e72f7be03567ae3", size = 134641, upload-time = "2024-12-03T17:11:30.494Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/6381342ea05e42205322170cebcc0f0b7c7b6c63e259a2bcade65c6be0b4/line_profiler-4.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d623e5b37fa48c7ad0c29b4353244346a5dcb1bf75e117e19400b8ffd3393d1b", size = 693309, upload-time = "2024-12-03T17:11:32.609Z" }, + { url = "https://files.pythonhosted.org/packages/28/5a/2aa1c21bf5568f019343a6e8505cba35c70edd9acb0ed863b0b8f928dd15/line_profiler-4.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efcdbed9ba9003792d8bfd56c11bb3d4e29ad7e0d2f583e1c774de73bbf02933", size = 720065, upload-time = "2024-12-03T17:11:34.78Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d3/e596439f55d347e5c9c6cde8fef6dcdab02f29e3fc8db7b14e0303b38274/line_profiler-4.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:df0149c191a95f2dbc93155b2f9faaee563362d61e78b8986cdb67babe017cdc", size = 1787230, upload-time = "2024-12-03T17:11:36.438Z" }, + { url = "https://files.pythonhosted.org/packages/75/45/bc7d816ab60f0d8397090a32c3f798a53253ceb18d83f900434425d3b70f/line_profiler-4.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5e3a1ca491a8606ed674882b59354087f6e9ab6b94aa6d5fa5d565c6f2acc7a8", size = 1701460, upload-time = "2024-12-03T17:11:38.593Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/b7c02db2668bfd8de7b84f3d13dc36e4aca7dc8dba978b34f9e56dd0f103/line_profiler-4.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:a85ff57d4ef9d899ca12d6b0883c3cab1786388b29d2fb5f30f909e70bb9a691", size = 128330, upload-time = "2024-12-03T17:11:40.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/33/44bdf36948154a76aee5652dd405ce50a45fa4177c987c1694eea13eac31/line_profiler-4.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:49db0804e9e330076f0b048d63fd3206331ca0104dd549f61b2466df0f10ecda", size = 218791, upload-time = "2024-12-03T17:11:41.16Z" }, + { url = "https://files.pythonhosted.org/packages/51/78/7a41c05af37e0b7230593f3ae8d06d45a122fb84e1e70dcbba319c080887/line_profiler-4.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2e983ed4fb2cd68bb8896f6bad7f29ddf9112b978f700448510477bc9fde18db", size = 140191, upload-time = "2024-12-03T17:11:43.044Z" }, + { url = "https://files.pythonhosted.org/packages/d9/03/ac68ebaffa41d4fda12d8ecb47b686d8c1a0fad6db03bdfb3490ad6035c7/line_profiler-4.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d6b27c5880b29369e6bebfe434a16c60cbcd290aa4c384ac612e5777737893f8", size = 133297, upload-time = "2024-12-03T17:11:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/da/19/2ae0d8f9e39ad3413a219f69acb23a371c99863d48cce0273926d9dc4204/line_profiler-4.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2584dc0af3107efa60bd2ccaa7233dca98e3dff4b11138c0ac30355bc87f1a", size = 691235, upload-time = "2024-12-03T17:11:46.932Z" }, + { url = "https://files.pythonhosted.org/packages/e4/36/ecc106dd448a112455a8585db0994886b0439bbf808215249a89302dd626/line_profiler-4.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6767d8b922a7368b6917a47c164c3d96d48b82109ad961ef518e78800947cef4", size = 718497, upload-time = "2024-12-03T17:11:48.961Z" }, + { url = "https://files.pythonhosted.org/packages/8a/61/6293341fbcc6c5b4469f49bd94f37fea5d2efc8cce441809012346a5b7d0/line_profiler-4.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3137672a769717be4da3a6e006c3bd7b66ad4a341ba89ee749ef96c158a15b22", size = 1701191, upload-time = "2024-12-03T17:11:50.41Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/ab8a94c30c082caca87bc0db78efe91372e45d35a700ef07ffe78ed10cda/line_profiler-4.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:727e970d358616a1a33d51d696efec932a5ef7730785df62658bd7e74aa58951", size = 128232, upload-time = "2024-12-03T17:11:51.741Z" }, + { url = "https://files.pythonhosted.org/packages/12/01/cf45c629c00ef4161e5d274b9f65f47f5635af5f267fc21d4351a52558c3/line_profiler-4.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:80dd7e7990e346ed8ef32702f8fe3c60abdb0de95980d422c02f1ef30a6a828d", size = 222647, upload-time = "2024-12-03T17:12:08.87Z" }, + { url = "https://files.pythonhosted.org/packages/6f/be/0660f801d3f1b3f407c0725fba8507f811c429910993b9ab8cae9949e72e/line_profiler-4.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:31e1057448cfdb2678756163135b43bbbf698b2a1f7c88eb807f3fb2cdc2e3e7", size = 141985, upload-time = "2024-12-03T17:12:10.044Z" }, + { url = "https://files.pythonhosted.org/packages/bb/46/c705efb02fc069524a154d0de34314e11e5b8fa8276a54677586a755d441/line_profiler-4.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3ea02ccd7dc97b5777c032297991b5637130fbd07fa2c6a1f89f248aa12ef71b", size = 135482, upload-time = "2024-12-03T17:12:11.301Z" }, + { url = "https://files.pythonhosted.org/packages/29/50/5f4d266f1a8c3cdfa0c1a9f9ab2cad6162fd40f503d3caac2e8eb7be3477/line_profiler-4.2.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4bbbc4e8545f0c187cfed7c323b8cc1121d28001b222b26f6bc3bc554ba82d4f", size = 700169, upload-time = "2024-12-03T17:12:12.672Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0a/be2eb9a67270d746f684dbdebf78a96836cef1ea2743f401d441e81a321e/line_profiler-4.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d76d37c1084210363261d08eaabd30310eefb707ba8ab736a61e43930afaf47", size = 719660, upload-time = "2024-12-03T17:12:14.006Z" }, + { url = "https://files.pythonhosted.org/packages/db/8b/7bd8dc5092c59158014f9bf079ce8785bf7c72a440e1e43a6e970e4516cb/line_profiler-4.2.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:22f84c3dbb807a26c115626bee19cb5f93683fa08c8d3836ec30af06fa9eb5c3", size = 1801928, upload-time = "2024-12-03T17:12:15.381Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d6/f817e4ccbc2dc896b256b9ffdf92b46f6e1563eba2889b07f0fb088283ae/line_profiler-4.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e6131bcd5888371b61e05631555592feba12e73c96596b8d26ffe03cea0fc088", size = 1705618, upload-time = "2024-12-03T17:12:17.682Z" }, + { url = "https://files.pythonhosted.org/packages/21/93/bf3e70e583a456520168b008e8024725c9c807380aae74a12b1f6f38c500/line_profiler-4.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:fb58aa12cf64f0176d84bc4033bb0701fe8075d5da57149839ef895d961bbdad", size = 128255, upload-time = "2024-12-03T17:12:18.977Z" }, +] + +[[package]] +name = "litestar" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "python_full_version >= '3.10'" }, + { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "httpx", marker = "python_full_version >= '3.10'" }, + { name = "litestar-htmx", marker = "python_full_version >= '3.10'" }, + { name = "msgspec", marker = "python_full_version >= '3.10'" }, + { name = "multidict", marker = "python_full_version >= '3.10'" }, + { name = "multipart", marker = "python_full_version >= '3.10'" }, + { name = "polyfactory", marker = "python_full_version >= '3.10'" }, + { name = "pyyaml", marker = "python_full_version >= '3.10'" }, + { name = "rich", marker = "python_full_version >= '3.10'" }, + { name = "rich-click", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/4e/3376d5737a4c2e26fb2991a046265c38335b134d3e04e93c6d754e962e4e/litestar-2.16.0.tar.gz", hash = "sha256:f65c0d543bfec12b7433dff624322936f30bbdfb54ad3c5b7ef22ab2d092be2d", size = 399637, upload-time = "2025-05-04T11:00:46.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/dc/4d1018577683918cd24a58228c90833f71f792aafcfffb44905c9062f737/litestar-2.16.0-py3-none-any.whl", hash = "sha256:8a48557198556f01d3d70da3859a471aa56595a4a344362d9529ed65804e3ee4", size = 573158, upload-time = "2025-05-04T11:00:44.558Z" }, +] + +[[package]] +name = "litestar-htmx" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/b9/7e296aa1adada25cce8e5f89a996b0e38d852d93b1b656a2058226c542a2/litestar_htmx-0.5.0.tar.gz", hash = "sha256:e02d1a3a92172c874835fa3e6749d65ae9fc626d0df46719490a16293e2146fb", size = 119755, upload-time = "2025-06-11T21:19:45.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/24/8d99982f0aa9c1cd82073c6232b54a0dbe6797c7d63c0583a6c68ee3ddf2/litestar_htmx-0.5.0-py3-none-any.whl", hash = "sha256:92833aa47e0d0e868d2a7dbfab75261f124f4b83d4f9ad12b57b9a68f86c50e6", size = 9970, upload-time = "2025-06-11T21:19:44.465Z" }, +] + +[[package]] +name = "lsprotocol" +version = "2023.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cattrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/f6/6e80484ec078d0b50699ceb1833597b792a6c695f90c645fbaf54b947e6f/lsprotocol-2023.0.1.tar.gz", hash = "sha256:cc5c15130d2403c18b734304339e51242d3018a05c4f7d0f198ad6e0cd21861d", size = 69434, upload-time = "2024-01-09T17:21:12.625Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/37/2351e48cb3309673492d3a8c59d407b75fb6630e560eb27ecd4da03adc9a/lsprotocol-2023.0.1-py3-none-any.whl", hash = "sha256:c75223c9e4af2f24272b14c6375787438279369236cd568f596d4951052a60f2", size = 70826, upload-time = "2024-01-09T17:21:14.491Z" }, +] + +[[package]] +name = "lxml" +version = "5.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/3d/14e82fc7c8fb1b7761f7e748fd47e2ec8276d137b6acfe5a4bb73853e08f/lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd", size = 3679479, upload-time = "2025-04-23T01:50:29.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/1f/a3b6b74a451ceb84b471caa75c934d2430a4d84395d38ef201d539f38cd1/lxml-5.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e7bc6df34d42322c5289e37e9971d6ed114e3776b45fa879f734bded9d1fea9c", size = 8076838, upload-time = "2025-04-23T01:44:29.325Z" }, + { url = "https://files.pythonhosted.org/packages/36/af/a567a55b3e47135b4d1f05a1118c24529104c003f95851374b3748139dc1/lxml-5.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6854f8bd8a1536f8a1d9a3655e6354faa6406621cf857dc27b681b69860645c7", size = 4381827, upload-time = "2025-04-23T01:44:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/50/ba/4ee47d24c675932b3eb5b6de77d0f623c2db6dc466e7a1f199792c5e3e3a/lxml-5.4.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:696ea9e87442467819ac22394ca36cb3d01848dad1be6fac3fb612d3bd5a12cf", size = 5204098, upload-time = "2025-04-23T01:44:35.809Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0f/b4db6dfebfefe3abafe360f42a3d471881687fd449a0b86b70f1f2683438/lxml-5.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ef80aeac414f33c24b3815ecd560cee272786c3adfa5f31316d8b349bfade28", size = 4930261, upload-time = "2025-04-23T01:44:38.271Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1f/0bb1bae1ce056910f8db81c6aba80fec0e46c98d77c0f59298c70cd362a3/lxml-5.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b9c2754cef6963f3408ab381ea55f47dabc6f78f4b8ebb0f0b25cf1ac1f7609", size = 5529621, upload-time = "2025-04-23T01:44:40.921Z" }, + { url = "https://files.pythonhosted.org/packages/21/f5/e7b66a533fc4a1e7fa63dd22a1ab2ec4d10319b909211181e1ab3e539295/lxml-5.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a62cc23d754bb449d63ff35334acc9f5c02e6dae830d78dab4dd12b78a524f4", size = 4983231, upload-time = "2025-04-23T01:44:43.871Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/a38244b669c2d95a6a101a84d3c85ba921fea827e9e5483e93168bf1ccb2/lxml-5.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f82125bc7203c5ae8633a7d5d20bcfdff0ba33e436e4ab0abc026a53a8960b7", size = 5084279, upload-time = "2025-04-23T01:44:46.632Z" }, + { url = "https://files.pythonhosted.org/packages/db/64/48cac242347a09a07740d6cee7b7fd4663d5c1abd65f2e3c60420e231b27/lxml-5.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:b67319b4aef1a6c56576ff544b67a2a6fbd7eaee485b241cabf53115e8908b8f", size = 4927405, upload-time = "2025-04-23T01:44:49.843Z" }, + { url = "https://files.pythonhosted.org/packages/98/89/97442835fbb01d80b72374f9594fe44f01817d203fa056e9906128a5d896/lxml-5.4.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:a8ef956fce64c8551221f395ba21d0724fed6b9b6242ca4f2f7beb4ce2f41997", size = 5550169, upload-time = "2025-04-23T01:44:52.791Z" }, + { url = "https://files.pythonhosted.org/packages/f1/97/164ca398ee654eb21f29c6b582685c6c6b9d62d5213abc9b8380278e9c0a/lxml-5.4.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:0a01ce7d8479dce84fc03324e3b0c9c90b1ece9a9bb6a1b6c9025e7e4520e78c", size = 5062691, upload-time = "2025-04-23T01:44:56.108Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bc/712b96823d7feb53482d2e4f59c090fb18ec7b0d0b476f353b3085893cda/lxml-5.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:91505d3ddebf268bb1588eb0f63821f738d20e1e7f05d3c647a5ca900288760b", size = 5133503, upload-time = "2025-04-23T01:44:59.222Z" }, + { url = "https://files.pythonhosted.org/packages/d4/55/a62a39e8f9da2a8b6002603475e3c57c870cd9c95fd4b94d4d9ac9036055/lxml-5.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a3bcdde35d82ff385f4ede021df801b5c4a5bcdfb61ea87caabcebfc4945dc1b", size = 4999346, upload-time = "2025-04-23T01:45:02.088Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/a393728ae001b92bb1a9e095e570bf71ec7f7fbae7688a4792222e56e5b9/lxml-5.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aea7c06667b987787c7d1f5e1dfcd70419b711cdb47d6b4bb4ad4b76777a0563", size = 5627139, upload-time = "2025-04-23T01:45:04.582Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5f/9dcaaad037c3e642a7ea64b479aa082968de46dd67a8293c541742b6c9db/lxml-5.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a7fb111eef4d05909b82152721a59c1b14d0f365e2be4c742a473c5d7372f4f5", size = 5465609, upload-time = "2025-04-23T01:45:07.649Z" }, + { url = "https://files.pythonhosted.org/packages/a7/0a/ebcae89edf27e61c45023005171d0ba95cb414ee41c045ae4caf1b8487fd/lxml-5.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43d549b876ce64aa18b2328faff70f5877f8c6dede415f80a2f799d31644d776", size = 5192285, upload-time = "2025-04-23T01:45:10.456Z" }, + { url = "https://files.pythonhosted.org/packages/42/ad/cc8140ca99add7d85c92db8b2354638ed6d5cc0e917b21d36039cb15a238/lxml-5.4.0-cp310-cp310-win32.whl", hash = "sha256:75133890e40d229d6c5837b0312abbe5bac1c342452cf0e12523477cd3aa21e7", size = 3477507, upload-time = "2025-04-23T01:45:12.474Z" }, + { url = "https://files.pythonhosted.org/packages/e9/39/597ce090da1097d2aabd2f9ef42187a6c9c8546d67c419ce61b88b336c85/lxml-5.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:de5b4e1088523e2b6f730d0509a9a813355b7f5659d70eb4f319c76beea2e250", size = 3805104, upload-time = "2025-04-23T01:45:15.104Z" }, + { url = "https://files.pythonhosted.org/packages/81/2d/67693cc8a605a12e5975380d7ff83020dcc759351b5a066e1cced04f797b/lxml-5.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:98a3912194c079ef37e716ed228ae0dcb960992100461b704aea4e93af6b0bb9", size = 8083240, upload-time = "2025-04-23T01:45:18.566Z" }, + { url = "https://files.pythonhosted.org/packages/73/53/b5a05ab300a808b72e848efd152fe9c022c0181b0a70b8bca1199f1bed26/lxml-5.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ea0252b51d296a75f6118ed0d8696888e7403408ad42345d7dfd0d1e93309a7", size = 4387685, upload-time = "2025-04-23T01:45:21.387Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/1a3879c5f512bdcd32995c301886fe082b2edd83c87d41b6d42d89b4ea4d/lxml-5.4.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b92b69441d1bd39f4940f9eadfa417a25862242ca2c396b406f9272ef09cdcaa", size = 4991164, upload-time = "2025-04-23T01:45:23.849Z" }, + { url = "https://files.pythonhosted.org/packages/f9/94/bbc66e42559f9d04857071e3b3d0c9abd88579367fd2588a4042f641f57e/lxml-5.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20e16c08254b9b6466526bc1828d9370ee6c0d60a4b64836bc3ac2917d1e16df", size = 4746206, upload-time = "2025-04-23T01:45:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/66/95/34b0679bee435da2d7cae895731700e519a8dfcab499c21662ebe671603e/lxml-5.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7605c1c32c3d6e8c990dd28a0970a3cbbf1429d5b92279e37fda05fb0c92190e", size = 5342144, upload-time = "2025-04-23T01:45:28.939Z" }, + { url = "https://files.pythonhosted.org/packages/e0/5d/abfcc6ab2fa0be72b2ba938abdae1f7cad4c632f8d552683ea295d55adfb/lxml-5.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ecf4c4b83f1ab3d5a7ace10bafcb6f11df6156857a3c418244cef41ca9fa3e44", size = 4825124, upload-time = "2025-04-23T01:45:31.361Z" }, + { url = "https://files.pythonhosted.org/packages/5a/78/6bd33186c8863b36e084f294fc0a5e5eefe77af95f0663ef33809cc1c8aa/lxml-5.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cef4feae82709eed352cd7e97ae062ef6ae9c7b5dbe3663f104cd2c0e8d94ba", size = 4876520, upload-time = "2025-04-23T01:45:34.191Z" }, + { url = "https://files.pythonhosted.org/packages/3b/74/4d7ad4839bd0fc64e3d12da74fc9a193febb0fae0ba6ebd5149d4c23176a/lxml-5.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:df53330a3bff250f10472ce96a9af28628ff1f4efc51ccba351a8820bca2a8ba", size = 4765016, upload-time = "2025-04-23T01:45:36.7Z" }, + { url = "https://files.pythonhosted.org/packages/24/0d/0a98ed1f2471911dadfc541003ac6dd6879fc87b15e1143743ca20f3e973/lxml-5.4.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:aefe1a7cb852fa61150fcb21a8c8fcea7b58c4cb11fbe59c97a0a4b31cae3c8c", size = 5362884, upload-time = "2025-04-23T01:45:39.291Z" }, + { url = "https://files.pythonhosted.org/packages/48/de/d4f7e4c39740a6610f0f6959052b547478107967362e8424e1163ec37ae8/lxml-5.4.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ef5a7178fcc73b7d8c07229e89f8eb45b2908a9238eb90dcfc46571ccf0383b8", size = 4902690, upload-time = "2025-04-23T01:45:42.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/8c/61763abd242af84f355ca4ef1ee096d3c1b7514819564cce70fd18c22e9a/lxml-5.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d2ed1b3cb9ff1c10e6e8b00941bb2e5bb568b307bfc6b17dffbbe8be5eecba86", size = 4944418, upload-time = "2025-04-23T01:45:46.051Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/6d7e3b63e7e282619193961a570c0a4c8a57fe820f07ca3fe2f6bd86608a/lxml-5.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:72ac9762a9f8ce74c9eed4a4e74306f2f18613a6b71fa065495a67ac227b3056", size = 4827092, upload-time = "2025-04-23T01:45:48.943Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/e60a306df54680b103348545706a98a7514a42c8b4fbfdcaa608567bb065/lxml-5.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f5cb182f6396706dc6cc1896dd02b1c889d644c081b0cdec38747573db88a7d7", size = 5418231, upload-time = "2025-04-23T01:45:51.481Z" }, + { url = "https://files.pythonhosted.org/packages/27/f2/9754aacd6016c930875854f08ac4b192a47fe19565f776a64004aa167521/lxml-5.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3a3178b4873df8ef9457a4875703488eb1622632a9cee6d76464b60e90adbfcd", size = 5261798, upload-time = "2025-04-23T01:45:54.146Z" }, + { url = "https://files.pythonhosted.org/packages/38/a2/0c49ec6941428b1bd4f280650d7b11a0f91ace9db7de32eb7aa23bcb39ff/lxml-5.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e094ec83694b59d263802ed03a8384594fcce477ce484b0cbcd0008a211ca751", size = 4988195, upload-time = "2025-04-23T01:45:56.685Z" }, + { url = "https://files.pythonhosted.org/packages/7a/75/87a3963a08eafc46a86c1131c6e28a4de103ba30b5ae903114177352a3d7/lxml-5.4.0-cp311-cp311-win32.whl", hash = "sha256:4329422de653cdb2b72afa39b0aa04252fca9071550044904b2e7036d9d97fe4", size = 3474243, upload-time = "2025-04-23T01:45:58.863Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/1f0964c4f6c2be861c50db380c554fb8befbea98c6404744ce243a3c87ef/lxml-5.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd3be6481ef54b8cfd0e1e953323b7aa9d9789b94842d0e5b142ef4bb7999539", size = 3815197, upload-time = "2025-04-23T01:46:01.096Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/d101ace719ca6a4ec043eb516fcfcb1b396a9fccc4fcd9ef593df34ba0d5/lxml-5.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b5aff6f3e818e6bdbbb38e5967520f174b18f539c2b9de867b1e7fde6f8d95a4", size = 8127392, upload-time = "2025-04-23T01:46:04.09Z" }, + { url = "https://files.pythonhosted.org/packages/11/84/beddae0cec4dd9ddf46abf156f0af451c13019a0fa25d7445b655ba5ccb7/lxml-5.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942a5d73f739ad7c452bf739a62a0f83e2578afd6b8e5406308731f4ce78b16d", size = 4415103, upload-time = "2025-04-23T01:46:07.227Z" }, + { url = "https://files.pythonhosted.org/packages/d0/25/d0d93a4e763f0462cccd2b8a665bf1e4343dd788c76dcfefa289d46a38a9/lxml-5.4.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460508a4b07364d6abf53acaa0a90b6d370fafde5693ef37602566613a9b0779", size = 5024224, upload-time = "2025-04-23T01:46:10.237Z" }, + { url = "https://files.pythonhosted.org/packages/31/ce/1df18fb8f7946e7f3388af378b1f34fcf253b94b9feedb2cec5969da8012/lxml-5.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529024ab3a505fed78fe3cc5ddc079464e709f6c892733e3f5842007cec8ac6e", size = 4769913, upload-time = "2025-04-23T01:46:12.757Z" }, + { url = "https://files.pythonhosted.org/packages/4e/62/f4a6c60ae7c40d43657f552f3045df05118636be1165b906d3423790447f/lxml-5.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ca56ebc2c474e8f3d5761debfd9283b8b18c76c4fc0967b74aeafba1f5647f9", size = 5290441, upload-time = "2025-04-23T01:46:16.037Z" }, + { url = "https://files.pythonhosted.org/packages/9e/aa/04f00009e1e3a77838c7fc948f161b5d2d5de1136b2b81c712a263829ea4/lxml-5.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a81e1196f0a5b4167a8dafe3a66aa67c4addac1b22dc47947abd5d5c7a3f24b5", size = 4820165, upload-time = "2025-04-23T01:46:19.137Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/e0b2f61fa2404bf0f1fdf1898377e5bd1b74cc9b2cf2c6ba8509b8f27990/lxml-5.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5", size = 4932580, upload-time = "2025-04-23T01:46:21.963Z" }, + { url = "https://files.pythonhosted.org/packages/24/a2/8263f351b4ffe0ed3e32ea7b7830f845c795349034f912f490180d88a877/lxml-5.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c5681160758d3f6ac5b4fea370495c48aac0989d6a0f01bb9a72ad8ef5ab75c4", size = 4759493, upload-time = "2025-04-23T01:46:24.316Z" }, + { url = "https://files.pythonhosted.org/packages/05/00/41db052f279995c0e35c79d0f0fc9f8122d5b5e9630139c592a0b58c71b4/lxml-5.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:2dc191e60425ad70e75a68c9fd90ab284df64d9cd410ba8d2b641c0c45bc006e", size = 5324679, upload-time = "2025-04-23T01:46:27.097Z" }, + { url = "https://files.pythonhosted.org/packages/1d/be/ee99e6314cdef4587617d3b3b745f9356d9b7dd12a9663c5f3b5734b64ba/lxml-5.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:67f779374c6b9753ae0a0195a892a1c234ce8416e4448fe1e9f34746482070a7", size = 4890691, upload-time = "2025-04-23T01:46:30.009Z" }, + { url = "https://files.pythonhosted.org/packages/ad/36/239820114bf1d71f38f12208b9c58dec033cbcf80101cde006b9bde5cffd/lxml-5.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:79d5bfa9c1b455336f52343130b2067164040604e41f6dc4d8313867ed540079", size = 4955075, upload-time = "2025-04-23T01:46:32.33Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e1/1b795cc0b174efc9e13dbd078a9ff79a58728a033142bc6d70a1ee8fc34d/lxml-5.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d3c30ba1c9b48c68489dc1829a6eede9873f52edca1dda900066542528d6b20", size = 4838680, upload-time = "2025-04-23T01:46:34.852Z" }, + { url = "https://files.pythonhosted.org/packages/72/48/3c198455ca108cec5ae3662ae8acd7fd99476812fd712bb17f1b39a0b589/lxml-5.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1af80c6316ae68aded77e91cd9d80648f7dd40406cef73df841aa3c36f6907c8", size = 5391253, upload-time = "2025-04-23T01:46:37.608Z" }, + { url = "https://files.pythonhosted.org/packages/d6/10/5bf51858971c51ec96cfc13e800a9951f3fd501686f4c18d7d84fe2d6352/lxml-5.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4d885698f5019abe0de3d352caf9466d5de2baded00a06ef3f1216c1a58ae78f", size = 5261651, upload-time = "2025-04-23T01:46:40.183Z" }, + { url = "https://files.pythonhosted.org/packages/2b/11/06710dd809205377da380546f91d2ac94bad9ff735a72b64ec029f706c85/lxml-5.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea53d51859b6c64e7c51d522c03cc2c48b9b5d6172126854cc7f01aa11f52bc", size = 5024315, upload-time = "2025-04-23T01:46:43.333Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b0/15b6217834b5e3a59ebf7f53125e08e318030e8cc0d7310355e6edac98ef/lxml-5.4.0-cp312-cp312-win32.whl", hash = "sha256:d90b729fd2732df28130c064aac9bb8aff14ba20baa4aee7bd0795ff1187545f", size = 3486149, upload-time = "2025-04-23T01:46:45.684Z" }, + { url = "https://files.pythonhosted.org/packages/91/1e/05ddcb57ad2f3069101611bd5f5084157d90861a2ef460bf42f45cced944/lxml-5.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1dc4ca99e89c335a7ed47d38964abcb36c5910790f9bd106f2a8fa2ee0b909d2", size = 3817095, upload-time = "2025-04-23T01:46:48.521Z" }, + { url = "https://files.pythonhosted.org/packages/87/cb/2ba1e9dd953415f58548506fa5549a7f373ae55e80c61c9041b7fd09a38a/lxml-5.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:773e27b62920199c6197130632c18fb7ead3257fce1ffb7d286912e56ddb79e0", size = 8110086, upload-time = "2025-04-23T01:46:52.218Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3e/6602a4dca3ae344e8609914d6ab22e52ce42e3e1638c10967568c5c1450d/lxml-5.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9c671845de9699904b1e9df95acfe8dfc183f2310f163cdaa91a3535af95de", size = 4404613, upload-time = "2025-04-23T01:46:55.281Z" }, + { url = "https://files.pythonhosted.org/packages/4c/72/bf00988477d3bb452bef9436e45aeea82bb40cdfb4684b83c967c53909c7/lxml-5.4.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9454b8d8200ec99a224df8854786262b1bd6461f4280064c807303c642c05e76", size = 5012008, upload-time = "2025-04-23T01:46:57.817Z" }, + { url = "https://files.pythonhosted.org/packages/92/1f/93e42d93e9e7a44b2d3354c462cd784dbaaf350f7976b5d7c3f85d68d1b1/lxml-5.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cccd007d5c95279e529c146d095f1d39ac05139de26c098166c4beb9374b0f4d", size = 4760915, upload-time = "2025-04-23T01:47:00.745Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/363009390d0b461cf9976a499e83b68f792e4c32ecef092f3f9ef9c4ba54/lxml-5.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fce1294a0497edb034cb416ad3e77ecc89b313cff7adbee5334e4dc0d11f422", size = 5283890, upload-time = "2025-04-23T01:47:04.702Z" }, + { url = "https://files.pythonhosted.org/packages/19/dc/6056c332f9378ab476c88e301e6549a0454dbee8f0ae16847414f0eccb74/lxml-5.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24974f774f3a78ac12b95e3a20ef0931795ff04dbb16db81a90c37f589819551", size = 4812644, upload-time = "2025-04-23T01:47:07.833Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8a/f8c66bbb23ecb9048a46a5ef9b495fd23f7543df642dabeebcb2eeb66592/lxml-5.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:497cab4d8254c2a90bf988f162ace2ddbfdd806fce3bda3f581b9d24c852e03c", size = 4921817, upload-time = "2025-04-23T01:47:10.317Z" }, + { url = "https://files.pythonhosted.org/packages/04/57/2e537083c3f381f83d05d9b176f0d838a9e8961f7ed8ddce3f0217179ce3/lxml-5.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e794f698ae4c5084414efea0f5cc9f4ac562ec02d66e1484ff822ef97c2cadff", size = 4753916, upload-time = "2025-04-23T01:47:12.823Z" }, + { url = "https://files.pythonhosted.org/packages/d8/80/ea8c4072109a350848f1157ce83ccd9439601274035cd045ac31f47f3417/lxml-5.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2c62891b1ea3094bb12097822b3d44b93fc6c325f2043c4d2736a8ff09e65f60", size = 5289274, upload-time = "2025-04-23T01:47:15.916Z" }, + { url = "https://files.pythonhosted.org/packages/b3/47/c4be287c48cdc304483457878a3f22999098b9a95f455e3c4bda7ec7fc72/lxml-5.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:142accb3e4d1edae4b392bd165a9abdee8a3c432a2cca193df995bc3886249c8", size = 4874757, upload-time = "2025-04-23T01:47:19.793Z" }, + { url = "https://files.pythonhosted.org/packages/2f/04/6ef935dc74e729932e39478e44d8cfe6a83550552eaa072b7c05f6f22488/lxml-5.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a42b3a19346e5601d1b8296ff6ef3d76038058f311902edd574461e9c036982", size = 4947028, upload-time = "2025-04-23T01:47:22.401Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f9/c33fc8daa373ef8a7daddb53175289024512b6619bc9de36d77dca3df44b/lxml-5.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4291d3c409a17febf817259cb37bc62cb7eb398bcc95c1356947e2871911ae61", size = 4834487, upload-time = "2025-04-23T01:47:25.513Z" }, + { url = "https://files.pythonhosted.org/packages/8d/30/fc92bb595bcb878311e01b418b57d13900f84c2b94f6eca9e5073ea756e6/lxml-5.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4f5322cf38fe0e21c2d73901abf68e6329dc02a4994e483adbcf92b568a09a54", size = 5381688, upload-time = "2025-04-23T01:47:28.454Z" }, + { url = "https://files.pythonhosted.org/packages/43/d1/3ba7bd978ce28bba8e3da2c2e9d5ae3f8f521ad3f0ca6ea4788d086ba00d/lxml-5.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0be91891bdb06ebe65122aa6bf3fc94489960cf7e03033c6f83a90863b23c58b", size = 5242043, upload-time = "2025-04-23T01:47:31.208Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cd/95fa2201041a610c4d08ddaf31d43b98ecc4b1d74b1e7245b1abdab443cb/lxml-5.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:15a665ad90054a3d4f397bc40f73948d48e36e4c09f9bcffc7d90c87410e478a", size = 5021569, upload-time = "2025-04-23T01:47:33.805Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a6/31da006fead660b9512d08d23d31e93ad3477dd47cc42e3285f143443176/lxml-5.4.0-cp313-cp313-win32.whl", hash = "sha256:d5663bc1b471c79f5c833cffbc9b87d7bf13f87e055a5c86c363ccd2348d7e82", size = 3485270, upload-time = "2025-04-23T01:47:36.133Z" }, + { url = "https://files.pythonhosted.org/packages/fc/14/c115516c62a7d2499781d2d3d7215218c0731b2c940753bf9f9b7b73924d/lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f", size = 3814606, upload-time = "2025-04-23T01:47:39.028Z" }, + { url = "https://files.pythonhosted.org/packages/1e/04/acd238222ea25683e43ac7113facc380b3aaf77c53e7d88c4f544cef02ca/lxml-5.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bda3ea44c39eb74e2488297bb39d47186ed01342f0022c8ff407c250ac3f498e", size = 8082189, upload-time = "2025-04-23T01:48:51.829Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4e/cc7fe9ccb9999cc648492ce970b63c657606aefc7d0fba46b17aa2ba93fb/lxml-5.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9ceaf423b50ecfc23ca00b7f50b64baba85fb3fb91c53e2c9d00bc86150c7e40", size = 4384950, upload-time = "2025-04-23T01:48:54.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/bf/acd219c489346d0243a30769b9d446b71e5608581db49a18c8d91a669e19/lxml-5.4.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:664cdc733bc87449fe781dbb1f309090966c11cc0c0cd7b84af956a02a8a4729", size = 5209823, upload-time = "2025-04-23T01:48:57.192Z" }, + { url = "https://files.pythonhosted.org/packages/57/51/ec31cd33175c09aa7b93d101f56eed43d89e15504455d884d021df7166a7/lxml-5.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67ed8a40665b84d161bae3181aa2763beea3747f748bca5874b4af4d75998f87", size = 4931808, upload-time = "2025-04-23T01:48:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/e5/68/865d229f191514da1777125598d028dc88a5ea300d68c30e1f120bfd01bd/lxml-5.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b4a3bd174cc9cdaa1afbc4620c049038b441d6ba07629d89a83b408e54c35cd", size = 5086067, upload-time = "2025-04-23T01:49:02.887Z" }, + { url = "https://files.pythonhosted.org/packages/82/01/4c958c5848b4e263cd9e83dff6b49f975a5a0854feb1070dfe0bdcdf70a0/lxml-5.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:b0989737a3ba6cf2a16efb857fb0dfa20bc5c542737fddb6d893fde48be45433", size = 4929026, upload-time = "2025-04-23T01:49:05.624Z" }, + { url = "https://files.pythonhosted.org/packages/55/31/5327d8af74d7f35e645b40ae6658761e1fee59ebecaa6a8d295e495c2ca9/lxml-5.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:dc0af80267edc68adf85f2a5d9be1cdf062f973db6790c1d065e45025fa26140", size = 5134245, upload-time = "2025-04-23T01:49:08.918Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c9/204eba2400beb0016dacc2c5335ecb1e37f397796683ffdb7f471e86bddb/lxml-5.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:639978bccb04c42677db43c79bdaa23785dc7f9b83bfd87570da8207872f1ce5", size = 5001020, upload-time = "2025-04-23T01:49:11.643Z" }, + { url = "https://files.pythonhosted.org/packages/07/53/979165f50a853dab1cf3b9e53105032d55f85c5993f94afc4d9a61a22877/lxml-5.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5a99d86351f9c15e4a901fc56404b485b1462039db59288b203f8c629260a142", size = 5192346, upload-time = "2025-04-23T01:49:14.868Z" }, + { url = "https://files.pythonhosted.org/packages/17/2b/f37b5ae28949143f863ba3066b30eede6107fc9a503bd0d01677d4e2a1e0/lxml-5.4.0-cp39-cp39-win32.whl", hash = "sha256:3e6d5557989cdc3ebb5302bbdc42b439733a841891762ded9514e74f60319ad6", size = 3478275, upload-time = "2025-04-23T01:49:17.249Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d5/b795a183680126147665a8eeda8e802c180f2f7661aa9a550bba5bcdae63/lxml-5.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:a8c9b7f16b63e65bbba889acb436a1034a82d34fa09752d754f88d708eca80e1", size = 3806275, upload-time = "2025-04-23T01:49:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b0/e4d1cbb8c078bc4ae44de9c6a79fec4e2b4151b1b4d50af71d799e76b177/lxml-5.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1b717b00a71b901b4667226bba282dd462c42ccf618ade12f9ba3674e1fabc55", size = 3892319, upload-time = "2025-04-23T01:49:22.069Z" }, + { url = "https://files.pythonhosted.org/packages/5b/aa/e2bdefba40d815059bcb60b371a36fbfcce970a935370e1b367ba1cc8f74/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27a9ded0f0b52098ff89dd4c418325b987feed2ea5cc86e8860b0f844285d740", size = 4211614, upload-time = "2025-04-23T01:49:24.599Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/91ff89d1e092e7cfdd8453a939436ac116db0a665e7f4be0cd8e65c7dc5a/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b7ce10634113651d6f383aa712a194179dcd496bd8c41e191cec2099fa09de5", size = 4306273, upload-time = "2025-04-23T01:49:27.355Z" }, + { url = "https://files.pythonhosted.org/packages/be/7c/8c3f15df2ca534589717bfd19d1e3482167801caedfa4d90a575facf68a6/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53370c26500d22b45182f98847243efb518d268374a9570409d2e2276232fd37", size = 4208552, upload-time = "2025-04-23T01:49:29.949Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/9567afb1665f64d73fc54eb904e418d1138d7f011ed00647121b4dd60b38/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c6364038c519dffdbe07e3cf42e6a7f8b90c275d4d1617a69bb59734c1a2d571", size = 4331091, upload-time = "2025-04-23T01:49:32.842Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ab/fdbbd91d8d82bf1a723ba88ec3e3d76c022b53c391b0c13cad441cdb8f9e/lxml-5.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b12cb6527599808ada9eb2cd6e0e7d3d8f13fe7bbb01c6311255a15ded4c7ab4", size = 3487862, upload-time = "2025-04-23T01:49:36.296Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fb/d19b67e4bb63adc20574ba3476cf763b3514df1a37551084b890254e4b15/lxml-5.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9459e6892f59ecea2e2584ee1058f5d8f629446eab52ba2305ae13a32a059530", size = 3891034, upload-time = "2025-04-23T01:50:12.71Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5d/6e1033ee0cdb2f9bc93164f9df14e42cb5bbf1bbed3bf67f687de2763104/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47fb24cc0f052f0576ea382872b3fc7e1f7e3028e53299ea751839418ade92a6", size = 4207420, upload-time = "2025-04-23T01:50:15.281Z" }, + { url = "https://files.pythonhosted.org/packages/f3/4b/23ac79efc32d913259d66672c5f93daac7750a3d97cdc1c1a9a5d1c1b46c/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50441c9de951a153c698b9b99992e806b71c1f36d14b154592580ff4a9d0d877", size = 4305106, upload-time = "2025-04-23T01:50:17.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7a/fe558bee63a62f7a75a52111c0a94556c1c1bdcf558cd7d52861de558759/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ab339536aa798b1e17750733663d272038bf28069761d5be57cb4a9b0137b4f8", size = 4205587, upload-time = "2025-04-23T01:50:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5b/3207e6bd8d67c952acfec6bac9d1fa0ee353202e7c40b335ebe00879ab7d/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9776af1aad5a4b4a1317242ee2bea51da54b2a7b7b48674be736d463c999f37d", size = 4329077, upload-time = "2025-04-23T01:50:23.996Z" }, + { url = "https://files.pythonhosted.org/packages/a1/25/d381abcfd00102d3304aa191caab62f6e3bcbac93ee248771db6be153dfd/lxml-5.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:63e7968ff83da2eb6fdda967483a7a023aa497d85ad8f05c3ad9b1f2e8c84987", size = 3486416, upload-time = "2025-04-23T01:50:26.388Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357, upload-time = "2024-10-18T15:20:51.44Z" }, + { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393, upload-time = "2024-10-18T15:20:52.426Z" }, + { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732, upload-time = "2024-10-18T15:20:53.578Z" }, + { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866, upload-time = "2024-10-18T15:20:55.06Z" }, + { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964, upload-time = "2024-10-18T15:20:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977, upload-time = "2024-10-18T15:20:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366, upload-time = "2024-10-18T15:20:58.235Z" }, + { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091, upload-time = "2024-10-18T15:20:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065, upload-time = "2024-10-18T15:21:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514, upload-time = "2024-10-18T15:21:01.122Z" }, + { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload-time = "2024-10-18T15:21:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload-time = "2024-10-18T15:21:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload-time = "2024-10-18T15:21:03.953Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload-time = "2024-10-18T15:21:06.495Z" }, + { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload-time = "2024-10-18T15:21:07.295Z" }, + { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload-time = "2024-10-18T15:21:08.073Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload-time = "2024-10-18T15:21:09.318Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload-time = "2024-10-18T15:21:10.185Z" }, + { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" }, + { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ea/9b1530c3fdeeca613faeb0fb5cbcf2389d816072fab72a71b45749ef6062/MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", size = 14344, upload-time = "2024-10-18T15:21:43.721Z" }, + { url = "https://files.pythonhosted.org/packages/4b/c2/fbdbfe48848e7112ab05e627e718e854d20192b674952d9042ebd8c9e5de/MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", size = 12389, upload-time = "2024-10-18T15:21:44.666Z" }, + { url = "https://files.pythonhosted.org/packages/f0/25/7a7c6e4dbd4f867d95d94ca15449e91e52856f6ed1905d58ef1de5e211d0/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", size = 21607, upload-time = "2024-10-18T15:21:45.452Z" }, + { url = "https://files.pythonhosted.org/packages/53/8f/f339c98a178f3c1e545622206b40986a4c3307fe39f70ccd3d9df9a9e425/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", size = 20728, upload-time = "2024-10-18T15:21:46.295Z" }, + { url = "https://files.pythonhosted.org/packages/1a/03/8496a1a78308456dbd50b23a385c69b41f2e9661c67ea1329849a598a8f9/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", size = 20826, upload-time = "2024-10-18T15:21:47.134Z" }, + { url = "https://files.pythonhosted.org/packages/e6/cf/0a490a4bd363048c3022f2f475c8c05582179bb179defcee4766fb3dcc18/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", size = 21843, upload-time = "2024-10-18T15:21:48.334Z" }, + { url = "https://files.pythonhosted.org/packages/19/a3/34187a78613920dfd3cdf68ef6ce5e99c4f3417f035694074beb8848cd77/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", size = 21219, upload-time = "2024-10-18T15:21:49.587Z" }, + { url = "https://files.pythonhosted.org/packages/17/d8/5811082f85bb88410ad7e452263af048d685669bbbfb7b595e8689152498/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", size = 20946, upload-time = "2024-10-18T15:21:50.441Z" }, + { url = "https://files.pythonhosted.org/packages/7c/31/bd635fb5989440d9365c5e3c47556cfea121c7803f5034ac843e8f37c2f2/MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", size = 15063, upload-time = "2024-10-18T15:21:51.385Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506, upload-time = "2024-10-18T15:21:52.974Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "more-itertools" +version = "10.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/a0/834b0cebabbfc7e311f30b46c8188790a37f89fc8d756660346fe5abfd09/more_itertools-10.7.0.tar.gz", hash = "sha256:9fddd5403be01a94b204faadcff459ec3568cf110265d3c54323e1e866ad29d3", size = 127671, upload-time = "2025-04-22T14:17:41.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/9f/7ba6f94fc1e9ac3d2b853fdff3035fb2fa5afbed898c4a72b8a020610594/more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e", size = 65278, upload-time = "2025-04-22T14:17:40.49Z" }, +] + +[[package]] +name = "msgpack" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/b1/ea4f68038a18c77c9467400d166d74c4ffa536f34761f7983a104357e614/msgpack-1.1.1.tar.gz", hash = "sha256:77b79ce34a2bdab2594f490c8e80dd62a02d650b91a75159a63ec413b8d104cd", size = 173555, upload-time = "2025-06-13T06:52:51.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/52/f30da112c1dc92cf64f57d08a273ac771e7b29dea10b4b30369b2d7e8546/msgpack-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:353b6fc0c36fde68b661a12949d7d49f8f51ff5fa019c1e47c87c4ff34b080ed", size = 81799, upload-time = "2025-06-13T06:51:37.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/7bfc0def2f04ab4145f7f108e3563f9b4abae4ab0ed78a61f350518cc4d2/msgpack-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:79c408fcf76a958491b4e3b103d1c417044544b68e96d06432a189b43d1215c8", size = 78278, upload-time = "2025-06-13T06:51:38.534Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c5/df5d6c1c39856bc55f800bf82778fd4c11370667f9b9e9d51b2f5da88f20/msgpack-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78426096939c2c7482bf31ef15ca219a9e24460289c00dd0b94411040bb73ad2", size = 402805, upload-time = "2025-06-13T06:51:39.538Z" }, + { url = "https://files.pythonhosted.org/packages/20/8e/0bb8c977efecfe6ea7116e2ed73a78a8d32a947f94d272586cf02a9757db/msgpack-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b17ba27727a36cb73aabacaa44b13090feb88a01d012c0f4be70c00f75048b4", size = 408642, upload-time = "2025-06-13T06:51:41.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/731d52c1aeec52006be6d1f8027c49fdc2cfc3ab7cbe7c28335b2910d7b6/msgpack-1.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a17ac1ea6ec3c7687d70201cfda3b1e8061466f28f686c24f627cae4ea8efd0", size = 395143, upload-time = "2025-06-13T06:51:42.575Z" }, + { url = "https://files.pythonhosted.org/packages/2b/92/b42911c52cda2ba67a6418ffa7d08969edf2e760b09015593c8a8a27a97d/msgpack-1.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88d1e966c9235c1d4e2afac21ca83933ba59537e2e2727a999bf3f515ca2af26", size = 395986, upload-time = "2025-06-13T06:51:43.807Z" }, + { url = "https://files.pythonhosted.org/packages/61/dc/8ae165337e70118d4dab651b8b562dd5066dd1e6dd57b038f32ebc3e2f07/msgpack-1.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f6d58656842e1b2ddbe07f43f56b10a60f2ba5826164910968f5933e5178af75", size = 402682, upload-time = "2025-06-13T06:51:45.534Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/555851cb98dcbd6ce041df1eacb25ac30646575e9cd125681aa2f4b1b6f1/msgpack-1.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96decdfc4adcbc087f5ea7ebdcfd3dee9a13358cae6e81d54be962efc38f6338", size = 406368, upload-time = "2025-06-13T06:51:46.97Z" }, + { url = "https://files.pythonhosted.org/packages/d4/64/39a26add4ce16f24e99eabb9005e44c663db00e3fce17d4ae1ae9d61df99/msgpack-1.1.1-cp310-cp310-win32.whl", hash = "sha256:6640fd979ca9a212e4bcdf6eb74051ade2c690b862b679bfcb60ae46e6dc4bfd", size = 65004, upload-time = "2025-06-13T06:51:48.582Z" }, + { url = "https://files.pythonhosted.org/packages/7d/18/73dfa3e9d5d7450d39debde5b0d848139f7de23bd637a4506e36c9800fd6/msgpack-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:8b65b53204fe1bd037c40c4148d00ef918eb2108d24c9aaa20bc31f9810ce0a8", size = 71548, upload-time = "2025-06-13T06:51:49.558Z" }, + { url = "https://files.pythonhosted.org/packages/7f/83/97f24bf9848af23fe2ba04380388216defc49a8af6da0c28cc636d722502/msgpack-1.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:71ef05c1726884e44f8b1d1773604ab5d4d17729d8491403a705e649116c9558", size = 82728, upload-time = "2025-06-13T06:51:50.68Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/2eaa388267a78401f6e182662b08a588ef4f3de6f0eab1ec09736a7aaa2b/msgpack-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:36043272c6aede309d29d56851f8841ba907a1a3d04435e43e8a19928e243c1d", size = 79279, upload-time = "2025-06-13T06:51:51.72Z" }, + { url = "https://files.pythonhosted.org/packages/f8/46/31eb60f4452c96161e4dfd26dbca562b4ec68c72e4ad07d9566d7ea35e8a/msgpack-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a32747b1b39c3ac27d0670122b57e6e57f28eefb725e0b625618d1b59bf9d1e0", size = 423859, upload-time = "2025-06-13T06:51:52.749Z" }, + { url = "https://files.pythonhosted.org/packages/45/16/a20fa8c32825cc7ae8457fab45670c7a8996d7746ce80ce41cc51e3b2bd7/msgpack-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a8b10fdb84a43e50d38057b06901ec9da52baac6983d3f709d8507f3889d43f", size = 429975, upload-time = "2025-06-13T06:51:53.97Z" }, + { url = "https://files.pythonhosted.org/packages/86/ea/6c958e07692367feeb1a1594d35e22b62f7f476f3c568b002a5ea09d443d/msgpack-1.1.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba0c325c3f485dc54ec298d8b024e134acf07c10d494ffa24373bea729acf704", size = 413528, upload-time = "2025-06-13T06:51:55.507Z" }, + { url = "https://files.pythonhosted.org/packages/75/05/ac84063c5dae79722bda9f68b878dc31fc3059adb8633c79f1e82c2cd946/msgpack-1.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:88daaf7d146e48ec71212ce21109b66e06a98e5e44dca47d853cbfe171d6c8d2", size = 413338, upload-time = "2025-06-13T06:51:57.023Z" }, + { url = "https://files.pythonhosted.org/packages/69/e8/fe86b082c781d3e1c09ca0f4dacd457ede60a13119b6ce939efe2ea77b76/msgpack-1.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8b55ea20dc59b181d3f47103f113e6f28a5e1c89fd5b67b9140edb442ab67f2", size = 422658, upload-time = "2025-06-13T06:51:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2b/bafc9924df52d8f3bb7c00d24e57be477f4d0f967c0a31ef5e2225e035c7/msgpack-1.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a28e8072ae9779f20427af07f53bbb8b4aa81151054e882aee333b158da8752", size = 427124, upload-time = "2025-06-13T06:51:59.969Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3b/1f717e17e53e0ed0b68fa59e9188f3f610c79d7151f0e52ff3cd8eb6b2dc/msgpack-1.1.1-cp311-cp311-win32.whl", hash = "sha256:7da8831f9a0fdb526621ba09a281fadc58ea12701bc709e7b8cbc362feabc295", size = 65016, upload-time = "2025-06-13T06:52:01.294Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/9d1780768d3b249accecc5a38c725eb1e203d44a191f7b7ff1941f7df60c/msgpack-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5fd1b58e1431008a57247d6e7cc4faa41c3607e8e7d4aaf81f7c29ea013cb458", size = 72267, upload-time = "2025-06-13T06:52:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/389b9c593eda2b8551b2e7126ad3a06af6f9b44274eb3a4f054d48ff7e47/msgpack-1.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae497b11f4c21558d95de9f64fff7053544f4d1a17731c866143ed6bb4591238", size = 82359, upload-time = "2025-06-13T06:52:03.909Z" }, + { url = "https://files.pythonhosted.org/packages/ab/65/7d1de38c8a22cf8b1551469159d4b6cf49be2126adc2482de50976084d78/msgpack-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:33be9ab121df9b6b461ff91baac6f2731f83d9b27ed948c5b9d1978ae28bf157", size = 79172, upload-time = "2025-06-13T06:52:05.246Z" }, + { url = "https://files.pythonhosted.org/packages/0f/bd/cacf208b64d9577a62c74b677e1ada005caa9b69a05a599889d6fc2ab20a/msgpack-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f64ae8fe7ffba251fecb8408540c34ee9df1c26674c50c4544d72dbf792e5ce", size = 425013, upload-time = "2025-06-13T06:52:06.341Z" }, + { url = "https://files.pythonhosted.org/packages/4d/ec/fd869e2567cc9c01278a736cfd1697941ba0d4b81a43e0aa2e8d71dab208/msgpack-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a494554874691720ba5891c9b0b39474ba43ffb1aaf32a5dac874effb1619e1a", size = 426905, upload-time = "2025-06-13T06:52:07.501Z" }, + { url = "https://files.pythonhosted.org/packages/55/2a/35860f33229075bce803a5593d046d8b489d7ba2fc85701e714fc1aaf898/msgpack-1.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb643284ab0ed26f6957d969fe0dd8bb17beb567beb8998140b5e38a90974f6c", size = 407336, upload-time = "2025-06-13T06:52:09.047Z" }, + { url = "https://files.pythonhosted.org/packages/8c/16/69ed8f3ada150bf92745fb4921bd621fd2cdf5a42e25eb50bcc57a5328f0/msgpack-1.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d275a9e3c81b1093c060c3837e580c37f47c51eca031f7b5fb76f7b8470f5f9b", size = 409485, upload-time = "2025-06-13T06:52:10.382Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b6/0c398039e4c6d0b2e37c61d7e0e9d13439f91f780686deb8ee64ecf1ae71/msgpack-1.1.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fd6b577e4541676e0cc9ddc1709d25014d3ad9a66caa19962c4f5de30fc09ef", size = 412182, upload-time = "2025-06-13T06:52:11.644Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d0/0cf4a6ecb9bc960d624c93effaeaae75cbf00b3bc4a54f35c8507273cda1/msgpack-1.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb29aaa613c0a1c40d1af111abf025f1732cab333f96f285d6a93b934738a68a", size = 419883, upload-time = "2025-06-13T06:52:12.806Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/9697c211720fa71a2dfb632cad6196a8af3abea56eece220fde4674dc44b/msgpack-1.1.1-cp312-cp312-win32.whl", hash = "sha256:870b9a626280c86cff9c576ec0d9cbcc54a1e5ebda9cd26dab12baf41fee218c", size = 65406, upload-time = "2025-06-13T06:52:14.271Z" }, + { url = "https://files.pythonhosted.org/packages/c0/23/0abb886e80eab08f5e8c485d6f13924028602829f63b8f5fa25a06636628/msgpack-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:5692095123007180dca3e788bb4c399cc26626da51629a31d40207cb262e67f4", size = 72558, upload-time = "2025-06-13T06:52:15.252Z" }, + { url = "https://files.pythonhosted.org/packages/a1/38/561f01cf3577430b59b340b51329803d3a5bf6a45864a55f4ef308ac11e3/msgpack-1.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3765afa6bd4832fc11c3749be4ba4b69a0e8d7b728f78e68120a157a4c5d41f0", size = 81677, upload-time = "2025-06-13T06:52:16.64Z" }, + { url = "https://files.pythonhosted.org/packages/09/48/54a89579ea36b6ae0ee001cba8c61f776451fad3c9306cd80f5b5c55be87/msgpack-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8ddb2bcfd1a8b9e431c8d6f4f7db0773084e107730ecf3472f1dfe9ad583f3d9", size = 78603, upload-time = "2025-06-13T06:52:17.843Z" }, + { url = "https://files.pythonhosted.org/packages/a0/60/daba2699b308e95ae792cdc2ef092a38eb5ee422f9d2fbd4101526d8a210/msgpack-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:196a736f0526a03653d829d7d4c5500a97eea3648aebfd4b6743875f28aa2af8", size = 420504, upload-time = "2025-06-13T06:52:18.982Z" }, + { url = "https://files.pythonhosted.org/packages/20/22/2ebae7ae43cd8f2debc35c631172ddf14e2a87ffcc04cf43ff9df9fff0d3/msgpack-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d592d06e3cc2f537ceeeb23d38799c6ad83255289bb84c2e5792e5a8dea268a", size = 423749, upload-time = "2025-06-13T06:52:20.211Z" }, + { url = "https://files.pythonhosted.org/packages/40/1b/54c08dd5452427e1179a40b4b607e37e2664bca1c790c60c442c8e972e47/msgpack-1.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4df2311b0ce24f06ba253fda361f938dfecd7b961576f9be3f3fbd60e87130ac", size = 404458, upload-time = "2025-06-13T06:52:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/2e/60/6bb17e9ffb080616a51f09928fdd5cac1353c9becc6c4a8abd4e57269a16/msgpack-1.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4141c5a32b5e37905b5940aacbc59739f036930367d7acce7a64e4dec1f5e0b", size = 405976, upload-time = "2025-06-13T06:52:22.995Z" }, + { url = "https://files.pythonhosted.org/packages/ee/97/88983e266572e8707c1f4b99c8fd04f9eb97b43f2db40e3172d87d8642db/msgpack-1.1.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b1ce7f41670c5a69e1389420436f41385b1aa2504c3b0c30620764b15dded2e7", size = 408607, upload-time = "2025-06-13T06:52:24.152Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/36c78af2efaffcc15a5a61ae0df53a1d025f2680122e2a9eb8442fed3ae4/msgpack-1.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4147151acabb9caed4e474c3344181e91ff7a388b888f1e19ea04f7e73dc7ad5", size = 424172, upload-time = "2025-06-13T06:52:25.704Z" }, + { url = "https://files.pythonhosted.org/packages/8c/87/a75eb622b555708fe0427fab96056d39d4c9892b0c784b3a721088c7ee37/msgpack-1.1.1-cp313-cp313-win32.whl", hash = "sha256:500e85823a27d6d9bba1d057c871b4210c1dd6fb01fbb764e37e4e8847376323", size = 65347, upload-time = "2025-06-13T06:52:26.846Z" }, + { url = "https://files.pythonhosted.org/packages/ca/91/7dc28d5e2a11a5ad804cf2b7f7a5fcb1eb5a4966d66a5d2b41aee6376543/msgpack-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d489fba546295983abd142812bda76b57e33d0b9f5d5b71c09a583285506f69", size = 72341, upload-time = "2025-06-13T06:52:27.835Z" }, + { url = "https://files.pythonhosted.org/packages/1f/bd/0792be119d7fe7dc2148689ef65c90507d82d20a204aab3b98c74a1f8684/msgpack-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5be6b6bc52fad84d010cb45433720327ce886009d862f46b26d4d154001994b", size = 81882, upload-time = "2025-06-13T06:52:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/ce06c8e26a816ae8730a8e030d263c5289adcaff9f0476f9b270bdd7c5c2/msgpack-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3a89cd8c087ea67e64844287ea52888239cbd2940884eafd2dcd25754fb72232", size = 78414, upload-time = "2025-06-13T06:52:40.341Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/190576c497677fb4a0d05d896b24aea6cdccd910f206aaa7b511901befed/msgpack-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d75f3807a9900a7d575d8d6674a3a47e9f227e8716256f35bc6f03fc597ffbf", size = 400927, upload-time = "2025-06-13T06:52:41.399Z" }, + { url = "https://files.pythonhosted.org/packages/ed/af/6a0aa5a06762e70726ec3c10fb966600d84a7220b52635cb0ab2dc64d32f/msgpack-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d182dac0221eb8faef2e6f44701812b467c02674a322c739355c39e94730cdbf", size = 405903, upload-time = "2025-06-13T06:52:42.699Z" }, + { url = "https://files.pythonhosted.org/packages/1e/80/3f3da358cecbbe8eb12360814bd1277d59d2608485934742a074d99894a9/msgpack-1.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b13fe0fb4aac1aa5320cd693b297fe6fdef0e7bea5518cbc2dd5299f873ae90", size = 393192, upload-time = "2025-06-13T06:52:43.986Z" }, + { url = "https://files.pythonhosted.org/packages/98/c6/3a0ec7fdebbb4f3f8f254696cd91d491c29c501dbebd86286c17e8f68cd7/msgpack-1.1.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:435807eeb1bc791ceb3247d13c79868deb22184e1fc4224808750f0d7d1affc1", size = 393851, upload-time = "2025-06-13T06:52:45.177Z" }, + { url = "https://files.pythonhosted.org/packages/39/37/df50d5f8e68514b60fbe70f6e8337ea2b32ae2be030871bcd9d1cf7d4b62/msgpack-1.1.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4835d17af722609a45e16037bb1d4d78b7bdf19d6c0128116d178956618c4e88", size = 400292, upload-time = "2025-06-13T06:52:46.381Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ec/1e067292e02d2ceb4c8cb5ba222c4f7bb28730eef5676740609dc2627e0f/msgpack-1.1.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8ef6e342c137888ebbfb233e02b8fbd689bb5b5fcc59b34711ac47ebd504478", size = 401873, upload-time = "2025-06-13T06:52:47.957Z" }, + { url = "https://files.pythonhosted.org/packages/d3/31/e8c9c6b5b58d64c9efa99c8d181fcc25f38ead357b0360379fbc8a4234ad/msgpack-1.1.1-cp39-cp39-win32.whl", hash = "sha256:61abccf9de335d9efd149e2fff97ed5974f2481b3353772e8e2dd3402ba2bd57", size = 65028, upload-time = "2025-06-13T06:52:49.166Z" }, + { url = "https://files.pythonhosted.org/packages/20/d6/cd62cded572e5e25892747a5d27850170bcd03c855e9c69c538e024de6f9/msgpack-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:40eae974c873b2992fd36424a5d9407f93e97656d999f43fca9d29f820899084", size = 71700, upload-time = "2025-06-13T06:52:50.244Z" }, +] + +[[package]] +name = "msgspec" +version = "0.19.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/9b/95d8ce458462b8b71b8a70fa94563b2498b89933689f3a7b8911edfae3d7/msgspec-0.19.0.tar.gz", hash = "sha256:604037e7cd475345848116e89c553aa9a233259733ab51986ac924ab1b976f8e", size = 216934, upload-time = "2024-12-27T17:40:28.597Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/40/817282b42f58399762267b30deb8ac011d8db373f8da0c212c85fbe62b8f/msgspec-0.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d8dd848ee7ca7c8153462557655570156c2be94e79acec3561cf379581343259", size = 190019, upload-time = "2024-12-27T17:39:13.803Z" }, + { url = "https://files.pythonhosted.org/packages/92/99/bd7ed738c00f223a8119928661167a89124140792af18af513e6519b0d54/msgspec-0.19.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0553bbc77662e5708fe66aa75e7bd3e4b0f209709c48b299afd791d711a93c36", size = 183680, upload-time = "2024-12-27T17:39:17.847Z" }, + { url = "https://files.pythonhosted.org/packages/e5/27/322badde18eb234e36d4a14122b89edd4e2973cdbc3da61ca7edf40a1ccd/msgspec-0.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe2c4bf29bf4e89790b3117470dea2c20b59932772483082c468b990d45fb947", size = 209334, upload-time = "2024-12-27T17:39:19.065Z" }, + { url = "https://files.pythonhosted.org/packages/c6/65/080509c5774a1592b2779d902a70b5fe008532759927e011f068145a16cb/msgspec-0.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e87ecfa9795ee5214861eab8326b0e75475c2e68a384002aa135ea2a27d909", size = 211551, upload-time = "2024-12-27T17:39:21.767Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/1c23c6b4ca6f4285c30a39def1054e2bee281389e4b681b5e3711bd5a8c9/msgspec-0.19.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3c4ec642689da44618f68c90855a10edbc6ac3ff7c1d94395446c65a776e712a", size = 215099, upload-time = "2024-12-27T17:39:24.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/fe/95f9654518879f3359d1e76bc41189113aa9102452170ab7c9a9a4ee52f6/msgspec-0.19.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2719647625320b60e2d8af06b35f5b12d4f4d281db30a15a1df22adb2295f633", size = 218211, upload-time = "2024-12-27T17:39:27.396Z" }, + { url = "https://files.pythonhosted.org/packages/79/f6/71ca7e87a1fb34dfe5efea8156c9ef59dd55613aeda2ca562f122cd22012/msgspec-0.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:695b832d0091edd86eeb535cd39e45f3919f48d997685f7ac31acb15e0a2ed90", size = 186174, upload-time = "2024-12-27T17:39:29.647Z" }, + { url = "https://files.pythonhosted.org/packages/24/d4/2ec2567ac30dab072cce3e91fb17803c52f0a37aab6b0c24375d2b20a581/msgspec-0.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa77046904db764b0462036bc63ef71f02b75b8f72e9c9dd4c447d6da1ed8f8e", size = 187939, upload-time = "2024-12-27T17:39:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/18226e4328897f4f19875cb62bb9259fe47e901eade9d9376ab5f251a929/msgspec-0.19.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:047cfa8675eb3bad68722cfe95c60e7afabf84d1bd8938979dd2b92e9e4a9551", size = 182202, upload-time = "2024-12-27T17:39:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/81/25/3a4b24d468203d8af90d1d351b77ea3cffb96b29492855cf83078f16bfe4/msgspec-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e78f46ff39a427e10b4a61614a2777ad69559cc8d603a7c05681f5a595ea98f7", size = 209029, upload-time = "2024-12-27T17:39:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/85/2e/db7e189b57901955239f7689b5dcd6ae9458637a9c66747326726c650523/msgspec-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c7adf191e4bd3be0e9231c3b6dc20cf1199ada2af523885efc2ed218eafd011", size = 210682, upload-time = "2024-12-27T17:39:36.384Z" }, + { url = "https://files.pythonhosted.org/packages/03/97/7c8895c9074a97052d7e4a1cc1230b7b6e2ca2486714eb12c3f08bb9d284/msgspec-0.19.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f04cad4385e20be7c7176bb8ae3dca54a08e9756cfc97bcdb4f18560c3042063", size = 214003, upload-time = "2024-12-27T17:39:39.097Z" }, + { url = "https://files.pythonhosted.org/packages/61/61/e892997bcaa289559b4d5869f066a8021b79f4bf8e955f831b095f47a4cd/msgspec-0.19.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45c8fb410670b3b7eb884d44a75589377c341ec1392b778311acdbfa55187716", size = 216833, upload-time = "2024-12-27T17:39:41.203Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3d/71b2dffd3a1c743ffe13296ff701ee503feaebc3f04d0e75613b6563c374/msgspec-0.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:70eaef4934b87193a27d802534dc466778ad8d536e296ae2f9334e182ac27b6c", size = 186184, upload-time = "2024-12-27T17:39:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5f/a70c24f075e3e7af2fae5414c7048b0e11389685b7f717bb55ba282a34a7/msgspec-0.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f98bd8962ad549c27d63845b50af3f53ec468b6318400c9f1adfe8b092d7b62f", size = 190485, upload-time = "2024-12-27T17:39:44.974Z" }, + { url = "https://files.pythonhosted.org/packages/89/b0/1b9763938cfae12acf14b682fcf05c92855974d921a5a985ecc197d1c672/msgspec-0.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:43bbb237feab761b815ed9df43b266114203f53596f9b6e6f00ebd79d178cdf2", size = 183910, upload-time = "2024-12-27T17:39:46.401Z" }, + { url = "https://files.pythonhosted.org/packages/87/81/0c8c93f0b92c97e326b279795f9c5b956c5a97af28ca0fbb9fd86c83737a/msgspec-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cfc033c02c3e0aec52b71710d7f84cb3ca5eb407ab2ad23d75631153fdb1f12", size = 210633, upload-time = "2024-12-27T17:39:49.099Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/c5422ce8af73928d194a6606f8ae36e93a52fd5e8df5abd366903a5ca8da/msgspec-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d911c442571605e17658ca2b416fd8579c5050ac9adc5e00c2cb3126c97f73bc", size = 213594, upload-time = "2024-12-27T17:39:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/19/2b/4137bc2ed45660444842d042be2cf5b18aa06efd2cda107cff18253b9653/msgspec-0.19.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:757b501fa57e24896cf40a831442b19a864f56d253679f34f260dcb002524a6c", size = 214053, upload-time = "2024-12-27T17:39:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e6/8ad51bdc806aac1dc501e8fe43f759f9ed7284043d722b53323ea421c360/msgspec-0.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5f0f65f29b45e2816d8bded36e6b837a4bf5fb60ec4bc3c625fa2c6da4124537", size = 219081, upload-time = "2024-12-27T17:39:55.142Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ef/27dd35a7049c9a4f4211c6cd6a8c9db0a50647546f003a5867827ec45391/msgspec-0.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:067f0de1c33cfa0b6a8206562efdf6be5985b988b53dd244a8e06f993f27c8c0", size = 187467, upload-time = "2024-12-27T17:39:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/3c/cb/2842c312bbe618d8fefc8b9cedce37f773cdc8fa453306546dba2c21fd98/msgspec-0.19.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f12d30dd6266557aaaf0aa0f9580a9a8fbeadfa83699c487713e355ec5f0bd86", size = 190498, upload-time = "2024-12-27T17:40:00.427Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/c40b01b93465e1a5f3b6c7d91b10fb574818163740cc3acbe722d1e0e7e4/msgspec-0.19.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82b2c42c1b9ebc89e822e7e13bbe9d17ede0c23c187469fdd9505afd5a481314", size = 183950, upload-time = "2024-12-27T17:40:04.219Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f0/5b764e066ce9aba4b70d1db8b087ea66098c7c27d59b9dd8a3532774d48f/msgspec-0.19.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19746b50be214a54239aab822964f2ac81e38b0055cca94808359d779338c10e", size = 210647, upload-time = "2024-12-27T17:40:05.606Z" }, + { url = "https://files.pythonhosted.org/packages/9d/87/bc14f49bc95c4cb0dd0a8c56028a67c014ee7e6818ccdce74a4862af259b/msgspec-0.19.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60ef4bdb0ec8e4ad62e5a1f95230c08efb1f64f32e6e8dd2ced685bcc73858b5", size = 213563, upload-time = "2024-12-27T17:40:10.516Z" }, + { url = "https://files.pythonhosted.org/packages/53/2f/2b1c2b056894fbaa975f68f81e3014bb447516a8b010f1bed3fb0e016ed7/msgspec-0.19.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac7f7c377c122b649f7545810c6cd1b47586e3aa3059126ce3516ac7ccc6a6a9", size = 213996, upload-time = "2024-12-27T17:40:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5a/4cd408d90d1417e8d2ce6a22b98a6853c1b4d7cb7669153e4424d60087f6/msgspec-0.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5bc1472223a643f5ffb5bf46ccdede7f9795078194f14edd69e3aab7020d327", size = 219087, upload-time = "2024-12-27T17:40:14.881Z" }, + { url = "https://files.pythonhosted.org/packages/23/d8/f15b40611c2d5753d1abb0ca0da0c75348daf1252220e5dda2867bd81062/msgspec-0.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:317050bc0f7739cb30d257ff09152ca309bf5a369854bbf1e57dffc310c1f20f", size = 187432, upload-time = "2024-12-27T17:40:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d0/323f867eaec1f2236ba30adf613777b1c97a7e8698e2e881656b21871fa4/msgspec-0.19.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15c1e86fff77184c20a2932cd9742bf33fe23125fa3fcf332df9ad2f7d483044", size = 189926, upload-time = "2024-12-27T17:40:18.939Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/c3e1b39bdae90a7258d77959f5f5e36ad44b40e2be91cff83eea33c54d43/msgspec-0.19.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3b5541b2b3294e5ffabe31a09d604e23a88533ace36ac288fa32a420aa38d229", size = 183873, upload-time = "2024-12-27T17:40:20.214Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a2/48f2c15c7644668e51f4dce99d5f709bd55314e47acb02e90682f5880f35/msgspec-0.19.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f5c043ace7962ef188746e83b99faaa9e3e699ab857ca3f367b309c8e2c6b12", size = 209272, upload-time = "2024-12-27T17:40:21.534Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/aa339cf08b990c3f07e67b229a3a8aa31bf129ed974b35e5daa0df7d9d56/msgspec-0.19.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca06aa08e39bf57e39a258e1996474f84d0dd8130d486c00bec26d797b8c5446", size = 211396, upload-time = "2024-12-27T17:40:22.897Z" }, + { url = "https://files.pythonhosted.org/packages/c7/00/c7fb9d524327c558b2803973cc3f988c5100a1708879970a9e377bdf6f4f/msgspec-0.19.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e695dad6897896e9384cf5e2687d9ae9feaef50e802f93602d35458e20d1fb19", size = 215002, upload-time = "2024-12-27T17:40:24.341Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bf/d9f9fff026c1248cde84a5ce62b3742e8a63a3c4e811f99f00c8babf7615/msgspec-0.19.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3be5c02e1fee57b54130316a08fe40cca53af92999a302a6054cd451700ea7db", size = 218132, upload-time = "2024-12-27T17:40:25.744Z" }, + { url = "https://files.pythonhosted.org/packages/00/03/b92011210f79794958167a3a3ea64a71135d9a2034cfb7597b545a42606d/msgspec-0.19.0-cp39-cp39-win_amd64.whl", hash = "sha256:0684573a821be3c749912acf5848cce78af4298345cb2d7a8b8948a0a5a27cfe", size = 186301, upload-time = "2024-12-27T17:40:27.076Z" }, +] + +[[package]] +name = "multidict" +version = "6.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b5/59f27b4ce9951a4bce56b88ba5ff5159486797ab18863f2b4c1c5e8465bd/multidict-6.5.0.tar.gz", hash = "sha256:942bd8002492ba819426a8d7aefde3189c1b87099cdf18aaaefefcf7f3f7b6d2", size = 98512, upload-time = "2025-06-17T14:15:56.556Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/88/f8354ef1cb1121234c3461ff3d11eac5f4fe115f00552d3376306275c9ab/multidict-6.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e118a202904623b1d2606d1c8614e14c9444b59d64454b0c355044058066469", size = 73858, upload-time = "2025-06-17T14:13:21.451Z" }, + { url = "https://files.pythonhosted.org/packages/49/04/634b49c7abe71bd1c61affaeaa0c2a46b6be8d599a07b495259615dbdfe0/multidict-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a42995bdcaff4e22cb1280ae7752c3ed3fbb398090c6991a2797a4a0e5ed16a9", size = 43186, upload-time = "2025-06-17T14:13:23.615Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ff/091ff4830ec8f96378578bfffa7f324a9dd16f60274cec861ae65ba10be3/multidict-6.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2261b538145723ca776e55208640fffd7ee78184d223f37c2b40b9edfe0e818a", size = 43031, upload-time = "2025-06-17T14:13:24.725Z" }, + { url = "https://files.pythonhosted.org/packages/10/c1/1b4137845f8b8dbc2332af54e2d7761c6a29c2c33c8d47a0c8c70676bac1/multidict-6.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e5b19f8cd67235fab3e195ca389490415d9fef5a315b1fa6f332925dc924262", size = 233588, upload-time = "2025-06-17T14:13:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/c3/77/cbe9a1f58c6d4f822663788e414637f256a872bc352cedbaf7717b62db58/multidict-6.5.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:177b081e4dec67c3320b16b3aa0babc178bbf758553085669382c7ec711e1ec8", size = 222714, upload-time = "2025-06-17T14:13:27.482Z" }, + { url = "https://files.pythonhosted.org/packages/6c/37/39e1142c2916973818515adc13bbdb68d3d8126935e3855200e059a79bab/multidict-6.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d30a2cc106a7d116b52ee046207614db42380b62e6b1dd2a50eba47c5ca5eb1", size = 242741, upload-time = "2025-06-17T14:13:28.92Z" }, + { url = "https://files.pythonhosted.org/packages/a3/aa/60c3ef0c87ccad3445bf01926a1b8235ee24c3dde483faef1079cc91706d/multidict-6.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a72933bc308d7a64de37f0d51795dbeaceebdfb75454f89035cdfc6a74cfd129", size = 235008, upload-time = "2025-06-17T14:13:30.587Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5e/f7e0fd5f5b8a7b9a75b0f5642ca6b6dde90116266920d8cf63b513f3908b/multidict-6.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96d109e663d032280ef8ef62b50924b2e887d5ddf19e301844a6cb7e91a172a6", size = 226627, upload-time = "2025-06-17T14:13:31.831Z" }, + { url = "https://files.pythonhosted.org/packages/b7/74/1bc0a3c6a9105051f68a6991fe235d7358836e81058728c24d5bbdd017cb/multidict-6.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b555329c9894332401f03b9a87016f0b707b6fccd4706793ec43b4a639e75869", size = 228232, upload-time = "2025-06-17T14:13:33.402Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/37118291cdc31f4cc680d54047cdea9b520e9a724a643919f71f8c2a2aeb/multidict-6.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6994bad9d471ef2156f2b6850b51e20ee409c6b9deebc0e57be096be9faffdce", size = 246616, upload-time = "2025-06-17T14:13:34.964Z" }, + { url = "https://files.pythonhosted.org/packages/ff/89/e2c08d6bdb21a1a55be4285510d058ace5f5acabe6b57900432e863d4c70/multidict-6.5.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:b15f817276c96cde9060569023808eec966bd8da56a97e6aa8116f34ddab6534", size = 235007, upload-time = "2025-06-17T14:13:36.428Z" }, + { url = "https://files.pythonhosted.org/packages/89/1e/e39a98e8e1477ec7a871b3c17265658fbe6d617048059ae7fa5011b224f3/multidict-6.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b4bf507c991db535a935b2127cf057a58dbc688c9f309c72080795c63e796f58", size = 244824, upload-time = "2025-06-17T14:13:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ba/63e11edd45c31e708c5a1904aa7ac4de01e13135a04cfe96bc71eb359b85/multidict-6.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:60c3f8f13d443426c55f88cf3172547bbc600a86d57fd565458b9259239a6737", size = 257229, upload-time = "2025-06-17T14:13:39.554Z" }, + { url = "https://files.pythonhosted.org/packages/0f/00/bdcceb6af424936adfc8b92a79d3a95863585f380071393934f10a63f9e3/multidict-6.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a10227168a24420c158747fc201d4279aa9af1671f287371597e2b4f2ff21879", size = 247118, upload-time = "2025-06-17T14:13:40.795Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a0/4aa79e991909cca36ca821a9ba5e8e81e4cd5b887c81f89ded994e0f49df/multidict-6.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e3b1425fe54ccfde66b8cfb25d02be34d5dfd2261a71561ffd887ef4088b4b69", size = 243948, upload-time = "2025-06-17T14:13:42.477Z" }, + { url = "https://files.pythonhosted.org/packages/21/8b/e45e19ce43afb31ff6b0fd5d5816b4fcc1fcc2f37e8a82aefae06c40c7a6/multidict-6.5.0-cp310-cp310-win32.whl", hash = "sha256:b4e47ef51237841d1087e1e1548071a6ef22e27ed0400c272174fa585277c4b4", size = 40433, upload-time = "2025-06-17T14:13:43.972Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6e/96e0ba4601343d9344e69503fca072ace19c35f7d4ca3d68401e59acdc8f/multidict-6.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:63b3b24fadc7067282c88fae5b2f366d5b3a7c15c021c2838de8c65a50eeefb4", size = 44423, upload-time = "2025-06-17T14:13:44.991Z" }, + { url = "https://files.pythonhosted.org/packages/eb/4a/9befa919d7a390f13a5511a69282b7437782071160c566de6e0ebf712c9f/multidict-6.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:8b2d61afbafc679b7eaf08e9de4fa5d38bd5dc7a9c0a577c9f9588fb49f02dbb", size = 41481, upload-time = "2025-06-17T14:13:49.389Z" }, + { url = "https://files.pythonhosted.org/packages/75/ba/484f8e96ee58ec4fef42650eb9dbbedb24f9bc155780888398a4725d2270/multidict-6.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8b4bf6bb15a05796a07a248084e3e46e032860c899c7a9b981030e61368dba95", size = 73283, upload-time = "2025-06-17T14:13:50.406Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/01d62ea6199d76934c87746695b3ed16aeedfdd564e8d89184577037baac/multidict-6.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46bb05d50219655c42a4b8fcda9c7ee658a09adbb719c48e65a20284e36328ea", size = 42937, upload-time = "2025-06-17T14:13:51.45Z" }, + { url = "https://files.pythonhosted.org/packages/da/cf/bb462d920f26d9e2e0aff8a78aeb06af1225b826e9a5468870c57591910a/multidict-6.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:54f524d73f4d54e87e03c98f6af601af4777e4668a52b1bd2ae0a4d6fc7b392b", size = 42748, upload-time = "2025-06-17T14:13:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b1/d5c11ea0fdad68d3ed45f0e2527de6496d2fac8afe6b8ca6d407c20ad00f/multidict-6.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529b03600466480ecc502000d62e54f185a884ed4570dee90d9a273ee80e37b5", size = 236448, upload-time = "2025-06-17T14:13:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/69/c3ceb264994f5b338c812911a8d660084f37779daef298fc30bd817f75c7/multidict-6.5.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69ad681ad7c93a41ee7005cc83a144b5b34a3838bcf7261e2b5356057b0f78de", size = 228695, upload-time = "2025-06-17T14:13:54.775Z" }, + { url = "https://files.pythonhosted.org/packages/81/3d/c23dcc0d34a35ad29974184db2878021d28fe170ecb9192be6bfee73f1f2/multidict-6.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fe9fada8bc0839466b09fa3f6894f003137942984843ec0c3848846329a36ae", size = 247434, upload-time = "2025-06-17T14:13:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/06/b3/06cf7a049129ff52525a859277abb5648e61d7afae7fb7ed02e3806be34e/multidict-6.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f94c6ea6405fcf81baef1e459b209a78cda5442e61b5b7a57ede39d99b5204a0", size = 239431, upload-time = "2025-06-17T14:13:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/b2fe2fafa23af0c6123aebe23b4cd23fdad01dfe7009bb85624e4636d0dd/multidict-6.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84ca75ad8a39ed75f079a8931435a5b51ee4c45d9b32e1740f99969a5d1cc2ee", size = 231542, upload-time = "2025-06-17T14:13:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c9/a52ca0a342a02411a31b6af197a6428a5137d805293f10946eeab614ec06/multidict-6.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be4c08f3a2a6cc42b414496017928d95898964fed84b1b2dace0c9ee763061f9", size = 233069, upload-time = "2025-06-17T14:13:59.834Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/a3328a3929b8e131e2678d5e65f552b0a6874fab62123e31f5a5625650b0/multidict-6.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:046a7540cfbb4d5dc846a1fd9843f3ba980c6523f2e0c5b8622b4a5c94138ae6", size = 250596, upload-time = "2025-06-17T14:14:01.178Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b8/aa3905a38a8287013aeb0a54c73f79ccd8b32d2f1d53e5934643a36502c2/multidict-6.5.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:64306121171d988af77d74be0d8c73ee1a69cf6f96aea7fa6030c88f32a152dd", size = 237858, upload-time = "2025-06-17T14:14:03.232Z" }, + { url = "https://files.pythonhosted.org/packages/d3/eb/f11d5af028014f402e5dd01ece74533964fa4e7bfae4af4824506fa8c398/multidict-6.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b4ac1dd5eb0ecf6f7351d5a9137f30a83f7182209c5d37f61614dfdce5714853", size = 249175, upload-time = "2025-06-17T14:14:04.561Z" }, + { url = "https://files.pythonhosted.org/packages/ac/57/d451905a62e5ef489cb4f92e8190d34ac5329427512afd7f893121da4e96/multidict-6.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bab4a8337235365f4111a7011a1f028826ca683834ebd12de4b85e2844359c36", size = 259532, upload-time = "2025-06-17T14:14:05.798Z" }, + { url = "https://files.pythonhosted.org/packages/d3/90/ff82b5ac5cabe3c79c50cf62a62f3837905aa717e67b6b4b7872804f23c8/multidict-6.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a05b5604c5a75df14a63eeeca598d11b2c3745b9008539b70826ea044063a572", size = 250554, upload-time = "2025-06-17T14:14:07.382Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5a/0cabc50d4bc16e61d8b0a8a74499a1409fa7b4ef32970b7662a423781fc7/multidict-6.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67c4a640952371c9ca65b6a710598be246ef3be5ca83ed38c16a7660d3980877", size = 248159, upload-time = "2025-06-17T14:14:08.65Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1d/adeabae0771544f140d9f42ab2c46eaf54e793325999c36106078b7f6600/multidict-6.5.0-cp311-cp311-win32.whl", hash = "sha256:fdeae096ca36c12d8aca2640b8407a9d94e961372c68435bef14e31cce726138", size = 40357, upload-time = "2025-06-17T14:14:09.91Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fe/bbd85ae65c96de5c9910c332ee1f4b7be0bf0fb21563895167bcb6502a1f/multidict-6.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:e2977ef8b7ce27723ee8c610d1bd1765da4f3fbe5a64f9bf1fd3b4770e31fbc0", size = 44432, upload-time = "2025-06-17T14:14:11.013Z" }, + { url = "https://files.pythonhosted.org/packages/96/af/f9052d9c4e65195b210da9f7afdea06d3b7592b3221cc0ef1b407f762faa/multidict-6.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:82d0cf0ea49bae43d9e8c3851e21954eff716259ff42da401b668744d1760bcb", size = 41408, upload-time = "2025-06-17T14:14:12.112Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fa/18f4950e00924f7e84c8195f4fc303295e14df23f713d64e778b8fa8b903/multidict-6.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1bb986c8ea9d49947bc325c51eced1ada6d8d9b4c5b15fd3fcdc3c93edef5a74", size = 73474, upload-time = "2025-06-17T14:14:13.528Z" }, + { url = "https://files.pythonhosted.org/packages/6c/66/0392a2a8948bccff57e4793c9dde3e5c088f01e8b7f8867ee58a2f187fc5/multidict-6.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:03c0923da300120830fc467e23805d63bbb4e98b94032bd863bc7797ea5fa653", size = 43741, upload-time = "2025-06-17T14:14:15.188Z" }, + { url = "https://files.pythonhosted.org/packages/98/3e/f48487c91b2a070566cfbab876d7e1ebe7deb0a8002e4e896a97998ae066/multidict-6.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4c78d5ec00fdd35c91680ab5cf58368faad4bd1a8721f87127326270248de9bc", size = 42143, upload-time = "2025-06-17T14:14:16.612Z" }, + { url = "https://files.pythonhosted.org/packages/3f/49/439c6cc1cd00365cf561bdd3579cc3fa1a0d38effb3a59b8d9562839197f/multidict-6.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aadc3cb78be90a887f8f6b73945b840da44b4a483d1c9750459ae69687940c97", size = 239303, upload-time = "2025-06-17T14:14:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/c4/24/491786269e90081cb536e4d7429508725bc92ece176d1204a4449de7c41c/multidict-6.5.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b02e1ca495d71e07e652e4cef91adae3bf7ae4493507a263f56e617de65dafc", size = 236913, upload-time = "2025-06-17T14:14:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/e8/76/bbe2558b820ebeca8a317ab034541790e8160ca4b1e450415383ac69b339/multidict-6.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7fe92a62326eef351668eec4e2dfc494927764a0840a1895cff16707fceffcd3", size = 250752, upload-time = "2025-06-17T14:14:20.297Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e3/3977f2c1123f553ceff9f53cd4de04be2c1912333c6fabbcd51531655476/multidict-6.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7673ee4f63879ecd526488deb1989041abcb101b2d30a9165e1e90c489f3f7fb", size = 243937, upload-time = "2025-06-17T14:14:21.935Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b8/7a6e9c13c79709cdd2f22ee849f058e6da76892d141a67acc0e6c30d845c/multidict-6.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa097ae2a29f573de7e2d86620cbdda5676d27772d4ed2669cfa9961a0d73955", size = 237419, upload-time = "2025-06-17T14:14:23.215Z" }, + { url = "https://files.pythonhosted.org/packages/84/9d/8557f5e88da71bc7e7a8ace1ada4c28197f3bfdc2dd6e51d3b88f2e16e8e/multidict-6.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:300da0fa4f8457d9c4bd579695496116563409e676ac79b5e4dca18e49d1c308", size = 237222, upload-time = "2025-06-17T14:14:24.516Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3b/8f023ad60e7969cb6bc0683738d0e1618f5ff5723d6d2d7818dc6df6ad3d/multidict-6.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9a19bd108c35877b57393243d392d024cfbfdefe759fd137abb98f6fc910b64c", size = 247861, upload-time = "2025-06-17T14:14:25.839Z" }, + { url = "https://files.pythonhosted.org/packages/af/1c/9cf5a099ce7e3189906cf5daa72c44ee962dcb4c1983659f3a6f8a7446ab/multidict-6.5.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f32a1777465a35c35ddbbd7fc1293077938a69402fcc59e40b2846d04a120dd", size = 243917, upload-time = "2025-06-17T14:14:27.164Z" }, + { url = "https://files.pythonhosted.org/packages/6c/bb/88ee66ebeef56868044bac58feb1cc25658bff27b20e3cfc464edc181287/multidict-6.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9cc1e10c14ce8112d1e6d8971fe3cdbe13e314f68bea0e727429249d4a6ce164", size = 249214, upload-time = "2025-06-17T14:14:28.795Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/a90e88cc4a1309f33088ab1cdd5c0487718f49dfb82c5ffc845bb17c1973/multidict-6.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e95c5e07a06594bdc288117ca90e89156aee8cb2d7c330b920d9c3dd19c05414", size = 258682, upload-time = "2025-06-17T14:14:30.066Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/16dd69a6811920a31f4e06114ebe67b1cd922c8b05c9c82b050706d0b6fe/multidict-6.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:40ff26f58323795f5cd2855e2718a1720a1123fb90df4553426f0efd76135462", size = 254254, upload-time = "2025-06-17T14:14:31.323Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a8/90193a5f5ca1bdbf92633d69a25a2ef9bcac7b412b8d48c84d01a2732518/multidict-6.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76803a29fd71869a8b59c2118c9dcfb3b8f9c8723e2cce6baeb20705459505cf", size = 247741, upload-time = "2025-06-17T14:14:32.717Z" }, + { url = "https://files.pythonhosted.org/packages/cd/43/29c7a747153c05b41d1f67455426af39ed88d6de3f21c232b8f2724bde13/multidict-6.5.0-cp312-cp312-win32.whl", hash = "sha256:df7ecbc65a53a2ce1b3a0c82e6ad1a43dcfe7c6137733f9176a92516b9f5b851", size = 41049, upload-time = "2025-06-17T14:14:33.941Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e8/8f3fc32b7e901f3a2719764d64aeaf6ae77b4ba961f1c3a3cf3867766636/multidict-6.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ec1c3fbbb0b655a6540bce408f48b9a7474fd94ed657dcd2e890671fefa7743", size = 44700, upload-time = "2025-06-17T14:14:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/24/e4/e250806adc98d524d41e69c8d4a42bc3513464adb88cb96224df12928617/multidict-6.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:2d24a00d34808b22c1f15902899b9d82d0faeca9f56281641c791d8605eacd35", size = 41703, upload-time = "2025-06-17T14:14:36.168Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c9/092c4e9402b6d16de761cff88cb842a5c8cc50ccecaf9c4481ba53264b9e/multidict-6.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:53d92df1752df67a928fa7f884aa51edae6f1cf00eeb38cbcf318cf841c17456", size = 73486, upload-time = "2025-06-17T14:14:37.238Z" }, + { url = "https://files.pythonhosted.org/packages/08/f9/6f7ddb8213f5fdf4db48d1d640b78e8aef89b63a5de8a2313286db709250/multidict-6.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:680210de2c38eef17ce46b8df8bf2c1ece489261a14a6e43c997d49843a27c99", size = 43745, upload-time = "2025-06-17T14:14:38.32Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a7/b9be0163bfeee3bb08a77a1705e24eb7e651d594ea554107fac8a1ca6a4d/multidict-6.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e279259bcb936732bfa1a8eec82b5d2352b3df69d2fa90d25808cfc403cee90a", size = 42135, upload-time = "2025-06-17T14:14:39.897Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/93c8203f943a417bda3c573a34d5db0cf733afdfffb0ca78545c7716dbd8/multidict-6.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1c185fc1069781e3fc8b622c4331fb3b433979850392daa5efbb97f7f9959bb", size = 238585, upload-time = "2025-06-17T14:14:41.332Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fe/2582b56a1807604774f566eeef183b0d6b148f4b89d1612cd077567b2e1e/multidict-6.5.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6bb5f65ff91daf19ce97f48f63585e51595539a8a523258b34f7cef2ec7e0617", size = 236174, upload-time = "2025-06-17T14:14:42.602Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c4/d8b66d42d385bd4f974cbd1eaa8b265e6b8d297249009f312081d5ded5c7/multidict-6.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8646b4259450c59b9286db280dd57745897897284f6308edbdf437166d93855", size = 250145, upload-time = "2025-06-17T14:14:43.944Z" }, + { url = "https://files.pythonhosted.org/packages/bc/64/62feda5093ee852426aae3df86fab079f8bf1cdbe403e1078c94672ad3ec/multidict-6.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d245973d4ecc04eea0a8e5ebec7882cf515480036e1b48e65dffcfbdf86d00be", size = 243470, upload-time = "2025-06-17T14:14:45.343Z" }, + { url = "https://files.pythonhosted.org/packages/67/dc/9f6fa6e854625cf289c0e9f4464b40212a01f76b2f3edfe89b6779b4fb93/multidict-6.5.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a133e7ddc9bc7fb053733d0ff697ce78c7bf39b5aec4ac12857b6116324c8d75", size = 236968, upload-time = "2025-06-17T14:14:46.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/ae/4b81c6e3745faee81a156f3f87402315bdccf04236f75c03e37be19c94ff/multidict-6.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80d696fa38d738fcebfd53eec4d2e3aeb86a67679fd5e53c325756682f152826", size = 236575, upload-time = "2025-06-17T14:14:47.929Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fa/4089d7642ea344226e1bfab60dd588761d4791754f8072e911836a39bedf/multidict-6.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:20d30c9410ac3908abbaa52ee5967a754c62142043cf2ba091e39681bd51d21a", size = 247632, upload-time = "2025-06-17T14:14:49.525Z" }, + { url = "https://files.pythonhosted.org/packages/16/ee/a353dac797de0f28fb7f078cc181c5f2eefe8dd16aa11a7100cbdc234037/multidict-6.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6c65068cc026f217e815fa519d8e959a7188e94ec163ffa029c94ca3ef9d4a73", size = 243520, upload-time = "2025-06-17T14:14:50.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/ec/560deb3d2d95822d6eb1bcb1f1cb728f8f0197ec25be7c936d5d6a5d133c/multidict-6.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e355ac668a8c3e49c2ca8daa4c92f0ad5b705d26da3d5af6f7d971e46c096da7", size = 248551, upload-time = "2025-06-17T14:14:52.229Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/ddf277e67c78205f6695f2a7639be459bca9cc353b962fd8085a492a262f/multidict-6.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08db204213d0375a91a381cae0677ab95dd8c67a465eb370549daf6dbbf8ba10", size = 258362, upload-time = "2025-06-17T14:14:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/02/fc/d64ee1df9b87c5210f2d4c419cab07f28589c81b4e5711eda05a122d0614/multidict-6.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ffa58e3e215af8f6536dc837a990e456129857bb6fd546b3991be470abd9597a", size = 253862, upload-time = "2025-06-17T14:14:55.323Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7c/a2743c00d9e25f4826d3a77cc13d4746398872cf21c843eef96bb9945665/multidict-6.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e86eb90015c6f21658dbd257bb8e6aa18bdb365b92dd1fba27ec04e58cdc31b", size = 247391, upload-time = "2025-06-17T14:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/9b/03/7773518db74c442904dbd349074f1e7f2a854cee4d9529fc59e623d3949e/multidict-6.5.0-cp313-cp313-win32.whl", hash = "sha256:f34a90fbd9959d0f857323bd3c52b3e6011ed48f78d7d7b9e04980b8a41da3af", size = 41115, upload-time = "2025-06-17T14:14:59.33Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9a/6fc51b1dc11a7baa944bc101a92167d8b0f5929d376a8c65168fc0d35917/multidict-6.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fcb2aa79ac6aef8d5b709bbfc2fdb1d75210ba43038d70fbb595b35af470ce06", size = 44768, upload-time = "2025-06-17T14:15:00.427Z" }, + { url = "https://files.pythonhosted.org/packages/82/2d/0d010be24b663b3c16e3d3307bbba2de5ae8eec496f6027d5c0515b371a8/multidict-6.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:6dcee5e7e92060b4bb9bb6f01efcbb78c13d0e17d9bc6eec71660dd71dc7b0c2", size = 41770, upload-time = "2025-06-17T14:15:01.854Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d1/a71711a5f32f84b7b036e82182e3250b949a0ce70d51a2c6a4079e665449/multidict-6.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:cbbc88abea2388fde41dd574159dec2cda005cb61aa84950828610cb5010f21a", size = 80450, upload-time = "2025-06-17T14:15:02.968Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a2/953a9eede63a98fcec2c1a2c1a0d88de120056219931013b871884f51b43/multidict-6.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:70b599f70ae6536e5976364d3c3cf36f40334708bd6cebdd1e2438395d5e7676", size = 46971, upload-time = "2025-06-17T14:15:04.149Z" }, + { url = "https://files.pythonhosted.org/packages/44/61/60250212953459edda2c729e1d85130912f23c67bd4f585546fe4bdb1578/multidict-6.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:828bab777aa8d29d59700018178061854e3a47727e0611cb9bec579d3882de3b", size = 45548, upload-time = "2025-06-17T14:15:05.666Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/e78ee82e96c495bc2582b303f68bed176b481c8d81a441fec07404fce2ca/multidict-6.5.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9695fc1462f17b131c111cf0856a22ff154b0480f86f539d24b2778571ff94d", size = 238545, upload-time = "2025-06-17T14:15:06.88Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0f/6132ca06670c8d7b374c3a4fd1ba896fc37fbb66b0de903f61db7d1020ec/multidict-6.5.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b5ac6ebaf5d9814b15f399337ebc6d3a7f4ce9331edd404e76c49a01620b68d", size = 229931, upload-time = "2025-06-17T14:15:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/c0/63/d9957c506e6df6b3e7a194f0eea62955c12875e454b978f18262a65d017b/multidict-6.5.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84a51e3baa77ded07be4766a9e41d977987b97e49884d4c94f6d30ab6acaee14", size = 248181, upload-time = "2025-06-17T14:15:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/43/3f/7d5490579640db5999a948e2c41d4a0efd91a75989bda3e0a03a79c92be2/multidict-6.5.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8de67f79314d24179e9b1869ed15e88d6ba5452a73fc9891ac142e0ee018b5d6", size = 241846, upload-time = "2025-06-17T14:15:11.596Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/252b1ce949ece52bba4c0de7aa2e3a3d5964e800bce71fb778c2e6c66f7c/multidict-6.5.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17f78a52c214481d30550ec18208e287dfc4736f0c0148208334b105fd9e0887", size = 232893, upload-time = "2025-06-17T14:15:12.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/0070bfd48c16afc26e056f2acce49e853c0d604a69c7124bc0bbdb1bcc0a/multidict-6.5.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2966d0099cb2e2039f9b0e73e7fd5eb9c85805681aa2a7f867f9d95b35356921", size = 228567, upload-time = "2025-06-17T14:15:14.267Z" }, + { url = "https://files.pythonhosted.org/packages/2a/31/90551c75322113ebf5fd9c5422e8641d6952f6edaf6b6c07fdc49b1bebdd/multidict-6.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:86fb42ed5ed1971c642cc52acc82491af97567534a8e381a8d50c02169c4e684", size = 246188, upload-time = "2025-06-17T14:15:15.985Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e2/aa4b02a55e7767ff292871023817fe4db83668d514dab7ccbce25eaf7659/multidict-6.5.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:4e990cbcb6382f9eae4ec720bcac6a1351509e6fc4a5bb70e4984b27973934e6", size = 235178, upload-time = "2025-06-17T14:15:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5c/f67e726717c4b138b166be1700e2b56e06fbbcb84643d15f9a9d7335ff41/multidict-6.5.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d99a59d64bb1f7f2117bec837d9e534c5aeb5dcedf4c2b16b9753ed28fdc20a3", size = 243422, upload-time = "2025-06-17T14:15:18.939Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/15fa318285e26a50aa3fa979bbcffb90f9b4d5ec58882d0590eda067d0da/multidict-6.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:e8ef15cc97c9890212e1caf90f0d63f6560e1e101cf83aeaf63a57556689fb34", size = 254898, upload-time = "2025-06-17T14:15:20.31Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3d/d6c6d1c2e9b61ca80313912d30bb90d4179335405e421ef0a164eac2c0f9/multidict-6.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:b8a09aec921b34bd8b9f842f0bcfd76c6a8c033dc5773511e15f2d517e7e1068", size = 247129, upload-time = "2025-06-17T14:15:21.665Z" }, + { url = "https://files.pythonhosted.org/packages/29/15/1568258cf0090bfa78d44be66247cfdb16e27dfd935c8136a1e8632d3057/multidict-6.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ff07b504c23b67f2044533244c230808a1258b3493aaf3ea2a0785f70b7be461", size = 243841, upload-time = "2025-06-17T14:15:23.38Z" }, + { url = "https://files.pythonhosted.org/packages/65/57/64af5dbcfd61427056e840c8e520b502879d480f9632fbe210929fd87393/multidict-6.5.0-cp313-cp313t-win32.whl", hash = "sha256:9232a117341e7e979d210e41c04e18f1dc3a1d251268df6c818f5334301274e1", size = 46761, upload-time = "2025-06-17T14:15:24.733Z" }, + { url = "https://files.pythonhosted.org/packages/26/a8/cac7f7d61e188ff44f28e46cb98f9cc21762e671c96e031f06c84a60556e/multidict-6.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:44cb5c53fb2d4cbcee70a768d796052b75d89b827643788a75ea68189f0980a1", size = 52112, upload-time = "2025-06-17T14:15:25.906Z" }, + { url = "https://files.pythonhosted.org/packages/51/9f/076533feb1b5488d22936da98b9c217205cfbf9f56f7174e8c5c86d86fe6/multidict-6.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:51d33fafa82640c0217391d4ce895d32b7e84a832b8aee0dcc1b04d8981ec7f4", size = 44358, upload-time = "2025-06-17T14:15:27.117Z" }, + { url = "https://files.pythonhosted.org/packages/68/0b/b024da30f18241e03a400aebdc3ca1bcbdc0561f9d48019cbe66549aea3e/multidict-6.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c0078358470da8dc90c37456f4a9cde9f86200949a048d53682b9cd21e5bbf2b", size = 73804, upload-time = "2025-06-17T14:15:28.305Z" }, + { url = "https://files.pythonhosted.org/packages/a3/8f/5e69092bb8a75b95dd27ed4d21220641ede7e127d8a0228cd5e1d5f2150e/multidict-6.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5cc7968b7d1bf8b973c307d38aa3a2f2c783f149bcac855944804252f1df5105", size = 43161, upload-time = "2025-06-17T14:15:29.47Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d9/51968d296800285343055d482b65001bda4fa4950aad5575afe17906f16f/multidict-6.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ad73a60e11aa92f1f2c9330efdeaac4531b719fc568eb8d312fd4112f34cc18", size = 42996, upload-time = "2025-06-17T14:15:30.622Z" }, + { url = "https://files.pythonhosted.org/packages/38/1c/19ce336cf8af2b7c530ea890496603eb9bbf0da4e3a8e0fcc3669ad30c21/multidict-6.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3233f21abdcd180b2624eb6988a1e1287210e99bca986d8320afca5005d85844", size = 231051, upload-time = "2025-06-17T14:15:32.296Z" }, + { url = "https://files.pythonhosted.org/packages/73/9b/2cf6eff5b30ff8a67ca231a741053c8cc8269fd860cac2c0e16b376de89d/multidict-6.5.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bee5c0b79fca78fd2ab644ca4dc831ecf793eb6830b9f542ee5ed2c91bc35a0e", size = 219511, upload-time = "2025-06-17T14:15:33.602Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ac/43c89a11d710ce6e5c824ece7b570fd79839e3d25a6a7d3b2526a77b290c/multidict-6.5.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e053a4d690f4352ce46583080fefade9a903ce0fa9d820db1be80bdb9304fa2f", size = 240287, upload-time = "2025-06-17T14:15:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/16/94/1896d424324618f2e2adbf9acb049aeef8da3f31c109e37ffda63b58d1b5/multidict-6.5.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42bdee30424c1f4dcda96e07ac60e2a4ede8a89f8ae2f48b5e4ccc060f294c52", size = 232748, upload-time = "2025-06-17T14:15:36.576Z" }, + { url = "https://files.pythonhosted.org/packages/e1/43/2f852c12622bda304a2e0c4419250de3cd0345776ae2e699416cbdc15c9f/multidict-6.5.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58b2ded1a7982cf7b8322b0645713a0086b2b3cf5bb9f7c01edfc1a9f98d20dc", size = 224910, upload-time = "2025-06-17T14:15:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/9c32a0305a11aec71a85f354d739011221507bce977a3be8d9fa248763e7/multidict-6.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f805b8b951d1fadc5bc18c3c93e509608ac5a883045ee33bc22e28806847c20", size = 225773, upload-time = "2025-06-17T14:15:39.645Z" }, + { url = "https://files.pythonhosted.org/packages/bc/81/488054827b644e615f59211fc26fd64b28a1366143e4985326802f18773b/multidict-6.5.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2540395b63723da748f850568357a39cd8d8d4403ca9439f9fcdad6dd423c780", size = 244097, upload-time = "2025-06-17T14:15:41.164Z" }, + { url = "https://files.pythonhosted.org/packages/9f/71/b9d96548da768dd7284c1f21187129a48906f526d5ed4f71bb050476d91f/multidict-6.5.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:c96aedff25f4e47b6697ba048b2c278f7caa6df82c7c3f02e077bcc8d47b4b76", size = 232831, upload-time = "2025-06-17T14:15:42.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/45/0c57c9bf9be7808252269f0d3964c1495413bcee36a7a7e836fdb778a578/multidict-6.5.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e80de5ad995de210fd02a65c2350649b8321d09bd2e44717eaefb0f5814503e8", size = 242201, upload-time = "2025-06-17T14:15:44.286Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/2441e56b32f7d25c917557641b35a89e0142a7412bc57182c80330975b8d/multidict-6.5.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:6cb9bcedd9391b313e5ec2fb3aa07c03e050550e7b9e4646c076d5c24ba01532", size = 254479, upload-time = "2025-06-17T14:15:45.718Z" }, + { url = "https://files.pythonhosted.org/packages/0d/93/acbc2fed235c7a7b2b21fe8c6ac1b612f7fee79dbddd9c73d42b1a65599c/multidict-6.5.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a7d130ed7a112e25ab47309962ecafae07d073316f9d158bc7b3936b52b80121", size = 244179, upload-time = "2025-06-17T14:15:47.174Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b2/07ce91400ee2b296de2d6d55f1d948d88d148182b35a3edcc480ddb0f99a/multidict-6.5.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:95750a9a9741cd1855d1b6cb4c6031ae01c01ad38d280217b64bfae986d39d56", size = 241173, upload-time = "2025-06-17T14:15:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/a0/09/61c0b044065a1d2e1329b0e4f0f2afa992d3bb319129b63dd63c54c2cc15/multidict-6.5.0-cp39-cp39-win32.whl", hash = "sha256:7f78caf409914f108f4212b53a9033abfdc2cbab0647e9ac3a25bb0f21ab43d2", size = 40467, upload-time = "2025-06-17T14:15:50.285Z" }, + { url = "https://files.pythonhosted.org/packages/7f/43/48c2837046222ea6800824d576f110d7622c4048b3dd252ef62c51a0969b/multidict-6.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:220c74009507e847a3a6fc5375875f2a2e05bd9ce28cf607be0e8c94600f4472", size = 44449, upload-time = "2025-06-17T14:15:51.84Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4e/b61b006e75c6e071fac1bd0f32696ad1b052772493c4e9d0121ba604b215/multidict-6.5.0-cp39-cp39-win_arm64.whl", hash = "sha256:d98f4ac9c1ede7e9d04076e2e6d967e15df0079a6381b297270f6bcab661195e", size = 41477, upload-time = "2025-06-17T14:15:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/44/d8/45e8fc9892a7386d074941429e033adb4640e59ff0780d96a8cf46fe788e/multidict-6.5.0-py3-none-any.whl", hash = "sha256:5634b35f225977605385f56153bd95a7133faffc0ffe12ad26e10517537e8dfc", size = 12181, upload-time = "2025-06-17T14:15:55.156Z" }, +] + +[[package]] +name = "multipart" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/91/6c93b6a95e6a99ef929a99d019fbf5b5f7fd3368389a0b1ec7ce0a23565b/multipart-1.2.1.tar.gz", hash = "sha256:829b909b67bc1ad1c6d4488fcdc6391c2847842b08323addf5200db88dbe9480", size = 36507, upload-time = "2024-11-29T08:45:48.818Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/d1/3598d1e73385baaab427392856f915487db7aa10abadd436f8f2d3e3b0f9/multipart-1.2.1-py3-none-any.whl", hash = "sha256:c03dc203bc2e67f6b46a599467ae0d87cf71d7530504b2c1ff4a9ea21d8b8c8c", size = 13730, upload-time = "2024-11-29T08:45:44.557Z" }, +] + +[[package]] +name = "mypy" +version = "1.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/92c7fa98112e4d9eb075a239caa4ef4649ad7d441545ccffbd5e34607cbb/mypy-1.16.1.tar.gz", hash = "sha256:6bd00a0a2094841c5e47e7374bb42b83d64c527a502e3334e1173a0c24437bab", size = 3324747, upload-time = "2025-06-16T16:51:35.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/12/2bf23a80fcef5edb75de9a1e295d778e0f46ea89eb8b115818b663eff42b/mypy-1.16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b4f0fed1022a63c6fec38f28b7fc77fca47fd490445c69d0a66266c59dd0b88a", size = 10958644, upload-time = "2025-06-16T16:51:11.649Z" }, + { url = "https://files.pythonhosted.org/packages/08/50/bfe47b3b278eacf348291742fd5e6613bbc4b3434b72ce9361896417cfe5/mypy-1.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86042bbf9f5a05ea000d3203cf87aa9d0ccf9a01f73f71c58979eb9249f46d72", size = 10087033, upload-time = "2025-06-16T16:35:30.089Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/40307c12fe25675a0776aaa2cdd2879cf30d99eec91b898de00228dc3ab5/mypy-1.16.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea7469ee5902c95542bea7ee545f7006508c65c8c54b06dc2c92676ce526f3ea", size = 11875645, upload-time = "2025-06-16T16:35:48.49Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d8/85bdb59e4a98b7a31495bd8f1a4445d8ffc86cde4ab1f8c11d247c11aedc/mypy-1.16.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:352025753ef6a83cb9e7f2427319bb7875d1fdda8439d1e23de12ab164179574", size = 12616986, upload-time = "2025-06-16T16:48:39.526Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d0/bb25731158fa8f8ee9e068d3e94fcceb4971fedf1424248496292512afe9/mypy-1.16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff9fa5b16e4c1364eb89a4d16bcda9987f05d39604e1e6c35378a2987c1aac2d", size = 12878632, upload-time = "2025-06-16T16:36:08.195Z" }, + { url = "https://files.pythonhosted.org/packages/2d/11/822a9beb7a2b825c0cb06132ca0a5183f8327a5e23ef89717c9474ba0bc6/mypy-1.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:1256688e284632382f8f3b9e2123df7d279f603c561f099758e66dd6ed4e8bd6", size = 9484391, upload-time = "2025-06-16T16:37:56.151Z" }, + { url = "https://files.pythonhosted.org/packages/9a/61/ec1245aa1c325cb7a6c0f8570a2eee3bfc40fa90d19b1267f8e50b5c8645/mypy-1.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:472e4e4c100062488ec643f6162dd0d5208e33e2f34544e1fc931372e806c0cc", size = 10890557, upload-time = "2025-06-16T16:37:21.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/bb/6eccc0ba0aa0c7a87df24e73f0ad34170514abd8162eb0c75fd7128171fb/mypy-1.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea16e2a7d2714277e349e24d19a782a663a34ed60864006e8585db08f8ad1782", size = 10012921, upload-time = "2025-06-16T16:51:28.659Z" }, + { url = "https://files.pythonhosted.org/packages/5f/80/b337a12e2006715f99f529e732c5f6a8c143bb58c92bb142d5ab380963a5/mypy-1.16.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08e850ea22adc4d8a4014651575567b0318ede51e8e9fe7a68f25391af699507", size = 11802887, upload-time = "2025-06-16T16:50:53.627Z" }, + { url = "https://files.pythonhosted.org/packages/d9/59/f7af072d09793d581a745a25737c7c0a945760036b16aeb620f658a017af/mypy-1.16.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22d76a63a42619bfb90122889b903519149879ddbf2ba4251834727944c8baca", size = 12531658, upload-time = "2025-06-16T16:33:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/82/c4/607672f2d6c0254b94a646cfc45ad589dd71b04aa1f3d642b840f7cce06c/mypy-1.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2c7ce0662b6b9dc8f4ed86eb7a5d505ee3298c04b40ec13b30e572c0e5ae17c4", size = 12732486, upload-time = "2025-06-16T16:37:03.301Z" }, + { url = "https://files.pythonhosted.org/packages/b6/5e/136555ec1d80df877a707cebf9081bd3a9f397dedc1ab9750518d87489ec/mypy-1.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:211287e98e05352a2e1d4e8759c5490925a7c784ddc84207f4714822f8cf99b6", size = 9479482, upload-time = "2025-06-16T16:47:37.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d6/39482e5fcc724c15bf6280ff5806548c7185e0c090712a3736ed4d07e8b7/mypy-1.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:af4792433f09575d9eeca5c63d7d90ca4aeceda9d8355e136f80f8967639183d", size = 11066493, upload-time = "2025-06-16T16:47:01.683Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/26c347890efc6b757f4d5bb83f4a0cf5958b8cf49c938ac99b8b72b420a6/mypy-1.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66df38405fd8466ce3517eda1f6640611a0b8e70895e2a9462d1d4323c5eb4b9", size = 10081687, upload-time = "2025-06-16T16:48:19.367Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/b5cb264c97b86914487d6a24bd8688c0172e37ec0f43e93b9691cae9468b/mypy-1.16.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44e7acddb3c48bd2713994d098729494117803616e116032af192871aed80b79", size = 11839723, upload-time = "2025-06-16T16:49:20.912Z" }, + { url = "https://files.pythonhosted.org/packages/15/f8/491997a9b8a554204f834ed4816bda813aefda31cf873bb099deee3c9a99/mypy-1.16.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ab5eca37b50188163fa7c1b73c685ac66c4e9bdee4a85c9adac0e91d8895e15", size = 12722980, upload-time = "2025-06-16T16:37:40.929Z" }, + { url = "https://files.pythonhosted.org/packages/df/f0/2bd41e174b5fd93bc9de9a28e4fb673113633b8a7f3a607fa4a73595e468/mypy-1.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb6229b2c9086247e21a83c309754b9058b438704ad2f6807f0d8227f6ebdd", size = 12903328, upload-time = "2025-06-16T16:34:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/61/81/5572108a7bec2c46b8aff7e9b524f371fe6ab5efb534d38d6b37b5490da8/mypy-1.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:1f0435cf920e287ff68af3d10a118a73f212deb2ce087619eb4e648116d1fe9b", size = 9562321, upload-time = "2025-06-16T16:48:58.823Z" }, + { url = "https://files.pythonhosted.org/packages/28/e3/96964af4a75a949e67df4b95318fe2b7427ac8189bbc3ef28f92a1c5bc56/mypy-1.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ddc91eb318c8751c69ddb200a5937f1232ee8efb4e64e9f4bc475a33719de438", size = 11063480, upload-time = "2025-06-16T16:47:56.205Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/cd1a42b8e5be278fab7010fb289d9307a63e07153f0ae1510a3d7b703193/mypy-1.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:87ff2c13d58bdc4bbe7dc0dedfe622c0f04e2cb2a492269f3b418df2de05c536", size = 10090538, upload-time = "2025-06-16T16:46:43.92Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4f/c3c6b4b66374b5f68bab07c8cabd63a049ff69796b844bc759a0ca99bb2a/mypy-1.16.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a7cfb0fe29fe5a9841b7c8ee6dffb52382c45acdf68f032145b75620acfbd6f", size = 11836839, upload-time = "2025-06-16T16:36:28.039Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7e/81ca3b074021ad9775e5cb97ebe0089c0f13684b066a750b7dc208438403/mypy-1.16.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:051e1677689c9d9578b9c7f4d206d763f9bbd95723cd1416fad50db49d52f359", size = 12715634, upload-time = "2025-06-16T16:50:34.441Z" }, + { url = "https://files.pythonhosted.org/packages/e9/95/bdd40c8be346fa4c70edb4081d727a54d0a05382d84966869738cfa8a497/mypy-1.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d5d2309511cc56c021b4b4e462907c2b12f669b2dbeb68300110ec27723971be", size = 12895584, upload-time = "2025-06-16T16:34:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fd/d486a0827a1c597b3b48b1bdef47228a6e9ee8102ab8c28f944cb83b65dc/mypy-1.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:4f58ac32771341e38a853c5d0ec0dfe27e18e27da9cdb8bbc882d2249c71a3ee", size = 9573886, upload-time = "2025-06-16T16:36:43.589Z" }, + { url = "https://files.pythonhosted.org/packages/49/5e/ed1e6a7344005df11dfd58b0fdd59ce939a0ba9f7ed37754bf20670b74db/mypy-1.16.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7fc688329af6a287567f45cc1cefb9db662defeb14625213a5b7da6e692e2069", size = 10959511, upload-time = "2025-06-16T16:47:21.945Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/a7cbc2541e91fe04f43d9e4577264b260fecedb9bccb64ffb1a34b7e6c22/mypy-1.16.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e198ab3f55924c03ead626ff424cad1732d0d391478dfbf7bb97b34602395da", size = 10075555, upload-time = "2025-06-16T16:50:14.084Z" }, + { url = "https://files.pythonhosted.org/packages/93/f7/c62b1e31a32fbd1546cca5e0a2e5f181be5761265ad1f2e94f2a306fa906/mypy-1.16.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09aa4f91ada245f0a45dbc47e548fd94e0dd5a8433e0114917dc3b526912a30c", size = 11874169, upload-time = "2025-06-16T16:49:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/c8/15/db580a28034657fb6cb87af2f8996435a5b19d429ea4dcd6e1c73d418e60/mypy-1.16.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13c7cd5b1cb2909aa318a90fd1b7e31f17c50b242953e7dd58345b2a814f6383", size = 12610060, upload-time = "2025-06-16T16:34:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/ec/78/c17f48f6843048fa92d1489d3095e99324f2a8c420f831a04ccc454e2e51/mypy-1.16.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:58e07fb958bc5d752a280da0e890c538f1515b79a65757bbdc54252ba82e0b40", size = 12875199, upload-time = "2025-06-16T16:35:14.448Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d6/ed42167d0a42680381653fd251d877382351e1bd2c6dd8a818764be3beb1/mypy-1.16.1-cp39-cp39-win_amd64.whl", hash = "sha256:f895078594d918f93337a505f8add9bd654d1a24962b4c6ed9390e12531eb31b", size = 9487033, upload-time = "2025-06-16T16:49:57.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d3/53e684e78e07c1a2bf7105715e5edd09ce951fc3f47cf9ed095ec1b7a037/mypy-1.16.1-py3-none-any.whl", hash = "sha256:5fc2ac4027d0ef28d6ba69a0343737a23c4d1b83672bf38d1fe237bdc0643b37", size = 2265923, upload-time = "2025-06-16T16:48:02.366Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "nox" +version = "2025.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "attrs" }, + { name = "colorlog" }, + { name = "dependency-groups" }, + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/80/47712208c410defec169992e57c179f0f4d92f5dd17ba8daca50a8077e23/nox-2025.5.1.tar.gz", hash = "sha256:2a571dfa7a58acc726521ac3cd8184455ebcdcbf26401c7b737b5bc6701427b2", size = 4023334, upload-time = "2025-05-01T16:35:48.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/be/7b423b02b09eb856beffe76fe8c4121c99852db74dd12a422dcb72d1134e/nox-2025.5.1-py3-none-any.whl", hash = "sha256:56abd55cf37ff523c254fcec4d152ed51e5fe80e2ab8317221d8b828ac970a31", size = 71753, upload-time = "2025-05-01T16:35:46.037Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.34.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/5e/94a8cb759e4e409022229418294e098ca7feca00eb3c467bb20cbd329bda/opentelemetry_api-1.34.1.tar.gz", hash = "sha256:64f0bd06d42824843731d05beea88d4d4b6ae59f9fe347ff7dfa2cc14233bbb3", size = 64987, upload-time = "2025-06-10T08:55:19.818Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/3a/2ba85557e8dc024c0842ad22c570418dc02c36cbd1ab4b832a93edf071b8/opentelemetry_api-1.34.1-py3-none-any.whl", hash = "sha256:b7df4cb0830d5a6c29ad0c0691dbae874d8daefa934b8b1d642de48323d32a8c", size = 65767, upload-time = "2025-06-10T08:54:56.717Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.34.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/41/fe20f9036433da8e0fcef568984da4c1d1c771fa072ecd1a4d98779dccdd/opentelemetry_sdk-1.34.1.tar.gz", hash = "sha256:8091db0d763fcd6098d4781bbc80ff0971f94e260739aa6afe6fd379cdf3aa4d", size = 159441, upload-time = "2025-06-10T08:55:33.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/1b/def4fe6aa73f483cabf4c748f4c25070d5f7604dcc8b52e962983491b29e/opentelemetry_sdk-1.34.1-py3-none-any.whl", hash = "sha256:308effad4059562f1d92163c61c8141df649da24ce361827812c40abb2a1e96e", size = 118477, upload-time = "2025-06-10T08:55:16.02Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.55b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/f0/f33458486da911f47c4aa6db9bda308bb80f3236c111bf848bd870c16b16/opentelemetry_semantic_conventions-0.55b1.tar.gz", hash = "sha256:ef95b1f009159c28d7a7849f5cbc71c4c34c845bb514d66adfdf1b3fff3598b3", size = 119829, upload-time = "2025-06-10T08:55:33.881Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/89/267b0af1b1d0ba828f0e60642b6a5116ac1fd917cde7fc02821627029bd1/opentelemetry_semantic_conventions-0.55b1-py3-none-any.whl", hash = "sha256:5da81dfdf7d52e3d37f8fe88d5e771e191de924cfff5f550ab0b8f7b2409baed", size = 196223, upload-time = "2025-06-10T08:55:17.638Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "parameterized" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/49/00c0c0cc24ff4266025a53e41336b79adaa5a4ebfad214f433d623f9865e/parameterized-0.9.0.tar.gz", hash = "sha256:7fc905272cefa4f364c1a3429cbbe9c0f98b793988efb5bf90aac80f08db09b1", size = 24351, upload-time = "2023-03-27T02:01:11.592Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/2f/804f58f0b856ab3bf21617cccf5b39206e6c4c94c2cd227bde125ea6105f/parameterized-0.9.0-py2.py3-none-any.whl", hash = "sha256:4e0758e3d41bea3bbd05ec14fc2c24736723f243b28d702081aef438c9372b1b", size = 20475, upload-time = "2023-03-27T02:01:09.31Z" }, +] + +[[package]] +name = "parso" +version = "0.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609, upload-time = "2024-04-05T09:43:55.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650, upload-time = "2024-04-05T09:43:53.299Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "pbs-installer" +version = "2025.6.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/02/bd162be66772b5dbdfd719d4ced63e14730d8260417db1c43ac8017e2b3e/pbs_installer-2025.6.12.tar.gz", hash = "sha256:ae2d3990848652dca699a680b00ea8e19b970cb6172967cb00539bfeed5a7465", size = 57106, upload-time = "2025-06-12T22:01:59.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/81/2c31b2137b771e61dc3183848273c3c901459abd367de462df7b9845cfea/pbs_installer-2025.6.12-py3-none-any.whl", hash = "sha256:438e75de131a2114ac5e86156fc51da7dadd6734844de329ad162cca63709297", size = 58847, upload-time = "2025-06-12T22:01:58.423Z" }, +] + +[package.optional-dependencies] +download = [ + { name = "httpx" }, +] +install = [ + { name = "zstandard" }, +] + +[[package]] +name = "pip" +version = "25.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/53/b309b4a497b09655cb7e07088966881a57d082f48ac3cb54ea729fd2c6cf/pip-25.0.1.tar.gz", hash = "sha256:88f96547ea48b940a3a385494e181e29fb8637898f88d88737c5049780f196ea", size = 1950850, upload-time = "2025-02-09T17:14:04.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/bc/b7db44f5f39f9d0494071bddae6880eb645970366d0a200022a1a93d57f5/pip-25.0.1-py3-none-any.whl", hash = "sha256:c46efd13b6aa8279f33f2864459c8ce587ea6a1a59ee20de055868d8f7688f7f", size = 1841526, upload-time = "2025-02-09T17:14:01.463Z" }, +] + +[[package]] +name = "pkginfo" +version = "1.12.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/03/e26bf3d6453b7fda5bd2b84029a426553bb373d6277ef6b5ac8863421f87/pkginfo-1.12.1.2.tar.gz", hash = "sha256:5cd957824ac36f140260964eba3c6be6442a8359b8c48f4adf90210f33a04b7b", size = 451828, upload-time = "2025-02-19T15:27:37.188Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/3d/f4f2ba829efb54b6cd2d91349c7463316a9cc55a43fc980447416c88540f/pkginfo-1.12.1.2-py3-none-any.whl", hash = "sha256:c783ac885519cab2c34927ccfa6bf64b5a704d7c69afaea583dd9b7afe969343", size = 32717, upload-time = "2025-02-19T15:27:33.071Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, +] + +[[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 = "poetry" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "build", marker = "python_full_version < '3.10'" }, + { name = "cachecontrol", extra = ["filecache"], marker = "python_full_version < '3.10'" }, + { name = "cleo", marker = "python_full_version < '3.10'" }, + { name = "dulwich", marker = "python_full_version < '3.10'" }, + { name = "fastjsonschema", marker = "python_full_version < '3.10'" }, + { name = "findpython", marker = "python_full_version < '3.10'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "installer", marker = "python_full_version < '3.10'" }, + { name = "keyring", marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pbs-installer", extra = ["download", "install"], marker = "python_full_version < '3.10'" }, + { name = "pkginfo", marker = "python_full_version < '3.10'" }, + { name = "platformdirs", marker = "python_full_version < '3.10'" }, + { name = "poetry-core", version = "2.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyproject-hooks", marker = "python_full_version < '3.10'" }, + { name = "requests", marker = "python_full_version < '3.10'" }, + { name = "requests-toolbelt", marker = "python_full_version < '3.10'" }, + { name = "shellingham", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "tomlkit", marker = "python_full_version < '3.10'" }, + { name = "trove-classifiers", marker = "python_full_version < '3.10'" }, + { name = "virtualenv", marker = "python_full_version < '3.10'" }, + { name = "xattr", marker = "python_full_version < '3.10' and sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/96/187b538742df11fe32beca5c146d9522b1fd9f42897f0772ff8dfc04972f/poetry-2.1.2.tar.gz", hash = "sha256:6a0694645ee24ba93cb94254db66e47971344562ddd5578e82bf35e572bc546d", size = 3434250, upload-time = "2025-03-29T21:40:37.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/62/8d4340e9f4af810633ad3ba5b2c1cc6a43c1a138e909c1450e44ba90aba1/poetry-2.1.2-py3-none-any.whl", hash = "sha256:df7dfe7e5f9cd50ed3b8d1a013afcc379645f66d7e9aa43728689e34fb016216", size = 277844, upload-time = "2025-03-29T21:40:35.534Z" }, +] + +[[package]] +name = "poetry" +version = "2.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version >= '3.10' and python_full_version < '3.13'", +] +dependencies = [ + { name = "build", marker = "python_full_version >= '3.10'" }, + { name = "cachecontrol", extra = ["filecache"], marker = "python_full_version >= '3.10'" }, + { name = "cleo", marker = "python_full_version >= '3.10'" }, + { name = "dulwich", marker = "python_full_version >= '3.10'" }, + { name = "fastjsonschema", marker = "python_full_version >= '3.10'" }, + { name = "findpython", marker = "python_full_version >= '3.10'" }, + { name = "installer", marker = "python_full_version >= '3.10'" }, + { name = "keyring", marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pbs-installer", extra = ["download", "install"], marker = "python_full_version >= '3.10'" }, + { name = "pkginfo", marker = "python_full_version >= '3.10'" }, + { name = "platformdirs", marker = "python_full_version >= '3.10'" }, + { name = "poetry-core", version = "2.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyproject-hooks", marker = "python_full_version >= '3.10'" }, + { name = "requests", marker = "python_full_version >= '3.10'" }, + { name = "requests-toolbelt", marker = "python_full_version >= '3.10'" }, + { name = "shellingham", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "tomlkit", marker = "python_full_version >= '3.10'" }, + { name = "trove-classifiers", marker = "python_full_version >= '3.10'" }, + { name = "virtualenv", marker = "python_full_version >= '3.10'" }, + { name = "xattr", marker = "python_full_version >= '3.10' and sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/12/1c8d8b2c6017a33a9c9c708c6d2bb883af7f447520a466dc21d2c74ecfe1/poetry-2.1.3.tar.gz", hash = "sha256:f2c9bd6790b19475976d88ea4553bcc3533c0dc73f740edc4fffe9e2add50594", size = 3435640, upload-time = "2025-05-04T13:38:43.927Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/d7/d2ea346dd948fef5ab2e40ac2b337e461015ecff72919507eb347dad85a7/poetry-2.1.3-py3-none-any.whl", hash = "sha256:7054d3f97ccce7f31961ead16250407c4577bfe57e2037a190ae2913fc40a20c", size = 278572, upload-time = "2025-05-04T13:38:41.521Z" }, +] + +[[package]] +name = "poetry-core" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/84/2a/572c141e2a15b933b4a49eb888b0ae7335604f57c0f91a7298ae56d2df7c/poetry_core-2.1.2.tar.gz", hash = "sha256:f9dbbbd0ebf9755476a1d57f04b30e9aecf71ca9dc2fcd4b17aba92c0002aa04", size = 364452, upload-time = "2025-03-29T20:38:17.236Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/a3/f1fd35e3863b50713642d8745ba28e12e7cb8a6a28beab064ff1e8455db4/poetry_core-2.1.2-py3-none-any.whl", hash = "sha256:ecb1e8f7d4f071a21cd0feb8c19bd1aec80de6fb0e82aa9d809a591e544431b4", size = 332318, upload-time = "2025-03-29T20:38:15.256Z" }, +] + +[[package]] +name = "poetry-core" +version = "2.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version >= '3.10' and python_full_version < '3.13'", +] +sdist = { url = "https://files.pythonhosted.org/packages/44/ca/c2d21635a4525d427ae969d4cde155fb055c3b5d0bc4199b6de35bb6a826/poetry_core-2.1.3.tar.gz", hash = "sha256:0522a015477ed622c89aad56a477a57813cace0c8e7ff2a2906b7ef4a2e296a4", size = 365027, upload-time = "2025-05-04T12:43:11.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/f1/fb218aebd29bca5c506230201c346881ae9b43de7bbb21a68dc648e972b3/poetry_core-2.1.3-py3-none-any.whl", hash = "sha256:2c704f05016698a54ca1d327f46ce2426d72eaca6ff614132c8477c292266771", size = 332607, upload-time = "2025-05-04T12:43:09.814Z" }, +] + +[[package]] +name = "poetry-plugin-export" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "poetry", version = "2.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "poetry", version = "2.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "poetry-core", version = "2.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "poetry-core", version = "2.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/25/c6516829d49afce7f3852ef7dd708b3d19cf647a6b5b834c369a59af52fe/poetry_plugin_export-1.9.0.tar.gz", hash = "sha256:6fc8755cfac93c74752f85510b171983e2e47d782d4ab5be4ffc4f6945be7967", size = 30835, upload-time = "2025-01-12T15:44:25.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/41/05f9494adb6ad0640be758de67db255a91a46362996020dd8cd21d0fcf60/poetry_plugin_export-1.9.0-py3-none-any.whl", hash = "sha256:e2621dd8c260dd705a8227f076075246a7ff5c697e18ddb90ff68081f47ee642", size = 11309, upload-time = "2025-01-12T15:44:23.522Z" }, +] + +[[package]] +name = "polyfactory" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "faker", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/d0/8ce6a9912a6f1077710ebc46a6aa9a79a64a06b69d2d6b4ccefc9765ce8f/polyfactory-2.21.0.tar.gz", hash = "sha256:a6d8dba91b2515d744cc014b5be48835633f7ccb72519a68f8801759e5b1737a", size = 246314, upload-time = "2025-04-18T10:19:33.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/ba/c148fba517a0aaccfc4fca5e61bf2a051e084a417403e930dc615886d4e6/polyfactory-2.21.0-py3-none-any.whl", hash = "sha256:9483b764756c8622313d99f375889b1c0d92f09affb05742d7bcfa2b5198d8c5", size = 60875, upload-time = "2025-04-18T10:19:31.881Z" }, +] + +[[package]] +name = "posthog" +version = "5.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backoff" }, + { name = "distro" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/20/60ae67bb9d82f00427946218d49e2e7e80fb41c15dc5019482289ec9ce8d/posthog-5.4.0.tar.gz", hash = "sha256:701669261b8d07cdde0276e5bc096b87f9e200e3b9589c5ebff14df658c5893c", size = 88076, upload-time = "2025-06-20T23:19:23.485Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/98/e480cab9a08d1c09b1c59a93dade92c1bb7544826684ff2acbfd10fcfbd4/posthog-5.4.0-py3-none-any.whl", hash = "sha256:284dfa302f64353484420b52d4ad81ff5c2c2d1d607c4e2db602ac72761831bd", size = 105364, upload-time = "2025-06-20T23:19:22.001Z" }, +] + +[[package]] +name = "priority" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792, upload-time = "2021-06-27T10:15:05.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" }, +] + +[[package]] +name = "propcache" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/14/510deed325e262afeb8b360043c5d7c960da7d3ecd6d6f9496c9c56dc7f4/propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770", size = 73178, upload-time = "2025-06-09T22:53:40.126Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4e/ad52a7925ff01c1325653a730c7ec3175a23f948f08626a534133427dcff/propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3", size = 43133, upload-time = "2025-06-09T22:53:41.965Z" }, + { url = "https://files.pythonhosted.org/packages/63/7c/e9399ba5da7780871db4eac178e9c2e204c23dd3e7d32df202092a1ed400/propcache-0.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3def3da3ac3ce41562d85db655d18ebac740cb3fa4367f11a52b3da9d03a5cc3", size = 43039, upload-time = "2025-06-09T22:53:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/22/e1/58da211eb8fdc6fc854002387d38f415a6ca5f5c67c1315b204a5d3e9d7a/propcache-0.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bec58347a5a6cebf239daba9bda37dffec5b8d2ce004d9fe4edef3d2815137e", size = 201903, upload-time = "2025-06-09T22:53:44.872Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0a/550ea0f52aac455cb90111c8bab995208443e46d925e51e2f6ebdf869525/propcache-0.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55ffda449a507e9fbd4aca1a7d9aa6753b07d6166140e5a18d2ac9bc49eac220", size = 213362, upload-time = "2025-06-09T22:53:46.707Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/9893b7d878deda9bb69fcf54600b247fba7317761b7db11fede6e0f28bd0/propcache-0.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a67fb39229a8a8491dd42f864e5e263155e729c2e7ff723d6e25f596b1e8cb", size = 210525, upload-time = "2025-06-09T22:53:48.547Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bb/38fd08b278ca85cde36d848091ad2b45954bc5f15cce494bb300b9285831/propcache-0.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da1cf97b92b51253d5b68cf5a2b9e0dafca095e36b7f2da335e27dc6172a614", size = 198283, upload-time = "2025-06-09T22:53:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/78/8c/9fe55bd01d362bafb413dfe508c48753111a1e269737fa143ba85693592c/propcache-0.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f559e127134b07425134b4065be45b166183fdcb433cb6c24c8e4149056ad50", size = 191872, upload-time = "2025-06-09T22:53:51.438Z" }, + { url = "https://files.pythonhosted.org/packages/54/14/4701c33852937a22584e08abb531d654c8bcf7948a8f87ad0a4822394147/propcache-0.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aff2e4e06435d61f11a428360a932138d0ec288b0a31dd9bd78d200bd4a2b339", size = 199452, upload-time = "2025-06-09T22:53:53.229Z" }, + { url = "https://files.pythonhosted.org/packages/16/44/447f2253d859602095356007657ee535e0093215ea0b3d1d6a41d16e5201/propcache-0.3.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4927842833830942a5d0a56e6f4839bc484785b8e1ce8d287359794818633ba0", size = 191567, upload-time = "2025-06-09T22:53:54.541Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b3/e4756258749bb2d3b46defcff606a2f47410bab82be5824a67e84015b267/propcache-0.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6107ddd08b02654a30fb8ad7a132021759d750a82578b94cd55ee2772b6ebea2", size = 193015, upload-time = "2025-06-09T22:53:56.44Z" }, + { url = "https://files.pythonhosted.org/packages/1e/df/e6d3c7574233164b6330b9fd697beeac402afd367280e6dc377bb99b43d9/propcache-0.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70bd8b9cd6b519e12859c99f3fc9a93f375ebd22a50296c3a295028bea73b9e7", size = 204660, upload-time = "2025-06-09T22:53:57.839Z" }, + { url = "https://files.pythonhosted.org/packages/b2/53/e4d31dd5170b4a0e2e6b730f2385a96410633b4833dc25fe5dffd1f73294/propcache-0.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2183111651d710d3097338dd1893fcf09c9f54e27ff1a8795495a16a469cc90b", size = 206105, upload-time = "2025-06-09T22:53:59.638Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fe/74d54cf9fbe2a20ff786e5f7afcfde446588f0cf15fb2daacfbc267b866c/propcache-0.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb075ad271405dcad8e2a7ffc9a750a3bf70e533bd86e89f0603e607b93aa64c", size = 196980, upload-time = "2025-06-09T22:54:01.071Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/c469c9d59dada8a7679625e0440b544fe72e99311a4679c279562051f6fc/propcache-0.3.2-cp310-cp310-win32.whl", hash = "sha256:404d70768080d3d3bdb41d0771037da19d8340d50b08e104ca0e7f9ce55fce70", size = 37679, upload-time = "2025-06-09T22:54:03.003Z" }, + { url = "https://files.pythonhosted.org/packages/38/35/07a471371ac89d418f8d0b699c75ea6dca2041fbda360823de21f6a9ce0a/propcache-0.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:7435d766f978b4ede777002e6b3b6641dd229cd1da8d3d3106a45770365f9ad9", size = 41459, upload-time = "2025-06-09T22:54:04.134Z" }, + { url = "https://files.pythonhosted.org/packages/80/8d/e8b436717ab9c2cfc23b116d2c297305aa4cd8339172a456d61ebf5669b8/propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be", size = 74207, upload-time = "2025-06-09T22:54:05.399Z" }, + { url = "https://files.pythonhosted.org/packages/d6/29/1e34000e9766d112171764b9fa3226fa0153ab565d0c242c70e9945318a7/propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f", size = 43648, upload-time = "2025-06-09T22:54:08.023Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/1ad5af0df781e76988897da39b5f086c2bf0f028b7f9bd1f409bb05b6874/propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9", size = 43496, upload-time = "2025-06-09T22:54:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ce/e96392460f9fb68461fabab3e095cb00c8ddf901205be4eae5ce246e5b7e/propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf", size = 217288, upload-time = "2025-06-09T22:54:10.466Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2a/866726ea345299f7ceefc861a5e782b045545ae6940851930a6adaf1fca6/propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9", size = 227456, upload-time = "2025-06-09T22:54:11.828Z" }, + { url = "https://files.pythonhosted.org/packages/de/03/07d992ccb6d930398689187e1b3c718339a1c06b8b145a8d9650e4726166/propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66", size = 225429, upload-time = "2025-06-09T22:54:13.823Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e6/116ba39448753b1330f48ab8ba927dcd6cf0baea8a0ccbc512dfb49ba670/propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df", size = 213472, upload-time = "2025-06-09T22:54:15.232Z" }, + { url = "https://files.pythonhosted.org/packages/a6/85/f01f5d97e54e428885a5497ccf7f54404cbb4f906688a1690cd51bf597dc/propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2", size = 204480, upload-time = "2025-06-09T22:54:17.104Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/7bf5ab9033b8b8194cc3f7cf1aaa0e9c3256320726f64a3e1f113a812dce/propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7", size = 214530, upload-time = "2025-06-09T22:54:18.512Z" }, + { url = "https://files.pythonhosted.org/packages/31/0b/bd3e0c00509b609317df4a18e6b05a450ef2d9a963e1d8bc9c9415d86f30/propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95", size = 205230, upload-time = "2025-06-09T22:54:19.947Z" }, + { url = "https://files.pythonhosted.org/packages/7a/23/fae0ff9b54b0de4e819bbe559508da132d5683c32d84d0dc2ccce3563ed4/propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e", size = 206754, upload-time = "2025-06-09T22:54:21.716Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/ad6a3c22630aaa5f618b4dc3c3598974a72abb4c18e45a50b3cdd091eb2f/propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e", size = 218430, upload-time = "2025-06-09T22:54:23.17Z" }, + { url = "https://files.pythonhosted.org/packages/5b/2c/ba4f1c0e8a4b4c75910742f0d333759d441f65a1c7f34683b4a74c0ee015/propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf", size = 223884, upload-time = "2025-06-09T22:54:25.539Z" }, + { url = "https://files.pythonhosted.org/packages/88/e4/ebe30fc399e98572019eee82ad0caf512401661985cbd3da5e3140ffa1b0/propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e", size = 211480, upload-time = "2025-06-09T22:54:26.892Z" }, + { url = "https://files.pythonhosted.org/packages/96/0a/7d5260b914e01d1d0906f7f38af101f8d8ed0dc47426219eeaf05e8ea7c2/propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897", size = 37757, upload-time = "2025-06-09T22:54:28.241Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2d/89fe4489a884bc0da0c3278c552bd4ffe06a1ace559db5ef02ef24ab446b/propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39", size = 41500, upload-time = "2025-06-09T22:54:29.4Z" }, + { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" }, + { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" }, + { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958, upload-time = "2025-06-09T22:54:35.186Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894, upload-time = "2025-06-09T22:54:36.708Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672, upload-time = "2025-06-09T22:54:38.062Z" }, + { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395, upload-time = "2025-06-09T22:54:39.634Z" }, + { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510, upload-time = "2025-06-09T22:54:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949, upload-time = "2025-06-09T22:54:43.038Z" }, + { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258, upload-time = "2025-06-09T22:54:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036, upload-time = "2025-06-09T22:54:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684, upload-time = "2025-06-09T22:54:47.63Z" }, + { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562, upload-time = "2025-06-09T22:54:48.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142, upload-time = "2025-06-09T22:54:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload-time = "2025-06-09T22:54:52.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload-time = "2025-06-09T22:54:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, + { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, + { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, + { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, + { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, + { url = "https://files.pythonhosted.org/packages/6c/39/8ea9bcfaaff16fd0b0fc901ee522e24c9ec44b4ca0229cfffb8066a06959/propcache-0.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a7fad897f14d92086d6b03fdd2eb844777b0c4d7ec5e3bac0fbae2ab0602bbe5", size = 74678, upload-time = "2025-06-09T22:55:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/d3/85/cab84c86966e1d354cf90cdc4ba52f32f99a5bca92a1529d666d957d7686/propcache-0.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1f43837d4ca000243fd7fd6301947d7cb93360d03cd08369969450cc6b2ce3b4", size = 43829, upload-time = "2025-06-09T22:55:42.417Z" }, + { url = "https://files.pythonhosted.org/packages/23/f7/9cb719749152d8b26d63801b3220ce2d3931312b2744d2b3a088b0ee9947/propcache-0.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:261df2e9474a5949c46e962065d88eb9b96ce0f2bd30e9d3136bcde84befd8f2", size = 43729, upload-time = "2025-06-09T22:55:43.651Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a2/0b2b5a210ff311260002a315f6f9531b65a36064dfb804655432b2f7d3e3/propcache-0.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e514326b79e51f0a177daab1052bc164d9d9e54133797a3a58d24c9c87a3fe6d", size = 204483, upload-time = "2025-06-09T22:55:45.327Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e0/7aff5de0c535f783b0c8be5bdb750c305c1961d69fbb136939926e155d98/propcache-0.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4a996adb6904f85894570301939afeee65f072b4fd265ed7e569e8d9058e4ec", size = 217425, upload-time = "2025-06-09T22:55:46.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/1d/65fa889eb3b2a7d6e4ed3c2b568a9cb8817547a1450b572de7bf24872800/propcache-0.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:76cace5d6b2a54e55b137669b30f31aa15977eeed390c7cbfb1dafa8dfe9a701", size = 214723, upload-time = "2025-06-09T22:55:48.342Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e2/eecf6989870988dfd731de408a6fa366e853d361a06c2133b5878ce821ad/propcache-0.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31248e44b81d59d6addbb182c4720f90b44e1efdc19f58112a3c3a1615fb47ef", size = 200166, upload-time = "2025-06-09T22:55:49.775Z" }, + { url = "https://files.pythonhosted.org/packages/12/06/c32be4950967f18f77489268488c7cdc78cbfc65a8ba8101b15e526b83dc/propcache-0.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abb7fa19dbf88d3857363e0493b999b8011eea856b846305d8c0512dfdf8fbb1", size = 194004, upload-time = "2025-06-09T22:55:51.335Z" }, + { url = "https://files.pythonhosted.org/packages/46/6c/17b521a6b3b7cbe277a4064ff0aa9129dd8c89f425a5a9b6b4dd51cc3ff4/propcache-0.3.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d81ac3ae39d38588ad0549e321e6f773a4e7cc68e7751524a22885d5bbadf886", size = 203075, upload-time = "2025-06-09T22:55:52.681Z" }, + { url = "https://files.pythonhosted.org/packages/62/cb/3bdba2b736b3e45bc0e40f4370f745b3e711d439ffbffe3ae416393eece9/propcache-0.3.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:cc2782eb0f7a16462285b6f8394bbbd0e1ee5f928034e941ffc444012224171b", size = 195407, upload-time = "2025-06-09T22:55:54.048Z" }, + { url = "https://files.pythonhosted.org/packages/29/bd/760c5c6a60a4a2c55a421bc34a25ba3919d49dee411ddb9d1493bb51d46e/propcache-0.3.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:db429c19a6c7e8a1c320e6a13c99799450f411b02251fb1b75e6217cf4a14fcb", size = 196045, upload-time = "2025-06-09T22:55:55.485Z" }, + { url = "https://files.pythonhosted.org/packages/76/58/ced2757a46f55b8c84358d6ab8de4faf57cba831c51e823654da7144b13a/propcache-0.3.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:21d8759141a9e00a681d35a1f160892a36fb6caa715ba0b832f7747da48fb6ea", size = 208432, upload-time = "2025-06-09T22:55:56.884Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ec/d98ea8d5a4d8fe0e372033f5254eddf3254344c0c5dc6c49ab84349e4733/propcache-0.3.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2ca6d378f09adb13837614ad2754fa8afaee330254f404299611bce41a8438cb", size = 210100, upload-time = "2025-06-09T22:55:58.498Z" }, + { url = "https://files.pythonhosted.org/packages/56/84/b6d8a7ecf3f62d7dd09d9d10bbf89fad6837970ef868b35b5ffa0d24d9de/propcache-0.3.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:34a624af06c048946709f4278b4176470073deda88d91342665d95f7c6270fbe", size = 200712, upload-time = "2025-06-09T22:55:59.906Z" }, + { url = "https://files.pythonhosted.org/packages/bf/32/889f4903ddfe4a9dc61da71ee58b763758cf2d608fe1decede06e6467f8d/propcache-0.3.2-cp39-cp39-win32.whl", hash = "sha256:4ba3fef1c30f306b1c274ce0b8baaa2c3cdd91f645c48f06394068f37d3837a1", size = 38187, upload-time = "2025-06-09T22:56:01.212Z" }, + { url = "https://files.pythonhosted.org/packages/67/74/d666795fb9ba1dc139d30de64f3b6fd1ff9c9d3d96ccfdb992cd715ce5d2/propcache-0.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:7a2368eed65fc69a7a7a40b27f22e85e7627b74216f0846b04ba5c116e191ec9", size = 42025, upload-time = "2025-06-09T22:56:02.875Z" }, + { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, +] + +[[package]] +name = "psutil" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003, upload-time = "2025-02-13T21:54:07.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051, upload-time = "2025-02-13T21:54:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535, upload-time = "2025-02-13T21:54:16.07Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004, upload-time = "2025-02-13T21:54:18.662Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986, upload-time = "2025-02-13T21:54:21.811Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544, upload-time = "2025-02-13T21:54:24.68Z" }, + { url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053, upload-time = "2025-02-13T21:54:34.31Z" }, + { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, +] + +[[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 = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.7" +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/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" }, + { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" }, + { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" }, + { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" }, + { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/53/ea/bbe9095cdd771987d13c82d104a9c8559ae9aec1e29f139e286fd2e9256e/pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d", size = 2028677, upload-time = "2025-04-23T18:32:27.227Z" }, + { url = "https://files.pythonhosted.org/packages/49/1d/4ac5ed228078737d457a609013e8f7edc64adc37b91d619ea965758369e5/pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954", size = 1864735, upload-time = "2025-04-23T18:32:29.019Z" }, + { url = "https://files.pythonhosted.org/packages/23/9a/2e70d6388d7cda488ae38f57bc2f7b03ee442fbcf0d75d848304ac7e405b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb", size = 1898467, upload-time = "2025-04-23T18:32:31.119Z" }, + { url = "https://files.pythonhosted.org/packages/ff/2e/1568934feb43370c1ffb78a77f0baaa5a8b6897513e7a91051af707ffdc4/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7", size = 1983041, upload-time = "2025-04-23T18:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/01/1a/1a1118f38ab64eac2f6269eb8c120ab915be30e387bb561e3af904b12499/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4", size = 2136503, upload-time = "2025-04-23T18:32:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/5c/da/44754d1d7ae0f22d6d3ce6c6b1486fc07ac2c524ed8f6eca636e2e1ee49b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b", size = 2736079, upload-time = "2025-04-23T18:32:37.659Z" }, + { url = "https://files.pythonhosted.org/packages/4d/98/f43cd89172220ec5aa86654967b22d862146bc4d736b1350b4c41e7c9c03/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3", size = 2006508, upload-time = "2025-04-23T18:32:39.637Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cc/f77e8e242171d2158309f830f7d5d07e0531b756106f36bc18712dc439df/pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a", size = 2113693, upload-time = "2025-04-23T18:32:41.818Z" }, + { url = "https://files.pythonhosted.org/packages/54/7a/7be6a7bd43e0a47c147ba7fbf124fe8aaf1200bc587da925509641113b2d/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782", size = 2074224, upload-time = "2025-04-23T18:32:44.033Z" }, + { url = "https://files.pythonhosted.org/packages/2a/07/31cf8fadffbb03be1cb520850e00a8490c0927ec456e8293cafda0726184/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9", size = 2245403, upload-time = "2025-04-23T18:32:45.836Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8d/bbaf4c6721b668d44f01861f297eb01c9b35f612f6b8e14173cb204e6240/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e", size = 2242331, upload-time = "2025-04-23T18:32:47.618Z" }, + { url = "https://files.pythonhosted.org/packages/bb/93/3cc157026bca8f5006250e74515119fcaa6d6858aceee8f67ab6dc548c16/pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9", size = 1910571, upload-time = "2025-04-23T18:32:49.401Z" }, + { url = "https://files.pythonhosted.org/packages/5b/90/7edc3b2a0d9f0dda8806c04e511a67b0b7a41d2187e2003673a996fb4310/pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3", size = 1956504, upload-time = "2025-04-23T18:32:51.287Z" }, + { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, + { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" }, + { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, + { url = "https://files.pythonhosted.org/packages/08/98/dbf3fdfabaf81cda5622154fda78ea9965ac467e3239078e0dcd6df159e7/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101", size = 2024034, upload-time = "2025-04-23T18:33:32.843Z" }, + { url = "https://files.pythonhosted.org/packages/8d/99/7810aa9256e7f2ccd492590f86b79d370df1e9292f1f80b000b6a75bd2fb/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64", size = 1858578, upload-time = "2025-04-23T18:33:34.912Z" }, + { url = "https://files.pythonhosted.org/packages/d8/60/bc06fa9027c7006cc6dd21e48dbf39076dc39d9abbaf718a1604973a9670/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d", size = 1892858, upload-time = "2025-04-23T18:33:36.933Z" }, + { url = "https://files.pythonhosted.org/packages/f2/40/9d03997d9518816c68b4dfccb88969756b9146031b61cd37f781c74c9b6a/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535", size = 2068498, upload-time = "2025-04-23T18:33:38.997Z" }, + { url = "https://files.pythonhosted.org/packages/d8/62/d490198d05d2d86672dc269f52579cad7261ced64c2df213d5c16e0aecb1/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d", size = 2108428, upload-time = "2025-04-23T18:33:41.18Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ec/4cd215534fd10b8549015f12ea650a1a973da20ce46430b68fc3185573e8/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6", size = 2069854, upload-time = "2025-04-23T18:33:43.446Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1a/abbd63d47e1d9b0d632fee6bb15785d0889c8a6e0a6c3b5a8e28ac1ec5d2/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca", size = 2237859, upload-time = "2025-04-23T18:33:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/80/1c/fa883643429908b1c90598fd2642af8839efd1d835b65af1f75fba4d94fe/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039", size = 2239059, upload-time = "2025-04-23T18:33:47.735Z" }, + { url = "https://files.pythonhosted.org/packages/d4/29/3cade8a924a61f60ccfa10842f75eb12787e1440e2b8660ceffeb26685e7/pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27", size = 2066661, upload-time = "2025-04-23T18:33:49.995Z" }, +] + +[[package]] +name = "pygls" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cattrs" }, + { name = "lsprotocol" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/b9/41d173dad9eaa9db9c785a85671fc3d68961f08d67706dc2e79011e10b5c/pygls-1.3.1.tar.gz", hash = "sha256:140edceefa0da0e9b3c533547c892a42a7d2fd9217ae848c330c53d266a55018", size = 45527, upload-time = "2024-03-26T18:44:25.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/19/b74a10dd24548e96e8c80226cbacb28b021bc3a168a7d2709fb0d0185348/pygls-1.3.1-py3-none-any.whl", hash = "sha256:6e00f11efc56321bdeb6eac04f6d86131f654c7d49124344a9ebb968da3dd91e", size = 56031, upload-time = "2024-03-26T18:44:24.249Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, +] + +[[package]] +name = "pyinstrument" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/d0/665828770e8fcd5c50880dc83f03811f814d6260bc6a8068dca0a520e68a/pyinstrument-5.0.2.tar.gz", hash = "sha256:e466033ead16a48ffa8bedbd633b90d416fa772b3b22f61226882ace0371f5f3", size = 263930, upload-time = "2025-05-24T15:47:13.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/25/f64d0be5f574d2df9ddac3e7a381863f92d8ad30170b1a9de0cf805f4318/pyinstrument-5.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1aeaf6b39ad40b3f03bea5fa3a9bd453a92aeb721dde29c1597f842ed9c8566a", size = 129638, upload-time = "2025-05-24T15:45:20.113Z" }, + { url = "https://files.pythonhosted.org/packages/6e/b8/bc6657f91a8d2f7cf58b0993aa4e6cf20e027b53aca65c2464a50738d711/pyinstrument-5.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d734bd236d00e0e7f950019c689eaba1c9dd15e355867d8926c8b18b6077b221", size = 122220, upload-time = "2025-05-24T15:45:22.4Z" }, + { url = "https://files.pythonhosted.org/packages/63/5f/9a7edf13333015a9ccfd3fcf5c75ea793fbb30b153aebf6c6ace40a607b2/pyinstrument-5.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:520208a9b6c3985473aa9c3f30875ae5e78e77a81081df1d8aeb4fd8b4caf197", size = 146928, upload-time = "2025-05-24T15:45:23.802Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f9/f7d7b28c9038f1a570e96c8eea2a9ffeeb3ee9e75cfc74a370554776f1a6/pyinstrument-5.0.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75e115b759288b8d65a0bf31a34a542ae102c58ef407e0614a43e0c39d261875", size = 157136, upload-time = "2025-05-24T15:45:25.629Z" }, + { url = "https://files.pythonhosted.org/packages/db/ee/aa99f275b3c5f0f32ccd37f77cb64e57597a1f26280aec03a50d2158eab7/pyinstrument-5.0.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:091f93e6787c485a7ddf670608c00448e858a056677fc25ce349f8e44d6a9e54", size = 144680, upload-time = "2025-05-24T15:45:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/cd7300a5e099c4ad971a647ea8fb9bd081482a9e5751479034e206cd1f69/pyinstrument-5.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b07971afa2652cb4f2bdcffaef11aefa32b5384c0cfb32acf9955e96dd8df8", size = 145624, upload-time = "2025-05-24T15:45:28.517Z" }, + { url = "https://files.pythonhosted.org/packages/30/59/1957e2ca2277ecc69e247383527df331002e23940d5b0a79fc5f3b870d60/pyinstrument-5.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e1bcb28a21b80eea5986eb5cb3180689b1d489b7c6fddf34e1f4df1f95d467ad", size = 145901, upload-time = "2025-05-24T15:45:30.365Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/396ebdf387cde376ac4b70d52f3df07374f2501ac4c09992dadf641cd71f/pyinstrument-5.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:80d28162070ff40c6d2ac7dc15b933ba20ef49e891a2e650cd2b91d30cd262b2", size = 145355, upload-time = "2025-05-24T15:45:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/48/5b/fec77476a9b4a316861b29f14cd0962871ad5c54c21e41c540f7c18c950c/pyinstrument-5.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c75e52a9bf76f084ba074323835cba4927ab3e572adfc96439698b097e523780", size = 145008, upload-time = "2025-05-24T15:45:33.417Z" }, + { url = "https://files.pythonhosted.org/packages/fd/75/dcd391ca2790b32e41bbd49ad33626e85eb1ce00116b273d5e1d99b3e829/pyinstrument-5.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ccefdd7dd938548ada43c95b24c42ec57e258ac7994a5ec7e4cc934fa4f1743b", size = 145396, upload-time = "2025-05-24T15:45:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5c/64e026ccf2c7908d10882955993e73ac35a1a77426bde2617973deeda07c/pyinstrument-5.0.2-cp310-cp310-win32.whl", hash = "sha256:6b617fb024c244738aa2f6b8c2a25853eac765360ac91062578bbbcc8e22ebfe", size = 123419, upload-time = "2025-05-24T15:45:36.276Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7c/7d221db96d461c7d28897499bdad55a8ae5ded983f60743bdfbf17438c20/pyinstrument-5.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6788c8f93c1a6e0ad8d0ccde1631d17eca3839945d0fa4d506cf5d4bd7a26b77", size = 124299, upload-time = "2025-05-24T15:45:37.642Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f2/b3f2416740be762fdfb052b63e1d85591682fa1d2ea6ee1b10db774f6350/pyinstrument-5.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0eec7a263cc1ccfb101594e13256115366338fee2a156be4172fe5315f71ec45", size = 129386, upload-time = "2025-05-24T15:45:39.429Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fa/a55b0bf911041b51d2a7a0e8a3feef5ed5ddb48ff0943fc667079955c14c/pyinstrument-5.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddd5effefb470d7f1886dc16467501b866e3b5883cf74773f13179e718b28393", size = 122100, upload-time = "2025-05-24T15:45:41.253Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e1/c42b94c795bc89d5a486ad7ef349fe3b7a8c3a4e730c09b5fa54af616a6b/pyinstrument-5.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e7458a6aa4048c1703354fc8a4a3c8b59d27b1409aafb707cf339d3c0bc794c", size = 145385, upload-time = "2025-05-24T15:45:43.024Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/b511141cc336ffeac284cce7d121f05802ffea4ab2c19df8869adda49743/pyinstrument-5.0.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2373dd699711463011ec14e4918427a777f7ab73b31ae374d960725dbd5d5a28", size = 156093, upload-time = "2025-05-24T15:45:44.755Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/4a7bc4f1c60d4886efb7397fd5bdcc7e537d01ec7372824cd834fff967a1/pyinstrument-5.0.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38ef498fbe71c2bbd11247b71e722290da93a367d88a5a8e0f66f6cc764c2b60", size = 143136, upload-time = "2025-05-24T15:45:46.469Z" }, + { url = "https://files.pythonhosted.org/packages/d8/69/0ac06cf609153fc5eb30ccc0071ce300a181f422836ca7ce8cd431ac3ab4/pyinstrument-5.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a58a8a50f0cb3ee1c2e43ffec51bf48f48945e141feed7ccd9194917b97fe5b", size = 144077, upload-time = "2025-05-24T15:45:48.333Z" }, + { url = "https://files.pythonhosted.org/packages/e3/24/12bd82822393f708e5da8f6c0b82def3f0cbe1f4fbd72a082688c583d7fa/pyinstrument-5.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad2a97c79ecf0e610df292abb5c46d01a4f99778598881d6e918650fa39801b6", size = 144545, upload-time = "2025-05-24T15:45:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/c9/62/40e7511fa46247ca56734d34e2d2eb6b14390c72b155255ecd1b2288d02d/pyinstrument-5.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:57ec0277042ee198eb749b76a975fe60f006cd51ea0c7ce3054c937577d19315", size = 144010, upload-time = "2025-05-24T15:45:52.256Z" }, + { url = "https://files.pythonhosted.org/packages/82/77/6d40880dc46a6243951ad7cd50a77f26f6ad126b80d803616934efccf539/pyinstrument-5.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:73d34047266f27acb67218e331288c0241cf0080fe4b87dfad5596236c71abd7", size = 143746, upload-time = "2025-05-24T15:45:53.702Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a2/08b056d2420199dab877c665ed45bb685863dc5b83d31b2c4311430b2bbd/pyinstrument-5.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cfdc23284a8e2f27637b357c226a15d52b96608d9dde187b68dfe33a947f4908", size = 143928, upload-time = "2025-05-24T15:45:55.103Z" }, + { url = "https://files.pythonhosted.org/packages/39/a1/bab336f70cd5f798d7fa21ec92784b99d3b2df0b5c1736a64fdaa4521004/pyinstrument-5.0.2-cp311-cp311-win32.whl", hash = "sha256:3e6fa135aee6af2c608e912d8d07906bbac3c5e564d94f92721831a957297c26", size = 123395, upload-time = "2025-05-24T15:45:56.469Z" }, + { url = "https://files.pythonhosted.org/packages/f2/15/8a7ac268ffe913aa64bb42ad43315dd0fc3ac493d451a50d4431ecb736c2/pyinstrument-5.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:6317df42a98a8074ccd25af5482312ec59a1f27c05dab408eb3c7b2081242733", size = 124198, upload-time = "2025-05-24T15:45:57.814Z" }, + { url = "https://files.pythonhosted.org/packages/95/36/4afdffbc4fd77dd0155c8943101f175e701ba00cb374c5e84e64790a2a32/pyinstrument-5.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d0b680ef269b528d8dcd8151362fba9683b0ac22ffe74cc8161c33b53c65b899", size = 129527, upload-time = "2025-05-24T15:45:59.216Z" }, + { url = "https://files.pythonhosted.org/packages/96/fe/7ea5af73d65f8f22585005f6e2ce1016fb3145a8ecc1ded51f965c2e98cc/pyinstrument-5.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1c70b50ec90ae793b74733a6fc992723c6ee27c0fcb7d99848239316ded61189", size = 122068, upload-time = "2025-05-24T15:46:01.05Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d2/cf8f3b8fde3f3b6768f8407c681fb57e7b5a5bf5e7450a9fbec15164987b/pyinstrument-5.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3aae5f4f78515009f72393fdb271a15861534a586401383785f823cf8f60aa02", size = 146679, upload-time = "2025-05-24T15:46:02.841Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/6c00273778596560c7033cfee34aab07da6009f32c5a4dbcc35b64700e73/pyinstrument-5.0.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3aec8bc3d1c064ff849ca3568d6b0a7cfa0162d590a9d4d250c7118d09518b22", size = 157606, upload-time = "2025-05-24T15:46:04.551Z" }, + { url = "https://files.pythonhosted.org/packages/4c/cc/ec099f566e381f8e5db21d9523dd97b3255047813da57481ab3f45436089/pyinstrument-5.0.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28d87fac2bc0fed802b14a26982440f36c85dc53f303530ff7665a6e470315bb", size = 144317, upload-time = "2025-05-24T15:46:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/37/a7/e2e54bf6d996b3c807534dbc4fe270f373660b89871c63965d3f895c285d/pyinstrument-5.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b9caac53c7eda8187ed122d4f7fcc6e3392f04c583d6d70b373351cede2b829", size = 145622, upload-time = "2025-05-24T15:46:07.334Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c6/0b084ddf8d836076e04912ea83ccae0f83bf4897d0168b0fd7684efdc2a4/pyinstrument-5.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8124419e8731a7bdbb9f7f885a8956806a4e9ab9dd19294f8a99e74c0bbdd327", size = 145645, upload-time = "2025-05-24T15:46:09.236Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4d/3e542c5986cc30bc86c304492f4696e58dc03d1816d35c5b2cabfac1d01e/pyinstrument-5.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:9990d9bd05fbb4fa83f24f0a62989b8e0a3ac15ff0fa19b49348c8ef5f9db50a", size = 145619, upload-time = "2025-05-24T15:46:10.643Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/1e4664bf5ada1cff56852d10954b1ff5a39dad17b9b98a2f27054a0c0d95/pyinstrument-5.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:1dc35f3d200866a43d4bc7570799a405f001591c8f19a30eb7a983a717c1e1f7", size = 145049, upload-time = "2025-05-24T15:46:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/fb/59/08a5237c8d1343842ac9ed3c661dce40c450f1750128fd4789ad80539253/pyinstrument-5.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a335a40d0ba1fe3658ef1a5ff2fc7a6870905828014645cb19dab5c1de379447", size = 145451, upload-time = "2025-05-24T15:46:13.49Z" }, + { url = "https://files.pythonhosted.org/packages/53/d0/321b5301e36ac1577dbf73cb49769779c41ebf72ba70a3f6f62d34df902b/pyinstrument-5.0.2-cp312-cp312-win32.whl", hash = "sha256:29e565ce85e03d2541330a8174124c1ecdb073d945962a8eb738d3b1c806ac83", size = 123491, upload-time = "2025-05-24T15:46:15.319Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a6/40f05febe6ab0856b4bfa119113d550d868d94a36b501e6b9fd64379b4ba/pyinstrument-5.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:300b0cc453ffe7661d5f3ceb94cdd98996fd9118f5ff1182b5336489c7d4e45c", size = 124277, upload-time = "2025-05-24T15:46:16.693Z" }, + { url = "https://files.pythonhosted.org/packages/03/88/48654e4b8c6853f218e0506e0609060a54559500b3af5ed6ac752ac4d64f/pyinstrument-5.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8141a5f78b927a88de46fb2bbb17e710e41d16e161fca99991635ff7196dbd5d", size = 129528, upload-time = "2025-05-24T15:46:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/92/a7/885418b733350f6c2b1d8fcca322a1eee87216a266ac516d7aefd6757ec8/pyinstrument-5.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:12a0095ae408dbbdd429501fd4c6a3ab51d1aeff5f31be36cc3eedc8c4870ede", size = 122072, upload-time = "2025-05-24T15:46:19.513Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d5/dd0b323d2949d1a3ee0531ec6cdd66c3c69c13b9a8739aeec929a0b55fd2/pyinstrument-5.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eca651d840e8e75ae5330abfc5c90f6ea4af3f78f9f0269231328305a5f9c667", size = 146874, upload-time = "2025-05-24T15:46:21.38Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3b/429572b57c9ae2874e86c48db91ddcd5d619bd798f73d7d2e51b28abb08d/pyinstrument-5.0.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:89d6ffc5459b19f1c85d4433bb9bbc8925ec04a8d7caf2694218b1f557555f23", size = 155257, upload-time = "2025-05-24T15:46:22.791Z" }, + { url = "https://files.pythonhosted.org/packages/7a/98/03cd22f68607362fd8d1ba72e6367104a9dc32bd4a0dbafc823c4e366f35/pyinstrument-5.0.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c84845ccc5318072708dc5535b6bedd54494e92a68e282e6b97b53c1db65331", size = 144380, upload-time = "2025-05-24T15:46:24.26Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c4/40d7b4be6c9620c4d9bbe9788eb9bac892f386c9bd40f1937464b2b95c09/pyinstrument-5.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6511092384b5729bbbf4b35534120d2969c5fdfd4f39080badedd973676b8725", size = 145794, upload-time = "2025-05-24T15:46:25.751Z" }, + { url = "https://files.pythonhosted.org/packages/05/07/3b2084b78521d5bbbc328ca9527fb54fbf645a5e62f25169b49f7bbb0bc3/pyinstrument-5.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73f08cff7a8d9714be15440046289ab1a70cbc429e09967a3a106ac61538773e", size = 145803, upload-time = "2025-05-24T15:46:27.277Z" }, + { url = "https://files.pythonhosted.org/packages/22/eb/e3ffcc8734e3d9f50b6bb750209c3ad0c4626dcc3754529741499d9f1d5c/pyinstrument-5.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3905b510cdab1a8255a23fbdedcba4685245cbf814fd80f5b2005b472161d16e", size = 145763, upload-time = "2025-05-24T15:46:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/c6/34/6b94945a02afced9e486e9a6b20de0edcfec543e4942dea96d745e2148ac/pyinstrument-5.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cd693a616166679da529168037c294ff25746c7ae5e8b547811fb25bb26439f5", size = 145208, upload-time = "2025-05-24T15:46:30.125Z" }, + { url = "https://files.pythonhosted.org/packages/99/af/0339bbfe52de9a7df01e5a244a5fec4c228d23b1f422a55318fc6d0b9d91/pyinstrument-5.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:83a1659a3bc4123c81fcddfcc86608f37bd6a951da9692766c2251500a77ac06", size = 145591, upload-time = "2025-05-24T15:46:31.556Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f4/76a2c652e203c15cbc7aa3f8341e07d1ea865764b3ed9f9a97b3c4a5eda2/pyinstrument-5.0.2-cp313-cp313-win32.whl", hash = "sha256:386d047db6c043dcc86bac592873234a89eaa258460e1ad8f47a11fcc7b024d5", size = 123490, upload-time = "2025-05-24T15:46:32.951Z" }, + { url = "https://files.pythonhosted.org/packages/e4/63/14f5c6253e8c85c758485c7717f542346a0d4487818afc28721912a1574b/pyinstrument-5.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:971c974c061019fa6177a021882255e639399bc15bf71b0a17979830702ad8d3", size = 124287, upload-time = "2025-05-24T15:46:34.333Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e2/26f60a7af42d5b93279ee81a7a176f9369cd65e4a4d800521e1ddf2c341f/pyinstrument-5.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:39cc70da2bf101e650bfcfbfea8270e308de47d785965203b42547f8af222d9e", size = 129634, upload-time = "2025-05-24T15:46:54.791Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b9/108149137ed72432aa775be19eb079c60deac76552809950a3016923c683/pyinstrument-5.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:230f6d4b5f41136a4f316fee2c9a9e7934ef89f498417d88aff5cbc8cf805b80", size = 122217, upload-time = "2025-05-24T15:46:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/29/ce/1c21256fe4af106e62d098d87a93f8bde4ba4aec99f99bcfbb6dd9cc1349/pyinstrument-5.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:034245ff794d0f3d5d81ca87642df0b16e3a2fcbced374e28ad8a1fe34758672", size = 146550, upload-time = "2025-05-24T15:46:57.623Z" }, + { url = "https://files.pythonhosted.org/packages/08/c6/4fcefada84c98ec18984e1fde54438c5fc894d2afc868a9d5962c0ab69f0/pyinstrument-5.0.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:953abfbae7bcefd496831451c33f15957b485df7f51a7108dc8e857bb8c4b5cf", size = 156570, upload-time = "2025-05-24T15:46:59.159Z" }, + { url = "https://files.pythonhosted.org/packages/73/d6/f5bae351da87609289268bce331a9bd24819bcf17129de07072b921fdcfd/pyinstrument-5.0.2-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eceadce147d9e7332c26c3beecdd25bb054d531d6ed75ab804e15310aa93e0fd", size = 144349, upload-time = "2025-05-24T15:47:00.608Z" }, + { url = "https://files.pythonhosted.org/packages/fa/fd/57b56cfdb9b0fb99aa66b9ada8b8cdb3e77112c1aa108037e014ce96cc5d/pyinstrument-5.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52bfaff4bad998163f9884a70418270389835a775164b4886357818b5736068c", size = 145279, upload-time = "2025-05-24T15:47:01.981Z" }, + { url = "https://files.pythonhosted.org/packages/cb/42/b7823f4df8289ae14d4ed57cd0880112063f45556468ff1af1506a9fb60a/pyinstrument-5.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3daeb12ca1af4ded4acc017991e646c5ad6db4c8c81195917603756674e6a5d0", size = 145580, upload-time = "2025-05-24T15:47:03.767Z" }, + { url = "https://files.pythonhosted.org/packages/c7/33/c2a307a285463dab10ef4507e44154e775b8437711ece1e9919fcee51ad3/pyinstrument-5.0.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:7ee5c9dc172879b7baa8884d38ef0ab4484fae382aee12a016a10aea8a67118e", size = 145000, upload-time = "2025-05-24T15:47:05.184Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f0/08dc32486782888cb238481adda0cf4d7d020b730a17774382d57e6f4745/pyinstrument-5.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4c990d675b7d9cb13af4abbb356c5bc65a4719befaf9deb6779aa9b194761654", size = 144652, upload-time = "2025-05-24T15:47:06.674Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2e/780a190cc50abc73b8bd533476da81c5de51cde5358f5166d3eca8553830/pyinstrument-5.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:640db54338ff66cb28c119f3c0ea0e158e112eb8477a12b94e85a19504a37235", size = 145095, upload-time = "2025-05-24T15:47:08.142Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8c/a004986442e75b916bc9fe83abc6da2d10f209af7634eb550552086ff39c/pyinstrument-5.0.2-cp39-cp39-win32.whl", hash = "sha256:7e611c9bffa0c446d694f40e56b2ab266ca97f0d093b16c360c1318625f0173b", size = 123428, upload-time = "2025-05-24T15:47:09.936Z" }, + { url = "https://files.pythonhosted.org/packages/55/55/a2b57979000af71adbf2cf0fcba35454e141af53466f7607eaff1db675d2/pyinstrument-5.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:cdec7ff308930f349904fdc1cb45491a157900303975572ee2dfb55feba79405", size = 124310, upload-time = "2025-05-24T15:47:11.767Z" }, +] + +[[package]] +name = "pyopenssl" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/8c/cd89ad05804f8e3c17dea8f178c3f40eeab5694c30e0c9f5bcd49f576fc3/pyopenssl-25.1.0.tar.gz", hash = "sha256:8d031884482e0c67ee92bf9a4d8cceb08d92aba7136432ffb0703c5280fc205b", size = 179937, upload-time = "2025-05-17T16:28:31.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/28/2659c02301b9500751f8d42f9a6632e1508aa5120de5e43042b8b30f8d5d/pyopenssl-25.1.0-py3-none-any.whl", hash = "sha256:2b11f239acc47ac2e5aca04fd7fa829800aeee22a2eb30d744572a157bd8a1ab", size = 56771, upload-time = "2025-05-17T16:28:29.197Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.401" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/9a/7ab2b333b921b2d6bfcffe05a0e0a0bbeff884bd6fb5ed50cd68e2898e53/pyright-1.1.401.tar.gz", hash = "sha256:788a82b6611fa5e34a326a921d86d898768cddf59edde8e93e56087d277cc6f1", size = 3894193, upload-time = "2025-05-21T10:44:52.03Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/e6/1f908fce68b0401d41580e0f9acc4c3d1b248adcff00dfaad75cd21a1370/pyright-1.1.401-py3-none-any.whl", hash = "sha256:6fde30492ba5b0d7667c16ecaf6c699fab8d7a1263f6a18549e0b00bf7724c06", size = 5629193, upload-time = "2025-05-21T10:44:50.129Z" }, +] + +[[package]] +name = "pytest" +version = "7.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/1f/9d8e98e4133ffb16c90f3b405c43e38d3abb715bb5d7a63a5a684f7e46a3/pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280", size = 1357116, upload-time = "2023-12-31T12:00:18.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8", size = 325287, upload-time = "2023-12-31T12:00:13.963Z" }, +] + +[[package]] +name = "pytest-aiohttp" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/4b/d326890c153f2c4ce1bf45d07683c08c10a1766058a22934620bc6ac6592/pytest_aiohttp-1.1.0.tar.gz", hash = "sha256:147de8cb164f3fc9d7196967f109ab3c0b93ea3463ab50631e56438eab7b5adc", size = 12842, upload-time = "2025-01-23T12:44:04.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/0f/e6af71c02e0f1098eaf7d2dbf3ffdf0a69fc1e0ef174f96af05cef161f1b/pytest_aiohttp-1.1.0-py3-none-any.whl", hash = "sha256:f39a11693a0dce08dd6c542d241e199dd8047a6e6596b2bcfa60d373f143456d", size = 8932, upload-time = "2025-01-23T12:44:03.27Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.23.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/b4/0b378b7bf26a8ae161c3890c0b48a91a04106c5713ce81b4b080ea2f4f18/pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3", size = 46920, upload-time = "2024-07-17T17:39:34.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/82/62e2d63639ecb0fbe8a7ee59ef0bc69a4669ec50f6d3459f74ad4e4189a2/pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2", size = 17663, upload-time = "2024-07-17T17:39:32.478Z" }, +] + +[[package]] +name = "pytest-codspeed" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "pytest" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/98/16fe3895b1b8a6d537a89eecb120b97358df8f0002c6ecd11555d6304dc8/pytest_codspeed-3.2.0.tar.gz", hash = "sha256:f9d1b1a3b2c69cdc0490a1e8b1ced44bffbd0e8e21d81a7160cfdd923f6e8155", size = 18409, upload-time = "2025-01-31T14:28:26.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/31/62b93ee025ca46016d01325f58997d32303752286bf929588c8796a25b13/pytest_codspeed-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5165774424c7ab8db7e7acdb539763a0e5657996effefdf0664d7fd95158d34", size = 26802, upload-time = "2025-01-31T14:28:10.723Z" }, + { url = "https://files.pythonhosted.org/packages/89/60/2bc46bdf8c8ddb7e59cd9d480dc887d0ac6039f88c856d1ae3d29a4e648d/pytest_codspeed-3.2.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bd55f92d772592c04a55209950c50880413ae46876e66bd349ef157075ca26c", size = 25442, upload-time = "2025-01-31T14:28:11.774Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/1b65ba0ae1af7fd7ce14a66e7599833efe8bbd0fcecd3614db0017ca224a/pytest_codspeed-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf6f56067538f4892baa8d7ab5ef4e45bb59033be1ef18759a2c7fc55b32035", size = 26810, upload-time = "2025-01-31T14:28:12.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/e6/d1fafb09a1c4983372f562d9e158735229cb0b11603a61d4fad05463f977/pytest_codspeed-3.2.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a687b05c3d145642061b45ea78e47e12f13ce510104d1a2cda00eee0e36f58", size = 25442, upload-time = "2025-01-31T14:28:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8b/9e95472589d17bb68960f2a09cfa8f02c4d43c82de55b73302bbe0fa4350/pytest_codspeed-3.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46a1afaaa1ac4c2ca5b0700d31ac46d80a27612961d031067d73c6ccbd8d3c2b", size = 27182, upload-time = "2025-01-31T14:28:15.828Z" }, + { url = "https://files.pythonhosted.org/packages/2a/18/82aaed8095e84d829f30dda3ac49fce4e69685d769aae463614a8d864cdd/pytest_codspeed-3.2.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c48ce3af3dfa78413ed3d69d1924043aa1519048dbff46edccf8f35a25dab3c2", size = 25933, upload-time = "2025-01-31T14:28:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/e2/15/60b18d40da66e7aa2ce4c4c66d5a17de20a2ae4a89ac09a58baa7a5bc535/pytest_codspeed-3.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66692506d33453df48b36a84703448cb8b22953eea51f03fbb2eb758dc2bdc4f", size = 27180, upload-time = "2025-01-31T14:28:18.056Z" }, + { url = "https://files.pythonhosted.org/packages/51/bd/6b164d4ae07d8bea5d02ad664a9762bdb63f83c0805a3c8fe7dc6ec38407/pytest_codspeed-3.2.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:479774f80d0bdfafa16112700df4dbd31bf2a6757fac74795fd79c0a7b3c389b", size = 25923, upload-time = "2025-01-31T14:28:19.725Z" }, + { url = "https://files.pythonhosted.org/packages/90/bb/5d73c59d750264863c25fc202bcc37c5f8a390df640a4760eba54151753e/pytest_codspeed-3.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:109f9f4dd1088019c3b3f887d003b7d65f98a7736ca1d457884f5aa293e8e81c", size = 26795, upload-time = "2025-01-31T14:28:22.021Z" }, + { url = "https://files.pythonhosted.org/packages/65/17/d4bf207b63f1edc5b9c06ad77df565d186e0fd40f13459bb124304b54b1d/pytest_codspeed-3.2.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2f69a03b52c9bb041aec1b8ee54b7b6c37a6d0a948786effa4c71157765b6da", size = 25433, upload-time = "2025-01-31T14:28:22.955Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9b/952c70bd1fae9baa58077272e7f191f377c86d812263c21b361195e125e6/pytest_codspeed-3.2.0-py3-none-any.whl", hash = "sha256:54b5c2e986d6a28e7b0af11d610ea57bd5531cec8326abe486f1b55b09d91c39", size = 15007, upload-time = "2025-01-31T14:28:24.458Z" }, +] + +[[package]] +name = "pytest-cov" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/15/da3df99fd551507694a9b01f512a2f6cf1254f33601605843c3775f39460/pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6", size = 63245, upload-time = "2023-05-24T18:44:56.845Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/4b/8b78d126e275efa2379b1c2e09dc52cf70df16fc3b90613ef82531499d73/pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a", size = 21949, upload-time = "2023-05-24T18:44:54.079Z" }, +] + +[[package]] +name = "pytest-django" +version = "4.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/fb/55d580352db26eb3d59ad50c64321ddfe228d3d8ac107db05387a2fadf3a/pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991", size = 86202, upload-time = "2025-04-03T18:56:09.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281, upload-time = "2025-04-03T18:56:07.678Z" }, +] + +[[package]] +name = "pytest-emoji" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/4d/d489f939f0717a034cea7955d36bc2a7a5ba1b263871e63ad8cb16d47555/pytest-emoji-0.2.0.tar.gz", hash = "sha256:e1bd4790d87649c2d09c272c88bdfc4d37c1cc7c7a46583087d7c510944571e8", size = 6171, upload-time = "2019-02-19T09:33:17.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/51/80af966c0aded877da7577d21c4601ca98c6f603c6e6073ddea071af01ec/pytest_emoji-0.2.0-py3-none-any.whl", hash = "sha256:6e34ed21970fa4b80a56ad11417456bd873eb066c02315fe9df0fafe6d4d4436", size = 5664, upload-time = "2019-02-19T09:33:15.771Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/28/67172c96ba684058a4d24ffe144d64783d2a270d0af0d9e792737bddc75c/pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e", size = 33241, upload-time = "2025-05-26T13:58:45.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" }, +] + +[[package]] +name = "pytest-snapshot" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/7b/ab8f1fc1e687218aa66acec1c3674d9c443f6a2dc8cb6a50f464548ffa34/pytest-snapshot-0.9.0.tar.gz", hash = "sha256:c7013c3abc3e860f9feff899f8b4debe3708650d8d8242a61bf2625ff64db7f3", size = 19877, upload-time = "2022-04-23T17:35:31.751Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/29/518f32faf6edad9f56d6e0107217f7de6b79f297a47170414a2bd4be7f01/pytest_snapshot-0.9.0-py3-none-any.whl", hash = "sha256:4b9fe1c21c868fe53a545e4e3184d36bc1c88946e3f5c1d9dd676962a9b3d4ab", size = 10715, upload-time = "2022-04-23T17:35:30.288Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/dc/865845cfe987b21658e871d16e0a24e871e00884c545f246dd8f6f69edda/pytest_xdist-3.7.0.tar.gz", hash = "sha256:f9248c99a7c15b7d2f90715df93610353a485827bc06eefb6566d23f6400f126", size = 87550, upload-time = "2025-05-26T21:18:20.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/b2/0e802fde6f1c5b2f7ae7e9ad42b83fd4ecebac18a8a8c2f2f14e39dce6e1/pytest_xdist-3.7.0-py3-none-any.whl", hash = "sha256:7d3fbd255998265052435eb9daa4e99b62e6fb9cfb6efd1f858d4d8c0c7f0ca0", size = 46142, upload-time = "2025-05-26T21:18:18.759Z" }, +] + +[package.optional-dependencies] +psutil = [ + { name = "psutil" }, +] + +[[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-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, + { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777, upload-time = "2024-08-06T20:33:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318, upload-time = "2024-08-06T20:33:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891, upload-time = "2024-08-06T20:33:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614, upload-time = "2024-08-06T20:33:34.157Z" }, + { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360, upload-time = "2024-08-06T20:33:35.84Z" }, + { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006, upload-time = "2024-08-06T20:33:37.501Z" }, + { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577, upload-time = "2024-08-06T20:33:39.389Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593, upload-time = "2024-08-06T20:33:46.63Z" }, + { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312, upload-time = "2024-08-06T20:33:49.073Z" }, +] + +[[package]] +name = "pyyaml-ft" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/eb/5a0d575de784f9a1f94e2b1288c6886f13f34185e13117ed530f32b6f8a8/pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab", size = 141057, upload-time = "2025-06-10T15:32:15.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/ba/a067369fe61a2e57fb38732562927d5bae088c73cb9bb5438736a9555b29/pyyaml_ft-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c1306282bc958bfda31237f900eb52c9bedf9b93a11f82e1aab004c9a5657a6", size = 187027, upload-time = "2025-06-10T15:31:48.722Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c5/a3d2020ce5ccfc6aede0d45bcb870298652ac0cf199f67714d250e0cdf39/pyyaml_ft-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30c5f1751625786c19de751e3130fc345ebcba6a86f6bddd6e1285342f4bbb69", size = 176146, upload-time = "2025-06-10T15:31:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bb/23a9739291086ca0d3189eac7cd92b4d00e9fdc77d722ab610c35f9a82ba/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fa992481155ddda2e303fcc74c79c05eddcdbc907b888d3d9ce3ff3e2adcfb0", size = 746792, upload-time = "2025-06-10T15:31:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c2/e8825f4ff725b7e560d62a3609e31d735318068e1079539ebfde397ea03e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cec6c92b4207004b62dfad1f0be321c9f04725e0f271c16247d8b39c3bf3ea42", size = 786772, upload-time = "2025-06-10T15:31:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/35/be/58a4dcae8854f2fdca9b28d9495298fd5571a50d8430b1c3033ec95d2d0e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06237267dbcab70d4c0e9436d8f719f04a51123f0ca2694c00dd4b68c338e40b", size = 778723, upload-time = "2025-06-10T15:31:56.093Z" }, + { url = "https://files.pythonhosted.org/packages/86/ed/fed0da92b5d5d7340a082e3802d84c6dc9d5fa142954404c41a544c1cb92/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a7f332bc565817644cdb38ffe4739e44c3e18c55793f75dddb87630f03fc254", size = 758478, upload-time = "2025-06-10T15:31:58.314Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/ac02afe286275980ecb2dcdc0156617389b7e0c0a3fcdedf155c67be2b80/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d10175a746be65f6feb86224df5d6bc5c049ebf52b89a88cf1cd78af5a367a8", size = 799159, upload-time = "2025-06-10T15:31:59.675Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ac/c492a9da2e39abdff4c3094ec54acac9747743f36428281fb186a03fab76/pyyaml_ft-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:58e1015098cf8d8aec82f360789c16283b88ca670fe4275ef6c48c5e30b22a96", size = 158779, upload-time = "2025-06-10T15:32:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9b/41998df3298960d7c67653669f37710fa2d568a5fc933ea24a6df60acaf6/pyyaml_ft-8.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5f3e2ceb790d50602b2fd4ec37abbd760a8c778e46354df647e7c5a4ebb", size = 191331, upload-time = "2025-06-10T15:32:02.602Z" }, + { url = "https://files.pythonhosted.org/packages/0f/16/2710c252ee04cbd74d9562ebba709e5a284faeb8ada88fcda548c9191b47/pyyaml_ft-8.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d445bf6ea16bb93c37b42fdacfb2f94c8e92a79ba9e12768c96ecde867046d1", size = 182879, upload-time = "2025-06-10T15:32:04.466Z" }, + { url = "https://files.pythonhosted.org/packages/9a/40/ae8163519d937fa7bfa457b6f78439cc6831a7c2b170e4f612f7eda71815/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c56bb46b4fda34cbb92a9446a841da3982cdde6ea13de3fbd80db7eeeab8b49", size = 811277, upload-time = "2025-06-10T15:32:06.214Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/28d82dbff7f87b96f0eeac79b7d972a96b4980c1e445eb6a857ba91eda00/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dab0abb46eb1780da486f022dce034b952c8ae40753627b27a626d803926483b", size = 831650, upload-time = "2025-06-10T15:32:08.076Z" }, + { url = "https://files.pythonhosted.org/packages/e8/df/161c4566facac7d75a9e182295c223060373d4116dead9cc53a265de60b9/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd48d639cab5ca50ad957b6dd632c7dd3ac02a1abe0e8196a3c24a52f5db3f7a", size = 815755, upload-time = "2025-06-10T15:32:09.435Z" }, + { url = "https://files.pythonhosted.org/packages/05/10/f42c48fa5153204f42eaa945e8d1fd7c10d6296841dcb2447bf7da1be5c4/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:052561b89d5b2a8e1289f326d060e794c21fa068aa11255fe71d65baf18a632e", size = 810403, upload-time = "2025-06-10T15:32:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/e369064aa51009eb9245399fd8ad2c562bd0bcd392a00be44b2a824ded7c/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3bb4b927929b0cb162fb1605392a321e3333e48ce616cdcfa04a839271373255", size = 835581, upload-time = "2025-06-10T15:32:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/c0/28/26534bed77109632a956977f60d8519049f545abc39215d086e33a61f1f2/pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793", size = 171579, upload-time = "2025-06-10T15:32:14.34Z" }, +] + +[[package]] +name = "quart" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "blinker" }, + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "flask" }, + { name = "hypercorn" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/9d/12e1143a5bd2ccc05c293a6f5ae1df8fd94a8fc1440ecc6c344b2b30ce13/quart-0.20.0.tar.gz", hash = "sha256:08793c206ff832483586f5ae47018c7e40bdd75d886fee3fabbdaa70c2cf505d", size = 63874, upload-time = "2024-12-23T13:53:05.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/e9/cc28f21f52913adf333f653b9e0a3bf9cb223f5083a26422968ba73edd8d/quart-0.20.0-py3-none-any.whl", hash = "sha256:003c08f551746710acb757de49d9b768986fd431517d0eb127380b656b98b8f1", size = 77960, upload-time = "2024-12-23T13:53:02.842Z" }, +] + +[[package]] +name = "rapidfuzz" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/f6/6895abc3a3d056b9698da3199b04c0e56226d530ae44a470edabf8b664f0/rapidfuzz-3.13.0.tar.gz", hash = "sha256:d2eaf3839e52cbcc0accbe9817a67b4b0fcf70aaeb229cfddc1c28061f9ce5d8", size = 57904226, upload-time = "2025-04-03T20:38:51.226Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/27/ca10b3166024ae19a7e7c21f73c58dfd4b7fef7420e5497ee64ce6b73453/rapidfuzz-3.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:aafc42a1dc5e1beeba52cd83baa41372228d6d8266f6d803c16dbabbcc156255", size = 1998899, upload-time = "2025-04-03T20:35:08.764Z" }, + { url = "https://files.pythonhosted.org/packages/f0/38/c4c404b13af0315483a6909b3a29636e18e1359307fb74a333fdccb3730d/rapidfuzz-3.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:85c9a131a44a95f9cac2eb6e65531db014e09d89c4f18c7b1fa54979cb9ff1f3", size = 1449949, upload-time = "2025-04-03T20:35:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/12/ae/15c71d68a6df6b8e24595421fdf5bcb305888318e870b7be8d935a9187ee/rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d7cec4242d30dd521ef91c0df872e14449d1dffc2a6990ede33943b0dae56c3", size = 1424199, upload-time = "2025-04-03T20:35:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9a/765beb9e14d7b30d12e2d6019e8b93747a0bedbc1d0cce13184fa3825426/rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e297c09972698c95649e89121e3550cee761ca3640cd005e24aaa2619175464e", size = 5352400, upload-time = "2025-04-03T20:35:15.421Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b8/49479fe6f06b06cd54d6345ed16de3d1ac659b57730bdbe897df1e059471/rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef0f5f03f61b0e5a57b1df7beafd83df993fd5811a09871bad6038d08e526d0d", size = 1652465, upload-time = "2025-04-03T20:35:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d8/08823d496b7dd142a7b5d2da04337df6673a14677cfdb72f2604c64ead69/rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d8cf5f7cd6e4d5eb272baf6a54e182b2c237548d048e2882258336533f3f02b7", size = 1616590, upload-time = "2025-04-03T20:35:20.482Z" }, + { url = "https://files.pythonhosted.org/packages/38/d4/5cfbc9a997e544f07f301c54d42aac9e0d28d457d543169e4ec859b8ce0d/rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9256218ac8f1a957806ec2fb9a6ddfc6c32ea937c0429e88cf16362a20ed8602", size = 3086956, upload-time = "2025-04-03T20:35:22.756Z" }, + { url = "https://files.pythonhosted.org/packages/25/1e/06d8932a72fa9576095234a15785136407acf8f9a7dbc8136389a3429da1/rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e1bdd2e6d0c5f9706ef7595773a81ca2b40f3b33fd7f9840b726fb00c6c4eb2e", size = 2494220, upload-time = "2025-04-03T20:35:25.563Z" }, + { url = "https://files.pythonhosted.org/packages/03/16/5acf15df63119d5ca3d9a54b82807866ff403461811d077201ca351a40c3/rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5280be8fd7e2bee5822e254fe0a5763aa0ad57054b85a32a3d9970e9b09bbcbf", size = 7585481, upload-time = "2025-04-03T20:35:27.426Z" }, + { url = "https://files.pythonhosted.org/packages/e1/cf/ebade4009431ea8e715e59e882477a970834ddaacd1a670095705b86bd0d/rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd742c03885db1fce798a1cd87a20f47f144ccf26d75d52feb6f2bae3d57af05", size = 2894842, upload-time = "2025-04-03T20:35:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bd/0732632bd3f906bf613229ee1b7cbfba77515db714a0e307becfa8a970ae/rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5435fcac94c9ecf0504bf88a8a60c55482c32e18e108d6079a0089c47f3f8cf6", size = 3438517, upload-time = "2025-04-03T20:35:31.381Z" }, + { url = "https://files.pythonhosted.org/packages/83/89/d3bd47ec9f4b0890f62aea143a1e35f78f3d8329b93d9495b4fa8a3cbfc3/rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:93a755266856599be4ab6346273f192acde3102d7aa0735e2f48b456397a041f", size = 4412773, upload-time = "2025-04-03T20:35:33.425Z" }, + { url = "https://files.pythonhosted.org/packages/b3/57/1a152a07883e672fc117c7f553f5b933f6e43c431ac3fd0e8dae5008f481/rapidfuzz-3.13.0-cp310-cp310-win32.whl", hash = "sha256:3abe6a4e8eb4cfc4cda04dd650a2dc6d2934cbdeda5def7e6fd1c20f6e7d2a0b", size = 1842334, upload-time = "2025-04-03T20:35:35.648Z" }, + { url = "https://files.pythonhosted.org/packages/a7/68/7248addf95b6ca51fc9d955161072285da3059dd1472b0de773cff910963/rapidfuzz-3.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:e8ddb58961401da7d6f55f185512c0d6bd24f529a637078d41dd8ffa5a49c107", size = 1624392, upload-time = "2025-04-03T20:35:37.294Z" }, + { url = "https://files.pythonhosted.org/packages/68/23/f41c749f2c61ed1ed5575eaf9e73ef9406bfedbf20a3ffa438d15b5bf87e/rapidfuzz-3.13.0-cp310-cp310-win_arm64.whl", hash = "sha256:c523620d14ebd03a8d473c89e05fa1ae152821920c3ff78b839218ff69e19ca3", size = 865584, upload-time = "2025-04-03T20:35:39.005Z" }, + { url = "https://files.pythonhosted.org/packages/87/17/9be9eff5a3c7dfc831c2511262082c6786dca2ce21aa8194eef1cb71d67a/rapidfuzz-3.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d395a5cad0c09c7f096433e5fd4224d83b53298d53499945a9b0e5a971a84f3a", size = 1999453, upload-time = "2025-04-03T20:35:40.804Z" }, + { url = "https://files.pythonhosted.org/packages/75/67/62e57896ecbabe363f027d24cc769d55dd49019e576533ec10e492fcd8a2/rapidfuzz-3.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7b3eda607a019169f7187328a8d1648fb9a90265087f6903d7ee3a8eee01805", size = 1450881, upload-time = "2025-04-03T20:35:42.734Z" }, + { url = "https://files.pythonhosted.org/packages/96/5c/691c5304857f3476a7b3df99e91efc32428cbe7d25d234e967cc08346c13/rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98e0bfa602e1942d542de077baf15d658bd9d5dcfe9b762aff791724c1c38b70", size = 1422990, upload-time = "2025-04-03T20:35:45.158Z" }, + { url = "https://files.pythonhosted.org/packages/46/81/7a7e78f977496ee2d613154b86b203d373376bcaae5de7bde92f3ad5a192/rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bef86df6d59667d9655905b02770a0c776d2853971c0773767d5ef8077acd624", size = 5342309, upload-time = "2025-04-03T20:35:46.952Z" }, + { url = "https://files.pythonhosted.org/packages/51/44/12fdd12a76b190fe94bf38d252bb28ddf0ab7a366b943e792803502901a2/rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fedd316c165beed6307bf754dee54d3faca2c47e1f3bcbd67595001dfa11e969", size = 1656881, upload-time = "2025-04-03T20:35:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/27/ae/0d933e660c06fcfb087a0d2492f98322f9348a28b2cc3791a5dbadf6e6fb/rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5158da7f2ec02a930be13bac53bb5903527c073c90ee37804090614cab83c29e", size = 1608494, upload-time = "2025-04-03T20:35:51.646Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2c/4b2f8aafdf9400e5599b6ed2f14bc26ca75f5a923571926ccbc998d4246a/rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b6f913ee4618ddb6d6f3e387b76e8ec2fc5efee313a128809fbd44e65c2bbb2", size = 3072160, upload-time = "2025-04-03T20:35:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/60/7d/030d68d9a653c301114101c3003b31ce01cf2c3224034cd26105224cd249/rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d25fdbce6459ccbbbf23b4b044f56fbd1158b97ac50994eaae2a1c0baae78301", size = 2491549, upload-time = "2025-04-03T20:35:55.391Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cd/7040ba538fc6a8ddc8816a05ecf46af9988b46c148ddd7f74fb0fb73d012/rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:25343ccc589a4579fbde832e6a1e27258bfdd7f2eb0f28cb836d6694ab8591fc", size = 7584142, upload-time = "2025-04-03T20:35:57.71Z" }, + { url = "https://files.pythonhosted.org/packages/c1/96/85f7536fbceb0aa92c04a1c37a3fc4fcd4e80649e9ed0fb585382df82edc/rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a9ad1f37894e3ffb76bbab76256e8a8b789657183870be11aa64e306bb5228fd", size = 2896234, upload-time = "2025-04-03T20:35:59.969Z" }, + { url = "https://files.pythonhosted.org/packages/55/fd/460e78438e7019f2462fe9d4ecc880577ba340df7974c8a4cfe8d8d029df/rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5dc71ef23845bb6b62d194c39a97bb30ff171389c9812d83030c1199f319098c", size = 3437420, upload-time = "2025-04-03T20:36:01.91Z" }, + { url = "https://files.pythonhosted.org/packages/cc/df/c3c308a106a0993befd140a414c5ea78789d201cf1dfffb8fd9749718d4f/rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b7f4c65facdb94f44be759bbd9b6dda1fa54d0d6169cdf1a209a5ab97d311a75", size = 4410860, upload-time = "2025-04-03T20:36:04.352Z" }, + { url = "https://files.pythonhosted.org/packages/75/ee/9d4ece247f9b26936cdeaae600e494af587ce9bf8ddc47d88435f05cfd05/rapidfuzz-3.13.0-cp311-cp311-win32.whl", hash = "sha256:b5104b62711565e0ff6deab2a8f5dbf1fbe333c5155abe26d2cfd6f1849b6c87", size = 1843161, upload-time = "2025-04-03T20:36:06.802Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5a/d00e1f63564050a20279015acb29ecaf41646adfacc6ce2e1e450f7f2633/rapidfuzz-3.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:9093cdeb926deb32a4887ebe6910f57fbcdbc9fbfa52252c10b56ef2efb0289f", size = 1629962, upload-time = "2025-04-03T20:36:09.133Z" }, + { url = "https://files.pythonhosted.org/packages/3b/74/0a3de18bc2576b794f41ccd07720b623e840fda219ab57091897f2320fdd/rapidfuzz-3.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:f70f646751b6aa9d05be1fb40372f006cc89d6aad54e9d79ae97bd1f5fce5203", size = 866631, upload-time = "2025-04-03T20:36:11.022Z" }, + { url = "https://files.pythonhosted.org/packages/13/4b/a326f57a4efed8f5505b25102797a58e37ee11d94afd9d9422cb7c76117e/rapidfuzz-3.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a1a6a906ba62f2556372282b1ef37b26bca67e3d2ea957277cfcefc6275cca7", size = 1989501, upload-time = "2025-04-03T20:36:13.43Z" }, + { url = "https://files.pythonhosted.org/packages/b7/53/1f7eb7ee83a06c400089ec7cb841cbd581c2edd7a4b21eb2f31030b88daa/rapidfuzz-3.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2fd0975e015b05c79a97f38883a11236f5a24cca83aa992bd2558ceaa5652b26", size = 1445379, upload-time = "2025-04-03T20:36:16.439Z" }, + { url = "https://files.pythonhosted.org/packages/07/09/de8069a4599cc8e6d194e5fa1782c561151dea7d5e2741767137e2a8c1f0/rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d4e13593d298c50c4f94ce453f757b4b398af3fa0fd2fde693c3e51195b7f69", size = 1405986, upload-time = "2025-04-03T20:36:18.447Z" }, + { url = "https://files.pythonhosted.org/packages/5d/77/d9a90b39c16eca20d70fec4ca377fbe9ea4c0d358c6e4736ab0e0e78aaf6/rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed6f416bda1c9133000009d84d9409823eb2358df0950231cc936e4bf784eb97", size = 5310809, upload-time = "2025-04-03T20:36:20.324Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7d/14da291b0d0f22262d19522afaf63bccf39fc027c981233fb2137a57b71f/rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1dc82b6ed01acb536b94a43996a94471a218f4d89f3fdd9185ab496de4b2a981", size = 1629394, upload-time = "2025-04-03T20:36:22.256Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/79ed7e4fa58f37c0f8b7c0a62361f7089b221fe85738ae2dbcfb815e985a/rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9d824de871daa6e443b39ff495a884931970d567eb0dfa213d234337343835f", size = 1600544, upload-time = "2025-04-03T20:36:24.207Z" }, + { url = "https://files.pythonhosted.org/packages/4e/20/e62b4d13ba851b0f36370060025de50a264d625f6b4c32899085ed51f980/rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d18228a2390375cf45726ce1af9d36ff3dc1f11dce9775eae1f1b13ac6ec50f", size = 3052796, upload-time = "2025-04-03T20:36:26.279Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8d/55fdf4387dec10aa177fe3df8dbb0d5022224d95f48664a21d6b62a5299d/rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5fe634c9482ec5d4a6692afb8c45d370ae86755e5f57aa6c50bfe4ca2bdd87", size = 2464016, upload-time = "2025-04-03T20:36:28.525Z" }, + { url = "https://files.pythonhosted.org/packages/9b/be/0872f6a56c0f473165d3b47d4170fa75263dc5f46985755aa9bf2bbcdea1/rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:694eb531889f71022b2be86f625a4209c4049e74be9ca836919b9e395d5e33b3", size = 7556725, upload-time = "2025-04-03T20:36:30.629Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f3/6c0750e484d885a14840c7a150926f425d524982aca989cdda0bb3bdfa57/rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11b47b40650e06147dee5e51a9c9ad73bb7b86968b6f7d30e503b9f8dd1292db", size = 2859052, upload-time = "2025-04-03T20:36:32.836Z" }, + { url = "https://files.pythonhosted.org/packages/6f/98/5a3a14701b5eb330f444f7883c9840b43fb29c575e292e09c90a270a6e07/rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:98b8107ff14f5af0243f27d236bcc6e1ef8e7e3b3c25df114e91e3a99572da73", size = 3390219, upload-time = "2025-04-03T20:36:35.062Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7d/f4642eaaeb474b19974332f2a58471803448be843033e5740965775760a5/rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b836f486dba0aceb2551e838ff3f514a38ee72b015364f739e526d720fdb823a", size = 4377924, upload-time = "2025-04-03T20:36:37.363Z" }, + { url = "https://files.pythonhosted.org/packages/8e/83/fa33f61796731891c3e045d0cbca4436a5c436a170e7f04d42c2423652c3/rapidfuzz-3.13.0-cp312-cp312-win32.whl", hash = "sha256:4671ee300d1818d7bdfd8fa0608580d7778ba701817216f0c17fb29e6b972514", size = 1823915, upload-time = "2025-04-03T20:36:39.451Z" }, + { url = "https://files.pythonhosted.org/packages/03/25/5ee7ab6841ca668567d0897905eebc79c76f6297b73bf05957be887e9c74/rapidfuzz-3.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e2065f68fb1d0bf65adc289c1bdc45ba7e464e406b319d67bb54441a1b9da9e", size = 1616985, upload-time = "2025-04-03T20:36:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/5e/3f0fb88db396cb692aefd631e4805854e02120a2382723b90dcae720bcc6/rapidfuzz-3.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:65cc97c2fc2c2fe23586599686f3b1ceeedeca8e598cfcc1b7e56dc8ca7e2aa7", size = 860116, upload-time = "2025-04-03T20:36:43.915Z" }, + { url = "https://files.pythonhosted.org/packages/0a/76/606e71e4227790750f1646f3c5c873e18d6cfeb6f9a77b2b8c4dec8f0f66/rapidfuzz-3.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:09e908064d3684c541d312bd4c7b05acb99a2c764f6231bd507d4b4b65226c23", size = 1982282, upload-time = "2025-04-03T20:36:46.149Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/d0b48c6b902607a59fd5932a54e3518dae8223814db8349b0176e6e9444b/rapidfuzz-3.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:57c390336cb50d5d3bfb0cfe1467478a15733703af61f6dffb14b1cd312a6fae", size = 1439274, upload-time = "2025-04-03T20:36:48.323Z" }, + { url = "https://files.pythonhosted.org/packages/59/cf/c3ac8c80d8ced6c1f99b5d9674d397ce5d0e9d0939d788d67c010e19c65f/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0da54aa8547b3c2c188db3d1c7eb4d1bb6dd80baa8cdaeaec3d1da3346ec9caa", size = 1399854, upload-time = "2025-04-03T20:36:50.294Z" }, + { url = "https://files.pythonhosted.org/packages/09/5d/ca8698e452b349c8313faf07bfa84e7d1c2d2edf7ccc67bcfc49bee1259a/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df8e8c21e67afb9d7fbe18f42c6111fe155e801ab103c81109a61312927cc611", size = 5308962, upload-time = "2025-04-03T20:36:52.421Z" }, + { url = "https://files.pythonhosted.org/packages/66/0a/bebada332854e78e68f3d6c05226b23faca79d71362509dbcf7b002e33b7/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:461fd13250a2adf8e90ca9a0e1e166515cbcaa5e9c3b1f37545cbbeff9e77f6b", size = 1625016, upload-time = "2025-04-03T20:36:54.639Z" }, + { url = "https://files.pythonhosted.org/packages/de/0c/9e58d4887b86d7121d1c519f7050d1be5eb189d8a8075f5417df6492b4f5/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2b3dd5d206a12deca16870acc0d6e5036abeb70e3cad6549c294eff15591527", size = 1600414, upload-time = "2025-04-03T20:36:56.669Z" }, + { url = "https://files.pythonhosted.org/packages/9b/df/6096bc669c1311568840bdcbb5a893edc972d1c8d2b4b4325c21d54da5b1/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1343d745fbf4688e412d8f398c6e6d6f269db99a54456873f232ba2e7aeb4939", size = 3053179, upload-time = "2025-04-03T20:36:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/5179c583b75fce3e65a5cd79a3561bd19abd54518cb7c483a89b284bf2b9/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b1b065f370d54551dcc785c6f9eeb5bd517ae14c983d2784c064b3aa525896df", size = 2456856, upload-time = "2025-04-03T20:37:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/6b/64/e9804212e3286d027ac35bbb66603c9456c2bce23f823b67d2f5cabc05c1/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:11b125d8edd67e767b2295eac6eb9afe0b1cdc82ea3d4b9257da4b8e06077798", size = 7567107, upload-time = "2025-04-03T20:37:04.521Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f2/7d69e7bf4daec62769b11757ffc31f69afb3ce248947aadbb109fefd9f65/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c33f9c841630b2bb7e69a3fb5c84a854075bb812c47620978bddc591f764da3d", size = 2854192, upload-time = "2025-04-03T20:37:06.905Z" }, + { url = "https://files.pythonhosted.org/packages/05/21/ab4ad7d7d0f653e6fe2e4ccf11d0245092bef94cdff587a21e534e57bda8/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae4574cb66cf1e85d32bb7e9ec45af5409c5b3970b7ceb8dea90168024127566", size = 3398876, upload-time = "2025-04-03T20:37:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/45bba94c2489cb1ee0130dcb46e1df4fa2c2b25269e21ffd15240a80322b/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e05752418b24bbd411841b256344c26f57da1148c5509e34ea39c7eb5099ab72", size = 4377077, upload-time = "2025-04-03T20:37:11.929Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f3/5e0c6ae452cbb74e5436d3445467447e8c32f3021f48f93f15934b8cffc2/rapidfuzz-3.13.0-cp313-cp313-win32.whl", hash = "sha256:0e1d08cb884805a543f2de1f6744069495ef527e279e05370dd7c83416af83f8", size = 1822066, upload-time = "2025-04-03T20:37:14.425Z" }, + { url = "https://files.pythonhosted.org/packages/96/e3/a98c25c4f74051df4dcf2f393176b8663bfd93c7afc6692c84e96de147a2/rapidfuzz-3.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9a7c6232be5f809cd39da30ee5d24e6cadd919831e6020ec6c2391f4c3bc9264", size = 1615100, upload-time = "2025-04-03T20:37:16.611Z" }, + { url = "https://files.pythonhosted.org/packages/60/b1/05cd5e697c00cd46d7791915f571b38c8531f714832eff2c5e34537c49ee/rapidfuzz-3.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:3f32f15bacd1838c929b35c84b43618481e1b3d7a61b5ed2db0291b70ae88b53", size = 858976, upload-time = "2025-04-03T20:37:19.336Z" }, + { url = "https://files.pythonhosted.org/packages/24/23/fceeab4ed5d0ecddd573b19502547fdc9be80418628bb8947fc22e905844/rapidfuzz-3.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cc64da907114d7a18b5e589057e3acaf2fec723d31c49e13fedf043592a3f6a7", size = 2002049, upload-time = "2025-04-03T20:37:21.715Z" }, + { url = "https://files.pythonhosted.org/packages/f4/20/189c716da9e3c5a907b4620b6c326fc09c47dab10bf025b9482932b972ba/rapidfuzz-3.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4d9d7f84c8e992a8dbe5a3fdbea73d733da39bf464e62c912ac3ceba9c0cff93", size = 1452832, upload-time = "2025-04-03T20:37:24.008Z" }, + { url = "https://files.pythonhosted.org/packages/e3/3c/195f8c4b4a76e00c4d2f5f4ebec2c2108a81afbb1339a3378cf9b370bd02/rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a79a2f07786a2070669b4b8e45bd96a01c788e7a3c218f531f3947878e0f956", size = 1426492, upload-time = "2025-04-03T20:37:26.25Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8e/e1eca4b25ecdfed51750008e9b0f5d3539bbd897f8ea14f525738775d1b6/rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9f338e71c45b69a482de8b11bf4a029993230760120c8c6e7c9b71760b6825a1", size = 5343427, upload-time = "2025-04-03T20:37:28.959Z" }, + { url = "https://files.pythonhosted.org/packages/48/0d/366b972b54d7d6edd83c86ebcdf5ca446f35fba72d8b283a3629f0677b7f/rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb40ca8ddfcd4edd07b0713a860be32bdf632687f656963bcbce84cea04b8d8", size = 1649583, upload-time = "2025-04-03T20:37:31.435Z" }, + { url = "https://files.pythonhosted.org/packages/93/1b/7f5841392bae67e645dc39e49b37824028a400c489e8afb16eb1e5095da8/rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48719f7dcf62dfb181063b60ee2d0a39d327fa8ad81b05e3e510680c44e1c078", size = 1615186, upload-time = "2025-04-03T20:37:33.686Z" }, + { url = "https://files.pythonhosted.org/packages/5e/00/861a4601e4685efd8161966cf35728806fb9df112b6951585bb194f74379/rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9327a4577f65fc3fb712e79f78233815b8a1c94433d0c2c9f6bc5953018b3565", size = 3080994, upload-time = "2025-04-03T20:37:35.935Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5a/19c03bc9a550f63875d8db25c3d9b2e6d98757bd28ea1a1fd40ec6b22ee1/rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:200030dfc0a1d5d6ac18e993c5097c870c97c41574e67f227300a1fb74457b1d", size = 2492755, upload-time = "2025-04-03T20:37:38.665Z" }, + { url = "https://files.pythonhosted.org/packages/f0/44/5b860b4dcab7ee6f4ded818d5b0bf548772519386418ab84e9f395c7e995/rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cc269e74cad6043cb8a46d0ce580031ab642b5930562c2bb79aa7fbf9c858d26", size = 7577160, upload-time = "2025-04-03T20:37:41.056Z" }, + { url = "https://files.pythonhosted.org/packages/d0/64/22aab1c17c96ae344a06e5be692a62977d6acd5dd7f8470a8e068111282a/rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:e62779c6371bd2b21dbd1fdce89eaec2d93fd98179d36f61130b489f62294a92", size = 2891173, upload-time = "2025-04-03T20:37:43.647Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/e4928f158c5cebe2877dc11dea62d230cc02bd977992cf4bf33c41ae6ffe/rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f4797f821dc5d7c2b6fc818b89f8a3f37bcc900dd9e4369e6ebf1e525efce5db", size = 3434650, upload-time = "2025-04-03T20:37:47.015Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d7/a126c0f4ae2b7927d2b7a4206e2b98db2940591d4edcb350d772b97d18ba/rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d21f188f6fe4fbf422e647ae9d5a68671d00218e187f91859c963d0738ccd88c", size = 4414291, upload-time = "2025-04-03T20:37:49.55Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b0/3ad076cd513f5562b99c9e62760f7c451cd29f3d47d80ae40c8070e813f4/rapidfuzz-3.13.0-cp39-cp39-win32.whl", hash = "sha256:45dd4628dd9c21acc5c97627dad0bb791764feea81436fb6e0a06eef4c6dceaa", size = 1845012, upload-time = "2025-04-03T20:37:52.423Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0f/b6a37389f33c777de96b26f0ae1362d3524cad3fb84468a46346c24b6a98/rapidfuzz-3.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:624a108122039af89ddda1a2b7ab2a11abe60c1521956f142f5d11bcd42ef138", size = 1627071, upload-time = "2025-04-03T20:37:54.757Z" }, + { url = "https://files.pythonhosted.org/packages/89/10/ce1083b678db3e39b9a42244471501fb4d925b7cab0a771790d2ca3b3c27/rapidfuzz-3.13.0-cp39-cp39-win_arm64.whl", hash = "sha256:435071fd07a085ecbf4d28702a66fd2e676a03369ee497cc38bcb69a46bc77e2", size = 867233, upload-time = "2025-04-03T20:37:57.825Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e1/f5d85ae3c53df6f817ca70dbdd37c83f31e64caced5bb867bec6b43d1fdf/rapidfuzz-3.13.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fe5790a36d33a5d0a6a1f802aa42ecae282bf29ac6f7506d8e12510847b82a45", size = 1904437, upload-time = "2025-04-03T20:38:00.255Z" }, + { url = "https://files.pythonhosted.org/packages/db/d7/ded50603dddc5eb182b7ce547a523ab67b3bf42b89736f93a230a398a445/rapidfuzz-3.13.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:cdb33ee9f8a8e4742c6b268fa6bd739024f34651a06b26913381b1413ebe7590", size = 1383126, upload-time = "2025-04-03T20:38:02.676Z" }, + { url = "https://files.pythonhosted.org/packages/c4/48/6f795e793babb0120b63a165496d64f989b9438efbeed3357d9a226ce575/rapidfuzz-3.13.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c99b76b93f7b495eee7dcb0d6a38fb3ce91e72e99d9f78faa5664a881cb2b7d", size = 1365565, upload-time = "2025-04-03T20:38:06.646Z" }, + { url = "https://files.pythonhosted.org/packages/f0/50/0062a959a2d72ed17815824e40e2eefdb26f6c51d627389514510a7875f3/rapidfuzz-3.13.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6af42f2ede8b596a6aaf6d49fdee3066ca578f4856b85ab5c1e2145de367a12d", size = 5251719, upload-time = "2025-04-03T20:38:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/e7/02/bd8b70cd98b7a88e1621264778ac830c9daa7745cd63e838bd773b1aeebd/rapidfuzz-3.13.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c0efa73afbc5b265aca0d8a467ae2a3f40d6854cbe1481cb442a62b7bf23c99", size = 2991095, upload-time = "2025-04-03T20:38:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8d/632d895cdae8356826184864d74a5f487d40cb79f50a9137510524a1ba86/rapidfuzz-3.13.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7ac21489de962a4e2fc1e8f0b0da4aa1adc6ab9512fd845563fecb4b4c52093a", size = 1553888, upload-time = "2025-04-03T20:38:15.357Z" }, + { url = "https://files.pythonhosted.org/packages/88/df/6060c5a9c879b302bd47a73fc012d0db37abf6544c57591bcbc3459673bd/rapidfuzz-3.13.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1ba007f4d35a45ee68656b2eb83b8715e11d0f90e5b9f02d615a8a321ff00c27", size = 1905935, upload-time = "2025-04-03T20:38:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6c/a0b819b829e20525ef1bd58fc776fb8d07a0c38d819e63ba2b7c311a2ed4/rapidfuzz-3.13.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d7a217310429b43be95b3b8ad7f8fc41aba341109dc91e978cd7c703f928c58f", size = 1383714, upload-time = "2025-04-03T20:38:20.628Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/3da3466cc8a9bfb9cd345ad221fac311143b6a9664b5af4adb95b5e6ce01/rapidfuzz-3.13.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:558bf526bcd777de32b7885790a95a9548ffdcce68f704a81207be4a286c1095", size = 1367329, upload-time = "2025-04-03T20:38:23.01Z" }, + { url = "https://files.pythonhosted.org/packages/da/f0/9f2a9043bfc4e66da256b15d728c5fc2d865edf0028824337f5edac36783/rapidfuzz-3.13.0-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:202a87760f5145140d56153b193a797ae9338f7939eb16652dd7ff96f8faf64c", size = 5251057, upload-time = "2025-04-03T20:38:25.52Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ff/af2cb1d8acf9777d52487af5c6b34ce9d13381a753f991d95ecaca813407/rapidfuzz-3.13.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cfcccc08f671646ccb1e413c773bb92e7bba789e3a1796fd49d23c12539fe2e4", size = 2992401, upload-time = "2025-04-03T20:38:28.196Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c5/c243b05a15a27b946180db0d1e4c999bef3f4221505dff9748f1f6c917be/rapidfuzz-3.13.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1f219f1e3c3194d7a7de222f54450ce12bc907862ff9a8962d83061c1f923c86", size = 1553782, upload-time = "2025-04-03T20:38:30.778Z" }, + { url = "https://files.pythonhosted.org/packages/67/28/76470c1da02ea9c0ff299aa06d87057122e94b55db60c4f57acbce7b0432/rapidfuzz-3.13.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ccbd0e7ea1a216315f63ffdc7cd09c55f57851afc8fe59a74184cb7316c0598b", size = 1908943, upload-time = "2025-04-03T20:38:33.632Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ff/fde4ebbc55da03a6319106eb287d87e2bc5e177e0c90c95c735086993c40/rapidfuzz-3.13.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a50856f49a4016ef56edd10caabdaf3608993f9faf1e05c3c7f4beeac46bd12a", size = 1387875, upload-time = "2025-04-03T20:38:36.536Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a1/ef21859170e9d7e7e7ee818e9541b71da756189586f87e129c7b13c79dd3/rapidfuzz-3.13.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fd05336db4d0b8348d7eaaf6fa3c517b11a56abaa5e89470ce1714e73e4aca7", size = 1373040, upload-time = "2025-04-03T20:38:39.294Z" }, + { url = "https://files.pythonhosted.org/packages/58/c7/2361a8787f12166212c7d4ad4d2a01b640164686ea39ee26b24fd12acd3e/rapidfuzz-3.13.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:573ad267eb9b3f6e9b04febce5de55d8538a87c56c64bf8fd2599a48dc9d8b77", size = 5254220, upload-time = "2025-04-03T20:38:42.201Z" }, + { url = "https://files.pythonhosted.org/packages/1d/55/a965d98d5acf4a27ddd1d6621f086231dd243820e8108e8da7fa8a01ca1f/rapidfuzz-3.13.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30fd1451f87ccb6c2f9d18f6caa483116bbb57b5a55d04d3ddbd7b86f5b14998", size = 2990908, upload-time = "2025-04-03T20:38:44.794Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/e49988ee08ddb6ca8757785577da0fe2302cf759a5b246f50eded8d66fdd/rapidfuzz-3.13.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6dd36d4916cf57ddb05286ed40b09d034ca5d4bca85c17be0cb6a21290597d9", size = 1555134, upload-time = "2025-04-03T20:38:47.337Z" }, +] + +[[package]] +name = "readchar" +version = "4.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/f8/8657b8cbb4ebeabfbdf991ac40eca8a1d1bd012011bd44ad1ed10f5cb494/readchar-4.2.1.tar.gz", hash = "sha256:91ce3faf07688de14d800592951e5575e9c7a3213738ed01d394dcc949b79adb", size = 9685, upload-time = "2024-11-04T18:28:07.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/10/e4b1e0e5b6b6745c8098c275b69bc9d73e9542d5c7da4f137542b499ed44/readchar-4.2.1-py3-none-any.whl", hash = "sha256:a769305cd3994bb5fa2764aa4073452dc105a4ec39068ffe6efd3c20c60acc77", size = 9350, upload-time = "2024-11-04T18:28:02.859Z" }, +] + +[[package]] +name = "requests" +version = "2.32.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, +] + +[[package]] +name = "rich-click" +version = "1.8.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "rich", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/a8/dcc0a8ec9e91d76ecad9413a84b6d3a3310c6111cfe012d75ed385c78d96/rich_click-1.8.9.tar.gz", hash = "sha256:fd98c0ab9ddc1cf9c0b7463f68daf28b4d0033a74214ceb02f761b3ff2af3136", size = 39378, upload-time = "2025-05-19T21:33:05.569Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/c2/9fce4c8a9587c4e90500114d742fe8ef0fd92d7bad29d136bb9941add271/rich_click-1.8.9-py3-none-any.whl", hash = "sha256:c3fa81ed8a671a10de65a9e20abf642cfdac6fdb882db1ef465ee33919fbcfe2", size = 36082, upload-time = "2025-05-19T21:33:04.195Z" }, +] + +[[package]] +name = "ruff" +version = "0.11.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/da/9c6f995903b4d9474b39da91d2d626659af3ff1eeb43e9ae7c119349dba6/ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514", size = 4282054, upload-time = "2025-06-05T21:00:15.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ce/a11d381192966e0b4290842cc8d4fac7dc9214ddf627c11c1afff87da29b/ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46", size = 10292516, upload-time = "2025-06-05T20:59:32.944Z" }, + { url = "https://files.pythonhosted.org/packages/78/db/87c3b59b0d4e753e40b6a3b4a2642dfd1dcaefbff121ddc64d6c8b47ba00/ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48", size = 11106083, upload-time = "2025-06-05T20:59:37.03Z" }, + { url = "https://files.pythonhosted.org/packages/77/79/d8cec175856ff810a19825d09ce700265f905c643c69f45d2b737e4a470a/ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b", size = 10436024, upload-time = "2025-06-05T20:59:39.741Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5b/f6d94f2980fa1ee854b41568368a2e1252681b9238ab2895e133d303538f/ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a", size = 10646324, upload-time = "2025-06-05T20:59:42.185Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9c/b4c2acf24ea4426016d511dfdc787f4ce1ceb835f3c5fbdbcb32b1c63bda/ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc", size = 10174416, upload-time = "2025-06-05T20:59:44.319Z" }, + { url = "https://files.pythonhosted.org/packages/f3/10/e2e62f77c65ede8cd032c2ca39c41f48feabedb6e282bfd6073d81bb671d/ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629", size = 11724197, upload-time = "2025-06-05T20:59:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f0/466fe8469b85c561e081d798c45f8a1d21e0b4a5ef795a1d7f1a9a9ec182/ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933", size = 12511615, upload-time = "2025-06-05T20:59:49.534Z" }, + { url = "https://files.pythonhosted.org/packages/17/0e/cefe778b46dbd0cbcb03a839946c8f80a06f7968eb298aa4d1a4293f3448/ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165", size = 12117080, upload-time = "2025-06-05T20:59:51.654Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2c/caaeda564cbe103bed145ea557cb86795b18651b0f6b3ff6a10e84e5a33f/ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71", size = 11326315, upload-time = "2025-06-05T20:59:54.469Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9", size = 11555640, upload-time = "2025-06-05T20:59:56.986Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d4/3d580c616316c7f07fb3c99dbecfe01fbaea7b6fd9a82b801e72e5de742a/ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc", size = 10507364, upload-time = "2025-06-05T20:59:59.154Z" }, + { url = "https://files.pythonhosted.org/packages/5a/dc/195e6f17d7b3ea6b12dc4f3e9de575db7983db187c378d44606e5d503319/ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7", size = 10141462, upload-time = "2025-06-05T21:00:01.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8e/39a094af6967faa57ecdeacb91bedfb232474ff8c3d20f16a5514e6b3534/ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432", size = 11121028, upload-time = "2025-06-05T21:00:04.06Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c0/b0b508193b0e8a1654ec683ebab18d309861f8bd64e3a2f9648b80d392cb/ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492", size = 11602992, upload-time = "2025-06-05T21:00:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/7c/91/263e33ab93ab09ca06ce4f8f8547a858cc198072f873ebc9be7466790bae/ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250", size = 10474944, upload-time = "2025-06-05T21:00:08.459Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/7c27734ac2073aae8efb0119cae6931b6fb48017adf048fdf85c19337afc/ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3", size = 11548669, upload-time = "2025-06-05T21:00:11.147Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" }, +] + +[[package]] +name = "runs" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "xmod" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/6d/b9aace390f62db5d7d2c77eafce3d42774f27f1829d24fa9b6f598b3ef71/runs-1.2.2.tar.gz", hash = "sha256:9dc1815e2895cfb3a48317b173b9f1eac9ba5549b36a847b5cc60c3bf82ecef1", size = 5474, upload-time = "2024-01-25T14:44:01.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/d6/17caf2e4af1dec288477a0cbbe4a96fbc9b8a28457dce3f1f452630ce216/runs-1.2.2-py3-none-any.whl", hash = "sha256:0980dcbc25aba1505f307ac4f0e9e92cbd0be2a15a1e983ee86c24c87b839dfd", size = 7033, upload-time = "2024-01-25T14:43:59.959Z" }, +] + +[[package]] +name = "sanic" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "html5tagger" }, + { name = "httptools" }, + { name = "multidict" }, + { name = "sanic-routing" }, + { name = "setuptools" }, + { name = "tracerite" }, + { name = "typing-extensions" }, + { name = "ujson", marker = "implementation_name == 'cpython' and sys_platform != 'win32'" }, + { name = "uvloop", marker = "implementation_name == 'cpython' and sys_platform != 'win32'" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/8b/08dc376390fe854ef32984973883b646ee68c6727da72ffcc65340d8f192/sanic-25.3.0.tar.gz", hash = "sha256:775d522001ec81f034ec8e4d7599e2175bfc097b8d57884f5e4c9322f5e369bb", size = 353027, upload-time = "2025-03-31T21:22:29.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/e1/b36ddc16862d63d22986ae21b04a79c8fb7ec48d5d664acdfd1c2acf78ac/sanic-25.3.0-py3-none-any.whl", hash = "sha256:fb519b38b4c220569b0e2e868583ffeaffaab96a78b2e42ae78bc56a644a4cd7", size = 246416, upload-time = "2025-03-31T21:22:27.946Z" }, +] + +[[package]] +name = "sanic-routing" +version = "23.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/5c/2a7edd14fbccca3719a8d680951d4b25f986752c781c61ccf156a6d1ebff/sanic-routing-23.12.0.tar.gz", hash = "sha256:1dcadc62c443e48c852392dba03603f9862b6197fc4cba5bbefeb1ace0848b04", size = 29473, upload-time = "2023-12-31T09:28:36.992Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/e3/3425c9a8773807ac2c01d6a56c8521733f09b627e5827e733c5cd36b9ac5/sanic_routing-23.12.0-py3-none-any.whl", hash = "sha256:1558a72afcb9046ed3134a5edae02fc1552cff08f0fff2e8d5de0877ea43ed73", size = 25522, upload-time = "2023-12-31T09:28:35.233Z" }, +] + +[[package]] +name = "sanic-testing" +version = "23.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/a0/379a5083a1289795697101f722703a26e9a119c2648a128b85d2eb6cf19a/sanic-testing-23.12.0.tar.gz", hash = "sha256:2b9c52b7314b7e1807958f41581e18b8254c5161c953e70fcf492e0dd2fe133f", size = 10675, upload-time = "2023-12-31T10:14:41.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/34/7914665b25f02e16f70d6f6120c90e262a61f6150e1ce8f226cc9625ab38/sanic_testing-23.12.0-py3-none-any.whl", hash = "sha256:d809911fca49cba93e1df9de5c6ab8d95d91bdc03b18ba8a25b4e0b66c4e4c73", size = 10342, upload-time = "2023-12-31T10:14:40.483Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739, upload-time = "2022-08-13T16:22:46.976Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99", size = 15221, upload-time = "2022-08-13T16:22:44.457Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/4c/af31e0201b48469786ddeb1bf6fd3dfa3a291cc613a0fe6a60163a7535f9/sentry_sdk-2.30.0.tar.gz", hash = "sha256:436369b02afef7430efb10300a344fb61a11fe6db41c2b11f41ee037d2dd7f45", size = 326767, upload-time = "2025-06-12T10:34:34.733Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/99/31ac6faaae33ea698086692638f58d14f121162a8db0039e68e94135e7f1/sentry_sdk-2.30.0-py2.py3-none-any.whl", hash = "sha256:59391db1550662f746ea09b483806a631c3ae38d6340804a1a4c0605044f6877", size = 343149, upload-time = "2025-06-12T10:34:32.896Z" }, +] + +[[package]] +name = "service-identity" +version = "24.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cryptography" }, + { name = "pyasn1" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/a5/dfc752b979067947261dbbf2543470c58efe735c3c1301dd870ef27830ee/service_identity-24.2.0.tar.gz", hash = "sha256:b8683ba13f0d39c6cd5d625d2c5f65421d6d707b013b375c355751557cbe8e09", size = 39245, upload-time = "2024-10-26T07:21:57.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/2c/ca6dd598b384bc1ce581e24aaae0f2bed4ccac57749d5c3befbb5e742081/service_identity-24.2.0-py3-none-any.whl", hash = "sha256:6b047fbd8a84fd0bb0d55ebce4031e400562b9196e1e0d3e0fe2b8a59f6d4a85", size = 11364, upload-time = "2024-10-26T07:21:56.302Z" }, +] + +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" }, +] + +[[package]] +name = "starlette" +version = "0.46.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, +] + +[[package]] +name = "taskgroup" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/b1/74babcc824a57904e919f3af16d86c08b524c0691504baf038ef2d7f655c/taskgroup-0.2.2-py2.py3-none-any.whl", hash = "sha256:e2c53121609f4ae97303e9ea1524304b4de6faf9eb2c9280c7f87976479a52fb", size = 14237, upload-time = "2025-01-03T09:24:11.41Z" }, +] + +[[package]] +name = "timeout-decorator" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/f8/0802dd14c58b5d3d72bb9caa4315535f58787a1dc50b81bbbcaaa15451be/timeout-decorator-0.5.0.tar.gz", hash = "sha256:6a2f2f58db1c5b24a2cc79de6345760377ad8bdc13813f5265f6c3e63d16b3d7", size = 4754, upload-time = "2020-11-15T00:53:06.506Z" } + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, +] + +[[package]] +name = "tracerite" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "html5tagger" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/b2/37b825b881f23bc56384c3142214ccbe5d9de7e7c5fe3d155fa032738b98/tracerite-1.1.3.tar.gz", hash = "sha256:119fc006f240aa03fffb41cf99cf82fda5c0449c7d4b6fe42c6340403578b31e", size = 269646, upload-time = "2025-06-19T17:47:42.289Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/bf/c65d36ec5a93048dd55b3247be26059970daad72263e35ecace2f3188b2c/tracerite-1.1.3-py3-none-any.whl", hash = "sha256:811d8e2e0fb563b77340eebe2e9f7b324acfe01e09ea58db8bcaecb24327c823", size = 12422, upload-time = "2025-06-19T17:47:40.173Z" }, +] + +[[package]] +name = "trove-classifiers" +version = "2025.5.9.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/04/1cd43f72c241fedcf0d9a18d0783953ee301eac9e5d9db1df0f0f089d9af/trove_classifiers-2025.5.9.12.tar.gz", hash = "sha256:7ca7c8a7a76e2cd314468c677c69d12cc2357711fcab4a60f87994c1589e5cb5", size = 16940, upload-time = "2025-05-09T12:04:48.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/ef/c6deb083748be3bcad6f471b6ae983950c161890bf5ae1b2af80cc56c530/trove_classifiers-2025.5.9.12-py3-none-any.whl", hash = "sha256:e381c05537adac78881c8fa345fd0e9970159f4e4a04fcc42cfd3129cca640ce", size = 14119, upload-time = "2025-05-09T12:04:46.38Z" }, +] + +[[package]] +name = "twisted" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "automat" }, + { name = "constantly" }, + { name = "hyperlink" }, + { name = "incremental" }, + { name = "typing-extensions" }, + { name = "zope-interface" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/0f/82716ed849bf7ea4984c21385597c949944f0f9b428b5710f79d0afc084d/twisted-25.5.0.tar.gz", hash = "sha256:1deb272358cb6be1e3e8fc6f9c8b36f78eb0fa7c2233d2dbe11ec6fee04ea316", size = 3545725, upload-time = "2025-06-07T09:52:24.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/66/ab7efd8941f0bc7b2bd555b0f0471bff77df4c88e0cc31120c82737fec77/twisted-25.5.0-py3-none-any.whl", hash = "sha256:8559f654d01a54a8c3efe66d533d43f383531ebf8d81d9f9ab4769d91ca15df7", size = 3204767, upload-time = "2025-06-07T09:52:21.428Z" }, +] + +[package.optional-dependencies] +tls = [ + { name = "idna" }, + { name = "pyopenssl" }, + { name = "service-identity" }, +] + +[[package]] +name = "txaio" +version = "23.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/91/bc9fd5aa84703f874dea27313b11fde505d343f3ef3ad702bddbe20bfd6e/txaio-23.1.1.tar.gz", hash = "sha256:f9a9216e976e5e3246dfd112ad7ad55ca915606b60b84a757ac769bd404ff704", size = 53704, upload-time = "2023-01-15T14:11:27.475Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/6c/a53cc9a97c2da76d9cd83c03f377468599a28f2d4ad9fc71c3b99640e71e/txaio-23.1.1-py2.py3-none-any.whl", hash = "sha256:aaea42f8aad50e0ecfb976130ada140797e9dcb85fad2cf72b0f37f8cefcb490", size = 30512, upload-time = "2023-01-15T14:11:24.999Z" }, +] + +[[package]] +name = "typer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/8c/7d682431efca5fd290017663ea4588bf6f2c6aad085c7f108c5dbc316e70/typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b", size = 102625, upload-time = "2025-05-26T14:30:31.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/42/3efaf858001d2c2913de7f354563e3a3a2f0decae3efe98427125a8f441e/typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", size = 46317, upload-time = "2025-05-26T14:30:30.523Z" }, +] + +[[package]] +name = "types-deprecated" +version = "1.2.15.20250304" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/67/eeefaaabb03b288aad85483d410452c8bbcbf8b2bd876b0e467ebd97415b/types_deprecated-1.2.15.20250304.tar.gz", hash = "sha256:c329030553029de5cc6cb30f269c11f4e00e598c4241290179f63cda7d33f719", size = 8015, upload-time = "2025-03-04T02:48:17.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/e3/c18aa72ab84e0bc127a3a94e93be1a6ac2cb281371d3a45376ab7cfdd31c/types_deprecated-1.2.15.20250304-py3-none-any.whl", hash = "sha256:86a65aa550ea8acf49f27e226b8953288cd851de887970fbbdf2239c116c3107", size = 8553, upload-time = "2025-03-04T02:48:16.666Z" }, +] + +[[package]] +name = "types-six" +version = "1.17.0.20250515" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/78/344047eeced8d230140aa3d9503aa969acb61c6095e7308bbc1ff1de3865/types_six-1.17.0.20250515.tar.gz", hash = "sha256:f4f7f0398cb79304e88397336e642b15e96fbeacf5b96d7625da366b069d2d18", size = 15598, upload-time = "2025-05-15T03:04:19.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/85/5ee1c8e35b33b9c8ea1816d5a4e119c27f8bb1539b73b1f636f07aa64750/types_six-1.17.0.20250515-py3-none-any.whl", hash = "sha256:adfaa9568caf35e03d80ffa4ed765c33b282579c869b40bf4b6009c7d8db3fb1", size = 19987, upload-time = "2025-05-15T03:04:18.556Z" }, +] + +[[package]] +name = "types-toml" +version = "0.10.8.20240310" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/47/3e4c75042792bff8e90d7991aa5c51812cc668828cc6cce711e97f63a607/types-toml-0.10.8.20240310.tar.gz", hash = "sha256:3d41501302972436a6b8b239c850b26689657e25281b48ff0ec06345b8830331", size = 4392, upload-time = "2024-03-10T02:18:37.518Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/a2/d32ab58c0b216912638b140ab2170ee4b8644067c293b170e19fba340ccc/types_toml-0.10.8.20240310-py3-none-any.whl", hash = "sha256:627b47775d25fa29977d9c70dc0cbab3f314f32c8d8d0c012f2ef5de7aaec05d", size = 4777, upload-time = "2024-03-10T02:18:36.568Z" }, +] + +[[package]] +name = "typeshed-client" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-resources" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/1e/f20e33447be772486acf028295cdd21437454a051adb602d52ddb5334f9e/typeshed_client-2.7.0.tar.gz", hash = "sha256:e63df1e738588ad39f1226de042f4407ab6a99c456f0837063afd83b1415447c", size = 433569, upload-time = "2024-07-16T17:01:17.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/39/4702c2901899c018189b9aa7eb75aa8eb54527aed71c3f285895190dc664/typeshed_client-2.7.0-py3-none-any.whl", hash = "sha256:97084e5abc58a76ace2c4618ecaebd625f2d19bbd85aa1b3fb86216bf174bbea", size = 624417, upload-time = "2024-07-16T17:01:15.246Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, +] + +[[package]] +name = "typing-inspect" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "ujson" +version = "5.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/00/3110fd566786bfa542adb7932d62035e0c0ef662a8ff6544b6643b3d6fd7/ujson-5.10.0.tar.gz", hash = "sha256:b3cd8f3c5d8c7738257f1018880444f7b7d9b66232c64649f562d7ba86ad4bc1", size = 7154885, upload-time = "2024-05-14T02:02:34.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/91/91678e49a9194f527e60115db84368c237ac7824992224fac47dcb23a5c6/ujson-5.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2601aa9ecdbee1118a1c2065323bda35e2c5a2cf0797ef4522d485f9d3ef65bd", size = 55354, upload-time = "2024-05-14T02:00:27.054Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/1ed8c9b782fa4f44c26c1c4ec686d728a4865479da5712955daeef0b2e7b/ujson-5.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:348898dd702fc1c4f1051bc3aacbf894caa0927fe2c53e68679c073375f732cf", size = 51808, upload-time = "2024-05-14T02:00:29.461Z" }, + { url = "https://files.pythonhosted.org/packages/51/bf/a3a38b2912288143e8e613c6c4c3f798b5e4e98c542deabf94c60237235f/ujson-5.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22cffecf73391e8abd65ef5f4e4dd523162a3399d5e84faa6aebbf9583df86d6", size = 51995, upload-time = "2024-05-14T02:00:30.93Z" }, + { url = "https://files.pythonhosted.org/packages/b4/6d/0df8f7a6f1944ba619d93025ce468c9252aa10799d7140e07014dfc1a16c/ujson-5.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26b0e2d2366543c1bb4fbd457446f00b0187a2bddf93148ac2da07a53fe51569", size = 53566, upload-time = "2024-05-14T02:00:33.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ec/370741e5e30d5f7dc7f31a478d5bec7537ce6bfb7f85e72acefbe09aa2b2/ujson-5.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:caf270c6dba1be7a41125cd1e4fc7ba384bf564650beef0df2dd21a00b7f5770", size = 58499, upload-time = "2024-05-14T02:00:34.742Z" }, + { url = "https://files.pythonhosted.org/packages/fe/29/72b33a88f7fae3c398f9ba3e74dc2e5875989b25f1c1f75489c048a2cf4e/ujson-5.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a245d59f2ffe750446292b0094244df163c3dc96b3ce152a2c837a44e7cda9d1", size = 997881, upload-time = "2024-05-14T02:00:36.492Z" }, + { url = "https://files.pythonhosted.org/packages/70/5c/808fbf21470e7045d56a282cf5e85a0450eacdb347d871d4eb404270ee17/ujson-5.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:94a87f6e151c5f483d7d54ceef83b45d3a9cca7a9cb453dbdbb3f5a6f64033f5", size = 1140631, upload-time = "2024-05-14T02:00:38.995Z" }, + { url = "https://files.pythonhosted.org/packages/8f/6a/e1e8281408e6270d6ecf2375af14d9e2f41c402ab6b161ecfa87a9727777/ujson-5.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29b443c4c0a113bcbb792c88bea67b675c7ca3ca80c3474784e08bba01c18d51", size = 1043511, upload-time = "2024-05-14T02:00:41.352Z" }, + { url = "https://files.pythonhosted.org/packages/23/ec/3c551ecfe048bcb3948725251fb0214b5844a12aa60bee08d78315bb1c39/ujson-5.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a5b366812c90e69d0f379a53648be10a5db38f9d4ad212b60af00bd4048d0f00", size = 55353, upload-time = "2024-05-14T02:00:48.04Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9f/4731ef0671a0653e9f5ba18db7c4596d8ecbf80c7922dd5fe4150f1aea76/ujson-5.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:502bf475781e8167f0f9d0e41cd32879d120a524b22358e7f205294224c71126", size = 51813, upload-time = "2024-05-14T02:00:49.28Z" }, + { url = "https://files.pythonhosted.org/packages/1f/2b/44d6b9c1688330bf011f9abfdb08911a9dc74f76926dde74e718d87600da/ujson-5.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b91b5d0d9d283e085e821651184a647699430705b15bf274c7896f23fe9c9d8", size = 51988, upload-time = "2024-05-14T02:00:50.484Z" }, + { url = "https://files.pythonhosted.org/packages/29/45/f5f5667427c1ec3383478092a414063ddd0dfbebbcc533538fe37068a0a3/ujson-5.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:129e39af3a6d85b9c26d5577169c21d53821d8cf68e079060602e861c6e5da1b", size = 53561, upload-time = "2024-05-14T02:00:52.146Z" }, + { url = "https://files.pythonhosted.org/packages/26/21/a0c265cda4dd225ec1be595f844661732c13560ad06378760036fc622587/ujson-5.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f77b74475c462cb8b88680471193064d3e715c7c6074b1c8c412cb526466efe9", size = 58497, upload-time = "2024-05-14T02:00:53.366Z" }, + { url = "https://files.pythonhosted.org/packages/28/36/8fde862094fd2342ccc427a6a8584fed294055fdee341661c78660f7aef3/ujson-5.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ec0ca8c415e81aa4123501fee7f761abf4b7f386aad348501a26940beb1860f", size = 997877, upload-time = "2024-05-14T02:00:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/90/37/9208e40d53baa6da9b6a1c719e0670c3f474c8fc7cc2f1e939ec21c1bc93/ujson-5.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ab13a2a9e0b2865a6c6db9271f4b46af1c7476bfd51af1f64585e919b7c07fd4", size = 1140632, upload-time = "2024-05-14T02:00:57.099Z" }, + { url = "https://files.pythonhosted.org/packages/89/d5/2626c87c59802863d44d19e35ad16b7e658e4ac190b0dead17ff25460b4c/ujson-5.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:57aaf98b92d72fc70886b5a0e1a1ca52c2320377360341715dd3933a18e827b1", size = 1043513, upload-time = "2024-05-14T02:00:58.488Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a6/fd3f8bbd80842267e2d06c3583279555e8354c5986c952385199d57a5b6c/ujson-5.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:98ba15d8cbc481ce55695beee9f063189dce91a4b08bc1d03e7f0152cd4bbdd5", size = 55642, upload-time = "2024-05-14T02:01:04.055Z" }, + { url = "https://files.pythonhosted.org/packages/a8/47/dd03fd2b5ae727e16d5d18919b383959c6d269c7b948a380fdd879518640/ujson-5.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9d2edbf1556e4f56e50fab7d8ff993dbad7f54bac68eacdd27a8f55f433578e", size = 51807, upload-time = "2024-05-14T02:01:05.25Z" }, + { url = "https://files.pythonhosted.org/packages/25/23/079a4cc6fd7e2655a473ed9e776ddbb7144e27f04e8fc484a0fb45fe6f71/ujson-5.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6627029ae4f52d0e1a2451768c2c37c0c814ffc04f796eb36244cf16b8e57043", size = 51972, upload-time = "2024-05-14T02:01:06.458Z" }, + { url = "https://files.pythonhosted.org/packages/04/81/668707e5f2177791869b624be4c06fb2473bf97ee33296b18d1cf3092af7/ujson-5.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8ccb77b3e40b151e20519c6ae6d89bfe3f4c14e8e210d910287f778368bb3d1", size = 53686, upload-time = "2024-05-14T02:01:07.618Z" }, + { url = "https://files.pythonhosted.org/packages/bd/50/056d518a386d80aaf4505ccf3cee1c40d312a46901ed494d5711dd939bc3/ujson-5.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3caf9cd64abfeb11a3b661329085c5e167abbe15256b3b68cb5d914ba7396f3", size = 58591, upload-time = "2024-05-14T02:01:08.901Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d6/aeaf3e2d6fb1f4cfb6bf25f454d60490ed8146ddc0600fae44bfe7eb5a72/ujson-5.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6e32abdce572e3a8c3d02c886c704a38a1b015a1fb858004e03d20ca7cecbb21", size = 997853, upload-time = "2024-05-14T02:01:10.772Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d5/1f2a5d2699f447f7d990334ca96e90065ea7f99b142ce96e85f26d7e78e2/ujson-5.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a65b6af4d903103ee7b6f4f5b85f1bfd0c90ba4eeac6421aae436c9988aa64a2", size = 1140689, upload-time = "2024-05-14T02:01:12.214Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2c/6990f4ccb41ed93744aaaa3786394bca0875503f97690622f3cafc0adfde/ujson-5.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:604a046d966457b6cdcacc5aa2ec5314f0e8c42bae52842c1e6fa02ea4bda42e", size = 1043576, upload-time = "2024-05-14T02:01:14.39Z" }, + { url = "https://files.pythonhosted.org/packages/0d/69/b3e3f924bb0e8820bb46671979770c5be6a7d51c77a66324cdb09f1acddb/ujson-5.10.0-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:618efd84dc1acbd6bff8eaa736bb6c074bfa8b8a98f55b61c38d4ca2c1f7f287", size = 55646, upload-time = "2024-05-14T02:01:19.26Z" }, + { url = "https://files.pythonhosted.org/packages/32/8a/9b748eb543c6cabc54ebeaa1f28035b1bd09c0800235b08e85990734c41e/ujson-5.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38d5d36b4aedfe81dfe251f76c0467399d575d1395a1755de391e58985ab1c2e", size = 51806, upload-time = "2024-05-14T02:01:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/39/50/4b53ea234413b710a18b305f465b328e306ba9592e13a791a6a6b378869b/ujson-5.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67079b1f9fb29ed9a2914acf4ef6c02844b3153913eb735d4bf287ee1db6e557", size = 51975, upload-time = "2024-05-14T02:01:21.904Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9d/8061934f960cdb6dd55f0b3ceeff207fcc48c64f58b43403777ad5623d9e/ujson-5.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7d0e0ceeb8fe2468c70ec0c37b439dd554e2aa539a8a56365fd761edb418988", size = 53693, upload-time = "2024-05-14T02:01:23.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/7bfa84b28519ddbb67efc8410765ca7da55e6b93aba84d97764cd5794dbc/ujson-5.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:59e02cd37bc7c44d587a0ba45347cc815fb7a5fe48de16bf05caa5f7d0d2e816", size = 58594, upload-time = "2024-05-14T02:01:25.554Z" }, + { url = "https://files.pythonhosted.org/packages/48/eb/85d465abafb2c69d9699cfa5520e6e96561db787d36c677370e066c7e2e7/ujson-5.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a890b706b64e0065f02577bf6d8ca3b66c11a5e81fb75d757233a38c07a1f20", size = 997853, upload-time = "2024-05-14T02:01:27.151Z" }, + { url = "https://files.pythonhosted.org/packages/9f/76/2a63409fc05d34dd7d929357b7a45e3a2c96f22b4225cd74becd2ba6c4cb/ujson-5.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:621e34b4632c740ecb491efc7f1fcb4f74b48ddb55e65221995e74e2d00bbff0", size = 1140694, upload-time = "2024-05-14T02:01:29.113Z" }, + { url = "https://files.pythonhosted.org/packages/45/ed/582c4daba0f3e1688d923b5cb914ada1f9defa702df38a1916c899f7c4d1/ujson-5.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9500e61fce0cfc86168b248104e954fead61f9be213087153d272e817ec7b4f", size = 1043580, upload-time = "2024-05-14T02:01:31.447Z" }, + { url = "https://files.pythonhosted.org/packages/97/94/50ff2f1b61d668907f20216873640ab19e0eaa77b51e64ee893f6adfb266/ujson-5.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dfef2814c6b3291c3c5f10065f745a1307d86019dbd7ea50e83504950136ed5b", size = 55421, upload-time = "2024-05-14T02:01:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b3/3d2ca621d8dbeaf6c5afd0725e1b4bbd465077acc69eff1e9302735d1432/ujson-5.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4734ee0745d5928d0ba3a213647f1c4a74a2a28edc6d27b2d6d5bd9fa4319e27", size = 51816, upload-time = "2024-05-14T02:01:51.047Z" }, + { url = "https://files.pythonhosted.org/packages/8d/af/5dc103cb4d08f051f82d162a738adb9da488d1e3fafb9fd9290ea3eabf8e/ujson-5.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d47ebb01bd865fdea43da56254a3930a413f0c5590372a1241514abae8aa7c76", size = 52023, upload-time = "2024-05-14T02:01:53.072Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dd/b9a6027ba782b0072bf24a70929e15a58686668c32a37aebfcfaa9e00bdd/ujson-5.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dee5e97c2496874acbf1d3e37b521dd1f307349ed955e62d1d2f05382bc36dd5", size = 53622, upload-time = "2024-05-14T02:01:54.738Z" }, + { url = "https://files.pythonhosted.org/packages/1f/28/bcf6df25c1a9f1989dc2ddc4ac8a80e246857e089f91a9079fd8a0a01459/ujson-5.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7490655a2272a2d0b072ef16b0b58ee462f4973a8f6bbe64917ce5e0a256f9c0", size = 58563, upload-time = "2024-05-14T02:01:55.991Z" }, + { url = "https://files.pythonhosted.org/packages/9e/82/89404453a102d06d0937f6807c0a7ef2eec68b200b4ce4386127f3c28156/ujson-5.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ba17799fcddaddf5c1f75a4ba3fd6441f6a4f1e9173f8a786b42450851bd74f1", size = 998050, upload-time = "2024-05-14T02:01:57.8Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/2a4ea07165cad217bc842bb684b053bafa8ffdb818c47911c621e97a33fc/ujson-5.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2aff2985cef314f21d0fecc56027505804bc78802c0121343874741650a4d3d1", size = 1140672, upload-time = "2024-05-14T02:01:59.875Z" }, + { url = "https://files.pythonhosted.org/packages/72/53/d7bdf6afabeba3ed899f89d993c7f202481fa291d8c5be031c98a181eda4/ujson-5.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ad88ac75c432674d05b61184178635d44901eb749786c8eb08c102330e6e8996", size = 1043577, upload-time = "2024-05-14T02:02:02.138Z" }, + { url = "https://files.pythonhosted.org/packages/95/53/e5f5e733fc3525e65f36f533b0dbece5e5e2730b760e9beacf7e3d9d8b26/ujson-5.10.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5b6fee72fa77dc172a28f21693f64d93166534c263adb3f96c413ccc85ef6e64", size = 51846, upload-time = "2024-05-14T02:02:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/59/1f/f7bc02a54ea7b47f3dc2d125a106408f18b0f47b14fc737f0913483ae82b/ujson-5.10.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:61d0af13a9af01d9f26d2331ce49bb5ac1fb9c814964018ac8df605b5422dcb3", size = 48103, upload-time = "2024-05-14T02:02:07.777Z" }, + { url = "https://files.pythonhosted.org/packages/1a/3a/d3921b6f29bc744d8d6c56db5f8bbcbe55115fd0f2b79c3c43ff292cc7c9/ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecb24f0bdd899d368b715c9e6664166cf694d1e57be73f17759573a6986dd95a", size = 47257, upload-time = "2024-05-14T02:02:09.46Z" }, + { url = "https://files.pythonhosted.org/packages/f1/04/f4e3883204b786717038064afd537389ba7d31a72b437c1372297cb651ea/ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbd8fd427f57a03cff3ad6574b5e299131585d9727c8c366da4624a9069ed746", size = 48468, upload-time = "2024-05-14T02:02:10.768Z" }, + { url = "https://files.pythonhosted.org/packages/17/cd/9c6547169eb01a22b04cbb638804ccaeb3c2ec2afc12303464e0f9b2ee5a/ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:beeaf1c48e32f07d8820c705ff8e645f8afa690cca1544adba4ebfa067efdc88", size = 54266, upload-time = "2024-05-14T02:02:12.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/a3a2356ca5a4b67fe32a0c31e49226114d5154ba2464bb1220a93eb383e8/ujson-5.10.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ba43cc34cce49cf2d4bc76401a754a81202d8aa926d0e2b79f0ee258cb15d3a4", size = 51855, upload-time = "2024-05-14T02:02:22.164Z" }, + { url = "https://files.pythonhosted.org/packages/73/3d/41e78e7500e75eb6b5a7ab06907a6df35603b92ac6f939b86f40e9fe2c06/ujson-5.10.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ac56eb983edce27e7f51d05bc8dd820586c6e6be1c5216a6809b0c668bb312b8", size = 48059, upload-time = "2024-05-14T02:02:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/be/14/e435cbe5b5189483adbba5fe328e88418ccd54b2b1f74baa4172384bb5cd/ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44bd4b23a0e723bf8b10628288c2c7c335161d6840013d4d5de20e48551773b", size = 47238, upload-time = "2024-05-14T02:02:24.873Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d9/b6f4d1e6bec20a3b582b48f64eaa25209fd70dc2892b21656b273bc23434/ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c10f4654e5326ec14a46bcdeb2b685d4ada6911050aa8baaf3501e57024b804", size = 48457, upload-time = "2024-05-14T02:02:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/23/1c/cfefabb5996e21a1a4348852df7eb7cfc69299143739e86e5b1071c78735/ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0de4971a89a762398006e844ae394bd46991f7c385d7a6a3b93ba229e6dac17e", size = 54238, upload-time = "2024-05-14T02:02:28.468Z" }, +] + +[[package]] +name = "unidiff" +version = "0.7.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/48/81be0ac96e423a877754153699731ef439fd7b80b4c8b5425c94ed079ebd/unidiff-0.7.5.tar.gz", hash = "sha256:2e5f0162052248946b9f0970a40e9e124236bf86c82b70821143a6fc1dea2574", size = 20931, upload-time = "2023-03-10T01:05:39.185Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/54/57c411a6e8f7bd7848c8b66e4dcaffa586bf4c02e63f2280db0327a4e6eb/unidiff-0.7.5-py2.py3-none-any.whl", hash = "sha256:c93bf2265cc1ba2a520e415ab05da587370bc2a3ae9e0414329f54f0c2fc09e8", size = 14386, upload-time = "2023-03-10T01:05:36.594Z" }, +] + +[[package]] +name = "unittest-xml-reporting" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/40/3bf1afc96e93c7322520981ac4593cbb29daa21b48d32746f05ab5563dca/unittest-xml-reporting-3.2.0.tar.gz", hash = "sha256:edd8d3170b40c3a81b8cf910f46c6a304ae2847ec01036d02e9c0f9b85762d28", size = 18002, upload-time = "2022-01-20T19:09:55.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/88/f6e9b87428584a3c62cac768185c438ca6d561367a5d267b293259d76075/unittest_xml_reporting-3.2.0-py2.py3-none-any.whl", hash = "sha256:f3d7402e5b3ac72a5ee3149278339db1a8f932ee405f48bcb9c681372f2717d5", size = 20936, upload-time = "2022-01-20T19:09:53.824Z" }, +] + +[[package]] +name = "urllib3" +version = "1.26.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/e8/6ff5e6bc22095cfc59b6ea711b687e2b7ed4bdb373f7eeec370a97d7392f/urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32", size = 307380, upload-time = "2024-08-29T15:43:11.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/cf/8435d5a7159e2a9c83a95896ed596f68cf798005fe107cc655b5c5c14704/urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", size = 144225, upload-time = "2024-08-29T15:43:08.921Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.34.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/ad/713be230bcda622eaa35c28f0d328c3675c371238470abdea52417f17a8e/uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a", size = 76631, upload-time = "2025-06-01T07:48:17.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/0d/8adfeaa62945f90d19ddc461c55f4a50c258af7662d34b6a3d5d1f8646f6/uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885", size = 62431, upload-time = "2025-06-01T07:48:15.664Z" }, +] + +[[package]] +name = "uvloop" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/76/44a55515e8c9505aa1420aebacf4dd82552e5e15691654894e90d0bd051a/uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f", size = 1442019, upload-time = "2024-10-14T23:37:20.068Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/62d5800358a78cc25c8a6c72ef8b10851bdb8cca22e14d9c74167b7f86da/uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d", size = 801898, upload-time = "2024-10-14T23:37:22.663Z" }, + { url = "https://files.pythonhosted.org/packages/f3/96/63695e0ebd7da6c741ccd4489b5947394435e198a1382349c17b1146bb97/uvloop-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f38b2e090258d051d68a5b14d1da7203a3c3677321cf32a95a6f4db4dd8b6f26", size = 3827735, upload-time = "2024-10-14T23:37:25.129Z" }, + { url = "https://files.pythonhosted.org/packages/61/e0/f0f8ec84979068ffae132c58c79af1de9cceeb664076beea86d941af1a30/uvloop-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c43e0f13022b998eb9b973b5e97200c8b90823454d4bc06ab33829e09fb9bb", size = 3825126, upload-time = "2024-10-14T23:37:27.59Z" }, + { url = "https://files.pythonhosted.org/packages/bf/fe/5e94a977d058a54a19df95f12f7161ab6e323ad49f4dabc28822eb2df7ea/uvloop-0.21.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10d66943def5fcb6e7b37310eb6b5639fd2ccbc38df1177262b0640c3ca68c1f", size = 3705789, upload-time = "2024-10-14T23:37:29.385Z" }, + { url = "https://files.pythonhosted.org/packages/26/dd/c7179618e46092a77e036650c1f056041a028a35c4d76945089fcfc38af8/uvloop-0.21.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:67dd654b8ca23aed0a8e99010b4c34aca62f4b7fce88f39d452ed7622c94845c", size = 3800523, upload-time = "2024-10-14T23:37:32.048Z" }, + { url = "https://files.pythonhosted.org/packages/57/a7/4cf0334105c1160dd6819f3297f8700fda7fc30ab4f61fbf3e725acbc7cc/uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8", size = 1447410, upload-time = "2024-10-14T23:37:33.612Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7c/1517b0bbc2dbe784b563d6ab54f2ef88c890fdad77232c98ed490aa07132/uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0", size = 805476, upload-time = "2024-10-14T23:37:36.11Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ea/0bfae1aceb82a503f358d8d2fa126ca9dbdb2ba9c7866974faec1cb5875c/uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e", size = 3960855, upload-time = "2024-10-14T23:37:37.683Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ca/0864176a649838b838f36d44bf31c451597ab363b60dc9e09c9630619d41/uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a375441696e2eda1c43c44ccb66e04d61ceeffcd76e4929e527b7fa401b90fb", size = 3973185, upload-time = "2024-10-14T23:37:40.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/bf/08ad29979a936d63787ba47a540de2132169f140d54aa25bc8c3df3e67f4/uvloop-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:baa0e6291d91649c6ba4ed4b2f982f9fa165b5bbd50a9e203c416a2797bab3c6", size = 3820256, upload-time = "2024-10-14T23:37:42.839Z" }, + { url = "https://files.pythonhosted.org/packages/da/e2/5cf6ef37e3daf2f06e651aae5ea108ad30df3cb269102678b61ebf1fdf42/uvloop-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4509360fcc4c3bd2c70d87573ad472de40c13387f5fda8cb58350a1d7475e58d", size = 3937323, upload-time = "2024-10-14T23:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284, upload-time = "2024-10-14T23:37:47.833Z" }, + { url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349, upload-time = "2024-10-14T23:37:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089, upload-time = "2024-10-14T23:37:51.703Z" }, + { url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770, upload-time = "2024-10-14T23:37:54.122Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321, upload-time = "2024-10-14T23:37:55.766Z" }, + { url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022, upload-time = "2024-10-14T23:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload-time = "2024-10-14T23:38:00.688Z" }, + { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload-time = "2024-10-14T23:38:02.309Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload-time = "2024-10-14T23:38:04.711Z" }, + { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload-time = "2024-10-14T23:38:06.385Z" }, + { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload-time = "2024-10-14T23:38:08.416Z" }, + { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a4/646a9d0edff7cde25fc1734695d3dfcee0501140dd0e723e4df3f0a50acb/uvloop-0.21.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c097078b8031190c934ed0ebfee8cc5f9ba9642e6eb88322b9958b649750f72b", size = 1439646, upload-time = "2024-10-14T23:38:24.656Z" }, + { url = "https://files.pythonhosted.org/packages/01/2e/e128c66106af9728f86ebfeeb52af27ecd3cb09336f3e2f3e06053707a15/uvloop-0.21.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:46923b0b5ee7fc0020bef24afe7836cb068f5050ca04caf6b487c513dc1a20b2", size = 800931, upload-time = "2024-10-14T23:38:26.087Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1a/9fbc2b1543d0df11f7aed1632f64bdf5ecc4053cf98cdc9edb91a65494f9/uvloop-0.21.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53e420a3afe22cdcf2a0f4846e377d16e718bc70103d7088a4f7623567ba5fb0", size = 3829660, upload-time = "2024-10-14T23:38:27.905Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c0/392e235e4100ae3b95b5c6dac77f82b529d2760942b1e7e0981e5d8e895d/uvloop-0.21.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88cb67cdbc0e483da00af0b2c3cdad4b7c61ceb1ee0f33fe00e09c81e3a6cb75", size = 3827185, upload-time = "2024-10-14T23:38:29.458Z" }, + { url = "https://files.pythonhosted.org/packages/e1/24/a5da6aba58f99aed5255eca87d58d1760853e8302d390820cc29058408e3/uvloop-0.21.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:221f4f2a1f46032b403bf3be628011caf75428ee3cc204a22addf96f586b19fd", size = 3705833, upload-time = "2024-10-14T23:38:31.155Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5c/6ba221bb60f1e6474474102e17e38612ec7a06dc320e22b687ab563d877f/uvloop-0.21.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2d1f581393673ce119355d56da84fe1dd9d2bb8b3d13ce792524e1607139feff", size = 3804696, upload-time = "2024-10-14T23:38:33.633Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.31.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c3a104fd0c495184c4f2336d65baf398e3c75d72ea94/virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af", size = 6076316, upload-time = "2025-05-08T17:58:23.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982, upload-time = "2025-05-08T17:58:21.15Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/36/db/3fff0bcbe339a6fa6a3b9e3fbc2bfb321ec2f4cd233692272c5a8d6cf801/websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5", size = 175424, upload-time = "2025-03-05T20:02:56.505Z" }, + { url = "https://files.pythonhosted.org/packages/46/e6/519054c2f477def4165b0ec060ad664ed174e140b0d1cbb9fafa4a54f6db/websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a", size = 173077, upload-time = "2025-03-05T20:02:58.37Z" }, + { url = "https://files.pythonhosted.org/packages/1a/21/c0712e382df64c93a0d16449ecbf87b647163485ca1cc3f6cbadb36d2b03/websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b", size = 173324, upload-time = "2025-03-05T20:02:59.773Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cb/51ba82e59b3a664df54beed8ad95517c1b4dc1a913730e7a7db778f21291/websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770", size = 182094, upload-time = "2025-03-05T20:03:01.827Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0f/bf3788c03fec679bcdaef787518dbe60d12fe5615a544a6d4cf82f045193/websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb", size = 181094, upload-time = "2025-03-05T20:03:03.123Z" }, + { url = "https://files.pythonhosted.org/packages/5e/da/9fb8c21edbc719b66763a571afbaf206cb6d3736d28255a46fc2fe20f902/websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054", size = 181397, upload-time = "2025-03-05T20:03:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/2e/65/65f379525a2719e91d9d90c38fe8b8bc62bd3c702ac651b7278609b696c4/websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee", size = 181794, upload-time = "2025-03-05T20:03:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/d9/26/31ac2d08f8e9304d81a1a7ed2851c0300f636019a57cbaa91342015c72cc/websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed", size = 181194, upload-time = "2025-03-05T20:03:08.844Z" }, + { url = "https://files.pythonhosted.org/packages/98/72/1090de20d6c91994cd4b357c3f75a4f25ee231b63e03adea89671cc12a3f/websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880", size = 181164, upload-time = "2025-03-05T20:03:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/2d/37/098f2e1c103ae8ed79b0e77f08d83b0ec0b241cf4b7f2f10edd0126472e1/websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411", size = 176381, upload-time = "2025-03-05T20:03:12.77Z" }, + { url = "https://files.pythonhosted.org/packages/75/8b/a32978a3ab42cebb2ebdd5b05df0696a09f4d436ce69def11893afa301f0/websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4", size = 176841, upload-time = "2025-03-05T20:03:14.367Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/48/4b67623bac4d79beb3a6bb27b803ba75c1bdedc06bd827e465803690a4b2/websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940", size = 173106, upload-time = "2025-03-05T20:03:29.404Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f0/adb07514a49fe5728192764e04295be78859e4a537ab8fcc518a3dbb3281/websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e", size = 173339, upload-time = "2025-03-05T20:03:30.755Z" }, + { url = "https://files.pythonhosted.org/packages/87/28/bd23c6344b18fb43df40d0700f6d3fffcd7cef14a6995b4f976978b52e62/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9", size = 174597, upload-time = "2025-03-05T20:03:32.247Z" }, + { url = "https://files.pythonhosted.org/packages/6d/79/ca288495863d0f23a60f546f0905ae8f3ed467ad87f8b6aceb65f4c013e4/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b", size = 174205, upload-time = "2025-03-05T20:03:33.731Z" }, + { url = "https://files.pythonhosted.org/packages/04/e4/120ff3180b0872b1fe6637f6f995bcb009fb5c87d597c1fc21456f50c848/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f", size = 174150, upload-time = "2025-03-05T20:03:35.757Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/30e2f9c539b8da8b1d76f64012f3b19253271a63413b2d3adb94b143407f/websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123", size = 176877, upload-time = "2025-03-05T20:03:37.199Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925, upload-time = "2024-11-08T15:52:18.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" }, +] + +[[package]] +name = "wheel" +version = "0.45.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/98/2d9906746cdc6a6ef809ae6338005b3f21bb568bea3165cfc6a243fdc25c/wheel-0.45.1.tar.gz", hash = "sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729", size = 107545, upload-time = "2024-11-23T00:18:23.513Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/2c/87f3254fd8ffd29e4c02732eee68a83a1d3c346ae39bc6822dcbcb697f2b/wheel-0.45.1-py3-none-any.whl", hash = "sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248", size = 72494, upload-time = "2024-11-23T00:18:21.207Z" }, +] + +[[package]] +name = "wsproto" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", size = 53425, upload-time = "2022-08-23T19:58:21.447Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736", size = 24226, upload-time = "2022-08-23T19:58:19.96Z" }, +] + +[[package]] +name = "xattr" +version = "1.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/bf/8b98081f9f8fd56d67b9478ff1e0f8c337cde08bcb92f0d592f0a7958983/xattr-1.1.4.tar.gz", hash = "sha256:b7b02ecb2270da5b7e7deaeea8f8b528c17368401c2b9d5f63e91f545b45d372", size = 16729, upload-time = "2025-01-06T19:19:32.557Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/9d/99cf83aa9e02604e88ad5e843b0f7a003740e24a60de71e7089acf54bee6/xattr-1.1.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:acb85b6249e9f3ea10cbb56df1021d43f4027212f0d004304bc9075dc7f54769", size = 23923, upload-time = "2025-01-06T19:17:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/2e/89/bf59d0b7b718823ae5535cdb367195c50681625e275896eb8eed7cfd4100/xattr-1.1.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1a848ab125c0fafdc501ccd83b4c9018bba576a037a4ca5960a22f39e295552e", size = 18886, upload-time = "2025-01-06T19:17:28.77Z" }, + { url = "https://files.pythonhosted.org/packages/33/e3/b5aeaa2ff5f4ee08024eb6b271f37f59a088849b1338e29836afb318df12/xattr-1.1.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:467ee77471d26ae5187ee7081b82175b5ca56ead4b71467ec2e6119d1b08beed", size = 19220, upload-time = "2025-01-06T19:17:34.285Z" }, + { url = "https://files.pythonhosted.org/packages/78/5b/f64ba0f93e6447e1997068959f22ff99e08d77dd88d9edcf97ddcb9e9016/xattr-1.1.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bb4bbe37ba95542081890dd34fa5347bef4651e276647adaa802d5d0d7d86452", size = 23920, upload-time = "2025-01-06T19:17:48.234Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/ad66655f0b1317b0a297aa2d6ed7d6e5d5343495841fad535bee37a56471/xattr-1.1.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3da489ecef798705f9a39ea8cea4ead0d1eeed55f92c345add89740bd930bab6", size = 18883, upload-time = "2025-01-06T19:17:49.46Z" }, + { url = "https://files.pythonhosted.org/packages/4d/5d/7d5154570bbcb898e6123c292f697c87c33e12258a1a8b9741539f952681/xattr-1.1.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:798dd0cbe696635a6f74b06fc430818bf9c3b24314e1502eadf67027ab60c9b0", size = 19221, upload-time = "2025-01-06T19:17:51.654Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2a/d0f9e46de4cec5e4aa45fd939549b977c49dd68202fa844d07cb24ce5f17/xattr-1.1.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ae6579dea05bf9f335a082f711d5924a98da563cac72a2d550f5b940c401c0e9", size = 23917, upload-time = "2025-01-06T19:18:00.868Z" }, + { url = "https://files.pythonhosted.org/packages/83/e0/a5764257cd9c9eb598f4648a3658d915dd3520ec111ecbd251b685de6546/xattr-1.1.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd6038ec9df2e67af23c212693751481d5f7e858156924f14340376c48ed9ac7", size = 18891, upload-time = "2025-01-06T19:18:02.029Z" }, + { url = "https://files.pythonhosted.org/packages/8b/83/a81a147987387fd2841a28f767efedb099cf90e23553ead458f2330e47c5/xattr-1.1.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:608b2877526674eb15df4150ef4b70b7b292ae00e65aecaae2f192af224be200", size = 19213, upload-time = "2025-01-06T19:18:03.303Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/3b8e82ba6f5d24753314ef9922390d9c8e78f157159621bb01f4741d3240/xattr-1.1.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878df1b38cfdadf3184ad8c7b0f516311128d5597b60ac0b3486948953658a83", size = 23910, upload-time = "2025-01-06T19:18:14.745Z" }, + { url = "https://files.pythonhosted.org/packages/77/8d/30b04121b42537aa969a797b89138bb1abd213d5777e9d4289284ebc7dee/xattr-1.1.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0c9b8350244a1c5454f93a8d572628ff71d7e2fc2f7480dcf4c4f0e8af3150fe", size = 18890, upload-time = "2025-01-06T19:18:17.68Z" }, + { url = "https://files.pythonhosted.org/packages/fe/94/a95c7db010265a449935452db54d614afb1e5e91b1530c61485fc0fea4b5/xattr-1.1.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a46bf48fb662b8bd745b78bef1074a1e08f41a531168de62b5d7bd331dadb11a", size = 19211, upload-time = "2025-01-06T19:18:24.625Z" }, + { url = "https://files.pythonhosted.org/packages/e7/59/367c9311e503a12899b2c7e5c931b3b6fd4b219943e9977cd212a7bc1a10/xattr-1.1.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e9f00315e6c02943893b77f544776b49c756ac76960bea7cb8d7e1b96aefc284", size = 23918, upload-time = "2025-01-06T19:18:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d1/a529542047e3922162c32c16268f0fb6268b8d0d5975bdca7cdb825fee1e/xattr-1.1.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c8f98775065260140efb348b1ff8d50fd66ddcbf0c685b76eb1e87b380aaffb3", size = 18882, upload-time = "2025-01-06T19:18:52.01Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8d/cb84dcb9ee687fed1e04f60d1674612db0a7736665735571d829ac488c0e/xattr-1.1.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b471c6a515f434a167ca16c5c15ff34ee42d11956baa749173a8a4e385ff23e7", size = 19216, upload-time = "2025-01-06T19:18:53.181Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c9/abcc190a7e24de9feead2404f3bd6dbaceda28034277ffc96ad21b2134f8/xattr-1.1.4-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c72667f19d3a9acf324aed97f58861d398d87e42314731e7c6ab3ac7850c971", size = 15610, upload-time = "2025-01-06T19:19:03.772Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e8/aa3b2db13f12f9fcbeb79c69a0e8a6dc420845e0a78a37a52bf392bc8471/xattr-1.1.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:67ae934d75ea2563fc48a27c5945749575c74a6de19fdd38390917ddcb0e4f24", size = 16100, upload-time = "2025-01-06T19:19:07.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/c1/d988495674cd86343a4bdce0217e78677550a5518204dfb39d9213fd6746/xattr-1.1.4-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee9455c501d19f065527afda974418b3ef7c61e85d9519d122cd6eb3cb7a00", size = 15608, upload-time = "2025-01-06T19:19:24.346Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/dd3d3f9308c8d9d66701fcfc58412c7ed1880161aa270807ce89111fbff7/xattr-1.1.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:89ed62ce430f5789e15cfc1ccabc172fd8b349c3a17c52d9e6c64ecedf08c265", size = 16098, upload-time = "2025-01-06T19:19:25.714Z" }, +] + +[[package]] +name = "xmod" +version = "1.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/b2/e3edc608823348e628a919e1d7129e641997afadd946febdd704aecc5881/xmod-1.8.1.tar.gz", hash = "sha256:38c76486b9d672c546d57d8035df0beb7f4a9b088bc3fb2de5431ae821444377", size = 3988, upload-time = "2024-01-04T18:03:17.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/0dc75b64a764ea1cb8e4c32d1fb273c147304d4e5483cd58be482dc62e45/xmod-1.8.1-py3-none-any.whl", hash = "sha256:a24e9458a4853489042522bdca9e50ee2eac5ab75c809a91150a8a7f40670d48", size = 4610, upload-time = "2024-01-04T18:03:16.078Z" }, +] + +[[package]] +name = "yarl" +version = "1.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/65/7fed0d774abf47487c64be14e9223749468922817b5e8792b8a64792a1bb/yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4", size = 132910, upload-time = "2025-06-10T00:42:31.108Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/988f55a52da99df9e56dc733b8e4e5a6ae2090081dc2754fc8fd34e60aa0/yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a", size = 90644, upload-time = "2025-06-10T00:42:33.851Z" }, + { url = "https://files.pythonhosted.org/packages/f7/de/30d98f03e95d30c7e3cc093759982d038c8833ec2451001d45ef4854edc1/yarl-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c869f2651cc77465f6cd01d938d91a11d9ea5d798738c1dc077f3de0b5e5fed", size = 89322, upload-time = "2025-06-10T00:42:35.688Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7a/f2f314f5ebfe9200724b0b748de2186b927acb334cf964fd312eb86fc286/yarl-1.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62915e6688eb4d180d93840cda4110995ad50c459bf931b8b3775b37c264af1e", size = 323786, upload-time = "2025-06-10T00:42:37.817Z" }, + { url = "https://files.pythonhosted.org/packages/15/3f/718d26f189db96d993d14b984ce91de52e76309d0fd1d4296f34039856aa/yarl-1.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41ebd28167bc6af8abb97fec1a399f412eec5fd61a3ccbe2305a18b84fb4ca73", size = 319627, upload-time = "2025-06-10T00:42:39.937Z" }, + { url = "https://files.pythonhosted.org/packages/a5/76/8fcfbf5fa2369157b9898962a4a7d96764b287b085b5b3d9ffae69cdefd1/yarl-1.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21242b4288a6d56f04ea193adde174b7e347ac46ce6bc84989ff7c1b1ecea84e", size = 339149, upload-time = "2025-06-10T00:42:42.627Z" }, + { url = "https://files.pythonhosted.org/packages/3c/95/d7fc301cc4661785967acc04f54a4a42d5124905e27db27bb578aac49b5c/yarl-1.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bea21cdae6c7eb02ba02a475f37463abfe0a01f5d7200121b03e605d6a0439f8", size = 333327, upload-time = "2025-06-10T00:42:44.842Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/e21269718349582eee81efc5c1c08ee71c816bfc1585b77d0ec3f58089eb/yarl-1.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f8a891e4a22a89f5dde7862994485e19db246b70bb288d3ce73a34422e55b23", size = 326054, upload-time = "2025-06-10T00:42:47.149Z" }, + { url = "https://files.pythonhosted.org/packages/32/ae/8616d1f07853704523519f6131d21f092e567c5af93de7e3e94b38d7f065/yarl-1.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd803820d44c8853a109a34e3660e5a61beae12970da479cf44aa2954019bf70", size = 315035, upload-time = "2025-06-10T00:42:48.852Z" }, + { url = "https://files.pythonhosted.org/packages/48/aa/0ace06280861ef055855333707db5e49c6e3a08840a7ce62682259d0a6c0/yarl-1.20.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b982fa7f74c80d5c0c7b5b38f908971e513380a10fecea528091405f519b9ebb", size = 338962, upload-time = "2025-06-10T00:42:51.024Z" }, + { url = "https://files.pythonhosted.org/packages/20/52/1e9d0e6916f45a8fb50e6844f01cb34692455f1acd548606cbda8134cd1e/yarl-1.20.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:33f29ecfe0330c570d997bcf1afd304377f2e48f61447f37e846a6058a4d33b2", size = 335399, upload-time = "2025-06-10T00:42:53.007Z" }, + { url = "https://files.pythonhosted.org/packages/f2/65/60452df742952c630e82f394cd409de10610481d9043aa14c61bf846b7b1/yarl-1.20.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:835ab2cfc74d5eb4a6a528c57f05688099da41cf4957cf08cad38647e4a83b30", size = 338649, upload-time = "2025-06-10T00:42:54.964Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f5/6cd4ff38dcde57a70f23719a838665ee17079640c77087404c3d34da6727/yarl-1.20.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46b5e0ccf1943a9a6e766b2c2b8c732c55b34e28be57d8daa2b3c1d1d4009309", size = 358563, upload-time = "2025-06-10T00:42:57.28Z" }, + { url = "https://files.pythonhosted.org/packages/d1/90/c42eefd79d0d8222cb3227bdd51b640c0c1d0aa33fe4cc86c36eccba77d3/yarl-1.20.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:df47c55f7d74127d1b11251fe6397d84afdde0d53b90bedb46a23c0e534f9d24", size = 357609, upload-time = "2025-06-10T00:42:59.055Z" }, + { url = "https://files.pythonhosted.org/packages/03/c8/cea6b232cb4617514232e0f8a718153a95b5d82b5290711b201545825532/yarl-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76d12524d05841276b0e22573f28d5fbcb67589836772ae9244d90dd7d66aa13", size = 350224, upload-time = "2025-06-10T00:43:01.248Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a3/eaa0ab9712f1f3d01faf43cf6f1f7210ce4ea4a7e9b28b489a2261ca8db9/yarl-1.20.1-cp310-cp310-win32.whl", hash = "sha256:6c4fbf6b02d70e512d7ade4b1f998f237137f1417ab07ec06358ea04f69134f8", size = 81753, upload-time = "2025-06-10T00:43:03.486Z" }, + { url = "https://files.pythonhosted.org/packages/8f/34/e4abde70a9256465fe31c88ed02c3f8502b7b5dead693a4f350a06413f28/yarl-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef6c4d69554d44b7f9d923245f8ad9a707d971e6209d51279196d8e8fe1ae16", size = 86817, upload-time = "2025-06-10T00:43:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b1/18/893b50efc2350e47a874c5c2d67e55a0ea5df91186b2a6f5ac52eff887cd/yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e", size = 133833, upload-time = "2025-06-10T00:43:07.393Z" }, + { url = "https://files.pythonhosted.org/packages/89/ed/b8773448030e6fc47fa797f099ab9eab151a43a25717f9ac043844ad5ea3/yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b", size = 91070, upload-time = "2025-06-10T00:43:09.538Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e3/409bd17b1e42619bf69f60e4f031ce1ccb29bd7380117a55529e76933464/yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b", size = 89818, upload-time = "2025-06-10T00:43:11.575Z" }, + { url = "https://files.pythonhosted.org/packages/f8/77/64d8431a4d77c856eb2d82aa3de2ad6741365245a29b3a9543cd598ed8c5/yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4", size = 347003, upload-time = "2025-06-10T00:43:14.088Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d2/0c7e4def093dcef0bd9fa22d4d24b023788b0a33b8d0088b51aa51e21e99/yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1", size = 336537, upload-time = "2025-06-10T00:43:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f3/fc514f4b2cf02cb59d10cbfe228691d25929ce8f72a38db07d3febc3f706/yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833", size = 362358, upload-time = "2025-06-10T00:43:18.704Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6d/a313ac8d8391381ff9006ac05f1d4331cee3b1efaa833a53d12253733255/yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d", size = 357362, upload-time = "2025-06-10T00:43:20.888Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/8f78a95d6935a70263d46caa3dd18e1f223cf2f2ff2037baa01a22bc5b22/yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8", size = 348979, upload-time = "2025-06-10T00:43:23.169Z" }, + { url = "https://files.pythonhosted.org/packages/cb/05/42773027968968f4f15143553970ee36ead27038d627f457cc44bbbeecf3/yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf", size = 337274, upload-time = "2025-06-10T00:43:27.111Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/665634aa196954156741ea591d2f946f1b78ceee8bb8f28488bf28c0dd62/yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e", size = 363294, upload-time = "2025-06-10T00:43:28.96Z" }, + { url = "https://files.pythonhosted.org/packages/eb/90/73448401d36fa4e210ece5579895731f190d5119c4b66b43b52182e88cd5/yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389", size = 358169, upload-time = "2025-06-10T00:43:30.701Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b0/fce922d46dc1eb43c811f1889f7daa6001b27a4005587e94878570300881/yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f", size = 362776, upload-time = "2025-06-10T00:43:32.51Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0d/b172628fce039dae8977fd22caeff3eeebffd52e86060413f5673767c427/yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845", size = 381341, upload-time = "2025-06-10T00:43:34.543Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9b/5b886d7671f4580209e855974fe1cecec409aa4a89ea58b8f0560dc529b1/yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1", size = 379988, upload-time = "2025-06-10T00:43:36.489Z" }, + { url = "https://files.pythonhosted.org/packages/73/be/75ef5fd0fcd8f083a5d13f78fd3f009528132a1f2a1d7c925c39fa20aa79/yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e", size = 371113, upload-time = "2025-06-10T00:43:38.592Z" }, + { url = "https://files.pythonhosted.org/packages/50/4f/62faab3b479dfdcb741fe9e3f0323e2a7d5cd1ab2edc73221d57ad4834b2/yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773", size = 81485, upload-time = "2025-06-10T00:43:41.038Z" }, + { url = "https://files.pythonhosted.org/packages/f0/09/d9c7942f8f05c32ec72cd5c8e041c8b29b5807328b68b4801ff2511d4d5e/yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e", size = 86686, upload-time = "2025-06-10T00:43:42.692Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload-time = "2025-06-10T00:43:44.369Z" }, + { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload-time = "2025-06-10T00:43:46.295Z" }, + { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload-time = "2025-06-10T00:43:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287, upload-time = "2025-06-10T00:43:49.924Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429, upload-time = "2025-06-10T00:43:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429, upload-time = "2025-06-10T00:43:53.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862, upload-time = "2025-06-10T00:43:55.766Z" }, + { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616, upload-time = "2025-06-10T00:43:58.056Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954, upload-time = "2025-06-10T00:43:59.773Z" }, + { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575, upload-time = "2025-06-10T00:44:02.051Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061, upload-time = "2025-06-10T00:44:04.196Z" }, + { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142, upload-time = "2025-06-10T00:44:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894, upload-time = "2025-06-10T00:44:08.379Z" }, + { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378, upload-time = "2025-06-10T00:44:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069, upload-time = "2025-06-10T00:44:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249, upload-time = "2025-06-10T00:44:14.731Z" }, + { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, + { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, + { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, + { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, + { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, + { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, + { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, + { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, + { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, + { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, + { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, + { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, + { url = "https://files.pythonhosted.org/packages/01/75/0d37402d208d025afa6b5b8eb80e466d267d3fd1927db8e317d29a94a4cb/yarl-1.20.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e42ba79e2efb6845ebab49c7bf20306c4edf74a0b20fc6b2ccdd1a219d12fad3", size = 134259, upload-time = "2025-06-10T00:45:29.882Z" }, + { url = "https://files.pythonhosted.org/packages/73/84/1fb6c85ae0cf9901046f07d0ac9eb162f7ce6d95db541130aa542ed377e6/yarl-1.20.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:41493b9b7c312ac448b7f0a42a089dffe1d6e6e981a2d76205801a023ed26a2b", size = 91269, upload-time = "2025-06-10T00:45:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9c/eae746b24c4ea29a5accba9a06c197a70fa38a49c7df244e0d3951108861/yarl-1.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f5a5928ff5eb13408c62a968ac90d43f8322fd56d87008b8f9dabf3c0f6ee983", size = 89995, upload-time = "2025-06-10T00:45:35.066Z" }, + { url = "https://files.pythonhosted.org/packages/fb/30/693e71003ec4bc1daf2e4cf7c478c417d0985e0a8e8f00b2230d517876fc/yarl-1.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30c41ad5d717b3961b2dd785593b67d386b73feca30522048d37298fee981805", size = 325253, upload-time = "2025-06-10T00:45:37.052Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a2/5264dbebf90763139aeb0b0b3154763239398400f754ae19a0518b654117/yarl-1.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:59febc3969b0781682b469d4aca1a5cab7505a4f7b85acf6db01fa500fa3f6ba", size = 320897, upload-time = "2025-06-10T00:45:39.962Z" }, + { url = "https://files.pythonhosted.org/packages/e7/17/77c7a89b3c05856489777e922f41db79ab4faf58621886df40d812c7facd/yarl-1.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2b6fb3622b7e5bf7a6e5b679a69326b4279e805ed1699d749739a61d242449e", size = 340696, upload-time = "2025-06-10T00:45:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/6d/55/28409330b8ef5f2f681f5b478150496ec9cf3309b149dab7ec8ab5cfa3f0/yarl-1.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:749d73611db8d26a6281086f859ea7ec08f9c4c56cec864e52028c8b328db723", size = 335064, upload-time = "2025-06-10T00:45:43.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/58/cb0257cbd4002828ff735f44d3c5b6966c4fd1fc8cc1cd3cd8a143fbc513/yarl-1.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9427925776096e664c39e131447aa20ec738bdd77c049c48ea5200db2237e000", size = 327256, upload-time = "2025-06-10T00:45:46.393Z" }, + { url = "https://files.pythonhosted.org/packages/53/f6/c77960370cfa46f6fb3d6a5a79a49d3abfdb9ef92556badc2dcd2748bc2a/yarl-1.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff70f32aa316393eaf8222d518ce9118148eddb8a53073c2403863b41033eed5", size = 316389, upload-time = "2025-06-10T00:45:48.358Z" }, + { url = "https://files.pythonhosted.org/packages/64/ab/be0b10b8e029553c10905b6b00c64ecad3ebc8ace44b02293a62579343f6/yarl-1.20.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c7ddf7a09f38667aea38801da8b8d6bfe81df767d9dfc8c88eb45827b195cd1c", size = 340481, upload-time = "2025-06-10T00:45:50.663Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c3/3f327bd3905a4916029bf5feb7f86dcf864c7704f099715f62155fb386b2/yarl-1.20.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:57edc88517d7fc62b174fcfb2e939fbc486a68315d648d7e74d07fac42cec240", size = 336941, upload-time = "2025-06-10T00:45:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/d1/42/040bdd5d3b3bb02b4a6ace4ed4075e02f85df964d6e6cb321795d2a6496a/yarl-1.20.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:dab096ce479d5894d62c26ff4f699ec9072269d514b4edd630a393223f45a0ee", size = 339936, upload-time = "2025-06-10T00:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1c/911867b8e8c7463b84dfdc275e0d99b04b66ad5132b503f184fe76be8ea4/yarl-1.20.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14a85f3bd2d7bb255be7183e5d7d6e70add151a98edf56a770d6140f5d5f4010", size = 360163, upload-time = "2025-06-10T00:45:56.87Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/8c389f6c6ca0379b57b2da87f1f126c834777b4931c5ee8427dd65d0ff6b/yarl-1.20.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c89b5c792685dd9cd3fa9761c1b9f46fc240c2a3265483acc1565769996a3f8", size = 359108, upload-time = "2025-06-10T00:45:58.869Z" }, + { url = "https://files.pythonhosted.org/packages/7f/09/ae4a649fb3964324c70a3e2b61f45e566d9ffc0affd2b974cbf628957673/yarl-1.20.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:69e9b141de5511021942a6866990aea6d111c9042235de90e08f94cf972ca03d", size = 351875, upload-time = "2025-06-10T00:46:01.45Z" }, + { url = "https://files.pythonhosted.org/packages/8d/43/bbb4ed4c34d5bb62b48bf957f68cd43f736f79059d4f85225ab1ef80f4b9/yarl-1.20.1-cp39-cp39-win32.whl", hash = "sha256:b5f307337819cdfdbb40193cad84978a029f847b0a357fbe49f712063cfc4f06", size = 82293, upload-time = "2025-06-10T00:46:03.763Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cd/ce185848a7dba68ea69e932674b5c1a42a1852123584bccc5443120f857c/yarl-1.20.1-cp39-cp39-win_amd64.whl", hash = "sha256:eae7bfe2069f9c1c5b05fc7fe5d612e5bbc089a39309904ee8b829e322dcad00", size = 87385, upload-time = "2025-06-10T00:46:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, +] + +[[package]] +name = "z3-solver" +version = "4.15.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/c5481ef8e1fb64f398cb81caca0a808b4eee845091d41fb6e72bf06a9ee2/z3_solver-4.15.1.0.tar.gz", hash = "sha256:e8522602a76f6e45c45e78eec7bff5cbaa44fa51e94dce0d5432b0f9ab3f7064", size = 5054686, upload-time = "2025-06-08T18:54:41.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8b/e47ed5d6e3b565e400f2948549a9d633bdeea0eb081ddb3047bd04266d92/z3_solver-4.15.1.0-py3-none-macosx_13_0_arm64.whl", hash = "sha256:4fdf8675500f32b03114670a8c734fa9fc9f8c9bd1047d575449ca69fa397ac5", size = 37542839, upload-time = "2025-06-08T18:54:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/f0/10/b9828d71ac9a65f9ddf75a94b95f269c063dc052ccb200ecfcd81cf5557a/z3_solver-4.15.1.0-py3-none-macosx_13_0_x86_64.whl", hash = "sha256:878814bef41ca3d9957923d07fc3084967d14dff1a3c039d00f76324461bb11b", size = 40356020, upload-time = "2025-06-08T18:54:28.354Z" }, + { url = "https://files.pythonhosted.org/packages/96/95/b37b98fa23811559987e8403729093b8fae1d0c5321286667768956e31da/z3_solver-4.15.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f1d15073c78d793be56ff334f3d4770fe66d57808fdad2780e25c936d8fab0a", size = 29530873, upload-time = "2025-06-08T18:54:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/54/c9/117858dc7396435026988fb3ab59c6634887488511cc1014007a81fa3b0e/z3_solver-4.15.1.0-py3-none-manylinux_2_34_aarch64.whl", hash = "sha256:1ac01865e9b07e35b8856157fa95259b1741529c05ef019f599675c7b0caab42", size = 27517613, upload-time = "2025-06-08T18:54:33.89Z" }, + { url = "https://files.pythonhosted.org/packages/32/c2/1cb7df76d243f33f99416e9fcfefc76195cf9305e23fc9296edf6d5fb6be/z3_solver-4.15.1.0-py3-none-win32.whl", hash = "sha256:0b41c73ed6ea30514210853e31b432c3654b36e7e7a74db23906ddba345cb654", size = 13363408, upload-time = "2025-06-08T18:54:36.521Z" }, + { url = "https://files.pythonhosted.org/packages/28/ee/110ee33282331c5dab4e63bb570b345d85b2ed5ee1d30a54a987903e22fe/z3_solver-4.15.1.0-py3-none-win_amd64.whl", hash = "sha256:1d858c5b7ecd60788576ec6ae62cc7b9ae142e9ed38dff3dfd415e2fe230c712", size = 16428380, upload-time = "2025-06-08T18:54:38.872Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] + +[[package]] +name = "zope-interface" +version = "7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/93/9210e7606be57a2dfc6277ac97dcc864fd8d39f142ca194fdc186d596fda/zope.interface-7.2.tar.gz", hash = "sha256:8b49f1a3d1ee4cdaf5b32d2e738362c7f5e40ac8b46dd7d1a65e82a4872728fe", size = 252960, upload-time = "2024-11-28T08:45:39.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/71/e6177f390e8daa7e75378505c5ab974e0bf59c1d3b19155638c7afbf4b2d/zope.interface-7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce290e62229964715f1011c3dbeab7a4a1e4971fd6f31324c4519464473ef9f2", size = 208243, upload-time = "2024-11-28T08:47:29.781Z" }, + { url = "https://files.pythonhosted.org/packages/52/db/7e5f4226bef540f6d55acfd95cd105782bc6ee044d9b5587ce2c95558a5e/zope.interface-7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:05b910a5afe03256b58ab2ba6288960a2892dfeef01336dc4be6f1b9ed02ab0a", size = 208759, upload-time = "2024-11-28T08:47:31.908Z" }, + { url = "https://files.pythonhosted.org/packages/28/ea/fdd9813c1eafd333ad92464d57a4e3a82b37ae57c19497bcffa42df673e4/zope.interface-7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:550f1c6588ecc368c9ce13c44a49b8d6b6f3ca7588873c679bd8fd88a1b557b6", size = 254922, upload-time = "2024-11-28T09:18:11.795Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d3/0000a4d497ef9fbf4f66bb6828b8d0a235e690d57c333be877bec763722f/zope.interface-7.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ef9e2f865721553c6f22a9ff97da0f0216c074bd02b25cf0d3af60ea4d6931d", size = 249367, upload-time = "2024-11-28T08:48:24.238Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e5/0b359e99084f033d413419eff23ee9c2bd33bca2ca9f4e83d11856f22d10/zope.interface-7.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27f926f0dcb058211a3bb3e0e501c69759613b17a553788b2caeb991bed3b61d", size = 254488, upload-time = "2024-11-28T08:48:28.816Z" }, + { url = "https://files.pythonhosted.org/packages/7b/90/12d50b95f40e3b2fc0ba7f7782104093b9fd62806b13b98ef4e580f2ca61/zope.interface-7.2-cp310-cp310-win_amd64.whl", hash = "sha256:144964649eba4c5e4410bb0ee290d338e78f179cdbfd15813de1a664e7649b3b", size = 211947, upload-time = "2024-11-28T08:48:18.831Z" }, + { url = "https://files.pythonhosted.org/packages/98/7d/2e8daf0abea7798d16a58f2f3a2bf7588872eee54ac119f99393fdd47b65/zope.interface-7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1909f52a00c8c3dcab6c4fad5d13de2285a4b3c7be063b239b8dc15ddfb73bd2", size = 208776, upload-time = "2024-11-28T08:47:53.009Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/0c03c7170fe61d0d371e4c7ea5b62b8cb79b095b3d630ca16719bf8b7b18/zope.interface-7.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:80ecf2451596f19fd607bb09953f426588fc1e79e93f5968ecf3367550396b22", size = 209296, upload-time = "2024-11-28T08:47:57.993Z" }, + { url = "https://files.pythonhosted.org/packages/49/b4/451f19448772b4a1159519033a5f72672221e623b0a1bd2b896b653943d8/zope.interface-7.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:033b3923b63474800b04cba480b70f6e6243a62208071fc148354f3f89cc01b7", size = 260997, upload-time = "2024-11-28T09:18:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/5aa4461c10718062c8f8711161faf3249d6d3679c24a0b81dd6fc8ba1dd3/zope.interface-7.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a102424e28c6b47c67923a1f337ede4a4c2bba3965b01cf707978a801fc7442c", size = 255038, upload-time = "2024-11-28T08:48:26.381Z" }, + { url = "https://files.pythonhosted.org/packages/9f/aa/1a28c02815fe1ca282b54f6705b9ddba20328fabdc37b8cf73fc06b172f0/zope.interface-7.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25e6a61dcb184453bb00eafa733169ab6d903e46f5c2ace4ad275386f9ab327a", size = 259806, upload-time = "2024-11-28T08:48:30.78Z" }, + { url = "https://files.pythonhosted.org/packages/a7/2c/82028f121d27c7e68632347fe04f4a6e0466e77bb36e104c8b074f3d7d7b/zope.interface-7.2-cp311-cp311-win_amd64.whl", hash = "sha256:3f6771d1647b1fc543d37640b45c06b34832a943c80d1db214a37c31161a93f1", size = 212305, upload-time = "2024-11-28T08:49:14.525Z" }, + { url = "https://files.pythonhosted.org/packages/68/0b/c7516bc3bad144c2496f355e35bd699443b82e9437aa02d9867653203b4a/zope.interface-7.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:086ee2f51eaef1e4a52bd7d3111a0404081dadae87f84c0ad4ce2649d4f708b7", size = 208959, upload-time = "2024-11-28T08:47:47.788Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e9/1463036df1f78ff8c45a02642a7bf6931ae4a38a4acd6a8e07c128e387a7/zope.interface-7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:21328fcc9d5b80768bf051faa35ab98fb979080c18e6f84ab3f27ce703bce465", size = 209357, upload-time = "2024-11-28T08:47:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/07/a8/106ca4c2add440728e382f1b16c7d886563602487bdd90004788d45eb310/zope.interface-7.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6dd02ec01f4468da0f234da9d9c8545c5412fef80bc590cc51d8dd084138a89", size = 264235, upload-time = "2024-11-28T09:18:15.56Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ca/57286866285f4b8a4634c12ca1957c24bdac06eae28fd4a3a578e30cf906/zope.interface-7.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e7da17f53e25d1a3bde5da4601e026adc9e8071f9f6f936d0fe3fe84ace6d54", size = 259253, upload-time = "2024-11-28T08:48:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/96/08/2103587ebc989b455cf05e858e7fbdfeedfc3373358320e9c513428290b1/zope.interface-7.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cab15ff4832580aa440dc9790b8a6128abd0b88b7ee4dd56abacbc52f212209d", size = 264702, upload-time = "2024-11-28T08:48:37.363Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c7/3c67562e03b3752ba4ab6b23355f15a58ac2d023a6ef763caaca430f91f2/zope.interface-7.2-cp312-cp312-win_amd64.whl", hash = "sha256:29caad142a2355ce7cfea48725aa8bcf0067e2b5cc63fcf5cd9f97ad12d6afb5", size = 212466, upload-time = "2024-11-28T08:49:14.397Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3b/e309d731712c1a1866d61b5356a069dd44e5b01e394b6cb49848fa2efbff/zope.interface-7.2-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:3e0350b51e88658d5ad126c6a57502b19d5f559f6cb0a628e3dc90442b53dd98", size = 208961, upload-time = "2024-11-28T08:48:29.865Z" }, + { url = "https://files.pythonhosted.org/packages/49/65/78e7cebca6be07c8fc4032bfbb123e500d60efdf7b86727bb8a071992108/zope.interface-7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15398c000c094b8855d7d74f4fdc9e73aa02d4d0d5c775acdef98cdb1119768d", size = 209356, upload-time = "2024-11-28T08:48:33.297Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/627384b745310d082d29e3695db5f5a9188186676912c14b61a78bbc6afe/zope.interface-7.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:802176a9f99bd8cc276dcd3b8512808716492f6f557c11196d42e26c01a69a4c", size = 264196, upload-time = "2024-11-28T09:18:17.584Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f6/54548df6dc73e30ac6c8a7ff1da73ac9007ba38f866397091d5a82237bd3/zope.interface-7.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb23f58a446a7f09db85eda09521a498e109f137b85fb278edb2e34841055398", size = 259237, upload-time = "2024-11-28T08:48:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/b6/66/ac05b741c2129fdf668b85631d2268421c5cd1a9ff99be1674371139d665/zope.interface-7.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a71a5b541078d0ebe373a81a3b7e71432c61d12e660f1d67896ca62d9628045b", size = 264696, upload-time = "2024-11-28T08:48:41.161Z" }, + { url = "https://files.pythonhosted.org/packages/0a/2f/1bccc6f4cc882662162a1158cda1a7f616add2ffe322b28c99cb031b4ffc/zope.interface-7.2-cp313-cp313-win_amd64.whl", hash = "sha256:4893395d5dd2ba655c38ceb13014fd65667740f09fa5bb01caa1e6284e48c0cd", size = 212472, upload-time = "2024-11-28T08:49:56.587Z" }, + { url = "https://files.pythonhosted.org/packages/8c/2c/1f49dc8b4843c4f0848d8e43191aed312bad946a1563d1bf9e46cf2816ee/zope.interface-7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bd449c306ba006c65799ea7912adbbfed071089461a19091a228998b82b1fdb", size = 208349, upload-time = "2024-11-28T08:49:28.872Z" }, + { url = "https://files.pythonhosted.org/packages/ed/7d/83ddbfc8424c69579a90fc8edc2b797223da2a8083a94d8dfa0e374c5ed4/zope.interface-7.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a19a6cc9c6ce4b1e7e3d319a473cf0ee989cbbe2b39201d7c19e214d2dfb80c7", size = 208799, upload-time = "2024-11-28T08:49:30.616Z" }, + { url = "https://files.pythonhosted.org/packages/36/22/b1abd91854c1be03f5542fe092e6a745096d2eca7704d69432e119100583/zope.interface-7.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72cd1790b48c16db85d51fbbd12d20949d7339ad84fd971427cf00d990c1f137", size = 254267, upload-time = "2024-11-28T09:18:21.059Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dd/fcd313ee216ad0739ae00e6126bc22a0af62a74f76a9ca668d16cd276222/zope.interface-7.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52e446f9955195440e787596dccd1411f543743c359eeb26e9b2c02b077b0519", size = 248614, upload-time = "2024-11-28T08:48:41.953Z" }, + { url = "https://files.pythonhosted.org/packages/88/d4/4ba1569b856870527cec4bf22b91fe704b81a3c1a451b2ccf234e9e0666f/zope.interface-7.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ad9913fd858274db8dd867012ebe544ef18d218f6f7d1e3c3e6d98000f14b75", size = 253800, upload-time = "2024-11-28T08:48:46.637Z" }, + { url = "https://files.pythonhosted.org/packages/69/da/c9cfb384c18bd3a26d9fc6a9b5f32ccea49ae09444f097eaa5ca9814aff9/zope.interface-7.2-cp39-cp39-win_amd64.whl", hash = "sha256:1090c60116b3da3bfdd0c03406e2f14a1ff53e5771aebe33fec1edc0a350175d", size = 211980, upload-time = "2024-11-28T08:50:35.681Z" }, +] + +[[package]] +name = "zstandard" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation == 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/f6/2ac0287b442160a89d726b17a9184a4c615bb5237db763791a7fd16d9df1/zstandard-0.23.0.tar.gz", hash = "sha256:b2d8c62d08e7255f68f7a740bae85b3c9b8e5466baa9cbf7f57f1cde0ac6bc09", size = 681701, upload-time = "2024-07-15T00:18:06.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/55/bd0487e86679db1823fc9ee0d8c9c78ae2413d34c0b461193b5f4c31d22f/zstandard-0.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bf0a05b6059c0528477fba9054d09179beb63744355cab9f38059548fedd46a9", size = 788701, upload-time = "2024-07-15T00:13:27.351Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8a/ccb516b684f3ad987dfee27570d635822e3038645b1a950c5e8022df1145/zstandard-0.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fc9ca1c9718cb3b06634c7c8dec57d24e9438b2aa9a0f02b8bb36bf478538880", size = 633678, upload-time = "2024-07-15T00:13:30.24Z" }, + { url = "https://files.pythonhosted.org/packages/12/89/75e633d0611c028e0d9af6df199423bf43f54bea5007e6718ab7132e234c/zstandard-0.23.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77da4c6bfa20dd5ea25cbf12c76f181a8e8cd7ea231c673828d0386b1740b8dc", size = 4941098, upload-time = "2024-07-15T00:13:32.526Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7a/bd7f6a21802de358b63f1ee636ab823711c25ce043a3e9f043b4fcb5ba32/zstandard-0.23.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2170c7e0367dde86a2647ed5b6f57394ea7f53545746104c6b09fc1f4223573", size = 5308798, upload-time = "2024-07-15T00:13:34.925Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/775f851a4a65013e88ca559c8ae42ac1352db6fcd96b028d0df4d7d1d7b4/zstandard-0.23.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c16842b846a8d2a145223f520b7e18b57c8f476924bda92aeee3a88d11cfc391", size = 5341840, upload-time = "2024-07-15T00:13:37.376Z" }, + { url = "https://files.pythonhosted.org/packages/09/4f/0cc49570141dd72d4d95dd6fcf09328d1b702c47a6ec12fbed3b8aed18a5/zstandard-0.23.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:157e89ceb4054029a289fb504c98c6a9fe8010f1680de0201b3eb5dc20aa6d9e", size = 5440337, upload-time = "2024-07-15T00:13:39.772Z" }, + { url = "https://files.pythonhosted.org/packages/e7/7c/aaa7cd27148bae2dc095191529c0570d16058c54c4597a7d118de4b21676/zstandard-0.23.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:203d236f4c94cd8379d1ea61db2fce20730b4c38d7f1c34506a31b34edc87bdd", size = 4861182, upload-time = "2024-07-15T00:13:42.495Z" }, + { url = "https://files.pythonhosted.org/packages/ac/eb/4b58b5c071d177f7dc027129d20bd2a44161faca6592a67f8fcb0b88b3ae/zstandard-0.23.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dc5d1a49d3f8262be192589a4b72f0d03b72dcf46c51ad5852a4fdc67be7b9e4", size = 4932936, upload-time = "2024-07-15T00:13:44.234Z" }, + { url = "https://files.pythonhosted.org/packages/44/f9/21a5fb9bb7c9a274b05ad700a82ad22ce82f7ef0f485980a1e98ed6e8c5f/zstandard-0.23.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:752bf8a74412b9892f4e5b58f2f890a039f57037f52c89a740757ebd807f33ea", size = 5464705, upload-time = "2024-07-15T00:13:46.822Z" }, + { url = "https://files.pythonhosted.org/packages/49/74/b7b3e61db3f88632776b78b1db597af3f44c91ce17d533e14a25ce6a2816/zstandard-0.23.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:80080816b4f52a9d886e67f1f96912891074903238fe54f2de8b786f86baded2", size = 4857882, upload-time = "2024-07-15T00:13:49.297Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7f/d8eb1cb123d8e4c541d4465167080bec88481ab54cd0b31eb4013ba04b95/zstandard-0.23.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84433dddea68571a6d6bd4fbf8ff398236031149116a7fff6f777ff95cad3df9", size = 4697672, upload-time = "2024-07-15T00:13:51.447Z" }, + { url = "https://files.pythonhosted.org/packages/5e/05/f7dccdf3d121309b60342da454d3e706453a31073e2c4dac8e1581861e44/zstandard-0.23.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ab19a2d91963ed9e42b4e8d77cd847ae8381576585bad79dbd0a8837a9f6620a", size = 5206043, upload-time = "2024-07-15T00:13:53.587Z" }, + { url = "https://files.pythonhosted.org/packages/86/9d/3677a02e172dccd8dd3a941307621c0cbd7691d77cb435ac3c75ab6a3105/zstandard-0.23.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:59556bf80a7094d0cfb9f5e50bb2db27fefb75d5138bb16fb052b61b0e0eeeb0", size = 5667390, upload-time = "2024-07-15T00:13:56.137Z" }, + { url = "https://files.pythonhosted.org/packages/41/7e/0012a02458e74a7ba122cd9cafe491facc602c9a17f590367da369929498/zstandard-0.23.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:27d3ef2252d2e62476389ca8f9b0cf2bbafb082a3b6bfe9d90cbcbb5529ecf7c", size = 5198901, upload-time = "2024-07-15T00:13:58.584Z" }, + { url = "https://files.pythonhosted.org/packages/65/3a/8f715b97bd7bcfc7342d8adcd99a026cb2fb550e44866a3b6c348e1b0f02/zstandard-0.23.0-cp310-cp310-win32.whl", hash = "sha256:5d41d5e025f1e0bccae4928981e71b2334c60f580bdc8345f824e7c0a4c2a813", size = 430596, upload-time = "2024-07-15T00:14:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/19/b7/b2b9eca5e5a01111e4fe8a8ffb56bdcdf56b12448a24effe6cfe4a252034/zstandard-0.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:519fbf169dfac1222a76ba8861ef4ac7f0530c35dd79ba5727014613f91613d4", size = 495498, upload-time = "2024-07-15T00:14:02.741Z" }, + { url = "https://files.pythonhosted.org/packages/9e/40/f67e7d2c25a0e2dc1744dd781110b0b60306657f8696cafb7ad7579469bd/zstandard-0.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:34895a41273ad33347b2fc70e1bff4240556de3c46c6ea430a7ed91f9042aa4e", size = 788699, upload-time = "2024-07-15T00:14:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/e8/46/66d5b55f4d737dd6ab75851b224abf0afe5774976fe511a54d2eb9063a41/zstandard-0.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:77ea385f7dd5b5676d7fd943292ffa18fbf5c72ba98f7d09fc1fb9e819b34c23", size = 633681, upload-time = "2024-07-15T00:14:13.99Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/677e65c095d8e12b66b8f862b069bcf1f1d781b9c9c6f12eb55000d57583/zstandard-0.23.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:983b6efd649723474f29ed42e1467f90a35a74793437d0bc64a5bf482bedfa0a", size = 4944328, upload-time = "2024-07-15T00:14:16.588Z" }, + { url = "https://files.pythonhosted.org/packages/59/cc/e76acb4c42afa05a9d20827116d1f9287e9c32b7ad58cc3af0721ce2b481/zstandard-0.23.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80a539906390591dd39ebb8d773771dc4db82ace6372c4d41e2d293f8e32b8db", size = 5311955, upload-time = "2024-07-15T00:14:19.389Z" }, + { url = "https://files.pythonhosted.org/packages/78/e4/644b8075f18fc7f632130c32e8f36f6dc1b93065bf2dd87f03223b187f26/zstandard-0.23.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:445e4cb5048b04e90ce96a79b4b63140e3f4ab5f662321975679b5f6360b90e2", size = 5344944, upload-time = "2024-07-15T00:14:22.173Z" }, + { url = "https://files.pythonhosted.org/packages/76/3f/dbafccf19cfeca25bbabf6f2dd81796b7218f768ec400f043edc767015a6/zstandard-0.23.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd30d9c67d13d891f2360b2a120186729c111238ac63b43dbd37a5a40670b8ca", size = 5442927, upload-time = "2024-07-15T00:14:24.825Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/d24a01a19b6733b9f218e94d1a87c477d523237e07f94899e1c10f6fd06c/zstandard-0.23.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d20fd853fbb5807c8e84c136c278827b6167ded66c72ec6f9a14b863d809211c", size = 4864910, upload-time = "2024-07-15T00:14:26.982Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a9/cf8f78ead4597264f7618d0875be01f9bc23c9d1d11afb6d225b867cb423/zstandard-0.23.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ed1708dbf4d2e3a1c5c69110ba2b4eb6678262028afd6c6fbcc5a8dac9cda68e", size = 4935544, upload-time = "2024-07-15T00:14:29.582Z" }, + { url = "https://files.pythonhosted.org/packages/2c/96/8af1e3731b67965fb995a940c04a2c20997a7b3b14826b9d1301cf160879/zstandard-0.23.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:be9b5b8659dff1f913039c2feee1aca499cfbc19e98fa12bc85e037c17ec6ca5", size = 5467094, upload-time = "2024-07-15T00:14:40.126Z" }, + { url = "https://files.pythonhosted.org/packages/ff/57/43ea9df642c636cb79f88a13ab07d92d88d3bfe3e550b55a25a07a26d878/zstandard-0.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:65308f4b4890aa12d9b6ad9f2844b7ee42c7f7a4fd3390425b242ffc57498f48", size = 4860440, upload-time = "2024-07-15T00:14:42.786Z" }, + { url = "https://files.pythonhosted.org/packages/46/37/edb78f33c7f44f806525f27baa300341918fd4c4af9472fbc2c3094be2e8/zstandard-0.23.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:98da17ce9cbf3bfe4617e836d561e433f871129e3a7ac16d6ef4c680f13a839c", size = 4700091, upload-time = "2024-07-15T00:14:45.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f1/454ac3962671a754f3cb49242472df5c2cced4eb959ae203a377b45b1a3c/zstandard-0.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8ed7d27cb56b3e058d3cf684d7200703bcae623e1dcc06ed1e18ecda39fee003", size = 5208682, upload-time = "2024-07-15T00:14:47.407Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/1734b0fff1634390b1b887202d557d2dd542de84a4c155c258cf75da4773/zstandard-0.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:b69bb4f51daf461b15e7b3db033160937d3ff88303a7bc808c67bbc1eaf98c78", size = 5669707, upload-time = "2024-07-15T00:15:03.529Z" }, + { url = "https://files.pythonhosted.org/packages/52/5a/87d6971f0997c4b9b09c495bf92189fb63de86a83cadc4977dc19735f652/zstandard-0.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:034b88913ecc1b097f528e42b539453fa82c3557e414b3de9d5632c80439a473", size = 5201792, upload-time = "2024-07-15T00:15:28.372Z" }, + { url = "https://files.pythonhosted.org/packages/79/02/6f6a42cc84459d399bd1a4e1adfc78d4dfe45e56d05b072008d10040e13b/zstandard-0.23.0-cp311-cp311-win32.whl", hash = "sha256:f2d4380bf5f62daabd7b751ea2339c1a21d1c9463f1feb7fc2bdcea2c29c3160", size = 430586, upload-time = "2024-07-15T00:15:32.26Z" }, + { url = "https://files.pythonhosted.org/packages/be/a2/4272175d47c623ff78196f3c10e9dc7045c1b9caf3735bf041e65271eca4/zstandard-0.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:62136da96a973bd2557f06ddd4e8e807f9e13cbb0bfb9cc06cfe6d98ea90dfe0", size = 495420, upload-time = "2024-07-15T00:15:34.004Z" }, + { url = "https://files.pythonhosted.org/packages/7b/83/f23338c963bd9de687d47bf32efe9fd30164e722ba27fb59df33e6b1719b/zstandard-0.23.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b4567955a6bc1b20e9c31612e615af6b53733491aeaa19a6b3b37f3b65477094", size = 788713, upload-time = "2024-07-15T00:15:35.815Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b3/1a028f6750fd9227ee0b937a278a434ab7f7fdc3066c3173f64366fe2466/zstandard-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e172f57cd78c20f13a3415cc8dfe24bf388614324d25539146594c16d78fcc8", size = 633459, upload-time = "2024-07-15T00:15:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/26/af/36d89aae0c1f95a0a98e50711bc5d92c144939efc1f81a2fcd3e78d7f4c1/zstandard-0.23.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0e166f698c5a3e914947388c162be2583e0c638a4703fc6a543e23a88dea3c1", size = 4945707, upload-time = "2024-07-15T00:15:39.872Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2e/2051f5c772f4dfc0aae3741d5fc72c3dcfe3aaeb461cc231668a4db1ce14/zstandard-0.23.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12a289832e520c6bd4dcaad68e944b86da3bad0d339ef7989fb7e88f92e96072", size = 5306545, upload-time = "2024-07-15T00:15:41.75Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9e/a11c97b087f89cab030fa71206963090d2fecd8eb83e67bb8f3ffb84c024/zstandard-0.23.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d50d31bfedd53a928fed6707b15a8dbeef011bb6366297cc435accc888b27c20", size = 5337533, upload-time = "2024-07-15T00:15:44.114Z" }, + { url = "https://files.pythonhosted.org/packages/fc/79/edeb217c57fe1bf16d890aa91a1c2c96b28c07b46afed54a5dcf310c3f6f/zstandard-0.23.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72c68dda124a1a138340fb62fa21b9bf4848437d9ca60bd35db36f2d3345f373", size = 5436510, upload-time = "2024-07-15T00:15:46.509Z" }, + { url = "https://files.pythonhosted.org/packages/81/4f/c21383d97cb7a422ddf1ae824b53ce4b51063d0eeb2afa757eb40804a8ef/zstandard-0.23.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53dd9d5e3d29f95acd5de6802e909ada8d8d8cfa37a3ac64836f3bc4bc5512db", size = 4859973, upload-time = "2024-07-15T00:15:49.939Z" }, + { url = "https://files.pythonhosted.org/packages/ab/15/08d22e87753304405ccac8be2493a495f529edd81d39a0870621462276ef/zstandard-0.23.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:6a41c120c3dbc0d81a8e8adc73312d668cd34acd7725f036992b1b72d22c1772", size = 4936968, upload-time = "2024-07-15T00:15:52.025Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fa/f3670a597949fe7dcf38119a39f7da49a8a84a6f0b1a2e46b2f71a0ab83f/zstandard-0.23.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:40b33d93c6eddf02d2c19f5773196068d875c41ca25730e8288e9b672897c105", size = 5467179, upload-time = "2024-07-15T00:15:54.971Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a9/dad2ab22020211e380adc477a1dbf9f109b1f8d94c614944843e20dc2a99/zstandard-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9206649ec587e6b02bd124fb7799b86cddec350f6f6c14bc82a2b70183e708ba", size = 4848577, upload-time = "2024-07-15T00:15:57.634Z" }, + { url = "https://files.pythonhosted.org/packages/08/03/dd28b4484b0770f1e23478413e01bee476ae8227bbc81561f9c329e12564/zstandard-0.23.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76e79bc28a65f467e0409098fa2c4376931fd3207fbeb6b956c7c476d53746dd", size = 4693899, upload-time = "2024-07-15T00:16:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/2b/64/3da7497eb635d025841e958bcd66a86117ae320c3b14b0ae86e9e8627518/zstandard-0.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:66b689c107857eceabf2cf3d3fc699c3c0fe8ccd18df2219d978c0283e4c508a", size = 5199964, upload-time = "2024-07-15T00:16:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/43/a4/d82decbab158a0e8a6ebb7fc98bc4d903266bce85b6e9aaedea1d288338c/zstandard-0.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9c236e635582742fee16603042553d276cca506e824fa2e6489db04039521e90", size = 5655398, upload-time = "2024-07-15T00:16:06.694Z" }, + { url = "https://files.pythonhosted.org/packages/f2/61/ac78a1263bc83a5cf29e7458b77a568eda5a8f81980691bbc6eb6a0d45cc/zstandard-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8fffdbd9d1408006baaf02f1068d7dd1f016c6bcb7538682622c556e7b68e35", size = 5191313, upload-time = "2024-07-15T00:16:09.758Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/967c478314e16af5baf849b6ee9d6ea724ae5b100eb506011f045d3d4e16/zstandard-0.23.0-cp312-cp312-win32.whl", hash = "sha256:dc1d33abb8a0d754ea4763bad944fd965d3d95b5baef6b121c0c9013eaf1907d", size = 430877, upload-time = "2024-07-15T00:16:11.758Z" }, + { url = "https://files.pythonhosted.org/packages/75/37/872d74bd7739639c4553bf94c84af7d54d8211b626b352bc57f0fd8d1e3f/zstandard-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:64585e1dba664dc67c7cdabd56c1e5685233fbb1fc1966cfba2a340ec0dfff7b", size = 495595, upload-time = "2024-07-15T00:16:13.731Z" }, + { url = "https://files.pythonhosted.org/packages/80/f1/8386f3f7c10261fe85fbc2c012fdb3d4db793b921c9abcc995d8da1b7a80/zstandard-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:576856e8594e6649aee06ddbfc738fec6a834f7c85bf7cadd1c53d4a58186ef9", size = 788975, upload-time = "2024-07-15T00:16:16.005Z" }, + { url = "https://files.pythonhosted.org/packages/16/e8/cbf01077550b3e5dc86089035ff8f6fbbb312bc0983757c2d1117ebba242/zstandard-0.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38302b78a850ff82656beaddeb0bb989a0322a8bbb1bf1ab10c17506681d772a", size = 633448, upload-time = "2024-07-15T00:16:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/06/27/4a1b4c267c29a464a161aeb2589aff212b4db653a1d96bffe3598f3f0d22/zstandard-0.23.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2240ddc86b74966c34554c49d00eaafa8200a18d3a5b6ffbf7da63b11d74ee2", size = 4945269, upload-time = "2024-07-15T00:16:20.136Z" }, + { url = "https://files.pythonhosted.org/packages/7c/64/d99261cc57afd9ae65b707e38045ed8269fbdae73544fd2e4a4d50d0ed83/zstandard-0.23.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ef230a8fd217a2015bc91b74f6b3b7d6522ba48be29ad4ea0ca3a3775bf7dd5", size = 5306228, upload-time = "2024-07-15T00:16:23.398Z" }, + { url = "https://files.pythonhosted.org/packages/7a/cf/27b74c6f22541f0263016a0fd6369b1b7818941de639215c84e4e94b2a1c/zstandard-0.23.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:774d45b1fac1461f48698a9d4b5fa19a69d47ece02fa469825b442263f04021f", size = 5336891, upload-time = "2024-07-15T00:16:26.391Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/89ac62eac46b69948bf35fcd90d37103f38722968e2981f752d69081ec4d/zstandard-0.23.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f77fa49079891a4aab203d0b1744acc85577ed16d767b52fc089d83faf8d8ed", size = 5436310, upload-time = "2024-07-15T00:16:29.018Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a8/5ca5328ee568a873f5118d5b5f70d1f36c6387716efe2e369010289a5738/zstandard-0.23.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac184f87ff521f4840e6ea0b10c0ec90c6b1dcd0bad2f1e4a9a1b4fa177982ea", size = 4859912, upload-time = "2024-07-15T00:16:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ca/3781059c95fd0868658b1cf0440edd832b942f84ae60685d0cfdb808bca1/zstandard-0.23.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c363b53e257246a954ebc7c488304b5592b9c53fbe74d03bc1c64dda153fb847", size = 4936946, upload-time = "2024-07-15T00:16:34.593Z" }, + { url = "https://files.pythonhosted.org/packages/ce/11/41a58986f809532742c2b832c53b74ba0e0a5dae7e8ab4642bf5876f35de/zstandard-0.23.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e7792606d606c8df5277c32ccb58f29b9b8603bf83b48639b7aedf6df4fe8171", size = 5466994, upload-time = "2024-07-15T00:16:36.887Z" }, + { url = "https://files.pythonhosted.org/packages/83/e3/97d84fe95edd38d7053af05159465d298c8b20cebe9ccb3d26783faa9094/zstandard-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a0817825b900fcd43ac5d05b8b3079937073d2b1ff9cf89427590718b70dd840", size = 4848681, upload-time = "2024-07-15T00:16:39.709Z" }, + { url = "https://files.pythonhosted.org/packages/6e/99/cb1e63e931de15c88af26085e3f2d9af9ce53ccafac73b6e48418fd5a6e6/zstandard-0.23.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9da6bc32faac9a293ddfdcb9108d4b20416219461e4ec64dfea8383cac186690", size = 4694239, upload-time = "2024-07-15T00:16:41.83Z" }, + { url = "https://files.pythonhosted.org/packages/ab/50/b1e703016eebbc6501fc92f34db7b1c68e54e567ef39e6e59cf5fb6f2ec0/zstandard-0.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fd7699e8fd9969f455ef2926221e0233f81a2542921471382e77a9e2f2b57f4b", size = 5200149, upload-time = "2024-07-15T00:16:44.287Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e0/932388630aaba70197c78bdb10cce2c91fae01a7e553b76ce85471aec690/zstandard-0.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d477ed829077cd945b01fc3115edd132c47e6540ddcd96ca169facff28173057", size = 5655392, upload-time = "2024-07-15T00:16:46.423Z" }, + { url = "https://files.pythonhosted.org/packages/02/90/2633473864f67a15526324b007a9f96c96f56d5f32ef2a56cc12f9548723/zstandard-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ce8b52c5987b3e34d5674b0ab529a4602b632ebab0a93b07bfb4dfc8f8a33", size = 5191299, upload-time = "2024-07-15T00:16:49.053Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4c/315ca5c32da7e2dc3455f3b2caee5c8c2246074a61aac6ec3378a97b7136/zstandard-0.23.0-cp313-cp313-win32.whl", hash = "sha256:a9b07268d0c3ca5c170a385a0ab9fb7fdd9f5fd866be004c4ea39e44edce47dd", size = 430862, upload-time = "2024-07-15T00:16:51.003Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/c6aaba098e2d04781e8f4f7c0ba3c7aa73d00e4c436bcc0cf059a66691d1/zstandard-0.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:f3513916e8c645d0610815c257cbfd3242adfd5c4cfa78be514e5a3ebb42a41b", size = 495578, upload-time = "2024-07-15T00:16:53.135Z" }, + { url = "https://files.pythonhosted.org/packages/fb/96/4fcafeb7e013a2386d22f974b5b97a0b9a65004ed58c87ae001599bfbd48/zstandard-0.23.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3aa014d55c3af933c1315eb4bb06dd0459661cc0b15cd61077afa6489bec63bb", size = 788697, upload-time = "2024-07-15T00:17:31.236Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/a52ce725be69b86a2967ecba0497a8184540cc284c0991125515449e54e2/zstandard-0.23.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7f0804bb3799414af278e9ad51be25edf67f78f916e08afdb983e74161b916", size = 633679, upload-time = "2024-07-15T00:17:32.911Z" }, + { url = "https://files.pythonhosted.org/packages/34/0f/3dc62db122f6a9c481c335fff6fc9f4e88d8f6e2d47321ee3937328addb4/zstandard-0.23.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb2b1ecfef1e67897d336de3a0e3f52478182d6a47eda86cbd42504c5cbd009a", size = 4940416, upload-time = "2024-07-15T00:17:34.849Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e5/9fe0dd8c85fdc2f635e6660d07872a5dc4b366db566630161e39f9f804e1/zstandard-0.23.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:837bb6764be6919963ef41235fd56a6486b132ea64afe5fafb4cb279ac44f259", size = 5307693, upload-time = "2024-07-15T00:17:37.355Z" }, + { url = "https://files.pythonhosted.org/packages/73/bf/fe62c0cd865c171ee8ed5bc83174b5382a2cb729c8d6162edfb99a83158b/zstandard-0.23.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1516c8c37d3a053b01c1c15b182f3b5f5eef19ced9b930b684a73bad121addf4", size = 5341236, upload-time = "2024-07-15T00:17:40.213Z" }, + { url = "https://files.pythonhosted.org/packages/39/86/4fe79b30c794286110802a6cd44a73b6a314ac8196b9338c0fbd78c2407d/zstandard-0.23.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48ef6a43b1846f6025dde6ed9fee0c24e1149c1c25f7fb0a0585572b2f3adc58", size = 5439101, upload-time = "2024-07-15T00:17:42.284Z" }, + { url = "https://files.pythonhosted.org/packages/72/ed/cacec235c581ebf8c608c7fb3d4b6b70d1b490d0e5128ea6996f809ecaef/zstandard-0.23.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11e3bf3c924853a2d5835b24f03eeba7fc9b07d8ca499e247e06ff5676461a15", size = 4860320, upload-time = "2024-07-15T00:17:44.21Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1e/2c589a2930f93946b132fc852c574a19d5edc23fad2b9e566f431050c7ec/zstandard-0.23.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2fb4535137de7e244c230e24f9d1ec194f61721c86ebea04e1581d9d06ea1269", size = 4931933, upload-time = "2024-07-15T00:17:46.455Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f5/30eadde3686d902b5d4692bb5f286977cbc4adc082145eb3f49d834b2eae/zstandard-0.23.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8c24f21fa2af4bb9f2c492a86fe0c34e6d2c63812a839590edaf177b7398f700", size = 5463878, upload-time = "2024-07-15T00:17:48.866Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c8/8aed1f0ab9854ef48e5ad4431367fcb23ce73f0304f7b72335a8edc66556/zstandard-0.23.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a8c86881813a78a6f4508ef9daf9d4995b8ac2d147dcb1a450448941398091c9", size = 4857192, upload-time = "2024-07-15T00:17:51.558Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/55e666cfbcd032b9e271865e8578fec56e5594d4faeac379d371526514f5/zstandard-0.23.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fe3b385d996ee0822fd46528d9f0443b880d4d05528fd26a9119a54ec3f91c69", size = 4696513, upload-time = "2024-07-15T00:17:53.924Z" }, + { url = "https://files.pythonhosted.org/packages/dc/bd/720b65bea63ec9de0ac7414c33b9baf271c8de8996e5ff324dc93fc90ff1/zstandard-0.23.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:82d17e94d735c99621bf8ebf9995f870a6b3e6d14543b99e201ae046dfe7de70", size = 5204823, upload-time = "2024-07-15T00:17:55.948Z" }, + { url = "https://files.pythonhosted.org/packages/d8/40/d678db1556e3941d330cd4e95623a63ef235b18547da98fa184cbc028ecf/zstandard-0.23.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c7c517d74bea1a6afd39aa612fa025e6b8011982a0897768a2f7c8ab4ebb78a2", size = 5666490, upload-time = "2024-07-15T00:17:58.327Z" }, + { url = "https://files.pythonhosted.org/packages/ed/cc/c89329723d7515898a1fc7ef5d251264078548c505719d13e9511800a103/zstandard-0.23.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fd7e0f1cfb70eb2f95a19b472ee7ad6d9a0a992ec0ae53286870c104ca939e5", size = 5196622, upload-time = "2024-07-15T00:18:00.404Z" }, + { url = "https://files.pythonhosted.org/packages/78/4c/634289d41e094327a94500dfc919e58841b10ea3a9efdfafbac614797ec2/zstandard-0.23.0-cp39-cp39-win32.whl", hash = "sha256:43da0f0092281bf501f9c5f6f3b4c975a8a0ea82de49ba3f7100e64d422a1274", size = 430620, upload-time = "2024-07-15T00:18:02.613Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e2/0b0c5a0f4f7699fecd92c1ba6278ef9b01f2b0b0dd46f62bfc6729c05659/zstandard-0.23.0-cp39-cp39-win_amd64.whl", hash = "sha256:f8346bfa098532bc1fb6c7ef06783e969d87a99dd1d2a5a18a892c1d7a643c58", size = 495528, upload-time = "2024-07-15T00:18:04.452Z" }, +]