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 608847c..a32a8d4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +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 7789878..0000000 --- a/.travis.yml +++ /dev/null @@ -1,29 +0,0 @@ -language: python -sudo: false -python: - - 2.7 - - 3.5 - - 3.6 - - 3.7 - - 3.8 - - 3.9-dev - - pypy - - pypy3 -matrix: - include: - - python: 3.6 - env: TOXENV=flake8,black,import-order,mypy,manifest -cache: pip -install: pip install tox-travis codecov -script: tox -after_success: codecov -deploy: - provider: pypi - on: - branch: master - tags: true - python: 3.7 - skip_existing: true - user: __token__ - password: - secure: WcZf7AVMDzheXWUxNhZF/TUcyvyCdHZGyhHTakjBhUs8I8khSvlMPofaXTdN1Qn3WbHPK+IXeIPh/2NX0Le3Cdzp08Q/Tgrf9EZ4y02UrZxwSxtsUmjCVd8GaCsQnhR5t5cgrtw33OAf0O22rUnMXsFtw7xMIuCNTgFiYclNbHzYbvnJAEcY3qE8RBbP8zF5Brx+Bl49SjfVR3dJ7CBkjgC9scZjSBAo/yc64d506W59LOjfvXEiDtGUH2gxZNwNiteZtI3frMYqLRjS563SwEFlG36B8g0hBOj6FVpU+YXeImYXw3XFqC6dCvcwn1dAf/vUZ4IDiDIVf5KvFcyDx0ZwZlMSzqlkLVpSDGqPU+7Mx15NW00Yk2+Zs2ZWFMK+g5WtSehhrAWR6El3d0MRlDXKgt9QbCRyh8b2jPV/vQZN2FOBOg9V9a6IszOy/W1J81q39cLOroBhQF4mDFYTAQ5QpBVUyauAfB49QzXsmSWy2uOTsbgo+oAc+OGJ6q9vXCzNqHxhUvtDT9HIq4w5ixw9wqtpSf6n+l2F2RFl5SzHIR7Dt0m9Eg2Ig5NqSGlymz46ZcxpRjd4wVXALD4M8usqy35jGTeEXsqSTO98n3jwKTj/7Xi6GOZuBlwW+SGAjXQ0vzlWD3AEv0Jnh+4AH5UqWwBeD1skw8gtbjM4dos= 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 index c573f21..21fda5f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,93 +1,159 @@ -# Contributing +# Contributing to `graphql-server` -Thanks for helping to make graphql-server-core awesome! +First off, thanks for taking the time to contribute! -We welcome all kinds of contributions: +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. -- Bug fixes -- Documentation improvements -- New features -- Refactoring & tidying +#### Table of contents +[How to contribute](#how-to-contribute) -## Getting started +- [Reporting bugs](#reporting-bugs) +- [Suggesting enhancements](#suggesting-enhancements) +- [Contributing to code](#contributing-to-code) -If you have a specific contribution in mind, be sure to check the [issues](https://github.com/graphql-python/graphql-server-core/issues) and [pull requests](https://github.com/graphql-python/graphql-server-core/pulls) in progress - someone could already be working on something similar and you can help out. +## How to contribute +### Reporting bugs -## Project setup +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. -### Development with virtualenv (recommended) +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. -After cloning this repo, create a virtualenv: +> **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. -```console -virtualenv graphql-server-core-dev -``` +#### Before submitting a bug report -Activate the virtualenv and install dependencies by running: +- Check that your issue does not already exist in the issue tracker on GitHub. -```console -python pip install -e ".[test]" -``` +#### How do I submit a bug report? -If you are using Linux or MacOS, you can make use of Makefile command -`make dev-setup`, which is a shortcut for the above python command. +Bugs are tracked on the issue tracker on GitHub where you can create a new one. -### Development on Conda +Explain the problem and include additional details to help maintainers reproduce +the problem: -You must create a new env (e.g. `graphql-sc-dev`) with the following command: +- **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.** -```sh -conda create -n graphql-sc-dev python=3.8 -``` +Provide more context by answering these questions: -Then activate the environment with `conda activate graphql-sc-dev`. +- **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. -Proceed to install all dependencies by running: +Include details about your configuration and environment: -```console -pip install -e ".[dev]" -``` +- **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?** -And you ready to start development! +### Suggesting enhancements -## Running tests +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. -After developing, the full test suite can be evaluated by running: +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). -```sh -pytest tests --cov=graphql-server-core -vv -``` +#### Before submitting an enhancement suggestion + +- Check that your issue does not already exist in the issue tracker on GitHub. -If you are using Linux or MacOS, you can make use of Makefile command -`make tests`, which is a shortcut for the above python command. +#### How do I submit an enhancement suggestion? -You can also test on several python environments by using tox. +Enhancement suggestions are tracked on the project's issue tracker on GitHub +where you can create a new one and provide the following information: -### Running tox on virtualenv +- **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. -Install tox: +### Contributing to code -```console -pip install tox +> 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` ``` -Run `tox` on your virtualenv (do not forget to activate it!) -and that's it! +Now, you will need to install the required dependencies for ``graphql-server`` and be sure +that the current tests are passing on your machine: -### Running tox on Conda +```shell +$ uv sync --group integrations +``` -In order to run `tox` command on conda, install -[tox-conda](https://github.com/tox-dev/tox-conda): +Some tests are known to be inconsistent. (The fix is in progress.) These tests are marked with the `pytest.mark.flaky` marker. -```sh -conda install -c conda-forge tox-conda +`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 ``` -This install tox underneath so no need to install it before. +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 -Then uncomment the `requires = tox-conda` line on `tox.ini` file. +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. +``` -Run `tox` and you will see all the environments being created -and all passing tests. :rocket: +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 12b4ad7..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,13 +0,0 @@ -include MANIFEST.in - -include README.md -include LICENSE -include CONTRIBUTING.md - -include codecov.yml -include tox.ini - -graft tests -prune bin - -global-exclude *.py[co] __pycache__ diff --git a/README.md b/README.md index fdb3d40..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` - * `load_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`. @@ -44,4 +53,12 @@ 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](CONTRIBUTING.md) + +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/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 cb802ee..0000000 --- a/graphql_server/__init__.py +++ /dev/null @@ -1,362 +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 promise import promisify, is_thenable, Promise - -from graphql import get_default_backend -from graphql.error import format_error as default_format_error -from graphql.execution import ExecutionResult -from graphql.execution.executors.sync import SyncExecutor -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] - - if execute_options.get("return_promise"): - results = [ - get_response(schema, params, catch_exc, allow_only_query, **execute_options) - for params in all_params - ] - else: - executor = execute_options.get("executor") - response_executor = executor if executor else SyncExecutor() - - response_promises = [ - response_executor.execute( - get_response, - schema, - params, - catch_exc, - allow_only_query, - **execute_options - ) - for params in all_params - ] - response_executor.wait_until_finished() - - results = [ - result.get() if is_thenable(result) else result - for result in response_promises - ] - - 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, pretty=False): - # type: (Union[Dict,List],Optional[bool]) -> 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. - """ - if pretty: - return json_encode_pretty(data) - 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 -): - # type: (...) -> ExecutionResult - """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, - variable_values=params.variables, - **kwargs - ) - except Exception as e: - return ExecutionResult(errors=[e], invalid=True) - - -@promisify -def execute_graphql_request_as_promise(*args, **kwargs): - return execute_graphql_request(*args, **kwargs) - - -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[Union[ExecutionResult, Promise[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. - """ - # Note: PyCharm will display a error due to the triple dot being used on Callable. - execute = ( - execute_graphql_request - ) # type: Callable[..., Union[Promise[ExecutionResult], ExecutionResult]] - if kwargs.get("return_promise", False): - execute = execute_graphql_request_as_promise - - # noinspection PyBroadException - try: - execution_result = execute(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 - - response = None - if execution_result: - if execution_result.invalid: - status_code = 400 - response = execution_result.to_dict(format_error=format_error) - - 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 70e1f4a..0000000 --- a/setup.cfg +++ /dev/null @@ -1,12 +0,0 @@ -[flake8] -exclude = docs -max-line-length = 88 - -[isort] -known_first_party=graphql_server - -[tool:pytest] -norecursedirs = venv .venv .tox .git .cache .mypy_cache .pytest_cache - -[bdist_wheel] -universal=1 diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index a6416c0..373d560 --- a/setup.py +++ b/setup.py @@ -1,57 +1,9 @@ -from setuptools import setup, find_packages +#!/usr/bin/env python -install_requires = [ - "graphql-core>=2.3,<3", - "promise>=2.3,<3", -] +# we use poetry for our build, but this file seems to be required +# in order to get GitHub dependencies graph to work -tests_requires = [ - "pytest==4.6.9", - "pytest-cov==2.8.1" -] +import setuptools -dev_requires = [ - 'flake8==3.7.9', - 'isort<4.0.0', - 'black==19.10b0', - 'mypy==0.761', - 'check-manifest>=0.40,<1', -] + tests_requires - -setup( - name="graphql-server-core", - version="2.0.0", - description="GraphQL Server tools for powering your server", - long_description=open("README.md").read(), - long_description_content_type="text/markdown", - 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.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: Implementation :: PyPy", - "License :: OSI Approved :: MIT License", - ], - keywords="api graphql protocol rest", - packages=find_packages(exclude=["tests"]), - install_requires=install_requires, - tests_require=tests_requires, - extras_require={ - 'test': tests_requires, - 'dev': dev_requires, - }, - include_package_data=True, - zip_safe=False, - platforms="any", -) +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/conftest.py b/tests/conftest.py deleted file mode 100644 index ae78c3d..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,4 +0,0 @@ -import sys - -if sys.version_info[:2] < (3, 4): - collect_ignore_glob = ["*_asyncio.py"] 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_asyncio.py b/tests/test_asyncio.py deleted file mode 100644 index db8fc02..0000000 --- a/tests/test_asyncio.py +++ /dev/null @@ -1,88 +0,0 @@ -from graphql.execution.executors.asyncio import AsyncioExecutor -from graphql.type.definition import GraphQLField, GraphQLNonNull, GraphQLObjectType -from graphql.type.scalars import GraphQLString -from graphql.type.schema import GraphQLSchema -from promise import Promise - -import asyncio -from graphql_server import RequestParams, run_http_query - -from .utils import as_dicts - - -def resolve_error_sync(_obj, _info): - raise ValueError("error sync") - - -async def resolve_error_async(_obj, _info): - await asyncio.sleep(0.001) - raise ValueError("error async") - - -def resolve_field_sync(_obj, _info): - return "sync" - - -async def resolve_field_async(_obj, info): - await asyncio.sleep(0.001) - return "async" - - -NonNullString = GraphQLNonNull(GraphQLString) - -QueryRootType = GraphQLObjectType( - name="QueryRoot", - fields={ - "errorSync": GraphQLField(NonNullString, resolver=resolve_error_sync), - "errorAsync": GraphQLField(NonNullString, resolver=resolve_error_async), - "fieldSync": GraphQLField(NonNullString, resolver=resolve_field_sync), - "fieldAsync": GraphQLField(NonNullString, resolver=resolve_field_async), - }, -) - -schema = GraphQLSchema(QueryRootType) - - -def test_get_responses_using_asyncio_executor(): - class TestExecutor(AsyncioExecutor): - called = False - waited = False - cleaned = False - - def wait_until_finished(self): - TestExecutor.waited = True - super().wait_until_finished() - - def clean(self): - TestExecutor.cleaned = True - super().clean() - - def execute(self, fn, *args, **kwargs): - TestExecutor.called = True - return super().execute(fn, *args, **kwargs) - - query = "{fieldSync fieldAsync}" - - loop = asyncio.get_event_loop() - - async def get_results(): - result_promises, params = run_http_query( - schema, - "get", - {}, - dict(query=query), - executor=TestExecutor(loop=loop), - return_promise=True, - ) - results = await Promise.all(result_promises) - return results, params - - results, params = loop.run_until_complete(get_results()) - - expected_results = [{"data": {"fieldSync": "sync", "fieldAsync": "async"}}] - - assert as_dicts(results) == expected_results - assert params == [RequestParams(query=query, variables=None, operation_name=None)] - assert TestExecutor.called - assert not TestExecutor.waited - assert TestExecutor.cleaned 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 fc4b73e..0000000 --- a/tests/test_helpers.py +++ /dev/null @@ -1,323 +0,0 @@ -import json - -from graphql.error import GraphQLError -from graphql.execution import ExecutionResult -from graphql.language.location import SourceLocation -from pytest import raises - -from graphql_server import ( - HttpQueryError, - ServerResponse, - encode_execution_results, - json_encode, - json_encode_pretty, - load_json_body, -) - - -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_json_encode_with_pretty_argument(): - result = json_encode({"query": "{test}"}, pretty=False) - assert result == '{"query":"{test}"}' - result = json_encode({"query": "{test}"}, pretty=True) - 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 e5bbb79..0000000 --- a/tests/test_query.py +++ /dev/null @@ -1,648 +0,0 @@ -import json - -from graphql.error import GraphQLError, GraphQLSyntaxError -from graphql.execution import ExecutionResult -from promise import Promise -from pytest import raises - -from graphql_server import ( - HttpQueryError, - RequestParams, - ServerResults, - encode_execution_results, - json_encode, - json_encode_pretty, - load_json_body, - run_http_query, -) - -from .schema import schema -from .utils import as_dicts - - -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"}} - ] - - -def test_get_responses_using_executor(): - class TestExecutor(object): - called = False - waited = False - cleaned = False - - def wait_until_finished(self): - TestExecutor.waited = True - - def clean(self): - TestExecutor.cleaned = True - - def execute(self, fn, *args, **kwargs): - TestExecutor.called = True - return fn(*args, **kwargs) - - query = "{test}" - results, params = run_http_query( - schema, "get", {}, dict(query=query), executor=TestExecutor(), - ) - - assert isinstance(results, list) - assert len(results) == 1 - assert isinstance(results[0], ExecutionResult) - - assert as_dicts(results) == [{"data": {"test": "Hello World"}}] - assert params == [RequestParams(query=query, variables=None, operation_name=None)] - assert TestExecutor.called - assert TestExecutor.waited - assert not TestExecutor.cleaned - - -def test_get_responses_using_executor_return_promise(): - class TestExecutor(object): - called = False - waited = False - cleaned = False - - def wait_until_finished(self): - TestExecutor.waited = True - - def clean(self): - TestExecutor.cleaned = True - - def execute(self, fn, *args, **kwargs): - TestExecutor.called = True - return fn(*args, **kwargs) - - query = "{test}" - result_promises, params = run_http_query( - schema, - "get", - {}, - dict(query=query), - executor=TestExecutor(), - return_promise=True, - ) - - assert isinstance(result_promises, list) - assert len(result_promises) == 1 - assert isinstance(result_promises[0], Promise) - results = Promise.all(result_promises).get() - - assert as_dicts(results) == [{"data": {"test": "Hello World"}}] - assert params == [RequestParams(query=query, variables=None, operation_name=None)] - assert TestExecutor.called - assert not TestExecutor.waited - assert TestExecutor.cleaned - - -def test_syntax_error_using_executor_return_promise(): - class TestExecutor(object): - called = False - waited = False - cleaned = False - - def wait_until_finished(self): - TestExecutor.waited = True - - def clean(self): - TestExecutor.cleaned = True - - def execute(self, fn, *args, **kwargs): - TestExecutor.called = True - return fn(*args, **kwargs) - - query = "this is a syntax error" - result_promises, params = run_http_query( - schema, - "get", - {}, - dict(query=query), - executor=TestExecutor(), - return_promise=True, - ) - - assert isinstance(result_promises, list) - assert len(result_promises) == 1 - assert isinstance(result_promises[0], Promise) - results = Promise.all(result_promises).get() - - assert isinstance(results, list) - assert len(results) == 1 - result = results[0] - assert isinstance(result, ExecutionResult) - - assert result.data is None - assert isinstance(result.errors, list) - assert len(result.errors) == 1 - error = result.errors[0] - assert isinstance(error, GraphQLSyntaxError) - - assert params == [RequestParams(query=query, variables=None, operation_name=None)] - assert not TestExecutor.called - assert not TestExecutor.waited - assert not TestExecutor.cleaned diff --git a/tests/utils.py b/tests/utils.py deleted file mode 100644 index 136f09f..0000000 --- a/tests/utils.py +++ /dev/null @@ -1,3 +0,0 @@ -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] diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 77a2bb6..0000000 --- a/tox.ini +++ /dev/null @@ -1,47 +0,0 @@ -[tox] -envlist = - black,flake8,import-order,mypy,manifest, - py{27,35,36,37,38,39-dev,py,py3} -; requires = tox-conda - -[testenv] -passenv = * -setenv = - PYTHONPATH = {toxinidir} -install_command = python -m pip install --ignore-installed {opts} {packages} -deps = -e.[test] -whitelist_externals = - python -commands = - pip install -U setuptools - pytest --cov-report=term-missing --cov=graphql_server tests {posargs} - -[testenv:black] -basepython=python3.6 -deps = -e.[dev] -commands = - black --check graphql_server tests - -[testenv:flake8] -basepython=python3.6 -deps = -e.[dev] -commands = - flake8 setup.py graphql_server tests - -[testenv:import-order] -basepython=python3.6 -deps = -e.[dev] -commands = - isort -rc graphql_server/ tests/ - -[testenv:mypy] -basepython=python3.6 -deps = -e.[dev] -commands = - mypy graphql_server tests --ignore-missing-imports - -[testenv:manifest] -basepython = python3.6 -deps = -e.[dev] -commands = - check-manifest -v 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" }, +]