From eea1b33da3103fb7004b139848c4fdfc8d375ff6 Mon Sep 17 00:00:00 2001 From: changeling Date: Wed, 15 May 2019 15:54:50 -0500 Subject: [PATCH 001/108] Correct typo in README (#2) Changed `laod_json_body` to `load_json_body` in README files. --- README.md | 2 +- README.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 74ddea6..ec7e022 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ The `graphql_server` package provides these public helper functions: * `run_http_query` * `encode_execution_results` - * `laod_json_body` + * `load_json_body` * `json_encode` * `json_encode_pretty` diff --git a/README.rst b/README.rst index d8f2633..03f4507 100644 --- a/README.rst +++ b/README.rst @@ -37,7 +37,7 @@ The ``graphql_server`` package provides these public helper functions: - ``run_http_query`` - ``encode_execution_results`` -- ``laod_json_body`` +- ``load_json_body`` - ``json_encode`` - ``json_encode_pretty`` From 786849b2ada27b3e6cccb2164765a157eaf8b983 Mon Sep 17 00:00:00 2001 From: Lucas Costa Date: Thu, 16 May 2019 19:00:12 -0300 Subject: [PATCH 002/108] Use Executor for getting responses Use an Executor of the same type as configured to get backend responses.This leverages the batching functionality, allowing batched requests to be processed concurrently. --- graphql_server/__init__.py | 16 ++++++++++++++-- tests/test_query.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index e7457f7..1facd4d 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -15,7 +15,9 @@ 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 promise import Promise, is_thenable from .error import HttpQueryError @@ -120,10 +122,20 @@ def run_http_query( all_params = [get_graphql_params(entry, extra_data) for entry in data] - results = [ - get_response(schema, params, catch_exc, allow_only_query, **execute_options) + 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) diff --git a/tests/test_query.py b/tests/test_query.py index 07c9e57..7a1857e 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -530,3 +530,36 @@ def test_batch_allows_post_with_operation_name(): assert as_dicts(results) == [ {"data": {"test": "Hello World", "shared": "Hello Everyone"}} ] + + +def test_get_reponses_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(), + return_promise=True, + ) + + 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 TestExecutor.cleaned From cdde7ca6fddd817e92e307d2e2c5e575adb07a62 Mon Sep 17 00:00:00 2001 From: Kris Kelley Date: Thu, 19 Sep 2019 13:08:08 -0600 Subject: [PATCH 003/108] load typo --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index d8f2633..03f4507 100644 --- a/README.rst +++ b/README.rst @@ -37,7 +37,7 @@ The ``graphql_server`` package provides these public helper functions: - ``run_http_query`` - ``encode_execution_results`` -- ``laod_json_body`` +- ``load_json_body`` - ``json_encode`` - ``json_encode_pretty`` From 76d872a093d7f3790b5af805007837840d2e29f0 Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Thu, 5 Dec 2019 21:41:32 +0100 Subject: [PATCH 004/108] Prepare next patch release Bump version number and remove duplicate README.rst --- README.rst | 60 ------------------------------------------------------ setup.cfg | 4 ++-- setup.py | 7 ++++--- tox.ini | 2 +- 4 files changed, 7 insertions(+), 66 deletions(-) delete mode 100644 README.rst diff --git a/README.rst b/README.rst deleted file mode 100644 index 03f4507..0000000 --- a/README.rst +++ /dev/null @@ -1,60 +0,0 @@ -GraphQL-Server-Core -=================== - -|Build Status| |Coverage Status| |PyPI version| - -GraphQL-Server-Core is a base library that serves as a helper for -building GraphQL servers or integrations into existing web frameworks -using `GraphQL-Core `__. - -Existing integrations built with GraphQL-Server-Core ----------------------------------------------------- - -=========================== ========================================================================================================== -Server integration Package -=========================== ========================================================================================================== -Flask `flask-graphql `__ -Sanic `sanic-graphql `__ -AIOHTTP `aiohttp-graphql `__ -WebOb (Pyramid, TurboGears) `webob-graphql `__ -WSGI `wsgi-graphql `__ -Responder `responder.ext.graphql `__ -=========================== ========================================================================================================== - -Other integrations using GraphQL-Core or Graphene -------------------------------------------------- - -================== ======================================================================== -Server integration Package -================== ======================================================================== -Django `graphene-django `__ -================== ======================================================================== - -Documentation -------------- - -The ``graphql_server`` package provides these public helper functions: - -- ``run_http_query`` -- ``encode_execution_results`` -- ``load_json_body`` -- ``json_encode`` -- ``json_encode_pretty`` - -All functions in the package are annotated with type hints and -docstrings, and you can build HTML documentation from these using -``bin/build_docs``. - -You can also use one of the existing integrations listed above as -blueprint to build your own integration or GraphQL server -implementations. - -Please let us know when you have built something new, so we can list it -here. - -.. |Build Status| image:: https://travis-ci.org/graphql-python/graphql-server-core.svg?branch=master - :target: https://travis-ci.org/graphql-python/graphql-server-core -.. |Coverage Status| image:: https://coveralls.io/repos/graphql-python/graphql-server-core/badge.svg?branch=master&service=github - :target: https://coveralls.io/github/graphql-python/graphql-server-core?branch=master -.. |PyPI version| image:: https://badge.fury.io/py/graphql-server-core.svg - :target: https://badge.fury.io/py/graphql-server-core diff --git a/setup.cfg b/setup.cfg index 8de4f2d..d05fda6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [flake8] -exclude = tests,scripts,setup.py,docs -max-line-length = 160 +exclude = docs +max-line-length = 88 [isort] known_first_party=graphql_server diff --git a/setup.py b/setup.py index 64892dc..8cef1e3 100644 --- a/setup.py +++ b/setup.py @@ -4,9 +4,10 @@ setup( name="graphql-server-core", - version="1.1.1", + version="1.1.2", description="GraphQL Server tools for powering your server", - long_description=open("README.rst").read(), + 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", @@ -30,7 +31,7 @@ keywords="api graphql protocol rest", packages=find_packages(exclude=["tests"]), install_requires=required_packages, - tests_require=["pytest>=3.0"], + tests_require=["pytest>=3.0,<4"], include_package_data=True, zip_safe=False, platforms="any", diff --git a/tox.ini b/tox.ini index 6560fc2..96aabc9 100644 --- a/tox.ini +++ b/tox.ini @@ -28,7 +28,7 @@ commands = basepython=python3.7 deps = isort - graphql-core>=2.1 + graphql-core>=2.1,<3 commands = isort -rc graphql_server/ tests/ From ef9643749a59e2bab8d312dcabba6fc1c91032ed Mon Sep 17 00:00:00 2001 From: Mel van Londen Date: Thu, 5 Dec 2019 16:25:36 -0800 Subject: [PATCH 005/108] update pypi deployment credentials --- .travis.yml | 46 ++++++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/.travis.yml b/.travis.yml index 2569cf7..aaaad21 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,34 +1,36 @@ language: python matrix: include: - - python: '3.7' - env: TOX_ENV=black,flake8,mypy,py37 - dist: xenial - sudo: true # required workaround for https://github.com/travis-ci/travis-ci/issues/9815 - - python: '3.6' - env: TOX_ENV=py36 - - python: '3.5' - env: TOX_ENV=py35 - - python: '3.4' - env: TOX_ENV=py34 - - python: '2.7' - env: TOX_ENV=py27 - - python: 'pypy3.5' - env: TOX_ENV=pypy3 - - python: 'pypy' - env: TOX_ENV=pypy + - python: "3.7" + env: TOX_ENV=black,flake8,mypy,py37 + dist: xenial + sudo: true + - python: "3.6" + env: TOX_ENV=py36 + - python: "3.5" + env: TOX_ENV=py35 + - python: "3.4" + env: TOX_ENV=py34 + - python: "2.7" + env: TOX_ENV=py27 + - python: pypy3.5 + env: TOX_ENV=pypy3 + - python: pypy + env: TOX_ENV=pypy cache: directories: - - $HOME/.cache/pip - - $TRAVIS_BUILD_DIR/.tox + - "$HOME/.cache/pip" + - "$TRAVIS_BUILD_DIR/.tox" install: -- pip install tox codecov + - pip install tox codecov script: -- tox -e $TOX_ENV -- --cov-report term-missing --cov=graphql_server + - tox -e $TOX_ENV -- --cov-report term-missing --cov=graphql_server after_success: -- codecov + - codecov deploy: provider: pypi - user: syrusakbary + user: __token__ + password: + secure: WcZf7AVMDzheXWUxNhZF/TUcyvyCdHZGyhHTakjBhUs8I8khSvlMPofaXTdN1Qn3WbHPK+IXeIPh/2NX0Le3Cdzp08Q/Tgrf9EZ4y02UrZxwSxtsUmjCVd8GaCsQnhR5t5cgrtw33OAf0O22rUnMXsFtw7xMIuCNTgFiYclNbHzYbvnJAEcY3qE8RBbP8zF5Brx+Bl49SjfVR3dJ7CBkjgC9scZjSBAo/yc64d506W59LOjfvXEiDtGUH2gxZNwNiteZtI3frMYqLRjS563SwEFlG36B8g0hBOj6FVpU+YXeImYXw3XFqC6dCvcwn1dAf/vUZ4IDiDIVf5KvFcyDx0ZwZlMSzqlkLVpSDGqPU+7Mx15NW00Yk2+Zs2ZWFMK+g5WtSehhrAWR6El3d0MRlDXKgt9QbCRyh8b2jPV/vQZN2FOBOg9V9a6IszOy/W1J81q39cLOroBhQF4mDFYTAQ5QpBVUyauAfB49QzXsmSWy2uOTsbgo+oAc+OGJ6q9vXCzNqHxhUvtDT9HIq4w5ixw9wqtpSf6n+l2F2RFl5SzHIR7Dt0m9Eg2Ig5NqSGlymz46ZcxpRjd4wVXALD4M8usqy35jGTeEXsqSTO98n3jwKTj/7Xi6GOZuBlwW+SGAjXQ0vzlWD3AEv0Jnh+4AH5UqWwBeD1skw8gtbjM4dos= on: tags: true From ed9f8340765b0e7f943a021d0ac3b4221c544a3c Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Fri, 6 Dec 2019 12:51:44 +0100 Subject: [PATCH 006/108] Travis deploy only in Python 3.7 environment --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index aaaad21..7f32f3d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -34,3 +34,4 @@ deploy: secure: WcZf7AVMDzheXWUxNhZF/TUcyvyCdHZGyhHTakjBhUs8I8khSvlMPofaXTdN1Qn3WbHPK+IXeIPh/2NX0Le3Cdzp08Q/Tgrf9EZ4y02UrZxwSxtsUmjCVd8GaCsQnhR5t5cgrtw33OAf0O22rUnMXsFtw7xMIuCNTgFiYclNbHzYbvnJAEcY3qE8RBbP8zF5Brx+Bl49SjfVR3dJ7CBkjgC9scZjSBAo/yc64d506W59LOjfvXEiDtGUH2gxZNwNiteZtI3frMYqLRjS563SwEFlG36B8g0hBOj6FVpU+YXeImYXw3XFqC6dCvcwn1dAf/vUZ4IDiDIVf5KvFcyDx0ZwZlMSzqlkLVpSDGqPU+7Mx15NW00Yk2+Zs2ZWFMK+g5WtSehhrAWR6El3d0MRlDXKgt9QbCRyh8b2jPV/vQZN2FOBOg9V9a6IszOy/W1J81q39cLOroBhQF4mDFYTAQ5QpBVUyauAfB49QzXsmSWy2uOTsbgo+oAc+OGJ6q9vXCzNqHxhUvtDT9HIq4w5ixw9wqtpSf6n+l2F2RFl5SzHIR7Dt0m9Eg2Ig5NqSGlymz46ZcxpRjd4wVXALD4M8usqy35jGTeEXsqSTO98n3jwKTj/7Xi6GOZuBlwW+SGAjXQ0vzlWD3AEv0Jnh+4AH5UqWwBeD1skw8gtbjM4dos= on: tags: true + python: 3.7 From 80641da4c332097cb9202da70f2011c90db55936 Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Fri, 6 Dec 2019 14:11:08 +0100 Subject: [PATCH 007/108] Add back pretty argument to json_encode --- graphql_server/__init__.py | 6 ++++-- tests/test_helpers.py | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 1facd4d..c208f3b 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -185,13 +185,15 @@ def load_json_body(data): raise HttpQueryError(400, "POST body sent invalid JSON.") -def json_encode(data): - # type: (Union[Dict,List]) -> str +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=(",", ":")) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 9f99669..53e9965 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -25,6 +25,13 @@ def test_json_encode_pretty(): 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}"} From 427cccb00ae41fb88e8fcaf89aa64f5edb855394 Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Fri, 6 Dec 2019 14:12:36 +0100 Subject: [PATCH 008/108] Prepare patch release --- setup.py | 3 ++- tox.ini | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 8cef1e3..da726f5 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ setup( name="graphql-server-core", - version="1.1.2", + version="1.1.3", description="GraphQL Server tools for powering your server", long_description=open("README.md").read(), long_description_content_type="text/markdown", @@ -25,6 +25,7 @@ "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", ], diff --git a/tox.ini b/tox.ini index 96aabc9..54ee181 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = black,flake8,mypy,py37,py36,py35,py34,py33,py27,pypy3,pypy +envlist = black,flake8,mypy,py38,py37,py36,py35,py34,py33,py27,pypy3,pypy skipsdist = true [testenv] From b99207a5c62a3de5222a4315c893e2df40a75678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20B=C5=82a=C5=BCewicz?= Date: Thu, 23 Jan 2020 21:03:06 +0100 Subject: [PATCH 009/108] Don't use executor in run_http_query when return_promise=True (#33) --- graphql_server/__init__.py | 40 +++++++++------ tests/test_asyncio.py | 99 ++++++++++++++++++++++++++++++++++++++ tests/test_query.py | 39 ++++++++++++--- tests/utils.py | 3 ++ tox.ini | 3 +- 5 files changed, 163 insertions(+), 21 deletions(-) create mode 100644 tests/test_asyncio.py create mode 100644 tests/utils.py diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index c208f3b..439d6f5 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -122,20 +122,32 @@ def run_http_query( all_params = [get_graphql_params(entry, extra_data) for entry in data] - 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 - ] + 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) diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py new file mode 100644 index 0000000..d3b5426 --- /dev/null +++ b/tests/test_asyncio.py @@ -0,0 +1,99 @@ +# flake8: noqa + +import pytest + +asyncio = pytest.importorskip("asyncio") + +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 graphql_server import RequestParams, run_http_query +from promise import Promise + +from .utils import as_dicts + + +def resolve_error_sync(_obj, _info): + raise ValueError("error sync") + + +@asyncio.coroutine +def resolve_error_async(_obj, _info): + yield from asyncio.sleep(0.001) + raise ValueError("error async") + + +def resolve_field_sync(_obj, _info): + return "sync" + + +@asyncio.coroutine +def resolve_field_async(_obj, info): + yield from 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_reponses_using_asyncioexecutor(): + 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() + + @asyncio.coroutine + def get_results(): + result_promises, params = run_http_query( + schema, + "get", + {}, + dict(query=query), + executor=TestExecutor(loop=loop), + return_promise=True, + ) + results = yield from 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_query.py b/tests/test_query.py index 7a1857e..d1739e8 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -1,6 +1,7 @@ import json from graphql.error import GraphQLError +from promise import Promise from graphql_server import ( HttpQueryError, @@ -15,11 +16,7 @@ from pytest import raises from .schema import schema - - -def as_dicts(results): - """Convert execution results to a list of tuples of dicts for better comparison.""" - return [result.to_dict(dict_class=dict) for result in results] +from .utils import as_dicts def test_request_params(): @@ -550,6 +547,34 @@ def execute(self, fn, *args, **kwargs): query = "{test}" results, params = run_http_query( + schema, "get", {}, dict(query=query), executor=TestExecutor(), + ) + + 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_reponses_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", {}, @@ -558,8 +583,10 @@ def execute(self, fn, *args, **kwargs): return_promise=True, ) + 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 TestExecutor.waited + assert not TestExecutor.waited assert TestExecutor.cleaned diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..136f09f --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,3 @@ +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 index 54ee181..ee8ae09 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,8 @@ deps = graphql-core>=2.1,<3 pytest-cov>=2.7 commands = - py{py,27,33,34,35,36,37}: py.test tests {posargs} + py{py,27}: py.test tests {posargs} --ignore=tests/test_asyncio.py + py{py3,33,34,35,36,37,38}: py.test tests {posargs} [testenv:black] basepython=python3.7 From eef09192f7f50b1b6aec85daef2473e786710f25 Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Thu, 23 Jan 2020 22:27:29 +0100 Subject: [PATCH 010/108] Use newer pytest, desupport Python 3.3 and 3.4, require Core 2.3 --- .travis.yml | 14 ++++++-------- graphql_server/__init__.py | 4 +++- setup.cfg | 2 +- setup.py | 10 +++++----- tests/conftest.py | 4 ++++ tests/test_asyncio.py | 24 +++++++++--------------- tox.ini | 19 ++++++++----------- 7 files changed, 36 insertions(+), 41 deletions(-) create mode 100644 tests/conftest.py diff --git a/.travis.yml b/.travis.yml index 7f32f3d..250247c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,17 +3,13 @@ matrix: include: - python: "3.7" env: TOX_ENV=black,flake8,mypy,py37 - dist: xenial - sudo: true - python: "3.6" env: TOX_ENV=py36 - python: "3.5" env: TOX_ENV=py35 - - python: "3.4" - env: TOX_ENV=py34 - python: "2.7" env: TOX_ENV=py27 - - python: pypy3.5 + - python: pypy3 env: TOX_ENV=pypy3 - python: pypy env: TOX_ENV=pypy @@ -29,9 +25,11 @@ after_success: - codecov deploy: provider: pypi - user: __token__ - password: - secure: WcZf7AVMDzheXWUxNhZF/TUcyvyCdHZGyhHTakjBhUs8I8khSvlMPofaXTdN1Qn3WbHPK+IXeIPh/2NX0Le3Cdzp08Q/Tgrf9EZ4y02UrZxwSxtsUmjCVd8GaCsQnhR5t5cgrtw33OAf0O22rUnMXsFtw7xMIuCNTgFiYclNbHzYbvnJAEcY3qE8RBbP8zF5Brx+Bl49SjfVR3dJ7CBkjgC9scZjSBAo/yc64d506W59LOjfvXEiDtGUH2gxZNwNiteZtI3frMYqLRjS563SwEFlG36B8g0hBOj6FVpU+YXeImYXw3XFqC6dCvcwn1dAf/vUZ4IDiDIVf5KvFcyDx0ZwZlMSzqlkLVpSDGqPU+7Mx15NW00Yk2+Zs2ZWFMK+g5WtSehhrAWR6El3d0MRlDXKgt9QbCRyh8b2jPV/vQZN2FOBOg9V9a6IszOy/W1J81q39cLOroBhQF4mDFYTAQ5QpBVUyauAfB49QzXsmSWy2uOTsbgo+oAc+OGJ6q9vXCzNqHxhUvtDT9HIq4w5ixw9wqtpSf6n+l2F2RFl5SzHIR7Dt0m9Eg2Ig5NqSGlymz46ZcxpRjd4wVXALD4M8usqy35jGTeEXsqSTO98n3jwKTj/7Xi6GOZuBlwW+SGAjXQ0vzlWD3AEv0Jnh+4AH5UqWwBeD1skw8gtbjM4dos= 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/graphql_server/__init__.py b/graphql_server/__init__.py index 439d6f5..1bcbfff 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -296,7 +296,9 @@ def execute_graphql_request( try: return document.execute( - operation_name=params.operation_name, variables=params.variables, **kwargs + operation_name=params.operation_name, + variable_values=params.variables, + **kwargs ) except Exception as e: return ExecutionResult(errors=[e], invalid=True) diff --git a/setup.cfg b/setup.cfg index d05fda6..ea8cbd3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,7 +6,7 @@ max-line-length = 88 known_first_party=graphql_server [tool:pytest] -norecursedirs = venv .tox .cache +norecursedirs = venv .venv .tox .git .cache .mypy_cache .pytest_cache [bdist_wheel] universal=1 diff --git a/setup.py b/setup.py index da726f5..75f12f5 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,11 @@ from setuptools import setup, find_packages -required_packages = ["graphql-core>=2.1,<3", "promise"] +required_packages = ["graphql-core>=2.3,<3", "promise>=2.3,<3"] +tests_require = ["pytest==4.6.9", "pytest-cov==2.8.1"] setup( name="graphql-server-core", - version="1.1.3", + version="1.2.0", description="GraphQL Server tools for powering your server", long_description=open("README.md").read(), long_description_content_type="text/markdown", @@ -20,8 +21,6 @@ "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.3", - "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", @@ -32,7 +31,8 @@ keywords="api graphql protocol rest", packages=find_packages(exclude=["tests"]), install_requires=required_packages, - tests_require=["pytest>=3.0,<4"], + tests_require=tests_require, + extras_require={"test": tests_require}, include_package_data=True, zip_safe=False, platforms="any", diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ae78c3d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,4 @@ +import sys + +if sys.version_info[:2] < (3, 4): + collect_ignore_glob = ["*_asyncio.py"] diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index d3b5426..9ee4386 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -1,8 +1,6 @@ -# flake8: noqa +import asyncio -import pytest - -asyncio = pytest.importorskip("asyncio") +from promise import Promise from graphql.execution.executors.asyncio import AsyncioExecutor from graphql.type.definition import ( @@ -13,7 +11,6 @@ from graphql.type.scalars import GraphQLString from graphql.type.schema import GraphQLSchema from graphql_server import RequestParams, run_http_query -from promise import Promise from .utils import as_dicts @@ -22,9 +19,8 @@ def resolve_error_sync(_obj, _info): raise ValueError("error sync") -@asyncio.coroutine -def resolve_error_async(_obj, _info): - yield from asyncio.sleep(0.001) +async def resolve_error_async(_obj, _info): + await asyncio.sleep(0.001) raise ValueError("error async") @@ -32,9 +28,8 @@ def resolve_field_sync(_obj, _info): return "sync" -@asyncio.coroutine -def resolve_field_async(_obj, info): - yield from asyncio.sleep(0.001) +async def resolve_field_async(_obj, info): + await asyncio.sleep(0.001) return "async" @@ -53,7 +48,7 @@ def resolve_field_async(_obj, info): schema = GraphQLSchema(QueryRootType) -def test_get_reponses_using_asyncioexecutor(): +def test_get_responses_using_asyncio_executor(): class TestExecutor(AsyncioExecutor): called = False waited = False @@ -75,8 +70,7 @@ def execute(self, fn, *args, **kwargs): loop = asyncio.get_event_loop() - @asyncio.coroutine - def get_results(): + async def get_results(): result_promises, params = run_http_query( schema, "get", @@ -85,7 +79,7 @@ def get_results(): executor=TestExecutor(loop=loop), return_promise=True, ) - results = yield from Promise.all(result_promises) + results = await Promise.all(result_promises) return results, params results, params = loop.run_until_complete(get_results()) diff --git a/tox.ini b/tox.ini index ee8ae09..f759b87 100644 --- a/tox.ini +++ b/tox.ini @@ -1,41 +1,38 @@ [tox] -envlist = black,flake8,mypy,py38,py37,py36,py35,py34,py33,py27,pypy3,pypy +envlist = black,flake8,mypy,py{38,37,36,35,py27,py3,py} skipsdist = true [testenv] setenv = PYTHONPATH = {toxinidir} deps = - pytest>=3.0,<4 - graphql-core>=2.1,<3 - pytest-cov>=2.7 + .[test] commands = - py{py,27}: py.test tests {posargs} --ignore=tests/test_asyncio.py - py{py3,33,34,35,36,37,38}: py.test tests {posargs} + pytest tests {posargs} [testenv:black] basepython=python3.7 -deps = black +deps = black==19.10b0 commands = black --check graphql_server tests [testenv:flake8] basepython=python3.7 -deps = flake8 +deps = flake8==3.7.9 commands = - flake8 graphql_server tests + flake8 setup.py graphql_server tests [testenv:isort] basepython=python3.7 deps = isort - graphql-core>=2.1,<3 + graphql-core>=2.3,<3 commands = isort -rc graphql_server/ tests/ [testenv:mypy] basepython=python3.7 -deps = mypy +deps = mypy==0.761 commands = mypy graphql_server tests --ignore-missing-imports From ad392b5406d90ae59080fc10799f8ab204c898c5 Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Thu, 23 Jan 2020 23:05:15 +0100 Subject: [PATCH 011/108] Return errors as promise when return_promise=True (#5) --- graphql_server/__init__.py | 18 ++++++++--- tests/test_query.py | 66 +++++++++++++++++++++++++++++++++++--- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 1bcbfff..1895a41 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -12,12 +12,14 @@ from collections import namedtuple import six + +from promise import promisify, is_thenable + 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 promise import Promise, is_thenable from .error import HttpQueryError @@ -304,6 +306,11 @@ def execute_graphql_request( 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 @@ -318,10 +325,13 @@ def get_response( that belong to an exception class that you need to pass as a parameter. """ # noinspection PyBroadException + execute = ( + execute_graphql_request_as_promise + if kwargs.get("return_promise", False) + else execute_graphql_request + ) try: - execution_result = execute_graphql_request( - schema, params, allow_only_query, **kwargs - ) + execution_result = execute(schema, params, allow_only_query, **kwargs) except catch_exc: return None diff --git a/tests/test_query.py b/tests/test_query.py index d1739e8..73c1674 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -1,8 +1,12 @@ import json -from graphql.error import GraphQLError +from pytest import raises + from promise import Promise +from graphql.error import GraphQLError, GraphQLSyntaxError +from graphql.execution import ExecutionResult + from graphql_server import ( HttpQueryError, RequestParams, @@ -13,7 +17,6 @@ load_json_body, run_http_query, ) -from pytest import raises from .schema import schema from .utils import as_dicts @@ -529,7 +532,7 @@ def test_batch_allows_post_with_operation_name(): ] -def test_get_reponses_using_executor(): +def test_get_responses_using_executor(): class TestExecutor(object): called = False waited = False @@ -550,6 +553,10 @@ def execute(self, fn, *args, **kwargs): 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 @@ -557,7 +564,7 @@ def execute(self, fn, *args, **kwargs): assert not TestExecutor.cleaned -def test_get_reponses_using_executor_return_promise(): +def test_get_responses_using_executor_return_promise(): class TestExecutor(object): called = False waited = False @@ -583,6 +590,9 @@ def execute(self, fn, *args, **kwargs): 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"}}] @@ -590,3 +600,51 @@ def execute(self, fn, *args, **kwargs): 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 From cfea8346186ef40a426d150397d9c99f7b73f649 Mon Sep 17 00:00:00 2001 From: KingDarBoja Date: Sat, 14 Mar 2020 14:50:48 -0500 Subject: [PATCH 012/108] chore: contributing docs and refactor dev packages --- .travis.yml | 38 +++++++---------- .vscode/settings.json | 3 ++ CONTRIBUTING.md | 94 +++++++++++++++++++++++++++++++++++++++++++ MANIFEST.in | 5 +++ README.md | 3 ++ setup.cfg | 4 +- setup.py | 28 ++++++++++--- tests/schema.py | 8 +--- tests/test_asyncio.py | 14 +++---- tests/test_helpers.py | 13 ++---- tests/test_query.py | 19 +++------ tox.ini | 34 ++++++++++------ 12 files changed, 182 insertions(+), 81 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 CONTRIBUTING.md diff --git a/.travis.yml b/.travis.yml index 250247c..93de77a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,28 +1,18 @@ language: python -matrix: - include: - - python: "3.7" - env: TOX_ENV=black,flake8,mypy,py37 - - python: "3.6" - env: TOX_ENV=py36 - - python: "3.5" - env: TOX_ENV=py35 - - python: "2.7" - env: TOX_ENV=py27 - - python: pypy3 - env: TOX_ENV=pypy3 - - python: pypy - env: TOX_ENV=pypy -cache: - directories: - - "$HOME/.cache/pip" - - "$TRAVIS_BUILD_DIR/.tox" -install: - - pip install tox codecov -script: - - tox -e $TOX_ENV -- --cov-report term-missing --cov=graphql_server -after_success: - - codecov +sudo: false +python: + - 2.7 + - 3.5 + - 3.6 + - 3.7 + - 3.8 + - 3.9-dev + - pypy + - pypy3 +cache: pip +install: pip install tox-travis codecov +script: tox +after_success: codecov deploy: provider: pypi on: diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..801b5ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.pythonPath": "D:\\Anaconda3\\envs\\graphql-sc-dev\\python.exe" +} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0def60d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,94 @@ +# Contributing + +Thanks for helping to make graphql-server-core awesome! + +We welcome all kinds of contributions: + +- Bug fixes +- Documentation improvements +- New features +- Refactoring & tidying + + +## Getting started + +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. + + +## Project setup + +### Development with virtualenv (recommended) + +After cloning this repo, create a virtualenv: + +```console +virtualenv graphql-server-core-dev +``` + +Activate the virtualenv and install dependencies by running: + +```console +python pip install -e ".[test]" +``` + +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. + +### Development on Conda + +You must create a new env (e.g. `graphql-sc-dev`) with the following command: + +```sh +conda create -n graphql-sc-dev python=3.8 +``` + +Then activate the environment with `conda activate graphql-sc-dev`. + +Proceed to install all dependencies by running: + +```console +pip install -e.[dev] +``` + +And you ready to start development! + +## Running tests + +After developing, the full test suite can be evaluated by running: + +```sh +pytest tests --cov=graphql-server-core -vv +``` + +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. + +You can also test on several python environments by using tox. + +### Running tox on virtualenv + +Install tox: + +```console +pip install tox +``` + +Run `tox` on your virtualenv (do not forget to activate it!) +and that's it! + +### Running tox on Conda + +In order to run `tox` command on conda, install +[tox-conda](https://github.com/tox-dev/tox-conda): + +```sh +conda install -c conda-forge tox-conda +``` + +This install tox underneath so no need to install it before. + +Then uncomment the `requires = tox-conda` line on `tox.ini` file. + +Run `tox` and you will see all the environments being created +and all passing tests. :rocket: + diff --git a/MANIFEST.in b/MANIFEST.in index 04f196a..1c690a9 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,7 @@ include README.md include LICENSE +include CONTRIBUTING.md + +include tox.ini + +global-exclude *.py[co] __pycache__ diff --git a/README.md b/README.md index ec7e022..fdb3d40 100644 --- a/README.md +++ b/README.md @@ -42,3 +42,6 @@ You can also use one of the existing integrations listed above as blueprint to build your own integration or GraphQL server implementations. Please let us know when you have built something new, so we can list it here. + +## Contributing +See [CONTRIBUTING.md](CONTRIBUTING.md) diff --git a/setup.cfg b/setup.cfg index ea8cbd3..da11080 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,12 +1,12 @@ [flake8] exclude = docs -max-line-length = 88 +max-line-length = 120 [isort] known_first_party=graphql_server [tool:pytest] -norecursedirs = venv .venv .tox .git .cache .mypy_cache .pytest_cache +norecursedirs = venv .venv .tox .git .cache .mypy_cache .pytest_cache [bdist_wheel] universal=1 diff --git a/setup.py b/setup.py index 75f12f5..11a6634 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,22 @@ from setuptools import setup, find_packages -required_packages = ["graphql-core>=2.3,<3", "promise>=2.3,<3"] -tests_require = ["pytest==4.6.9", "pytest-cov==2.8.1"] +install_requires = [ + "graphql-core>=2.3,<3", + "promise>=2.3,<3", +] + +tests_requires = [ + "pytest==4.6.9", + "pytest-cov==2.8.1" +] + +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", @@ -30,9 +45,12 @@ ], keywords="api graphql protocol rest", packages=find_packages(exclude=["tests"]), - install_requires=required_packages, - tests_require=tests_require, - extras_require={"test": tests_require}, + 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", diff --git a/tests/schema.py b/tests/schema.py index c60b0ed..4dd02f6 100644 --- a/tests/schema.py +++ b/tests/schema.py @@ -1,9 +1,5 @@ -from graphql.type.definition import ( - GraphQLArgument, - GraphQLField, - GraphQLNonNull, - GraphQLObjectType, -) +from graphql.type.definition import (GraphQLArgument, GraphQLField, + GraphQLNonNull, GraphQLObjectType) from graphql.type.scalars import GraphQLString from graphql.type.schema import GraphQLSchema diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index 9ee4386..cb879b1 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -1,15 +1,11 @@ -import asyncio - -from promise import Promise - from graphql.execution.executors.asyncio import AsyncioExecutor -from graphql.type.definition import ( - GraphQLField, - GraphQLNonNull, - GraphQLObjectType, -) +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 diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 53e9965..9318887 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -3,17 +3,12 @@ from graphql.error import GraphQLError from graphql.execution import ExecutionResult from graphql.language.location import SourceLocation - -from graphql_server import ( - HttpQueryError, - ServerResponse, - encode_execution_results, - json_encode, - json_encode_pretty, - load_json_body, -) from pytest import raises +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}"}) diff --git a/tests/test_query.py b/tests/test_query.py index 73c1674..f5ae509 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -1,22 +1,13 @@ import json -from pytest import raises - -from promise import Promise - 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 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 diff --git a/tox.ini b/tox.ini index f759b87..05f83f4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,38 +1,48 @@ [tox] -envlist = black,flake8,mypy,py{38,37,36,35,py27,py3,py} -skipsdist = true +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} -deps = - .[test] +install_command = python -m pip install --ignore-installed {opts} {packages} +deps = -e.[test] +whitelist_externals = + python commands = - pytest tests {posargs} + pip install -U setuptools + pytest --cov-report=term-missing --cov=graphql_server tests {posargs} [testenv:black] basepython=python3.7 -deps = black==19.10b0 +deps = -e.[dev] commands = black --check graphql_server tests [testenv:flake8] basepython=python3.7 -deps = flake8==3.7.9 +deps = -e.[dev] commands = flake8 setup.py graphql_server tests -[testenv:isort] +[testenv:import-order] basepython=python3.7 -deps = - isort - graphql-core>=2.3,<3 +deps = -e.[dev] commands = isort -rc graphql_server/ tests/ [testenv:mypy] basepython=python3.7 -deps = mypy==0.761 +deps = -e.[dev] commands = mypy graphql_server tests --ignore-missing-imports +[testenv:manifest] +basepython = python3.7 +deps = -e.[dev] +commands = + check-manifest -v + From 1afbb629ef78004861faa90f1ac977bb2e794560 Mon Sep 17 00:00:00 2001 From: KingDarBoja Date: Sat, 14 Mar 2020 14:56:39 -0500 Subject: [PATCH 013/108] chore: comment out tox-conda --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 05f83f4..4f2fde0 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,7 @@ envlist = black,flake8,import-order,mypy,manifest py{27,35,36,37,38,39-dev,py,py3} -requires = tox-conda +; requires = tox-conda [testenv] passenv = * From 7b3f8c01012af6dc71a1a54459ce8445b671318a Mon Sep 17 00:00:00 2001 From: KingDarBoja Date: Sat, 14 Mar 2020 22:00:24 -0500 Subject: [PATCH 014/108] chore: fix mypy issues --- graphql_server/__init__.py | 18 +++++++++--------- tox.ini | 3 +-- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 1895a41..4f91de9 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -13,7 +13,7 @@ import six -from promise import promisify, is_thenable +from promise import promisify, is_thenable, Promise from graphql import get_default_backend from graphql.error import format_error as default_format_error @@ -266,6 +266,7 @@ def execute_graphql_request( 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 @@ -318,18 +319,18 @@ def get_response( allow_only_query=False, # type: bool **kwargs # type: Any ): - # type: (...) -> Optional[ExecutionResult] + # 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 - execute = ( - execute_graphql_request_as_promise - if kwargs.get("return_promise", False) - else execute_graphql_request - ) try: execution_result = execute(schema, params, allow_only_query, **kwargs) except catch_exc: @@ -350,11 +351,10 @@ def format_execution_result( """ status_code = 200 + response = None if execution_result: if execution_result.invalid: status_code = 400 response = execution_result.to_dict(format_error=format_error) - else: - response = None return FormattedResult(response, status_code) diff --git a/tox.ini b/tox.ini index 4f2fde0..d9cea63 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,6 @@ [tox] envlist = - black,flake8,import-order,mypy,manifest - py{27,35,36,37,38,39-dev,py,py3} + black,flake8,import-order,mypy,manifest,py{27,35,36,37,38,39-dev,py,py3} ; requires = tox-conda [testenv] From 3b74a14cfe831f3a0bb312356d96bb8c45ed9cf2 Mon Sep 17 00:00:00 2001 From: KingDarBoja Date: Sat, 14 Mar 2020 22:11:09 -0500 Subject: [PATCH 015/108] chore: apply black formatting --- graphql_server/__init__.py | 4 +++- setup.cfg | 2 +- tests/schema.py | 8 ++++++-- tests/test_asyncio.py | 3 +-- tests/test_helpers.py | 11 ++++++++--- tests/test_query.py | 13 ++++++++++--- 6 files changed, 29 insertions(+), 12 deletions(-) diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 4f91de9..cb802ee 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -326,7 +326,9 @@ def get_response( 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]] + execute = ( + execute_graphql_request + ) # type: Callable[..., Union[Promise[ExecutionResult], ExecutionResult]] if kwargs.get("return_promise", False): execute = execute_graphql_request_as_promise diff --git a/setup.cfg b/setup.cfg index da11080..70e1f4a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [flake8] exclude = docs -max-line-length = 120 +max-line-length = 88 [isort] known_first_party=graphql_server diff --git a/tests/schema.py b/tests/schema.py index 4dd02f6..c60b0ed 100644 --- a/tests/schema.py +++ b/tests/schema.py @@ -1,5 +1,9 @@ -from graphql.type.definition import (GraphQLArgument, GraphQLField, - GraphQLNonNull, GraphQLObjectType) +from graphql.type.definition import ( + GraphQLArgument, + GraphQLField, + GraphQLNonNull, + GraphQLObjectType, +) from graphql.type.scalars import GraphQLString from graphql.type.schema import GraphQLSchema diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index cb879b1..db8fc02 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -1,6 +1,5 @@ from graphql.execution.executors.asyncio import AsyncioExecutor -from graphql.type.definition import (GraphQLField, GraphQLNonNull, - GraphQLObjectType) +from graphql.type.definition import GraphQLField, GraphQLNonNull, GraphQLObjectType from graphql.type.scalars import GraphQLString from graphql.type.schema import GraphQLSchema from promise import Promise diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 9318887..fc4b73e 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -5,9 +5,14 @@ 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) +from graphql_server import ( + HttpQueryError, + ServerResponse, + encode_execution_results, + json_encode, + json_encode_pretty, + load_json_body, +) def test_json_encode(): diff --git a/tests/test_query.py b/tests/test_query.py index f5ae509..e5bbb79 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -5,9 +5,16 @@ 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 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 From 5487aba043c15bbec6eb261ef98c304ece6a778f Mon Sep 17 00:00:00 2001 From: KingDarBoja Date: Sat, 14 Mar 2020 22:15:35 -0500 Subject: [PATCH 016/108] chore: add vscode to gitignore --- .gitignore | 1 + .vscode/settings.json | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.gitignore b/.gitignore index 2452aab..608847c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ .pytest_cache .tox .venv +.vscode /build/ /dist/ diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 801b5ee..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "python.pythonPath": "D:\\Anaconda3\\envs\\graphql-sc-dev\\python.exe" -} \ No newline at end of file From d433a6538bf532b21c46fb3c4f0de3fbcd5b182e Mon Sep 17 00:00:00 2001 From: KingDarBoja Date: Sat, 14 Mar 2020 23:10:36 -0500 Subject: [PATCH 017/108] chore: test travis env tools --- tox.ini | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tox.ini b/tox.ini index d9cea63..b5a4e24 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,13 @@ [tox] envlist = - black,flake8,import-order,mypy,manifest,py{27,35,36,37,38,39-dev,py,py3} + black,flake8,import-order,mypy,manifest, + py{27,35,36,37,38,39-dev,py,py3} ; requires = tox-conda +[travis] +python = + 3.6: py36,black,flake8,import-order,mypy + [testenv] passenv = * setenv = @@ -16,31 +21,31 @@ commands = pytest --cov-report=term-missing --cov=graphql_server tests {posargs} [testenv:black] -basepython=python3.7 +basepython=python3.6 deps = -e.[dev] commands = black --check graphql_server tests [testenv:flake8] -basepython=python3.7 +basepython=python3.6 deps = -e.[dev] commands = flake8 setup.py graphql_server tests [testenv:import-order] -basepython=python3.7 +basepython=python3.6 deps = -e.[dev] commands = isort -rc graphql_server/ tests/ [testenv:mypy] -basepython=python3.7 +basepython=python3.6 deps = -e.[dev] commands = mypy graphql_server tests --ignore-missing-imports [testenv:manifest] -basepython = python3.7 +basepython = python3.6 deps = -e.[dev] commands = check-manifest -v From 2b9d47d20bf42ad4c7588449cbfce3bdef95cd90 Mon Sep 17 00:00:00 2001 From: KingDarBoja Date: Sat, 14 Mar 2020 23:16:16 -0500 Subject: [PATCH 018/108] chore: test to run flake8 on Travis isolated --- .travis.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.travis.yml b/.travis.yml index 93de77a..940940d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,6 +9,10 @@ python: - 3.9-dev - pypy - pypy3 +matrix: + include: + - python: 3.6 + env: TOXENV=flake8 cache: pip install: pip install tox-travis codecov script: tox From 6f6bd474be3f90534649052ab3887db7397d377e Mon Sep 17 00:00:00 2001 From: KingDarBoja Date: Sat, 14 Mar 2020 23:43:50 -0500 Subject: [PATCH 019/108] chore: add the rest of special envs to Travis --- .travis.yml | 8 ++++++++ tox.ini | 5 ----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 940940d..0510a4a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,6 +13,14 @@ matrix: include: - python: 3.6 env: TOXENV=flake8 + - python: 3.6 + env: TOXENV=black + - python: 3.6 + env: TOXENV=import-order + - python: 3.6 + env: TOXENV=mypy + - python: 3.6 + env: TOXENV=manifest cache: pip install: pip install tox-travis codecov script: tox diff --git a/tox.ini b/tox.ini index b5a4e24..77a2bb6 100644 --- a/tox.ini +++ b/tox.ini @@ -4,10 +4,6 @@ envlist = py{27,35,36,37,38,39-dev,py,py3} ; requires = tox-conda -[travis] -python = - 3.6: py36,black,flake8,import-order,mypy - [testenv] passenv = * setenv = @@ -49,4 +45,3 @@ basepython = python3.6 deps = -e.[dev] commands = check-manifest -v - From b73f3f8775557ad02bf1d0c039aaea5f41514b05 Mon Sep 17 00:00:00 2001 From: KingDarBoja Date: Sun, 15 Mar 2020 00:01:19 -0500 Subject: [PATCH 020/108] chore: update manifest --- MANIFEST.in | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/MANIFEST.in b/MANIFEST.in index 1c690a9..12b4ad7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,7 +1,13 @@ +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__ From 9d37c7b0448eb2259940d43ba8846a840b6e758f Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Mon, 16 Mar 2020 09:16:52 -0500 Subject: [PATCH 021/108] chore: merge dev envs on single tox env Co-Authored-By: Jonathan Kim --- .travis.yml | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/.travis.yml b/.travis.yml index 0510a4a..ecffbda 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,16 +11,8 @@ python: - pypy3 matrix: include: - - python: 3.6 - env: TOXENV=flake8 - - python: 3.6 - env: TOXENV=black - - python: 3.6 - env: TOXENV=import-order - - python: 3.6 - env: TOXENV=mypy - - python: 3.6 - env: TOXENV=manifest + - python: 3.7 + env: TOXENV=flake8,black,import-order,mypy,manifest cache: pip install: pip install tox-travis codecov script: tox From d8b98b7342bc4a451ffb324082f4f8dd3b9397e1 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Mon, 16 Mar 2020 09:17:34 -0500 Subject: [PATCH 022/108] docs: Update CONTRIBUTING.md Co-Authored-By: Jonathan Kim --- CONTRIBUTING.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0def60d..c573f21 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,7 @@ Then activate the environment with `conda activate graphql-sc-dev`. Proceed to install all dependencies by running: ```console -pip install -e.[dev] +pip install -e ".[dev]" ``` And you ready to start development! @@ -91,4 +91,3 @@ Then uncomment the `requires = tox-conda` line on `tox.ini` file. Run `tox` and you will see all the environments being created and all passing tests. :rocket: - From 3e8545f9a0f1aa596e48a8fd662264430e70ae6b Mon Sep 17 00:00:00 2001 From: KingDarBoja Date: Mon, 16 Mar 2020 09:36:07 -0500 Subject: [PATCH 023/108] chore: set py36 for special envs on travis --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index ecffbda..7789878 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,7 +11,7 @@ python: - pypy3 matrix: include: - - python: 3.7 + - python: 3.6 env: TOXENV=flake8,black,import-order,mypy,manifest cache: pip install: pip install tox-travis codecov From 340236fa759917a51803583ba193074b34d584e1 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Tue, 17 Mar 2020 17:31:10 +0000 Subject: [PATCH 024/108] v2.0.0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 11a6634..a6416c0 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ setup( name="graphql-server-core", - version="1.2.0", + version="2.0.0", description="GraphQL Server tools for powering your server", long_description=open("README.md").read(), long_description_content_type="text/markdown", From 865ee9d5602f352c958f6f7e15adbe9abe216784 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Sun, 12 Apr 2020 12:59:17 -0500 Subject: [PATCH 025/108] Migration to graphql-core-v3 (#36) * feat: server-core compatible with graphql-core-v3 - Bump dependencies - Refactor code to use f-strings format (3.6+) - Rename public data structures BREAKING CHANGE: - Requires graphql-core-v3 - Drop support for Python 2 and below 3.6 - Remove executor check as graphql-core-v3 does not have SyncExecutor * chore: drop unsupported py versions on tox and travis * tests: apply minor fixes to older tests * chore: apply black formatting * chore: fix flake8 issues * chore: remove promise package * tests: achieve 100% coverage * chore: apply compatible isort-black options * chore: solve dev tools issues * chore: remove pypy3 from tox envlist * chore: remove pypy3 from travis * tests: re-add helper tests * chore: pin graphql-core to 3.1.0 * refactor: use graphql and graphql-sync functions * tests: remove Promise and use async await iterator * refactor: remove pytest-asyncio * chore: set graphql-core dependency semver Co-Authored-By: Jonathan Kim Co-authored-by: Jonathan Kim --- .travis.yml | 6 +- README.md | 2 + graphql_server/__init__.py | 298 +++++++++++++-------------------- setup.cfg | 5 + setup.py | 24 +-- tests/conftest.py | 4 - tests/schema.py | 33 ++-- tests/test_asyncio.py | 63 +++---- tests/test_error.py | 44 ++--- tests/test_helpers.py | 127 ++------------ tests/test_query.py | 334 +++++++++++++++---------------------- tests/utils.py | 17 +- tox.ini | 12 +- 13 files changed, 368 insertions(+), 601 deletions(-) delete mode 100644 tests/conftest.py diff --git a/.travis.yml b/.travis.yml index 7789878..29bac19 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,17 +1,13 @@ 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 + - python: 3.7 env: TOXENV=flake8,black,import-order,mypy,manifest cache: pip install: pip install tox-travis codecov diff --git a/README.md b/README.md index fdb3d40..9e228f1 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ The `graphql_server` package provides these public helper functions: * `json_encode` * `json_encode_pretty` +**NOTE:** the `json_encode_pretty` is kept as backward compatibility change as it uses `json_encode` with `pretty` parameter set to `True`. + All functions in the package are annotated with type hints and docstrings, and you can build HTML documentation from these using `bin/build_docs`. diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index cb802ee..29efffa 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -6,37 +6,19 @@ 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 +from collections.abc import MutableMapping +from typing import Any, Callable, Dict, List, Optional, Type, Union -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 graphql import ExecutionResult, GraphQLError, GraphQLSchema, OperationType +from graphql import format_error as format_error_default +from graphql import get_operation_ast, parse +from graphql.graphql import graphql, graphql_sync +from graphql.pyutils import AwaitableOrValue 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", @@ -44,18 +26,17 @@ "json_encode", "json_encode_pretty", "HttpQueryError", - "RequestParams", - "ServerResults", + "GraphQLParams", + "GraphQLResponse", "ServerResponse", + "format_execution_result", ] # The public data structures -RequestParams = namedtuple("RequestParams", "query variables operation_name") - -ServerResults = namedtuple("ServerResults", "results params") - +GraphQLParams = namedtuple("GraphQLParams", "query variables operation_name") +GraphQLResponse = namedtuple("GraphQLResponse", "results params") ServerResponse = namedtuple("ServerResponse", "body status_code") @@ -63,14 +44,15 @@ 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 -): + schema: GraphQLSchema, + request_method: str, + data: Union[Dict, List[Dict]], + query_data: Optional[Dict] = None, + batch_enabled: bool = False, + catch: bool = False, + run_sync: bool = True, + **execute_options, +) -> GraphQLResponse: """Execute GraphQL coming from an HTTP query against a given schema. You need to pass the schema (that is supposed to be already validated), @@ -87,7 +69,7 @@ def run_http_query( 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)) + raise TypeError(f"Expected a GraphQL schema, but received {schema!r}.") if request_method not in ("get", "post"): raise HttpQueryError( 405, @@ -95,9 +77,7 @@ def run_http_query( headers={"Allow": "GET, POST"}, ) if catch: - catch_exc = ( - HttpQueryError - ) # type: Union[Type[HttpQueryError], Type[_NoException]] + catch_exc: Union[Type[HttpQueryError], Type[_NoException]] = HttpQueryError else: catch_exc = _NoException is_batch = isinstance(data, list) @@ -108,7 +88,7 @@ def run_http_query( if not is_batch: if not isinstance(data, (dict, MutableMapping)): raise HttpQueryError( - 400, "GraphQL params should be a dict. Received {!r}.".format(data) + 400, f"GraphQL params should be a dict. Received {data!r}." ) data = [data] elif not batch_enabled: @@ -117,50 +97,45 @@ def run_http_query( if not data: raise HttpQueryError(400, "Received an empty list in the batch request.") - extra_data = {} # type: Dict[str, Any] + extra_data: 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] + all_params: List[GraphQLParams] = [ + get_graphql_params(entry, extra_data) for entry in data + ] + + results: List[Optional[AwaitableOrValue[ExecutionResult]]] = [ + get_response( + schema, params, catch_exc, allow_only_query, run_sync, **execute_options + ) + for params in all_params + ] + return GraphQLResponse(results, all_params) - 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 - ] +def json_encode(data: Union[Dict, List], pretty: bool = False) -> str: + """Serialize the given data(a dictionary or a list) using JSON. - return ServerResults(results, all_params) + 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 not pretty: + return json.dumps(data, separators=(",", ":")) + return json.dumps(data, indent=2, separators=(",", ": ")) + + +def json_encode_pretty(data: Union[Dict, List]) -> str: + return json_encode(data, True) 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 + execution_results: List[Optional[ExecutionResult]], + format_error: Callable[[GraphQLError], Dict] = format_error_default, + is_batch: bool = False, + encode: Callable[[Dict], Any] = json_encode, +) -> ServerResponse: """Serialize the ExecutionResults. This function takes the ExecutionResults that are returned by run_http_query() @@ -174,7 +149,7 @@ def encode_execution_results( 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) + format_execution_result(execution_result, format_error) for execution_result in execution_results ] result, status_codes = zip(*results) @@ -183,7 +158,7 @@ def encode_execution_results( if not is_batch: result = result[0] - return ServerResponse((encode or json_encode)(result), status_code) + return ServerResponse(encode(result), status_code) def load_json_body(data): @@ -199,24 +174,6 @@ def load_json_body(data): 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") @@ -226,8 +183,7 @@ 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 +def get_graphql_params(data: Dict, query_data: Dict) -> GraphQLParams: """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. @@ -240,18 +196,17 @@ def get_graphql_params(data, query_data): # document_id = data.get('documentId') operation_name = data.get("operationName") or query_data.get("operationName") - return RequestParams(query, load_json_variables(variables), operation_name) + return GraphQLParams(query, load_json_variables(variables), operation_name) -def load_json_variables(variables): - # type: (Optional[Union[str, Dict]]) -> Optional[Dict] +def load_json_variables(variables: 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): + if variables and isinstance(variables, str): try: return json.loads(variables) except Exception: @@ -259,82 +214,63 @@ def load_json_variables(variables): 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]]] + schema: GraphQLSchema, + params: GraphQLParams, + catch_exc: Type[BaseException], + allow_only_query: bool = False, + run_sync: bool = True, + **kwargs, +) -> Optional[AwaitableOrValue[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. + This does the same as graphql_impl() except that you can either + throw an error on the ExecutionResult if allow_only_query is set to True + or 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 + + if not params.query: + raise HttpQueryError(400, "Must provide query string.") # noinspection PyBroadException try: - execution_result = execute(schema, params, allow_only_query, **kwargs) + # Parse document to trigger a new HttpQueryError if allow_only_query is True + try: + document = parse(params.query) + except GraphQLError as e: + return ExecutionResult(data=None, errors=[e]) + except Exception as e: + e = GraphQLError(str(e), original_error=e) + return ExecutionResult(data=None, errors=[e]) + + if allow_only_query: + operation_ast = get_operation_ast(document, params.operation_name) + if operation_ast: + operation = operation_ast.operation.value + if operation != OperationType.QUERY.value: + raise HttpQueryError( + 405, + f"Can only perform a {operation} operation from a POST request.", # noqa + headers={"Allow": "POST"}, + ) + + if run_sync: + execution_result = graphql_sync( + schema=schema, + source=params.query, + variable_values=params.variables, + operation_name=params.operation_name, + **kwargs, + ) + else: + execution_result = graphql( # type: ignore + schema=schema, + source=params.query, + variable_values=params.variables, + operation_name=params.operation_name, + **kwargs, + ) except catch_exc: return None @@ -342,21 +278,23 @@ def get_response( def format_execution_result( - execution_result, # type: Optional[ExecutionResult] - format_error, # type: Optional[Callable[[Exception], Dict]] -): - # type: (...) -> FormattedResult + execution_result: Optional[ExecutionResult], + format_error: Optional[Callable[[GraphQLError], Dict]] = format_error_default, +) -> 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: Optional[Dict[str, Any]] = None - response = None if execution_result: - if execution_result.invalid: + if execution_result.errors: + fe = [format_error(e) for e in execution_result.errors] # type: ignore + response = {"errors": fe} status_code = 400 - response = execution_result.to_dict(format_error=format_error) + else: + response = {"data": execution_result.data} return FormattedResult(response, status_code) diff --git a/setup.cfg b/setup.cfg index 70e1f4a..78bddbd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,9 +4,14 @@ max-line-length = 88 [isort] known_first_party=graphql_server +multi_line_output=3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True [tool:pytest] norecursedirs = venv .venv .tox .git .cache .mypy_cache .pytest_cache +markers = asyncio [bdist_wheel] universal=1 diff --git a/setup.py b/setup.py index a6416c0..2bedab1 100644 --- a/setup.py +++ b/setup.py @@ -1,28 +1,27 @@ from setuptools import setup, find_packages install_requires = [ - "graphql-core>=2.3,<3", - "promise>=2.3,<3", + "graphql-core>=3.1.0,<4", ] tests_requires = [ - "pytest==4.6.9", - "pytest-cov==2.8.1" + "pytest>=5.3,<5.4", + "pytest-cov>=2.8,<3", ] dev_requires = [ - 'flake8==3.7.9', - 'isort<4.0.0', - 'black==19.10b0', - 'mypy==0.761', - 'check-manifest>=0.40,<1', + "flake8>=3.7,<4", + "isort>=4,<5", + "black==19.10b0", + "mypy>=0.761,<0.770", + "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=open("README.md", encoding="utf-8").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", @@ -33,14 +32,9 @@ "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", 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 index c60b0ed..c7665ba 100644 --- a/tests/schema.py +++ b/tests/schema.py @@ -1,15 +1,15 @@ -from graphql.type.definition import ( +from graphql import ( GraphQLArgument, GraphQLField, GraphQLNonNull, GraphQLObjectType, + GraphQLSchema, + GraphQLString, ) -from graphql.type.scalars import GraphQLString -from graphql.type.schema import GraphQLSchema -def resolve_error(*_args): - raise ValueError("Throws!") +def resolve_thrower(*_args): + raise Exception("Throws!") def resolve_request(_obj, info): @@ -20,22 +20,16 @@ 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), + "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_thrower), + "request": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_request), + "context": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_context), "test": GraphQLField( - GraphQLString, - {"who": GraphQLArgument(GraphQLString)}, - resolver=resolve_test, + type_=GraphQLString, + args={"who": GraphQLArgument(GraphQLString)}, + resolve=lambda obj, info, who="World": "Hello %s" % who, ), }, ) @@ -43,10 +37,9 @@ def resolve_test(_obj, _info, who="World"): MutationRootType = GraphQLObjectType( name="MutationRoot", fields={ - "writeTest": GraphQLField( - type=QueryRootType, resolver=lambda *_args: QueryRootType - ) + "writeTest": GraphQLField(type_=QueryRootType, resolve=lambda *_: QueryRootType) }, ) schema = GraphQLSchema(QueryRootType, MutationRootType) +invalid_schema = GraphQLSchema() diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index db8fc02..e07a2f8 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -1,11 +1,14 @@ -from graphql.execution.executors.asyncio import AsyncioExecutor -from graphql.type.definition import GraphQLField, GraphQLNonNull, GraphQLObjectType +import asyncio + +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 graphql_server import GraphQLParams, run_http_query from .utils import as_dicts @@ -33,10 +36,10 @@ async def resolve_field_async(_obj, info): 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), + "errorSync": GraphQLField(NonNullString, resolve=resolve_error_sync), + "errorAsync": GraphQLField(NonNullString, resolve=resolve_error_async), + "fieldSync": GraphQLField(NonNullString, resolve=resolve_field_sync), + "fieldAsync": GraphQLField(NonNullString, resolve=resolve_field_async), }, ) @@ -44,45 +47,25 @@ async def resolve_field_async(_obj, info): 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, + schema, "get", {}, dict(query=query), run_sync=False ) - results = await Promise.all(result_promises) - return results, params + res = [await result for result in result_promises] + return res, params - results, params = loop.run_until_complete(get_results()) + try: + results, params = loop.run_until_complete(get_results()) + finally: + loop.close() - expected_results = [{"data": {"fieldSync": "sync", "fieldAsync": "async"}}] + expected_results = [ + {"data": {"fieldSync": "sync", "fieldAsync": "async"}, "errors": None} + ] 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 + assert params == [GraphQLParams(query=query, variables=None, operation_name=None)] diff --git a/tests/test_error.py b/tests/test_error.py index a0f7017..4dfdc93 100644 --- a/tests/test_error.py +++ b/tests/test_error.py @@ -1,28 +1,34 @@ 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_can_create_http_query_error(): + error = HttpQueryError(400, "Bad error") + assert error.status_code == 400 + assert error.message == "Bad error" + assert not error.is_graphql_error + assert error.headers is None 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"}) + error = HttpQueryError(400, "Bad error") + assert error == error + same_error = HttpQueryError(400, "Bad error") + assert error == same_error + different_error = HttpQueryError(400, "Not really bad error") + assert error != different_error + different_error = HttpQueryError(405, "Bad error") + assert error != different_error + different_error = HttpQueryError(400, "Bad error", headers={"Allow": "ALL"}) + assert error != different_error 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"})) + errors = { + HttpQueryError(400, "Bad error 1"), + HttpQueryError(400, "Bad error 2"), + HttpQueryError(403, "Bad error 1"), + } + assert HttpQueryError(400, "Bad error 1") in errors + assert HttpQueryError(400, "Bad error 2") in errors + assert HttpQueryError(403, "Bad error 1") in errors + assert HttpQueryError(403, "Bad error 2") not in errors diff --git a/tests/test_helpers.py b/tests/test_helpers.py index fc4b73e..d2c6b50 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,8 +1,8 @@ import json +from graphql import Source from graphql.error import GraphQLError from graphql.execution import ExecutionResult -from graphql.language.location import SourceLocation from pytest import raises from graphql_server import ( @@ -20,11 +20,6 @@ def test_json_encode(): 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}"}' @@ -88,7 +83,10 @@ def test_encode_execution_results_with_error(): None, [ GraphQLError( - "Some error", locations=[SourceLocation(1, 2)], path=["somePath"] + "Some error", + source=Source(body="Some error"), + positions=[1], + path=["somePath"], ) ], ), @@ -100,7 +98,6 @@ def test_encode_execution_results_with_error(): assert isinstance(output.body, str) assert isinstance(output.status_code, int) assert json.loads(output.body) == { - "data": None, "errors": [ { "message": "Some error", @@ -109,26 +106,6 @@ def test_encode_execution_results_with_error(): } ], } - 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 @@ -149,7 +126,10 @@ def test_encode_execution_results_with_format_error(): None, [ GraphQLError( - "Some msg", locations=[SourceLocation(1, 2)], path=["some", "path"] + "Some msg", + source=Source("Some msg"), + positions=[1], + path=["some", "path"], ) ], ) @@ -157,7 +137,7 @@ def test_encode_execution_results_with_format_error(): def format_error(error): return { - "msg": str(error), + "msg": error.message, "loc": "{}:{}".format(error.locations[0].line, error.locations[0].column), "pth": "/".join(error.path), } @@ -167,10 +147,9 @@ def format_error(error): 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 + assert output.status_code == 400 def test_encode_execution_results_with_batch(): @@ -211,88 +190,6 @@ def test_encode_execution_results_with_batch_and_empty_result(): 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)] @@ -307,7 +204,7 @@ def encode(result): assert output.status_code == 200 -def test_encode_execution_results_with_pretty(): +def test_encode_execution_results_with_pretty_encode(): execution_results = [ExecutionResult({"test": "Hello World"}, None)] output = encode_execution_results(execution_results, encode=json_encode_pretty) diff --git a/tests/test_query.py b/tests/test_query.py index e5bbb79..5e9618c 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -1,43 +1,59 @@ import json -from graphql.error import GraphQLError, GraphQLSyntaxError +from graphql.error import GraphQLError from graphql.execution import ExecutionResult -from promise import Promise from pytest import raises from graphql_server import ( + GraphQLParams, + GraphQLResponse, HttpQueryError, - RequestParams, - ServerResults, encode_execution_results, + format_execution_result, json_encode, - json_encode_pretty, load_json_body, run_http_query, ) -from .schema import schema +from .schema import invalid_schema, schema from .utils import as_dicts def test_request_params(): - assert issubclass(RequestParams, tuple) + assert issubclass(GraphQLParams, tuple) # noinspection PyUnresolvedReferences - assert RequestParams._fields == ("query", "variables", "operation_name") + assert GraphQLParams._fields == ("query", "variables", "operation_name") def test_server_results(): - assert issubclass(ServerResults, tuple) + assert issubclass(GraphQLResponse, tuple) # noinspection PyUnresolvedReferences - assert ServerResults._fields == ("results", "params") + assert GraphQLResponse._fields == ("results", "params") + + +def test_validate_schema(): + query = "{test}" + results, params = run_http_query(invalid_schema, "get", {}, dict(query=query)) + assert as_dicts(results) == [ + { + "data": None, + "errors": [ + { + "locations": None, + "message": "Query root type must be provided.", + "path": None, + } + ], + } + ] 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)] + assert as_dicts(results) == [{"data": {"test": "Hello World"}, "errors": None}] + assert params == [GraphQLParams(query=query, variables=None, operation_name=None)] def test_allows_get_with_variable_values(): @@ -51,7 +67,7 @@ def test_allows_get_with_variable_values(): ), ) - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] + assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}, "errors": None}] def test_allows_get_with_operation_name(): @@ -73,7 +89,7 @@ def test_allows_get_with_operation_name(): ) assert as_dicts(results) == [ - {"data": {"test": "Hello World", "shared": "Hello Everyone"}} + {"data": {"test": "Hello World", "shared": "Hello Everyone"}, "errors": None} ] @@ -84,16 +100,19 @@ def test_reports_validation_errors(): assert as_dicts(results) == [ { + "data": None, "errors": [ { - "message": 'Cannot query field "unknownOne" on type "QueryRoot".', + "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 9}], + "path": None, }, { - "message": 'Cannot query field "unknownTwo" on type "QueryRoot".', + "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 21}], + "path": None, }, - ] + ], } ] @@ -132,14 +151,17 @@ def test_errors_when_missing_operation_name(): assert as_dicts(results) == [ { + "data": None, "errors": [ { + "locations": None, "message": ( "Must provide operation name" " if query contains multiple operations." - ) + ), + "path": None, } - ] + ], } ] assert isinstance(results[0].errors[0], GraphQLError) @@ -217,7 +239,7 @@ def test_allows_mutation_to_exist_within_a_get(): ), ) - assert as_dicts(results) == [{"data": {"test": "Hello World"}}] + assert as_dicts(results) == [{"data": {"test": "Hello World"}, "errors": None}] def test_allows_sending_a_mutation_via_post(): @@ -228,7 +250,7 @@ def test_allows_sending_a_mutation_via_post(): query_data=dict(query="mutation TestMutation { writeTest { test } }"), ) - assert as_dicts(results) == [{"data": {"writeTest": {"test": "Hello World"}}}] + assert results == [({"writeTest": {"test": "Hello World"}}, None)] def test_allows_post_with_url_encoding(): @@ -236,7 +258,7 @@ def test_allows_post_with_url_encoding(): schema, "post", {}, query_data=dict(query="{test}") ) - assert as_dicts(results) == [{"data": {"test": "Hello World"}}] + assert results == [({"test": "Hello World"}, None)] def test_supports_post_json_query_with_string_variables(): @@ -250,7 +272,20 @@ def test_supports_post_json_query_with_string_variables(): ), ) - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] + assert results == [({"test": "Hello Dolly"}, None)] + + +def test_supports_post_json_query_with_json_variables(): + result = load_json_body( + """ + { + "query": "query helloWho($who: String){ test(who: $who) }", + "variables": {"who": "Dolly"} + } + """ + ) + + assert result["variables"] == {"who": "Dolly"} def test_supports_post_url_encoded_query_with_string_variables(): @@ -264,7 +299,7 @@ def test_supports_post_url_encoded_query_with_string_variables(): ), ) - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] + assert results == [({"test": "Hello Dolly"}, None)] def test_supports_post_json_query_with_get_variable_values(): @@ -275,7 +310,7 @@ def test_supports_post_json_query_with_get_variable_values(): query_data=dict(variables={"who": "Dolly"}), ) - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] + assert results == [({"test": "Hello Dolly"}, None)] def test_post_url_encoded_query_with_get_variable_values(): @@ -286,7 +321,7 @@ def test_post_url_encoded_query_with_get_variable_values(): query_data=dict(variables='{"who": "Dolly"}'), ) - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] + assert results == [({"test": "Hello Dolly"}, None)] def test_supports_post_raw_text_query_with_get_variable_values(): @@ -297,7 +332,7 @@ def test_supports_post_raw_text_query_with_get_variable_values(): query_data=dict(variables='{"who": "Dolly"}'), ) - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] + assert results == [({"test": "Hello Dolly"}, None)] def test_allows_post_with_operation_name(): @@ -317,9 +352,7 @@ def test_allows_post_with_operation_name(): ), ) - assert as_dicts(results) == [ - {"data": {"test": "Hello World", "shared": "Hello Everyone"}} - ] + assert results == [({"test": "Hello World", "shared": "Hello Everyone"}, None)] def test_allows_post_with_get_operation_name(): @@ -339,55 +372,46 @@ def test_allows_post_with_get_operation_name(): query_data=dict(operationName="helloWorld"), ) - assert as_dicts(results) == [ - {"data": {"test": "Hello World", "shared": "Hello Everyone"}} - ] + assert results == [({"test": "Hello World", "shared": "Hello Everyone"}, None)] 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 + results, params = run_http_query(schema, "get", data=dict(query="{test}")) + result = {"data": results[0].data} - assert body == "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + assert json_encode(result, pretty=True) == ( + "{\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 + results, params = run_http_query(schema, "get", data=dict(query="{test}")) + result = {"data": results[0].data} - assert body == '{"data":{"test":"Hello World"}}' + assert json_encode(result) == '{"data":{"test":"Hello World"}}' def test_handles_field_errors_caught_by_graphql(): - results, params = run_http_query(schema, "get", dict(query="{error}")) + results, params = run_http_query(schema, "get", data=dict(query="{thrower}")) - assert as_dicts(results) == [ - { - "data": None, - "errors": [ - { - "message": "Throws!", - "locations": [{"line": 1, "column": 2}], - "path": ["error"], - } - ], - } + assert results == [ + (None, [{"message": "Throws!", "locations": [(1, 2)], "path": ["thrower"]}]) ] def test_handles_syntax_errors_caught_by_graphql(): - results, params = run_http_query(schema, "get", dict(query="syntaxerror")) + results, params = run_http_query(schema, "get", data=dict(query="syntaxerror")) - assert as_dicts(results) == [ - { - "errors": [ + assert results == [ + ( + None, + [ { - "locations": [{"line": 1, "column": 1}], - "message": "Syntax Error GraphQL (1:1)" - ' Unexpected Name "syntaxerror"\n\n1: syntaxerror\n ^\n', + "locations": [(1, 1)], + "message": "Syntax Error: Unexpected Name 'syntaxerror'.", } - ] - } + ], + ) ] @@ -400,10 +424,7 @@ def test_handles_errors_caused_by_a_lack_of_query(): 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"}]} - ] + assert results == [(None, [{"message": "Must provide Source. Received: 42."}])] def test_handles_batch_correctly_if_is_disabled(): @@ -447,10 +468,11 @@ def test_handles_poorly_formed_variables(): def test_handles_bad_schema(): with raises(TypeError) as exc_info: # noinspection PyTypeChecker - run_http_query("not a schema", "get", {"query": "{error}"}) # type: ignore + run_http_query("not a schema", "get", {}) # type: ignore - msg = str(exc_info.value) - assert msg == "Expected a GraphQL schema, but received 'not a schema'." + assert str(exc_info.value) == ( + "Expected a GraphQL schema, but received 'not a schema'." + ) def test_handles_unsupported_http_methods(): @@ -464,12 +486,54 @@ def test_handles_unsupported_http_methods(): ) +def test_format_execution_result(): + result = format_execution_result(None) + assert result == GraphQLResponse(None, 200) + data = {"answer": 42} + result = format_execution_result(ExecutionResult(data, None)) + assert result == GraphQLResponse({"data": data}, 200) + errors = [GraphQLError("bad")] + result = format_execution_result(ExecutionResult(None, errors)) + assert result == GraphQLResponse({"errors": errors}, 400) + + +def test_encode_execution_results(): + data = {"answer": 42} + errors = [GraphQLError("bad")] + results = [ExecutionResult(data, None), ExecutionResult(None, errors)] + result = encode_execution_results(results) + assert result == ('{"data":{"answer":42}}', 400) + + +def test_encode_execution_results_batch(): + data = {"answer": 42} + errors = [GraphQLError("bad")] + results = [ExecutionResult(data, None), ExecutionResult(None, errors)] + result = encode_execution_results(results, is_batch=True) + assert result == ( + '[{"data":{"answer":42}},' + '{"errors":[{"message":"bad","locations":null,"path":null}]}]', + 400, + ) + + +def test_encode_execution_results_not_encoded(): + data = {"answer": 42} + results = [ExecutionResult(data, None)] + result = encode_execution_results(results, encode=lambda r: r) + assert result == ({"data": data}, 200) + + def test_passes_request_into_request_context(): results, params = run_http_query( - schema, "get", {}, dict(query="{request}"), context_value={"q": "testing"} + schema, + "get", + {}, + query_data=dict(query="{request}"), + context_value={"q": "testing"}, ) - assert as_dicts(results) == [{"data": {"request": "testing"}}] + assert results == [({"request": "testing"}, None)] def test_supports_pretty_printing_context(): @@ -478,24 +542,24 @@ def __str__(self): return "CUSTOM CONTEXT" results, params = run_http_query( - schema, "get", {}, dict(query="{context}"), context_value=Context() + schema, "get", {}, query_data=dict(query="{context}"), context_value=Context() ) - assert as_dicts(results) == [{"data": {"context": "CUSTOM CONTEXT"}}] + assert results == [({"context": "CUSTOM CONTEXT"}, None)] 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"}}}] + assert results == [({"writeTest": {"test": "Hello World"}}, None)] 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"}}] + assert results == [({"test": "Hello World"}, None)] def test_batch_supports_post_json_query_with_json_variables(): @@ -505,7 +569,7 @@ def test_batch_supports_post_json_query_with_json_variables(): ) results, params = run_http_query(schema, "post", data, batch_enabled=True) - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] + assert results == [({"test": "Hello Dolly"}, None)] def test_batch_allows_post_with_operation_name(): @@ -525,124 +589,4 @@ def test_batch_allows_post_with_operation_name(): 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 + assert results == [({"test": "Hello World", "shared": "Hello Everyone"}, None)] diff --git a/tests/utils.py b/tests/utils.py index 136f09f..895c777 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,3 +1,16 @@ -def as_dicts(results): +from typing import List + +from graphql import ExecutionResult + + +def as_dicts(results: List[ExecutionResult]): """Convert execution results to a list of tuples of dicts for better comparison.""" - return [result.to_dict(dict_class=dict) for result in results] + return [ + { + "data": result.data, + "errors": [error.formatted for error in result.errors] + if result.errors + else result.errors, + } + for result in results + ] diff --git a/tox.ini b/tox.ini index 77a2bb6..2453c8b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = black,flake8,import-order,mypy,manifest, - py{27,35,36,37,38,39-dev,py,py3} + py{36,37,38,39-dev} ; requires = tox-conda [testenv] @@ -17,31 +17,31 @@ commands = pytest --cov-report=term-missing --cov=graphql_server tests {posargs} [testenv:black] -basepython=python3.6 +basepython=python3.7 deps = -e.[dev] commands = black --check graphql_server tests [testenv:flake8] -basepython=python3.6 +basepython=python3.7 deps = -e.[dev] commands = flake8 setup.py graphql_server tests [testenv:import-order] -basepython=python3.6 +basepython=python3.7 deps = -e.[dev] commands = isort -rc graphql_server/ tests/ [testenv:mypy] -basepython=python3.6 +basepython=python3.7 deps = -e.[dev] commands = mypy graphql_server tests --ignore-missing-imports [testenv:manifest] -basepython = python3.6 +basepython = python3.7 deps = -e.[dev] commands = check-manifest -v From 66b8a2bf13d29e70d1ea424f0588a176ad988c00 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Tue, 5 May 2020 08:36:33 -0500 Subject: [PATCH 026/108] Merge flask-graphql (#37) * refactor: add flask-graphql as optional feature * refactor(server): default_format_error to __all__ * chore: rename dir flask-graphql to flask * chore: add extras require all key * chore: update gitignore * fix(sc): move params query check to try-except * refactor(flask): remove unused backend param * tests(flask): graphiqlview and graphqlview * styles: apply black, isort, flake8 formatting * chore: add all requires to test env * chore(flask): remove blueprint module * refactor(flask): remove py27 imports and unused test * styles: apply black, isort and flake8 formatting --- .gitignore | 208 ++++++++- graphql_server/__init__.py | 7 +- graphql_server/flask/__init__.py | 3 + graphql_server/flask/graphqlview.py | 151 ++++++ graphql_server/flask/render_graphiql.py | 148 ++++++ setup.py | 14 +- tests/flask/__init__.py | 0 tests/flask/app.py | 18 + tests/flask/schema.py | 41 ++ tests/flask/test_graphiqlview.py | 60 +++ tests/flask/test_graphqlview.py | 581 ++++++++++++++++++++++++ 11 files changed, 1213 insertions(+), 18 deletions(-) create mode 100644 graphql_server/flask/__init__.py create mode 100644 graphql_server/flask/graphqlview.py create mode 100644 graphql_server/flask/render_graphiql.py create mode 100644 tests/flask/__init__.py create mode 100644 tests/flask/app.py create mode 100644 tests/flask/schema.py create mode 100644 tests/flask/test_graphiqlview.py create mode 100644 tests/flask/test_graphqlview.py diff --git a/.gitignore b/.gitignore index 608847c..1789e38 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,203 @@ -*.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 + +# 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/ .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 + +# 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/graphql_server/__init__.py b/graphql_server/__init__.py index 29efffa..c4685c0 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -30,6 +30,7 @@ "GraphQLResponse", "ServerResponse", "format_execution_result", + "format_error_default", ] @@ -230,11 +231,11 @@ def get_response( as a parameter. """ - if not params.query: - raise HttpQueryError(400, "Must provide query string.") - # noinspection PyBroadException try: + if not params.query: + raise HttpQueryError(400, "Must provide query string.") + # Parse document to trigger a new HttpQueryError if allow_only_query is True try: document = parse(params.query) diff --git a/graphql_server/flask/__init__.py b/graphql_server/flask/__init__.py new file mode 100644 index 0000000..8f5beaf --- /dev/null +++ b/graphql_server/flask/__init__.py @@ -0,0 +1,3 @@ +from .graphqlview import GraphQLView + +__all__ = ["GraphQLView"] diff --git a/graphql_server/flask/graphqlview.py b/graphql_server/flask/graphqlview.py new file mode 100644 index 0000000..d1d971a --- /dev/null +++ b/graphql_server/flask/graphqlview.py @@ -0,0 +1,151 @@ +from functools import partial + +from flask import Response, request +from flask.views import View +from graphql.error import GraphQLError +from graphql.type.schema import GraphQLSchema + +from graphql_server import ( + HttpQueryError, + encode_execution_results, + format_error_default, + json_encode, + load_json_body, + run_http_query, +) + +from .render_graphiql import render_graphiql + + +class GraphQLView(View): + schema = None + executor = None + root_value = None + pretty = False + graphiql = False + graphiql_version = None + graphiql_template = None + graphiql_html_title = None + middleware = None + batch = False + + methods = ["GET", "POST", "PUT", "DELETE"] + + def __init__(self, **kwargs): + super(GraphQLView, self).__init__() + for key, value in kwargs.items(): + if hasattr(self, key): + setattr(self, key, value) + + assert isinstance( + self.schema, GraphQLSchema + ), "A Schema is required to be provided to GraphQLView." + + # noinspection PyUnusedLocal + def get_root_value(self): + return self.root_value + + def get_context_value(self): + return request + + def get_middleware(self): + return self.middleware + + def get_executor(self): + return self.executor + + def render_graphiql(self, params, result): + return render_graphiql( + params=params, + result=result, + graphiql_version=self.graphiql_version, + graphiql_template=self.graphiql_template, + graphiql_html_title=self.graphiql_html_title, + ) + + format_error = staticmethod(format_error_default) + encode = staticmethod(json_encode) + + def dispatch_request(self): + try: + request_method = request.method.lower() + data = self.parse_body() + + show_graphiql = request_method == "get" and self.should_display_graphiql() + catch = show_graphiql + + pretty = self.pretty or show_graphiql or request.args.get("pretty") + + extra_options = {} + executor = self.get_executor() + if executor: + # We only include it optionally since + # executor is not a valid argument in all backends + extra_options["executor"] = executor + + execution_results, all_params = run_http_query( + self.schema, + request_method, + data, + query_data=request.args, + batch_enabled=self.batch, + catch=catch, + # Execute options + root_value=self.get_root_value(), + context_value=self.get_context_value(), + middleware=self.get_middleware(), + **extra_options + ) + result, status_code = encode_execution_results( + execution_results, + is_batch=isinstance(data, list), + format_error=self.format_error, + encode=partial(self.encode, pretty=pretty), + ) + + if show_graphiql: + return self.render_graphiql(params=all_params[0], result=result) + + return Response(result, status=status_code, content_type="application/json") + + except HttpQueryError as e: + parsed_error = GraphQLError(e.message) + return Response( + self.encode(dict(errors=[self.format_error(parsed_error)])), + status=e.status_code, + headers=e.headers, + content_type="application/json", + ) + + # Flask + def parse_body(self): + # We use mimetype here since we don't need the other + # information provided by content_type + content_type = request.mimetype + if content_type == "application/graphql": + return {"query": request.data.decode("utf8")} + + elif content_type == "application/json": + return load_json_body(request.data.decode("utf8")) + + elif content_type in ( + "application/x-www-form-urlencoded", + "multipart/form-data", + ): + return request.form + + return {} + + def should_display_graphiql(self): + if not self.graphiql or "raw" in request.args: + return False + + return self.request_wants_html() + + def request_wants_html(self): + best = request.accept_mimetypes.best_match(["application/json", "text/html"]) + return ( + best == "text/html" + and request.accept_mimetypes[best] + > request.accept_mimetypes["application/json"] + ) diff --git a/graphql_server/flask/render_graphiql.py b/graphql_server/flask/render_graphiql.py new file mode 100644 index 0000000..d395d44 --- /dev/null +++ b/graphql_server/flask/render_graphiql.py @@ -0,0 +1,148 @@ +from flask import render_template_string + +GRAPHIQL_VERSION = "0.11.11" + +TEMPLATE = """ + + + + {{graphiql_html_title|default("GraphiQL", true)}} + + + + + + + + + + + +""" + + +def render_graphiql( + params, + result, + graphiql_version=None, + graphiql_template=None, + graphiql_html_title=None, +): + graphiql_version = graphiql_version or GRAPHIQL_VERSION + template = graphiql_template or TEMPLATE + + return render_template_string( + template, + graphiql_version=graphiql_version, + graphiql_html_title=graphiql_html_title, + result=result, + params=params, + ) diff --git a/setup.py b/setup.py index 2bedab1..d8568a9 100644 --- a/setup.py +++ b/setup.py @@ -17,6 +17,12 @@ "check-manifest>=0.40,<1", ] + tests_requires +install_flask_requires = [ + "flask>=0.7.0", +] + +install_all_requires = install_requires + install_flask_requires + setup( name="graphql-server-core", version="2.0.0", @@ -40,10 +46,12 @@ keywords="api graphql protocol rest", packages=find_packages(exclude=["tests"]), install_requires=install_requires, - tests_require=tests_requires, + tests_require=install_all_requires + tests_requires, extras_require={ - 'test': tests_requires, - 'dev': dev_requires, + "all": install_all_requires, + "test": install_all_requires + tests_requires, + "dev": dev_requires, + "flask": install_flask_requires, }, include_package_data=True, zip_safe=False, diff --git a/tests/flask/__init__.py b/tests/flask/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/flask/app.py b/tests/flask/app.py new file mode 100644 index 0000000..01f6fa8 --- /dev/null +++ b/tests/flask/app.py @@ -0,0 +1,18 @@ +from flask import Flask + +from graphql_server.flask import GraphQLView +from tests.flask.schema import Schema + + +def create_app(path="/graphql", **kwargs): + app = Flask(__name__) + app.debug = True + app.add_url_rule( + path, view_func=GraphQLView.as_view("graphql", schema=Schema, **kwargs) + ) + return app + + +if __name__ == "__main__": + app = create_app(graphiql=True) + app.run() diff --git a/tests/flask/schema.py b/tests/flask/schema.py new file mode 100644 index 0000000..5d4c52c --- /dev/null +++ b/tests/flask/schema.py @@ -0,0 +1,41 @@ +from graphql.type.definition import ( + GraphQLArgument, + GraphQLField, + GraphQLNonNull, + GraphQLObjectType, +) +from graphql.type.scalars import GraphQLString +from graphql.type.schema import GraphQLSchema + + +def resolve_raises(*_): + raise Exception("Throws!") + + +QueryRootType = GraphQLObjectType( + name="QueryRoot", + fields={ + "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_raises), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context.args.get("q"), + ), + "context": GraphQLField( + GraphQLNonNull(GraphQLString), resolve=lambda obj, info: info.context + ), + "test": GraphQLField( + type_=GraphQLString, + args={"who": GraphQLArgument(GraphQLString)}, + resolve=lambda obj, info, who="World": "Hello %s" % who, + ), + }, +) + +MutationRootType = GraphQLObjectType( + name="MutationRoot", + fields={ + "writeTest": GraphQLField(type_=QueryRootType, resolve=lambda *_: QueryRootType) + }, +) + +Schema = GraphQLSchema(QueryRootType, MutationRootType) diff --git a/tests/flask/test_graphiqlview.py b/tests/flask/test_graphiqlview.py new file mode 100644 index 0000000..4a55710 --- /dev/null +++ b/tests/flask/test_graphiqlview.py @@ -0,0 +1,60 @@ +import pytest +from flask import url_for + +from .app import create_app + + +@pytest.fixture +def app(): + # import app factory pattern + app = create_app(graphiql=True) + + # pushes an application context manually + ctx = app.app_context() + ctx.push() + return app + + +@pytest.fixture +def client(app): + return app.test_client() + + +def test_graphiql_is_enabled(app, client): + with app.test_request_context(): + response = client.get( + url_for("graphql", externals=False), headers={"Accept": "text/html"} + ) + assert response.status_code == 200 + + +def test_graphiql_renders_pretty(app, client): + with app.test_request_context(): + response = client.get( + url_for("graphql", query="{test}"), headers={"Accept": "text/html"} + ) + assert response.status_code == 200 + pretty_response = ( + "{\n" + ' "data": {\n' + ' "test": "Hello World"\n' + " }\n" + "}".replace('"', '\\"').replace("\n", "\\n") + ) + + assert pretty_response in response.data.decode("utf-8") + + +def test_graphiql_default_title(app, client): + with app.test_request_context(): + response = client.get(url_for("graphql"), headers={"Accept": "text/html"}) + assert "GraphiQL" in response.data.decode("utf-8") + + +@pytest.mark.parametrize( + "app", [create_app(graphiql=True, graphiql_html_title="Awesome")] +) +def test_graphiql_custom_title(app, client): + with app.test_request_context(): + response = client.get(url_for("graphql"), headers={"Accept": "text/html"}) + assert "Awesome" in response.data.decode("utf-8") diff --git a/tests/flask/test_graphqlview.py b/tests/flask/test_graphqlview.py new file mode 100644 index 0000000..0f65072 --- /dev/null +++ b/tests/flask/test_graphqlview.py @@ -0,0 +1,581 @@ +import json +from io import StringIO +from urllib.parse import urlencode + +import pytest +from flask import url_for + +from .app import create_app + + +@pytest.fixture +def app(request): + # import app factory pattern + app = create_app() + + # pushes an application context manually + ctx = app.app_context() + ctx.push() + return app + + +@pytest.fixture +def client(app): + return app.test_client() + + +def url_string(app, **url_params): + with app.test_request_context(): + string = url_for("graphql") + + if url_params: + string += "?" + urlencode(url_params) + + return string + + +def response_json(response): + return json.loads(response.data.decode()) + + +def json_dump_kwarg(**kwargs): + return json.dumps(kwargs) + + +def json_dump_kwarg_list(**kwargs): + return json.dumps([kwargs]) + + +def test_allows_get_with_query_param(app, client): + response = client.get(url_string(app, query="{test}")) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_allows_get_with_variable_values(app, client): + response = client.get( + url_string( + app, + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_allows_get_with_operation_name(app, client): + response = client.get( + url_string( + app, + 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 response.status_code == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +def test_reports_validation_errors(app, client): + response = client.get(url_string(app, query="{ test, unknownOne, unknownTwo }")) + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 9}], + "path": None, + }, + { + "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 21}], + "path": None, + }, + ] + } + + +def test_errors_when_missing_operation_name(app, client): + response = client.get( + url_string( + app, + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + ) + ) + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Must provide operation name if query contains multiple operations.", # noqa: E501 + "locations": None, + "path": None, + } + ] + } + + +def test_errors_when_sending_a_mutation_via_get(app, client): + response = client.get( + url_string( + app, + query=""" + mutation TestMutation { writeTest { test } } + """, + ) + ) + assert response.status_code == 405 + assert response_json(response) == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + "locations": None, + "path": None, + } + ] + } + + +def test_errors_when_selecting_a_mutation_within_a_get(app, client): + response = client.get( + url_string( + app, + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestMutation", + ) + ) + + assert response.status_code == 405 + assert response_json(response) == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + "locations": None, + "path": None, + } + ] + } + + +def test_allows_mutation_to_exist_within_a_get(app, client): + response = client.get( + url_string( + app, + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestQuery", + ) + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_allows_post_with_json_encoding(app, client): + response = client.post( + url_string(app), + data=json_dump_kwarg(query="{test}"), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_allows_sending_a_mutation_via_post(app, client): + response = client.post( + url_string(app), + data=json_dump_kwarg(query="mutation TestMutation { writeTest { test } }"), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} + + +def test_allows_post_with_url_encoding(app, client): + response = client.post( + url_string(app), + data=urlencode(dict(query="{test}")), + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_supports_post_json_query_with_string_variables(app, client): + response = client.post( + url_string(app), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_json_query_with_json_variables(app, client): + response = client.post( + url_string(app), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_url_encoded_query_with_string_variables(app, client): + response = client.post( + url_string(app), + data=urlencode( + dict( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + ), + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_json_quey_with_get_variable_values(app, client): + response = client.post( + url_string(app, variables=json.dumps({"who": "Dolly"})), + data=json_dump_kwarg(query="query helloWho($who: String){ test(who: $who) }",), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_post_url_encoded_query_with_get_variable_values(app, client): + response = client.post( + url_string(app, variables=json.dumps({"who": "Dolly"})), + data=urlencode(dict(query="query helloWho($who: String){ test(who: $who) }",)), + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_raw_text_query_with_get_variable_values(app, client): + response = client.post( + url_string(app, variables=json.dumps({"who": "Dolly"})), + data="query helloWho($who: String){ test(who: $who) }", + content_type="application/graphql", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_allows_post_with_operation_name(app, client): + response = client.post( + url_string(app), + data=json_dump_kwarg( + 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", + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +def test_allows_post_with_get_operation_name(app, client): + response = client.post( + url_string(app, operationName="helloWorld"), + data=""" + 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") + } + """, + content_type="application/graphql", + ) + + assert response.status_code == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.parametrize("app", [create_app(pretty=True)]) +def test_supports_pretty_printing(app, client): + response = client.get(url_string(app, query="{test}")) + + assert response.data.decode() == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) + + +@pytest.mark.parametrize("app", [create_app(pretty=False)]) +def test_not_pretty_by_default(app, client): + response = client.get(url_string(app, query="{test}")) + + assert response.data.decode() == '{"data":{"test":"Hello World"}}' + + +def test_supports_pretty_printing_by_request(app, client): + response = client.get(url_string(app, query="{test}", pretty="1")) + + assert response.data.decode() == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) + + +def test_handles_field_errors_caught_by_graphql(app, client): + response = client.get(url_string(app, query="{thrower}")) + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "locations": [{"column": 2, "line": 1}], + "path": ["thrower"], + "message": "Throws!", + } + ] + } + + +def test_handles_syntax_errors_caught_by_graphql(app, client): + response = client.get(url_string(app, query="syntaxerror")) + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "locations": [{"column": 1, "line": 1}], + "message": "Syntax Error: Unexpected Name 'syntaxerror'.", + "path": None, + } + ] + } + + +def test_handles_errors_caused_by_a_lack_of_query(app, client): + response = client.get(url_string(app)) + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + {"message": "Must provide query string.", "locations": None, "path": None} + ] + } + + +def test_handles_batch_correctly_if_is_disabled(app, client): + response = client.post(url_string(app), data="[]", content_type="application/json") + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Batch GraphQL requests are not enabled.", + "locations": None, + "path": None, + } + ] + } + + +def test_handles_incomplete_json_bodies(app, client): + response = client.post( + url_string(app), data='{"query":', content_type="application/json" + ) + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + {"message": "POST body sent invalid JSON.", "locations": None, "path": None} + ] + } + + +def test_handles_plain_post_text(app, client): + response = client.post( + url_string(app, variables=json.dumps({"who": "Dolly"})), + data="query helloWho($who: String){ test(who: $who) }", + content_type="text/plain", + ) + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + {"message": "Must provide query string.", "locations": None, "path": None} + ] + } + + +def test_handles_poorly_formed_variables(app, client): + response = client.get( + url_string( + app, + query="query helloWho($who: String){ test(who: $who) }", + variables="who:You", + ) + ) + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + {"message": "Variables are invalid JSON.", "locations": None, "path": None} + ] + } + + +def test_handles_unsupported_http_methods(app, client): + response = client.put(url_string(app, query="{test}")) + assert response.status_code == 405 + assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] + assert response_json(response) == { + "errors": [ + { + "message": "GraphQL only supports GET and POST requests.", + "locations": None, + "path": None, + } + ] + } + + +def test_passes_request_into_request_context(app, client): + response = client.get(url_string(app, query="{request}", q="testing")) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"request": "testing"}} + + +@pytest.mark.parametrize( + "app", [create_app(get_context_value=lambda: "CUSTOM CONTEXT")] +) +def test_passes_custom_context_into_context(app, client): + response = client.get(url_string(app, query="{context}")) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"context": "CUSTOM CONTEXT"}} + + +def test_post_multipart_data(app, client): + query = "mutation TestMutation { writeTest { test } }" + response = client.post( + url_string(app), + data={"query": query, "file": (StringIO(), "text1.txt")}, + content_type="multipart/form-data", + ) + + assert response.status_code == 200 + assert response_json(response) == { + "data": {u"writeTest": {u"test": u"Hello World"}} + } + + +@pytest.mark.parametrize("app", [create_app(batch=True)]) +def test_batch_allows_post_with_json_encoding(app, client): + response = client.post( + url_string(app), + data=json_dump_kwarg_list( + # id=1, + query="{test}" + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == [ + { + # 'id': 1, + "data": {"test": "Hello World"} + } + ] + + +@pytest.mark.parametrize("app", [create_app(batch=True)]) +def test_batch_supports_post_json_query_with_json_variables(app, client): + response = client.post( + url_string(app), + data=json_dump_kwarg_list( + # id=1, + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == [ + { + # 'id': 1, + "data": {"test": "Hello Dolly"} + } + ] + + +@pytest.mark.parametrize("app", [create_app(batch=True)]) +def test_batch_allows_post_with_operation_name(app, client): + response = client.post( + url_string(app), + data=json_dump_kwarg_list( + # id=1, + 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", + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == [ + { + # 'id': 1, + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + ] From ea817040b585e103575c6e611b09dcb65dde3627 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sun, 10 May 2020 19:42:05 +0100 Subject: [PATCH 027/108] Remove references to executor in Flask view (#40) --- graphql_server/flask/graphqlview.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/graphql_server/flask/graphqlview.py b/graphql_server/flask/graphqlview.py index d1d971a..1a2f9af 100644 --- a/graphql_server/flask/graphqlview.py +++ b/graphql_server/flask/graphqlview.py @@ -19,7 +19,6 @@ class GraphQLView(View): schema = None - executor = None root_value = None pretty = False graphiql = False @@ -51,9 +50,6 @@ def get_context_value(self): def get_middleware(self): return self.middleware - def get_executor(self): - return self.executor - def render_graphiql(self, params, result): return render_graphiql( params=params, @@ -76,13 +72,6 @@ def dispatch_request(self): pretty = self.pretty or show_graphiql or request.args.get("pretty") - extra_options = {} - executor = self.get_executor() - if executor: - # We only include it optionally since - # executor is not a valid argument in all backends - extra_options["executor"] = executor - execution_results, all_params = run_http_query( self.schema, request_method, @@ -94,7 +83,6 @@ def dispatch_request(self): root_value=self.get_root_value(), context_value=self.get_context_value(), middleware=self.get_middleware(), - **extra_options ) result, status_code = encode_execution_results( execution_results, From 6c13ef6481e9d51657525cfabbbd2e637deb6d29 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sun, 10 May 2020 19:46:44 +0100 Subject: [PATCH 028/108] Return 200 errors (#39) --- graphql_server/__init__.py | 8 +++++++- setup.py | 2 +- tests/flask/test_graphqlview.py | 5 +++-- tests/test_helpers.py | 6 ++++-- tests/test_query.py | 9 +++++++++ 5 files changed, 24 insertions(+), 6 deletions(-) diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index c4685c0..4e5ad8f 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -294,7 +294,13 @@ def format_execution_result( if execution_result.errors: fe = [format_error(e) for e in execution_result.errors] # type: ignore response = {"errors": fe} - status_code = 400 + + if execution_result.errors and any( + not getattr(e, "path", None) for e in execution_result.errors + ): + status_code = 400 + else: + response["data"] = execution_result.data else: response = {"data": execution_result.data} diff --git a/setup.py b/setup.py index d8568a9..15397cc 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ extras_require={ "all": install_all_requires, "test": install_all_requires + tests_requires, - "dev": dev_requires, + "dev": install_all_requires + dev_requires, "flask": install_flask_requires, }, include_package_data=True, diff --git a/tests/flask/test_graphqlview.py b/tests/flask/test_graphqlview.py index 0f65072..d2f478d 100644 --- a/tests/flask/test_graphqlview.py +++ b/tests/flask/test_graphqlview.py @@ -371,7 +371,7 @@ def test_supports_pretty_printing_by_request(app, client): def test_handles_field_errors_caught_by_graphql(app, client): response = client.get(url_string(app, query="{thrower}")) - assert response.status_code == 400 + assert response.status_code == 200 assert response_json(response) == { "errors": [ { @@ -379,7 +379,8 @@ def test_handles_field_errors_caught_by_graphql(app, client): "path": ["thrower"], "message": "Throws!", } - ] + ], + "data": None, } diff --git a/tests/test_helpers.py b/tests/test_helpers.py index d2c6b50..ad62f62 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -105,8 +105,9 @@ def test_encode_execution_results_with_error(): "path": ["somePath"], } ], + "data": None, } - assert output.status_code == 400 + assert output.status_code == 200 def test_encode_execution_results_with_empty_result(): @@ -148,8 +149,9 @@ def format_error(error): assert isinstance(output.status_code, int) assert json.loads(output.body) == { "errors": [{"msg": "Some msg", "loc": "1:2", "pth": "some/path"}], + "data": None, } - assert output.status_code == 400 + assert output.status_code == 200 def test_encode_execution_results_with_batch(): diff --git a/tests/test_query.py b/tests/test_query.py index 5e9618c..7f5ab6f 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -92,6 +92,9 @@ def test_allows_get_with_operation_name(): {"data": {"test": "Hello World", "shared": "Hello Everyone"}, "errors": None} ] + response = encode_execution_results(results) + assert response.status_code == 200 + def test_reports_validation_errors(): results, params = run_http_query( @@ -116,6 +119,9 @@ def test_reports_validation_errors(): } ] + response = encode_execution_results(results) + assert response.status_code == 400 + def test_non_dict_params_in_non_batch_query(): with raises(HttpQueryError) as exc_info: @@ -398,6 +404,9 @@ def test_handles_field_errors_caught_by_graphql(): (None, [{"message": "Throws!", "locations": [(1, 2)], "path": ["thrower"]}]) ] + response = encode_execution_results(results) + assert response.status_code == 200 + def test_handles_syntax_errors_caught_by_graphql(): results, params = run_http_query(schema, "get", data=dict(query="syntaxerror")) From 35ed87d2372ed5443aa904ebeaa79d5d701f4bc0 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Sat, 6 Jun 2020 11:40:21 -0500 Subject: [PATCH 029/108] Merge sanic-graphql (#38) * refactor: add sanic-graphql as optional feature * refactor: sanic tests and remove executor parameter * styles: apply black and flake8 formatting --- graphql_server/sanic/__init__.py | 3 + graphql_server/sanic/graphqlview.py | 190 ++++++++ graphql_server/sanic/render_graphiql.py | 185 +++++++ setup.cfg | 1 + setup.py | 12 +- tests/sanic/__init__.py | 0 tests/sanic/app.py | 28 ++ tests/sanic/schema.py | 72 +++ tests/sanic/test_graphiqlview.py | 88 ++++ tests/sanic/test_graphqlview.py | 610 ++++++++++++++++++++++++ 10 files changed, 1188 insertions(+), 1 deletion(-) create mode 100644 graphql_server/sanic/__init__.py create mode 100644 graphql_server/sanic/graphqlview.py create mode 100644 graphql_server/sanic/render_graphiql.py create mode 100644 tests/sanic/__init__.py create mode 100644 tests/sanic/app.py create mode 100644 tests/sanic/schema.py create mode 100644 tests/sanic/test_graphiqlview.py create mode 100644 tests/sanic/test_graphqlview.py diff --git a/graphql_server/sanic/__init__.py b/graphql_server/sanic/__init__.py new file mode 100644 index 0000000..8f5beaf --- /dev/null +++ b/graphql_server/sanic/__init__.py @@ -0,0 +1,3 @@ +from .graphqlview import GraphQLView + +__all__ = ["GraphQLView"] diff --git a/graphql_server/sanic/graphqlview.py b/graphql_server/sanic/graphqlview.py new file mode 100644 index 0000000..fd22af2 --- /dev/null +++ b/graphql_server/sanic/graphqlview.py @@ -0,0 +1,190 @@ +import copy +from cgi import parse_header +from collections.abc import MutableMapping +from functools import partial + +from graphql import GraphQLError +from graphql.type.schema import GraphQLSchema +from sanic.response import HTTPResponse +from sanic.views import HTTPMethodView + +from graphql_server import ( + HttpQueryError, + encode_execution_results, + format_error_default, + json_encode, + load_json_body, + run_http_query, +) + +from .render_graphiql import render_graphiql + + +class GraphQLView(HTTPMethodView): + schema = None + root_value = None + context = None + pretty = False + graphiql = False + graphiql_version = None + graphiql_template = None + middleware = None + batch = False + jinja_env = None + max_age = 86400 + enable_async = False + + methods = ["GET", "POST", "PUT", "DELETE"] + + def __init__(self, **kwargs): + super(GraphQLView, self).__init__() + for key, value in kwargs.items(): + if hasattr(self, key): + setattr(self, key, value) + + assert isinstance( + self.schema, GraphQLSchema + ), "A Schema is required to be provided to GraphQLView." + + def get_root_value(self): + return self.root_value + + def get_context(self, request): + context = ( + copy.copy(self.context) + if self.context and isinstance(self.context, MutableMapping) + else {} + ) + if isinstance(context, MutableMapping) and "request" not in context: + context.update({"request": request}) + return context + + def get_middleware(self): + return self.middleware + + async def render_graphiql(self, params, result): + return await render_graphiql( + jinja_env=self.jinja_env, + params=params, + result=result, + graphiql_version=self.graphiql_version, + graphiql_template=self.graphiql_template, + ) + + format_error = staticmethod(format_error_default) + encode = staticmethod(json_encode) + + async def dispatch_request(self, request, *args, **kwargs): + try: + request_method = request.method.lower() + data = self.parse_body(request) + + show_graphiql = request_method == "get" and self.should_display_graphiql( + request + ) + catch = show_graphiql + + pretty = self.pretty or show_graphiql or request.args.get("pretty") + + if request_method != "options": + execution_results, all_params = run_http_query( + self.schema, + request_method, + data, + query_data=request.args, + batch_enabled=self.batch, + catch=catch, + # Execute options + run_sync=not self.enable_async, + root_value=self.get_root_value(), + context_value=self.get_context(request), + middleware=self.get_middleware(), + ) + exec_res = ( + [await ex for ex in execution_results] + if self.enable_async + else execution_results + ) + result, status_code = encode_execution_results( + exec_res, + is_batch=isinstance(data, list), + format_error=self.format_error, + encode=partial(self.encode, pretty=pretty), # noqa: ignore + ) + + if show_graphiql: + return await self.render_graphiql( + params=all_params[0], result=result + ) + + return HTTPResponse( + result, status=status_code, content_type="application/json" + ) + + else: + return self.process_preflight(request) + + except HttpQueryError as e: + parsed_error = GraphQLError(e.message) + return HTTPResponse( + self.encode(dict(errors=[self.format_error(parsed_error)])), + status=e.status_code, + headers=e.headers, + content_type="application/json", + ) + + # noinspection PyBroadException + def parse_body(self, request): + content_type = self.get_mime_type(request) + if content_type == "application/graphql": + return {"query": request.body.decode("utf8")} + + elif content_type == "application/json": + return load_json_body(request.body.decode("utf8")) + + elif content_type in ( + "application/x-www-form-urlencoded", + "multipart/form-data", + ): + return request.form + + return {} + + @staticmethod + def get_mime_type(request): + # We use mime type here since we don't need the other + # information provided by content_type + if "content-type" not in request.headers: + return None + + mime_type, _ = parse_header(request.headers["content-type"]) + return mime_type + + def should_display_graphiql(self, request): + if not self.graphiql or "raw" in request.args: + return False + + return self.request_wants_html(request) + + @staticmethod + def request_wants_html(request): + accept = request.headers.get("accept", {}) + return "text/html" in accept or "*/*" in accept + + def process_preflight(self, request): + """ Preflight request support for apollo-client + https://www.w3.org/TR/cors/#resource-preflight-requests """ + origin = request.headers.get("Origin", "") + method = request.headers.get("Access-Control-Request-Method", "").upper() + + if method and method in self.methods: + return HTTPResponse( + status=200, + headers={ + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": ", ".join(self.methods), + "Access-Control-Max-Age": str(self.max_age), + }, + ) + else: + return HTTPResponse(status=400) diff --git a/graphql_server/sanic/render_graphiql.py b/graphql_server/sanic/render_graphiql.py new file mode 100644 index 0000000..ca21ee3 --- /dev/null +++ b/graphql_server/sanic/render_graphiql.py @@ -0,0 +1,185 @@ +import json +import re + +from sanic.response import html + +GRAPHIQL_VERSION = "0.7.1" + +TEMPLATE = """ + + + + + + + + + + + + + + +""" + + +def escape_js_value(value): + quotation = False + if value.startswith('"') and value.endswith('"'): + quotation = True + value = value[1 : len(value) - 1] + + value = value.replace("\\\\n", "\\\\\\n").replace("\\n", "\\\\n") + if quotation: + value = '"' + value.replace('\\\\"', '"').replace('"', '\\"') + '"' + + return value + + +def process_var(template, name, value, jsonify=False): + pattern = r"{{\s*" + name + r"(\s*|[^}]+)*\s*}}" + if jsonify and value not in ["null", "undefined"]: + value = json.dumps(value) + value = escape_js_value(value) + + return re.sub(pattern, value, template) + + +def simple_renderer(template, **values): + replace = ["graphiql_version"] + replace_jsonify = ["query", "result", "variables", "operation_name"] + + for r in replace: + template = process_var(template, r, values.get(r, "")) + + for r in replace_jsonify: + template = process_var(template, r, values.get(r, ""), True) + + return template + + +async def render_graphiql( + jinja_env=None, + graphiql_version=None, + graphiql_template=None, + params=None, + result=None, +): + graphiql_version = graphiql_version or GRAPHIQL_VERSION + template = graphiql_template or TEMPLATE + template_vars = { + "graphiql_version": graphiql_version, + "query": params and params.query, + "variables": params and params.variables, + "operation_name": params and params.operation_name, + "result": result, + } + + if jinja_env: + template = jinja_env.from_string(template) + if jinja_env.is_async: + source = await template.render_async(**template_vars) + else: + source = template.render(**template_vars) + else: + source = simple_renderer(template, **template_vars) + + return html(source) diff --git a/setup.cfg b/setup.cfg index 78bddbd..b943008 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,7 @@ [flake8] exclude = docs max-line-length = 88 +ignore = E203, E501, W503 [isort] known_first_party=graphql_server diff --git a/setup.py b/setup.py index 15397cc..fbf8637 100644 --- a/setup.py +++ b/setup.py @@ -7,6 +7,8 @@ tests_requires = [ "pytest>=5.3,<5.4", "pytest-cov>=2.8,<3", + "aiohttp>=3.5.0,<4", + "Jinja2>=2.10.1,<3", ] dev_requires = [ @@ -21,7 +23,14 @@ "flask>=0.7.0", ] -install_all_requires = install_requires + install_flask_requires +install_sanic_requires = [ + "sanic>=19.9.0,<20", +] + +install_all_requires = \ + install_requires + \ + install_flask_requires + \ + install_sanic_requires setup( name="graphql-server-core", @@ -52,6 +61,7 @@ "test": install_all_requires + tests_requires, "dev": install_all_requires + dev_requires, "flask": install_flask_requires, + "sanic": install_sanic_requires, }, include_package_data=True, zip_safe=False, diff --git a/tests/sanic/__init__.py b/tests/sanic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/sanic/app.py b/tests/sanic/app.py new file mode 100644 index 0000000..f5a74cf --- /dev/null +++ b/tests/sanic/app.py @@ -0,0 +1,28 @@ +from urllib.parse import urlencode + +from sanic import Sanic +from sanic.testing import SanicTestClient + +from graphql_server.sanic import GraphQLView + +from .schema import Schema + + +def create_app(path="/graphql", **kwargs): + app = Sanic(__name__) + app.debug = True + + schema = kwargs.pop("schema", None) or Schema + app.add_route(GraphQLView.as_view(schema=schema, **kwargs), path) + + app.client = SanicTestClient(app) + return app + + +def url_string(uri="/graphql", **url_params): + string = "/graphql" + + if url_params: + string += "?" + urlencode(url_params) + + return string diff --git a/tests/sanic/schema.py b/tests/sanic/schema.py new file mode 100644 index 0000000..a129d92 --- /dev/null +++ b/tests/sanic/schema.py @@ -0,0 +1,72 @@ +import asyncio + +from graphql.type.definition import ( + GraphQLArgument, + GraphQLField, + GraphQLNonNull, + GraphQLObjectType, +) +from graphql.type.scalars import GraphQLString +from graphql.type.schema import GraphQLSchema + + +def resolve_raises(*_): + raise Exception("Throws!") + + +# Sync schema +QueryRootType = GraphQLObjectType( + name="QueryRoot", + fields={ + "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_raises), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"].args.get("q"), + ), + "context": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"], + ), + "test": GraphQLField( + type_=GraphQLString, + args={"who": GraphQLArgument(GraphQLString)}, + resolve=lambda obj, info, who=None: "Hello %s" % (who or "World"), + ), + }, +) + +MutationRootType = GraphQLObjectType( + name="MutationRoot", + fields={ + "writeTest": GraphQLField(type_=QueryRootType, resolve=lambda *_: QueryRootType) + }, +) + +Schema = GraphQLSchema(QueryRootType, MutationRootType) + + +# Schema with async methods +async def resolver_field_async_1(_obj, info): + await asyncio.sleep(0.001) + return "hey" + + +async def resolver_field_async_2(_obj, info): + await asyncio.sleep(0.003) + return "hey2" + + +def resolver_field_sync(_obj, info): + return "hey3" + + +AsyncQueryType = GraphQLObjectType( + name="AsyncQueryType", + fields={ + "a": GraphQLField(GraphQLString, resolve=resolver_field_async_1), + "b": GraphQLField(GraphQLString, resolve=resolver_field_async_2), + "c": GraphQLField(GraphQLString, resolve=resolver_field_sync), + }, +) + +AsyncSchema = GraphQLSchema(AsyncQueryType) diff --git a/tests/sanic/test_graphiqlview.py b/tests/sanic/test_graphiqlview.py new file mode 100644 index 0000000..60ecc75 --- /dev/null +++ b/tests/sanic/test_graphiqlview.py @@ -0,0 +1,88 @@ +import pytest +from jinja2 import Environment + +from .app import create_app, url_string +from .schema import AsyncSchema + + +@pytest.fixture +def pretty_response(): + return ( + "{\n" + ' "data": {\n' + ' "test": "Hello World"\n' + " }\n" + "}".replace('"', '\\"').replace("\n", "\\n") + ) + + +@pytest.mark.parametrize("app", [create_app(graphiql=True)]) +def test_graphiql_is_enabled(app): + _, response = app.client.get( + uri=url_string(query="{test}"), headers={"Accept": "text/html"} + ) + assert response.status == 200 + + +@pytest.mark.parametrize("app", [create_app(graphiql=True)]) +def test_graphiql_simple_renderer(app, pretty_response): + _, response = app.client.get( + uri=url_string(query="{test}"), headers={"Accept": "text/html"} + ) + assert response.status == 200 + assert pretty_response in response.body.decode("utf-8") + + +@pytest.mark.parametrize("app", [create_app(graphiql=True, jinja_env=Environment())]) +def test_graphiql_jinja_renderer(app, pretty_response): + _, response = app.client.get( + uri=url_string(query="{test}"), headers={"Accept": "text/html"} + ) + assert response.status == 200 + assert pretty_response in response.body.decode("utf-8") + + +@pytest.mark.parametrize( + "app", [create_app(graphiql=True, jinja_env=Environment(enable_async=True))] +) +def test_graphiql_jinja_async_renderer(app, pretty_response): + _, response = app.client.get( + uri=url_string(query="{test}"), headers={"Accept": "text/html"} + ) + assert response.status == 200 + assert pretty_response in response.body.decode("utf-8") + + +@pytest.mark.parametrize("app", [create_app(graphiql=True)]) +def test_graphiql_html_is_not_accepted(app): + _, response = app.client.get( + uri=url_string(), headers={"Accept": "application/json"} + ) + assert response.status == 400 + + +@pytest.mark.parametrize( + "app", [create_app(graphiql=True, schema=AsyncSchema, enable_async=True)] +) +def test_graphiql_asyncio_schema(app): + query = "{a,b,c}" + _, response = app.client.get( + uri=url_string(query=query), headers={"Accept": "text/html"} + ) + + expected_response = ( + ( + "{\n" + ' "data": {\n' + ' "a": "hey",\n' + ' "b": "hey2",\n' + ' "c": "hey3"\n' + " }\n" + "}" + ) + .replace('"', '\\"') + .replace("\n", "\\n") + ) + + assert response.status == 200 + assert expected_response in response.body.decode("utf-8") diff --git a/tests/sanic/test_graphqlview.py b/tests/sanic/test_graphqlview.py new file mode 100644 index 0000000..7325e6d --- /dev/null +++ b/tests/sanic/test_graphqlview.py @@ -0,0 +1,610 @@ +import json +from urllib.parse import urlencode + +import pytest + +from .app import create_app, url_string +from .schema import AsyncSchema + + +def response_json(response): + return json.loads(response.body.decode()) + + +def json_dump_kwarg(**kwargs): + return json.dumps(kwargs) + + +def json_dump_kwarg_list(**kwargs): + return json.dumps([kwargs]) + + +@pytest.mark.parametrize("app", [create_app()]) +def test_allows_get_with_query_param(app): + _, response = app.client.get(uri=url_string(query="{test}")) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_allows_get_with_variable_values(app): + _, response = app.client.get( + uri=url_string( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_allows_get_with_operation_name(app): + _, response = app.client.get( + uri=url_string( + 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 response.status == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_reports_validation_errors(app): + _, response = app.client.get( + uri=url_string(query="{ test, unknownOne, unknownTwo }") + ) + + assert response.status == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 9}], + "path": None, + }, + { + "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 21}], + "path": None, + }, + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_errors_when_missing_operation_name(app): + _, response = app.client.get( + uri=url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """ + ) + ) + + assert response.status == 400 + assert response_json(response) == { + "errors": [ + { + "locations": None, + "message": "Must provide operation name if query contains multiple operations.", + "path": None, + } + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_errors_when_sending_a_mutation_via_get(app): + _, response = app.client.get( + uri=url_string( + query=""" + mutation TestMutation { writeTest { test } } + """ + ) + ) + assert response.status == 405 + assert response_json(response) == { + "errors": [ + { + "locations": None, + "message": "Can only perform a mutation operation from a POST request.", + "path": None, + } + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_errors_when_selecting_a_mutation_within_a_get(app): + _, response = app.client.get( + uri=url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestMutation", + ) + ) + + assert response.status == 405 + assert response_json(response) == { + "errors": [ + { + "locations": None, + "message": "Can only perform a mutation operation from a POST request.", + "path": None, + } + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_allows_mutation_to_exist_within_a_get(app): + _, response = app.client.get( + uri=url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestQuery", + ) + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_allows_post_with_json_encoding(app): + _, response = app.client.post( + uri=url_string(), + data=json_dump_kwarg(query="{test}"), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_allows_sending_a_mutation_via_post(app): + _, response = app.client.post( + uri=url_string(), + data=json_dump_kwarg(query="mutation TestMutation { writeTest { test } }"), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_allows_post_with_url_encoding(app): + # Example of how sanic does send data using url enconding + # can be found at their repo. + # https://github.com/huge-success/sanic/blob/master/tests/test_requests.py#L927 + payload = "query={test}" + _, response = app.client.post( + uri=url_string(), + data=payload, + headers={"content-type": "application/x-www-form-urlencoded"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_supports_post_json_query_with_string_variables(app): + _, response = app.client.post( + uri=url_string(), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_supports_post_json_query_with_json_variables(app): + _, response = app.client.post( + uri=url_string(), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_supports_post_url_encoded_query_with_string_variables(app): + _, response = app.client.post( + uri=url_string(), + data=urlencode( + dict( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + ), + headers={"content-type": "application/x-www-form-urlencoded"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_supports_post_json_query_with_get_variable_values(app): + _, response = app.client.post( + uri=url_string(variables=json.dumps({"who": "Dolly"})), + data=json_dump_kwarg(query="query helloWho($who: String){ test(who: $who) }",), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_post_url_encoded_query_with_get_variable_values(app): + _, response = app.client.post( + uri=url_string(variables=json.dumps({"who": "Dolly"})), + data=urlencode(dict(query="query helloWho($who: String){ test(who: $who) }",)), + headers={"content-type": "application/x-www-form-urlencoded"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_supports_post_raw_text_query_with_get_variable_values(app): + _, response = app.client.post( + uri=url_string(variables=json.dumps({"who": "Dolly"})), + data="query helloWho($who: String){ test(who: $who) }", + headers={"content-type": "application/graphql"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_allows_post_with_operation_name(app): + _, response = app.client.post( + uri=url_string(), + data=json_dump_kwarg( + 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", + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_allows_post_with_get_operation_name(app): + _, response = app.client.post( + uri=url_string(operationName="helloWorld"), + data=""" + 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") + } + """, + headers={"content-type": "application/graphql"}, + ) + + assert response.status == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.parametrize("app", [create_app(pretty=True)]) +def test_supports_pretty_printing(app): + _, response = app.client.get(uri=url_string(query="{test}")) + + assert response.body.decode() == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) + + +@pytest.mark.parametrize("app", [create_app(pretty=False)]) +def test_not_pretty_by_default(app): + _, response = app.client.get(url_string(query="{test}")) + + assert response.body.decode() == '{"data":{"test":"Hello World"}}' + + +@pytest.mark.parametrize("app", [create_app()]) +def test_supports_pretty_printing_by_request(app): + _, response = app.client.get(uri=url_string(query="{test}", pretty="1")) + + assert response.body.decode() == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) + + +@pytest.mark.parametrize("app", [create_app()]) +def test_handles_field_errors_caught_by_graphql(app): + _, response = app.client.get(uri=url_string(query="{thrower}")) + assert response.status == 200 + assert response_json(response) == { + "data": None, + "errors": [ + { + "locations": [{"column": 2, "line": 1}], + "message": "Throws!", + "path": ["thrower"], + } + ], + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_handles_syntax_errors_caught_by_graphql(app): + _, response = app.client.get(uri=url_string(query="syntaxerror")) + assert response.status == 400 + assert response_json(response) == { + "errors": [ + { + "locations": [{"column": 1, "line": 1}], + "message": "Syntax Error: Unexpected Name 'syntaxerror'.", + "path": None, + } + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_handles_errors_caused_by_a_lack_of_query(app): + _, response = app.client.get(uri=url_string()) + + assert response.status == 400 + assert response_json(response) == { + "errors": [ + {"locations": None, "message": "Must provide query string.", "path": None} + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_handles_batch_correctly_if_is_disabled(app): + _, response = app.client.post( + uri=url_string(), data="[]", headers={"content-type": "application/json"} + ) + + assert response.status == 400 + assert response_json(response) == { + "errors": [ + { + "locations": None, + "message": "Batch GraphQL requests are not enabled.", + "path": None, + } + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_handles_incomplete_json_bodies(app): + _, response = app.client.post( + uri=url_string(), data='{"query":', headers={"content-type": "application/json"} + ) + + assert response.status == 400 + assert response_json(response) == { + "errors": [ + {"locations": None, "message": "POST body sent invalid JSON.", "path": None} + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_handles_plain_post_text(app): + _, response = app.client.post( + uri=url_string(variables=json.dumps({"who": "Dolly"})), + data="query helloWho($who: String){ test(who: $who) }", + headers={"content-type": "text/plain"}, + ) + assert response.status == 400 + assert response_json(response) == { + "errors": [ + {"locations": None, "message": "Must provide query string.", "path": None} + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_handles_poorly_formed_variables(app): + _, response = app.client.get( + uri=url_string( + query="query helloWho($who: String){ test(who: $who) }", variables="who:You" + ) + ) + assert response.status == 400 + assert response_json(response) == { + "errors": [ + {"locations": None, "message": "Variables are invalid JSON.", "path": None} + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_handles_unsupported_http_methods(app): + _, response = app.client.put(uri=url_string(query="{test}")) + assert response.status == 405 + assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] + assert response_json(response) == { + "errors": [ + { + "locations": None, + "message": "GraphQL only supports GET and POST requests.", + "path": None, + } + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_passes_request_into_request_context(app): + _, response = app.client.get(uri=url_string(query="{request}", q="testing")) + + assert response.status == 200 + assert response_json(response) == {"data": {"request": "testing"}} + + +@pytest.mark.parametrize("app", [create_app(context="CUSTOM CONTEXT")]) +def test_supports_pretty_printing_on_custom_context_response(app): + _, response = app.client.get(uri=url_string(query="{context}")) + + assert response.status == 200 + assert "data" in response_json(response) + assert response_json(response)["data"]["context"] == "" + + +@pytest.mark.parametrize("app", [create_app()]) +def test_post_multipart_data(app): + query = "mutation TestMutation { writeTest { test } }" + + data = ( + "------sanicgraphql\r\n" + + 'Content-Disposition: form-data; name="query"\r\n' + + "\r\n" + + query + + "\r\n" + + "------sanicgraphql--\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + 'Content-Disposition: form-data; name="file"; filename="text1.txt"; filename*=utf-8\'\'text1.txt\r\n' + + "\r\n" + + "\r\n" + + "------sanicgraphql--\r\n" + ) + + _, response = app.client.post( + uri=url_string(), + data=data, + headers={"content-type": "multipart/form-data; boundary=----sanicgraphql"}, + ) + + assert response.status == 200 + assert response_json(response) == { + "data": {u"writeTest": {u"test": u"Hello World"}} + } + + +@pytest.mark.parametrize("app", [create_app(batch=True)]) +def test_batch_allows_post_with_json_encoding(app): + _, response = app.client.post( + uri=url_string(), + data=json_dump_kwarg_list(id=1, query="{test}"), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == [{"data": {"test": "Hello World"}}] + + +@pytest.mark.parametrize("app", [create_app(batch=True)]) +def test_batch_supports_post_json_query_with_json_variables(app): + _, response = app.client.post( + uri=url_string(), + data=json_dump_kwarg_list( + id=1, + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == [{"data": {"test": "Hello Dolly"}}] + + +@pytest.mark.parametrize("app", [create_app(batch=True)]) +def test_batch_allows_post_with_operation_name(app): + _, response = app.client.post( + uri=url_string(), + data=json_dump_kwarg_list( + id=1, + 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", + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == [ + {"data": {"test": "Hello World", "shared": "Hello Everyone"}} + ] + + +@pytest.mark.parametrize("app", [create_app(schema=AsyncSchema, enable_async=True)]) +def test_async_schema(app): + query = "{a,b,c}" + _, response = app.client.get(uri=url_string(query=query)) + + assert response.status == 200 + assert response_json(response) == {"data": {"a": "hey", "b": "hey2", "c": "hey3"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_preflight_request(app): + _, response = app.client.options( + uri=url_string(), headers={"Access-Control-Request-Method": "POST"} + ) + + assert response.status == 200 + + +@pytest.mark.parametrize("app", [create_app()]) +def test_preflight_incorrect_request(app): + _, response = app.client.options( + uri=url_string(), headers={"Access-Control-Request-Method": "OPTIONS"} + ) + + assert response.status == 400 From 8e2f147a2c23ec7275fe1924dc0c190370ffd256 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Wed, 10 Jun 2020 12:25:34 -0500 Subject: [PATCH 030/108] Merge aiohttp-graphql (#42) * refactor: add aiohttp-graphql as optional feature * tests: cleanup aiohttp subpackage --- graphql_server/aiohttp/__init__.py | 3 + graphql_server/aiohttp/graphqlview.py | 217 +++++++ graphql_server/aiohttp/render_graphiql.py | 208 +++++++ setup.py | 8 +- tests/aiohttp/__init__.py | 1 + tests/aiohttp/app.py | 22 + tests/aiohttp/schema.py | 85 +++ tests/aiohttp/test_graphiqlview.py | 112 ++++ tests/aiohttp/test_graphqlview.py | 675 ++++++++++++++++++++++ 9 files changed, 1330 insertions(+), 1 deletion(-) create mode 100644 graphql_server/aiohttp/__init__.py create mode 100644 graphql_server/aiohttp/graphqlview.py create mode 100644 graphql_server/aiohttp/render_graphiql.py create mode 100644 tests/aiohttp/__init__.py create mode 100644 tests/aiohttp/app.py create mode 100644 tests/aiohttp/schema.py create mode 100644 tests/aiohttp/test_graphiqlview.py create mode 100644 tests/aiohttp/test_graphqlview.py diff --git a/graphql_server/aiohttp/__init__.py b/graphql_server/aiohttp/__init__.py new file mode 100644 index 0000000..8f5beaf --- /dev/null +++ b/graphql_server/aiohttp/__init__.py @@ -0,0 +1,3 @@ +from .graphqlview import GraphQLView + +__all__ = ["GraphQLView"] diff --git a/graphql_server/aiohttp/graphqlview.py b/graphql_server/aiohttp/graphqlview.py new file mode 100644 index 0000000..9581e12 --- /dev/null +++ b/graphql_server/aiohttp/graphqlview.py @@ -0,0 +1,217 @@ +import copy +from collections.abc import MutableMapping +from functools import partial + +from aiohttp import web +from graphql import GraphQLError +from graphql.type.schema import GraphQLSchema + +from graphql_server import ( + HttpQueryError, + encode_execution_results, + format_error_default, + json_encode, + load_json_body, + run_http_query, +) + +from .render_graphiql import render_graphiql + + +class GraphQLView: + schema = None + root_value = None + context = None + pretty = False + graphiql = False + graphiql_version = None + graphiql_template = None + middleware = None + batch = False + jinja_env = None + max_age = 86400 + enable_async = False + subscriptions = None + + accepted_methods = ["GET", "POST", "PUT", "DELETE"] + + format_error = staticmethod(format_error_default) + encode = staticmethod(json_encode) + + def __init__(self, **kwargs): + super(GraphQLView, self).__init__() + for key, value in kwargs.items(): + if hasattr(self, key): + setattr(self, key, value) + + assert isinstance( + self.schema, GraphQLSchema + ), "A Schema is required to be provided to GraphQLView." + + def get_root_value(self): + return self.root_value + + def get_context(self, request): + context = ( + copy.copy(self.context) + if self.context and isinstance(self.context, MutableMapping) + else {} + ) + if isinstance(context, MutableMapping) and "request" not in context: + context.update({"request": request}) + return context + + def get_middleware(self): + return self.middleware + + # This method can be static + async def parse_body(self, request): + content_type = request.content_type + # request.text() is the aiohttp equivalent to + # request.body.decode("utf8") + if content_type == "application/graphql": + r_text = await request.text() + return {"query": r_text} + + if content_type == "application/json": + text = await request.text() + return load_json_body(text) + + if content_type in ( + "application/x-www-form-urlencoded", + "multipart/form-data", + ): + # TODO: seems like a multidict would be more appropriate + # than casting it and de-duping variables. Alas, it's what + # graphql-python wants. + return dict(await request.post()) + + return {} + + def render_graphiql(self, params, result): + return render_graphiql( + jinja_env=self.jinja_env, + params=params, + result=result, + graphiql_version=self.graphiql_version, + graphiql_template=self.graphiql_template, + subscriptions=self.subscriptions, + ) + + # TODO: + # use this method to replace flask and sanic + # checks as this is equivalent to `should_display_graphiql` and + # `request_wants_html` methods. + def is_graphiql(self, request): + return all( + [ + self.graphiql, + request.method.lower() == "get", + "raw" not in request.query, + any( + [ + "text/html" in request.headers.get("accept", {}), + "*/*" in request.headers.get("accept", {}), + ] + ), + ] + ) + + # TODO: Same stuff as above method. + def is_pretty(self, request): + return any( + [self.pretty, self.is_graphiql(request), request.query.get("pretty")] + ) + + async def __call__(self, request): + try: + data = await self.parse_body(request) + request_method = request.method.lower() + is_graphiql = self.is_graphiql(request) + is_pretty = self.is_pretty(request) + + # TODO: way better than if-else so better + # implement this too on flask and sanic + if request_method == "options": + return self.process_preflight(request) + + execution_results, all_params = run_http_query( + self.schema, + request_method, + data, + query_data=request.query, + batch_enabled=self.batch, + catch=is_graphiql, + # Execute options + run_sync=not self.enable_async, + root_value=self.get_root_value(), + context_value=self.get_context(request), + middleware=self.get_middleware(), + ) + + exec_res = ( + [await ex for ex in execution_results] + if self.enable_async + else execution_results + ) + result, status_code = encode_execution_results( + exec_res, + is_batch=isinstance(data, list), + format_error=self.format_error, + encode=partial(self.encode, pretty=is_pretty), # noqa: ignore + ) + + if is_graphiql: + return await self.render_graphiql(params=all_params[0], result=result) + + return web.Response( + text=result, status=status_code, content_type="application/json", + ) + + except HttpQueryError as err: + parsed_error = GraphQLError(err.message) + return web.Response( + body=self.encode(dict(errors=[self.format_error(parsed_error)])), + status=err.status_code, + headers=err.headers, + content_type="application/json", + ) + + def process_preflight(self, request): + """ + Preflight request support for apollo-client + https://www.w3.org/TR/cors/#resource-preflight-requests + """ + headers = request.headers + origin = headers.get("Origin", "") + method = headers.get("Access-Control-Request-Method", "").upper() + + if method and method in self.accepted_methods: + return web.Response( + status=200, + headers={ + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": ", ".join(self.accepted_methods), + "Access-Control-Max-Age": str(self.max_age), + }, + ) + return web.Response(status=400) + + @classmethod + def attach(cls, app, *, route_path="/graphql", route_name="graphql", **kwargs): + view = cls(**kwargs) + app.router.add_route("*", route_path, _asyncify(view), name=route_name) + + +def _asyncify(handler): + """Return an async version of the given handler. + + This is mainly here because ``aiohttp`` can't infer the async definition of + :py:meth:`.GraphQLView.__call__` and raises a :py:class:`DeprecationWarning` + in tests. Wrapping it into an async function avoids the noisy warning. + """ + + async def _dispatch(request): + return await handler(request) + + return _dispatch diff --git a/graphql_server/aiohttp/render_graphiql.py b/graphql_server/aiohttp/render_graphiql.py new file mode 100644 index 0000000..9da47d3 --- /dev/null +++ b/graphql_server/aiohttp/render_graphiql.py @@ -0,0 +1,208 @@ +import json +import re + +from aiohttp import web + +GRAPHIQL_VERSION = "0.17.5" + +TEMPLATE = """ + + + + + + + + + + + + + + + + +""" + + +def escape_js_value(value): + quotation = False + if value.startswith('"') and value.endswith('"'): + quotation = True + value = value[1:-1] + + value = value.replace("\\\\n", "\\\\\\n").replace("\\n", "\\\\n") + if quotation: + value = '"' + value.replace('\\\\"', '"').replace('"', '\\"') + '"' + + return value + + +def process_var(template, name, value, jsonify=False): + pattern = r"{{\s*" + name + r"(\s*|[^}]+)*\s*}}" + if jsonify and value not in ["null", "undefined"]: + value = json.dumps(value) + value = escape_js_value(value) + + return re.sub(pattern, value, template) + + +def simple_renderer(template, **values): + replace = ["graphiql_version", "subscriptions"] + replace_jsonify = ["query", "result", "variables", "operation_name"] + + for rep in replace: + template = process_var(template, rep, values.get(rep, "")) + + for rep in replace_jsonify: + template = process_var(template, rep, values.get(rep, ""), True) + + return template + + +async def render_graphiql( + jinja_env=None, + graphiql_version=None, + graphiql_template=None, + params=None, + result=None, + subscriptions=None, +): + graphiql_version = graphiql_version or GRAPHIQL_VERSION + template = graphiql_template or TEMPLATE + template_vars = { + "graphiql_version": graphiql_version, + "query": params and params.query, + "variables": params and params.variables, + "operation_name": params and params.operation_name, + "result": result, + "subscriptions": subscriptions or "", + } + + if jinja_env: + template = jinja_env.from_string(template) + if jinja_env.is_async: + source = await template.render_async(**template_vars) + else: + source = template.render(**template_vars) + else: + source = simple_renderer(template, **template_vars) + + return web.Response(text=source, content_type="text/html") diff --git a/setup.py b/setup.py index fbf8637..6135166 100644 --- a/setup.py +++ b/setup.py @@ -27,10 +27,15 @@ "sanic>=19.9.0,<20", ] +install_aiohttp_requires = [ + "aiohttp>=3.5.0,<4", +] + install_all_requires = \ install_requires + \ install_flask_requires + \ - install_sanic_requires + install_sanic_requires + \ + install_aiohttp_requires setup( name="graphql-server-core", @@ -62,6 +67,7 @@ "dev": install_all_requires + dev_requires, "flask": install_flask_requires, "sanic": install_sanic_requires, + "aiohttp": install_aiohttp_requires, }, include_package_data=True, zip_safe=False, diff --git a/tests/aiohttp/__init__.py b/tests/aiohttp/__init__.py new file mode 100644 index 0000000..943d58f --- /dev/null +++ b/tests/aiohttp/__init__.py @@ -0,0 +1 @@ +# aiohttp-graphql tests diff --git a/tests/aiohttp/app.py b/tests/aiohttp/app.py new file mode 100644 index 0000000..36d7de6 --- /dev/null +++ b/tests/aiohttp/app.py @@ -0,0 +1,22 @@ +from urllib.parse import urlencode + +from aiohttp import web + +from graphql_server.aiohttp import GraphQLView +from tests.aiohttp.schema import Schema + + +def create_app(schema=Schema, **kwargs): + app = web.Application() + # Only needed to silence aiohttp deprecation warnings + GraphQLView.attach(app, schema=schema, **kwargs) + return app + + +def url_string(**url_params): + base_url = "/graphql" + + if url_params: + return f"{base_url}?{urlencode(url_params)}" + + return base_url diff --git a/tests/aiohttp/schema.py b/tests/aiohttp/schema.py new file mode 100644 index 0000000..9198b12 --- /dev/null +++ b/tests/aiohttp/schema.py @@ -0,0 +1,85 @@ +import asyncio + +from graphql.type.definition import ( + GraphQLArgument, + GraphQLField, + GraphQLNonNull, + GraphQLObjectType, +) +from graphql.type.scalars import GraphQLString +from graphql.type.schema import GraphQLSchema + + +def resolve_raises(*_): + raise Exception("Throws!") + + +# Sync schema +QueryRootType = GraphQLObjectType( + name="QueryRoot", + fields={ + "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_raises,), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info, *args: info.context["request"].query.get("q"), + ), + "context": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info, *args: info.context["request"], + ), + "test": GraphQLField( + type_=GraphQLString, + args={"who": GraphQLArgument(GraphQLString)}, + resolve=lambda obj, info, who=None: "Hello %s" % (who or "World"), + ), + }, +) + + +MutationRootType = GraphQLObjectType( + name="MutationRoot", + fields={ + "writeTest": GraphQLField( + type_=QueryRootType, resolve=lambda *args: QueryRootType + ) + }, +) + +SubscriptionsRootType = GraphQLObjectType( + name="SubscriptionsRoot", + fields={ + "subscriptionsTest": GraphQLField( + type_=QueryRootType, resolve=lambda *args: QueryRootType + ) + }, +) + +Schema = GraphQLSchema(QueryRootType, MutationRootType, SubscriptionsRootType) + + +# Schema with async methods +async def resolver_field_async_1(_obj, info): + await asyncio.sleep(0.001) + return "hey" + + +async def resolver_field_async_2(_obj, info): + await asyncio.sleep(0.003) + return "hey2" + + +def resolver_field_sync(_obj, info): + return "hey3" + + +AsyncQueryType = GraphQLObjectType( + "AsyncQueryType", + { + "a": GraphQLField(GraphQLString, resolve=resolver_field_async_1), + "b": GraphQLField(GraphQLString, resolve=resolver_field_async_2), + "c": GraphQLField(GraphQLString, resolve=resolver_field_sync), + }, +) + + +AsyncSchema = GraphQLSchema(AsyncQueryType) diff --git a/tests/aiohttp/test_graphiqlview.py b/tests/aiohttp/test_graphiqlview.py new file mode 100644 index 0000000..04a9b50 --- /dev/null +++ b/tests/aiohttp/test_graphiqlview.py @@ -0,0 +1,112 @@ +import pytest +from aiohttp.test_utils import TestClient, TestServer +from jinja2 import Environment + +from tests.aiohttp.app import create_app, url_string +from tests.aiohttp.schema import AsyncSchema, Schema + + +@pytest.fixture +def app(): + app = create_app() + return app + + +@pytest.fixture +async def client(app): + client = TestClient(TestServer(app)) + await client.start_server() + yield client + await client.close() + + +@pytest.fixture +def view_kwargs(): + return { + "schema": Schema, + "graphiql": True, + } + + +@pytest.fixture +def pretty_response(): + return ( + "{\n" + ' "data": {\n' + ' "test": "Hello World"\n' + " }\n" + "}".replace('"', '\\"').replace("\n", "\\n") + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(graphiql=True)]) +async def test_graphiql_is_enabled(app, client): + response = await client.get( + url_string(query="{test}"), headers={"Accept": "text/html"} + ) + assert response.status == 200 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(graphiql=True)]) +async def test_graphiql_simple_renderer(app, client, pretty_response): + response = await client.get( + url_string(query="{test}"), headers={"Accept": "text/html"}, + ) + assert response.status == 200 + assert pretty_response in await response.text() + + +class TestJinjaEnv: + @pytest.mark.asyncio + @pytest.mark.parametrize( + "app", [create_app(graphiql=True, jinja_env=Environment())] + ) + async def test_graphiql_jinja_renderer(self, app, client, pretty_response): + response = await client.get( + url_string(query="{test}"), headers={"Accept": "text/html"}, + ) + assert response.status == 200 + assert pretty_response in await response.text() + + +@pytest.mark.asyncio +async def test_graphiql_html_is_not_accepted(client): + response = await client.get("/graphql", headers={"Accept": "application/json"},) + assert response.status == 400 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(graphiql=True)]) +async def test_graphiql_get_mutation(app, client): + response = await client.get( + url_string(query="mutation TestMutation { writeTest { test } }"), + headers={"Accept": "text/html"}, + ) + assert response.status == 200 + assert "response: null" in await response.text() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(graphiql=True)]) +async def test_graphiql_get_subscriptions(client): + response = await client.get( + url_string( + query="subscription TestSubscriptions { subscriptionsTest { test } }" + ), + headers={"Accept": "text/html"}, + ) + assert response.status == 200 + assert "response: null" in await response.text() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(schema=AsyncSchema, enable_async=True)]) +async def test_graphiql_async_schema(app, client): + response = await client.get( + url_string(query="{a,b,c}"), headers={"Accept": "text/html"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {"a": "hey", "b": "hey2", "c": "hey3"}} diff --git a/tests/aiohttp/test_graphqlview.py b/tests/aiohttp/test_graphqlview.py new file mode 100644 index 0000000..0f6becb --- /dev/null +++ b/tests/aiohttp/test_graphqlview.py @@ -0,0 +1,675 @@ +import json +from urllib.parse import urlencode + +import pytest +from aiohttp import FormData +from aiohttp.test_utils import TestClient, TestServer + +from .app import create_app, url_string +from .schema import AsyncSchema + + +@pytest.fixture +def app(): + app = create_app() + return app + + +@pytest.fixture +async def client(app): + client = TestClient(TestServer(app)) + await client.start_server() + yield client + await client.close() + + +@pytest.mark.asyncio +async def test_allows_get_with_query_param(client): + response = await client.get(url_string(query="{test}")) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello World"}} + + +@pytest.mark.asyncio +async def test_allows_get_with_variable_values(client): + response = await client.get( + url_string( + query="query helloWho($who: String) { test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + ) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_allows_get_with_operation_name(client): + response = await client.get( + url_string( + 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 response.status == 200 + assert await response.json() == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.asyncio +async def test_reports_validation_errors(client): + response = await client.get(url_string(query="{ test, unknownOne, unknownTwo }")) + + assert response.status == 400 + assert await response.json() == { + "errors": [ + { + "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 9}], + "path": None, + }, + { + "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 21}], + "path": None, + }, + ], + } + + +@pytest.mark.asyncio +async def test_errors_when_missing_operation_name(client): + response = await client.get( + url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + subscription TestSubscriptions { subscriptionsTest { test } } + """ + ) + ) + + assert response.status == 400 + assert await response.json() == { + "errors": [ + { + "message": ( + "Must provide operation name if query contains multiple " + "operations." + ), + "locations": None, + "path": None, + }, + ] + } + + +@pytest.mark.asyncio +async def test_errors_when_sending_a_mutation_via_get(client): + response = await client.get( + url_string( + query=""" + mutation TestMutation { writeTest { test } } + """ + ) + ) + assert response.status == 405 + assert await response.json() == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + "locations": None, + "path": None, + }, + ], + } + + +@pytest.mark.asyncio +async def test_errors_when_selecting_a_mutation_within_a_get(client): + response = await client.get( + url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestMutation", + ) + ) + + assert response.status == 405 + assert await response.json() == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + "locations": None, + "path": None, + }, + ], + } + + +@pytest.mark.asyncio +async def test_errors_when_selecting_a_subscription_within_a_get(client): + response = await client.get( + url_string( + query=""" + subscription TestSubscriptions { subscriptionsTest { test } } + """, + operationName="TestSubscriptions", + ) + ) + + assert response.status == 405 + assert await response.json() == { + "errors": [ + { + "message": "Can only perform a subscription operation from a POST " + "request.", + "locations": None, + "path": None, + }, + ], + } + + +@pytest.mark.asyncio +async def test_allows_mutation_to_exist_within_a_get(client): + response = await client.get( + url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestQuery", + ) + ) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello World"}} + + +@pytest.mark.asyncio +async def test_allows_post_with_json_encoding(client): + response = await client.post( + "/graphql", + data=json.dumps(dict(query="{test}")), + headers={"content-type": "application/json"}, + ) + + assert await response.json() == {"data": {"test": "Hello World"}} + assert response.status == 200 + + +@pytest.mark.asyncio +async def test_allows_sending_a_mutation_via_post(client): + response = await client.post( + "/graphql", + data=json.dumps(dict(query="mutation TestMutation { writeTest { test } }",)), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {"writeTest": {"test": "Hello World"}}} + + +@pytest.mark.asyncio +async def test_allows_post_with_url_encoding(client): + data = FormData() + data.add_field("query", "{test}") + response = await client.post( + "/graphql", + data=data(), + headers={"content-type": "application/x-www-form-urlencoded"}, + ) + + assert await response.json() == {"data": {"test": "Hello World"}} + assert response.status == 200 + + +@pytest.mark.asyncio +async def test_supports_post_json_query_with_string_variables(client): + response = await client.post( + "/graphql", + data=json.dumps( + dict( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_json_query_with_json_variables(client): + response = await client.post( + "/graphql", + data=json.dumps( + dict( + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ) + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_url_encoded_query_with_string_variables(client): + response = await client.post( + "/graphql", + data=urlencode( + dict( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ), + ), + headers={"content-type": "application/x-www-form-urlencoded"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_json_quey_with_get_variable_values(client): + response = await client.post( + url_string(variables=json.dumps({"who": "Dolly"})), + data=json.dumps(dict(query="query helloWho($who: String){ test(who: $who) }",)), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_post_url_encoded_query_with_get_variable_values(client): + response = await client.post( + url_string(variables=json.dumps({"who": "Dolly"})), + data=urlencode(dict(query="query helloWho($who: String){ test(who: $who) }",)), + headers={"content-type": "application/x-www-form-urlencoded"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_raw_text_query_with_get_variable_values(client): + response = await client.post( + url_string(variables=json.dumps({"who": "Dolly"})), + data="query helloWho($who: String){ test(who: $who) }", + headers={"content-type": "application/graphql"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_allows_post_with_operation_name(client): + response = await client.post( + "/graphql", + data=json.dumps( + 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", + ) + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.asyncio +async def test_allows_post_with_get_operation_name(client): + response = await client.post( + url_string(operationName="helloWorld"), + data=""" + 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") + } + """, + headers={"content-type": "application/graphql"}, + ) + + assert response.status == 200 + assert await response.json() == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.asyncio +async def test_supports_pretty_printing(client): + response = await client.get(url_string(query="{test}", pretty="1")) + + text = await response.text() + assert text == "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + + +@pytest.mark.asyncio +async def test_not_pretty_by_default(client): + response = await client.get(url_string(query="{test}")) + + assert await response.text() == '{"data":{"test":"Hello World"}}' + + +@pytest.mark.asyncio +async def test_supports_pretty_printing_by_request(client): + response = await client.get(url_string(query="{test}", pretty="1")) + + assert await response.text() == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) + + +@pytest.mark.asyncio +async def test_handles_field_errors_caught_by_graphql(client): + response = await client.get(url_string(query="{thrower}")) + assert response.status == 200 + assert await response.json() == { + "data": None, + "errors": [ + { + "locations": [{"column": 2, "line": 1}], + "message": "Throws!", + "path": ["thrower"], + } + ], + } + + +@pytest.mark.asyncio +async def test_handles_syntax_errors_caught_by_graphql(client): + response = await client.get(url_string(query="syntaxerror")) + + assert response.status == 400 + assert await response.json() == { + "errors": [ + { + "locations": [{"column": 1, "line": 1}], + "message": "Syntax Error: Unexpected Name 'syntaxerror'.", + "path": None, + }, + ], + } + + +@pytest.mark.asyncio +async def test_handles_errors_caused_by_a_lack_of_query(client): + response = await client.get("/graphql") + + assert response.status == 400 + assert await response.json() == { + "errors": [ + {"message": "Must provide query string.", "locations": None, "path": None} + ] + } + + +@pytest.mark.asyncio +async def test_handles_batch_correctly_if_is_disabled(client): + response = await client.post( + "/graphql", data="[]", headers={"content-type": "application/json"}, + ) + + assert response.status == 400 + assert await response.json() == { + "errors": [ + { + "message": "Batch GraphQL requests are not enabled.", + "locations": None, + "path": None, + } + ] + } + + +@pytest.mark.asyncio +async def test_handles_incomplete_json_bodies(client): + response = await client.post( + "/graphql", data='{"query":', headers={"content-type": "application/json"}, + ) + + assert response.status == 400 + assert await response.json() == { + "errors": [ + { + "message": "POST body sent invalid JSON.", + "locations": None, + "path": None, + } + ] + } + + +@pytest.mark.asyncio +async def test_handles_plain_post_text(client): + response = await client.post( + url_string(variables=json.dumps({"who": "Dolly"})), + data="query helloWho($who: String){ test(who: $who) }", + headers={"content-type": "text/plain"}, + ) + assert response.status == 400 + assert await response.json() == { + "errors": [ + {"message": "Must provide query string.", "locations": None, "path": None} + ] + } + + +@pytest.mark.asyncio +async def test_handles_poorly_formed_variables(client): + response = await client.get( + url_string( + query="query helloWho($who: String){ test(who: $who) }", variables="who:You" + ), + ) + assert response.status == 400 + assert await response.json() == { + "errors": [ + {"message": "Variables are invalid JSON.", "locations": None, "path": None} + ] + } + + +@pytest.mark.asyncio +async def test_handles_unsupported_http_methods(client): + response = await client.put(url_string(query="{test}")) + assert response.status == 405 + assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] + assert await response.json() == { + "errors": [ + { + "message": "GraphQL only supports GET and POST requests.", + "locations": None, + "path": None, + } + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +@pytest.mark.asyncio +async def test_passes_request_into_request_context(app, client): + response = await client.get(url_string(query="{request}", q="testing")) + + assert response.status == 200 + assert await response.json() == { + "data": {"request": "testing"}, + } + + +class TestCustomContext: + @pytest.mark.parametrize( + "app", [create_app(context="CUSTOM CONTEXT")], + ) + @pytest.mark.asyncio + async def test_context_remapped(self, app, client): + response = await client.get(url_string(query="{context}")) + + _json = await response.json() + assert response.status == 200 + assert "Request" in _json["data"]["context"] + assert "CUSTOM CONTEXT" not in _json["data"]["context"] + + @pytest.mark.parametrize("app", [create_app(context={"request": "test"})]) + @pytest.mark.asyncio + async def test_request_not_replaced(self, app, client): + response = await client.get(url_string(query="{context}")) + + _json = await response.json() + assert response.status == 200 + assert _json["data"]["context"] == "test" + + +@pytest.mark.asyncio +async def test_post_multipart_data(client): + query = "mutation TestMutation { writeTest { test } }" + + data = ( + "------aiohttpgraphql\r\n" + + 'Content-Disposition: form-data; name="query"\r\n' + + "\r\n" + + query + + "\r\n" + + "------aiohttpgraphql--\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + 'Content-Disposition: form-data; name="file"; filename="text1.txt"; filename*=utf-8\'\'text1.txt\r\n' # noqa: ignore + + "\r\n" + + "\r\n" + + "------aiohttpgraphql--\r\n" + ) + + response = await client.post( + "/graphql", + data=data, + headers={"content-type": "multipart/form-data; boundary=----aiohttpgraphql"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {u"writeTest": {u"test": u"Hello World"}}} + + +class TestBatchExecutor: + @pytest.mark.asyncio + @pytest.mark.parametrize("app", [create_app(batch=True)]) + async def test_batch_allows_post_with_json_encoding(self, app, client): + response = await client.post( + "/graphql", + data=json.dumps([dict(id=1, query="{test}")]), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == [{"data": {"test": "Hello World"}}] + + @pytest.mark.asyncio + @pytest.mark.parametrize("app", [create_app(batch=True)]) + async def test_batch_supports_post_json_query_with_json_variables( + self, app, client + ): + response = await client.post( + "/graphql", + data=json.dumps( + [ + dict( + id=1, + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ) + ] + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == [{"data": {"test": "Hello Dolly"}}] + + @pytest.mark.asyncio + @pytest.mark.parametrize("app", [create_app(batch=True)]) + async def test_batch_allows_post_with_operation_name(self, app, client): + response = await client.post( + "/graphql", + data=json.dumps( + [ + dict( + id=1, + 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", + ) + ] + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == [ + {"data": {"test": "Hello World", "shared": "Hello Everyone"}} + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(schema=AsyncSchema, enable_async=True)]) +async def test_async_schema(app, client): + response = await client.get(url_string(query="{a,b,c}")) + + assert response.status == 200 + assert await response.json() == {"data": {"a": "hey", "b": "hey2", "c": "hey3"}} + + +@pytest.mark.asyncio +async def test_preflight_request(client): + response = await client.options( + "/graphql", headers={"Access-Control-Request-Method": "POST"}, + ) + + assert response.status == 200 + + +@pytest.mark.asyncio +async def test_preflight_incorrect_request(client): + response = await client.options( + "/graphql", headers={"Access-Control-Request-Method": "OPTIONS"}, + ) + + assert response.status == 400 From eaf75e6e1ee243e71eafe46c3401a97deb0e85df Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Sun, 5 Jul 2020 09:19:24 -0500 Subject: [PATCH 031/108] Merge webob-graphql (#45) * refactor: add webob-graphql as optional feature * fix render template on webob * fix context on webob graphqlview * fix last missing test of webob graphiqlview * styles: apply black formatting --- graphql_server/webob/__init__.py | 3 + graphql_server/webob/graphqlview.py | 148 ++++++ graphql_server/webob/render_graphiql.py | 172 +++++++ setup.py | 6 + tests/aiohttp/test_graphiqlview.py | 11 +- tests/webob/__init__.py | 0 tests/webob/app.py | 46 ++ tests/webob/schema.py | 43 ++ tests/webob/test_graphiqlview.py | 43 ++ tests/webob/test_graphqlview.py | 571 ++++++++++++++++++++++++ 10 files changed, 1041 insertions(+), 2 deletions(-) create mode 100644 graphql_server/webob/__init__.py create mode 100644 graphql_server/webob/graphqlview.py create mode 100644 graphql_server/webob/render_graphiql.py create mode 100644 tests/webob/__init__.py create mode 100644 tests/webob/app.py create mode 100644 tests/webob/schema.py create mode 100644 tests/webob/test_graphiqlview.py create mode 100644 tests/webob/test_graphqlview.py diff --git a/graphql_server/webob/__init__.py b/graphql_server/webob/__init__.py new file mode 100644 index 0000000..8f5beaf --- /dev/null +++ b/graphql_server/webob/__init__.py @@ -0,0 +1,3 @@ +from .graphqlview import GraphQLView + +__all__ = ["GraphQLView"] diff --git a/graphql_server/webob/graphqlview.py b/graphql_server/webob/graphqlview.py new file mode 100644 index 0000000..a7cec7a --- /dev/null +++ b/graphql_server/webob/graphqlview.py @@ -0,0 +1,148 @@ +import copy +from collections.abc import MutableMapping +from functools import partial + +from graphql.error import GraphQLError +from graphql.type.schema import GraphQLSchema +from webob import Response + +from graphql_server import ( + HttpQueryError, + encode_execution_results, + format_error_default, + json_encode, + load_json_body, + run_http_query, +) + +from .render_graphiql import render_graphiql + + +class GraphQLView: + schema = None + request = None + root_value = None + context = None + pretty = False + graphiql = False + graphiql_version = None + graphiql_template = None + middleware = None + batch = False + enable_async = False + charset = "UTF-8" + + def __init__(self, **kwargs): + super(GraphQLView, self).__init__() + for key, value in kwargs.items(): + if hasattr(self, key): + setattr(self, key, value) + + assert isinstance( + self.schema, GraphQLSchema + ), "A Schema is required to be provided to GraphQLView." + + def get_root_value(self): + return self.root_value + + def get_context(self, request): + context = ( + copy.copy(self.context) + if self.context and isinstance(self.context, MutableMapping) + else {} + ) + if isinstance(context, MutableMapping) and "request" not in context: + context.update({"request": request}) + return context + + def get_middleware(self): + return self.middleware + + format_error = staticmethod(format_error_default) + encode = staticmethod(json_encode) + + def dispatch_request(self, request): + try: + request_method = request.method.lower() + data = self.parse_body(request) + + show_graphiql = request_method == "get" and self.should_display_graphiql( + request + ) + catch = show_graphiql + + pretty = self.pretty or show_graphiql or request.params.get("pretty") + + execution_results, all_params = run_http_query( + self.schema, + request_method, + data, + query_data=request.params, + batch_enabled=self.batch, + catch=catch, + # Execute options + run_sync=not self.enable_async, + root_value=self.get_root_value(), + context_value=self.get_context(request), + middleware=self.get_middleware(), + ) + result, status_code = encode_execution_results( + execution_results, + is_batch=isinstance(data, list), + format_error=self.format_error, + encode=partial(self.encode, pretty=pretty), # noqa + ) + + if show_graphiql: + return Response( + render_graphiql(params=all_params[0], result=result), + charset=self.charset, + content_type="text/html", + ) + + return Response( + result, + status=status_code, + charset=self.charset, + content_type="application/json", + ) + + except HttpQueryError as e: + parsed_error = GraphQLError(e.message) + return Response( + self.encode(dict(errors=[self.format_error(parsed_error)])), + status=e.status_code, + charset=self.charset, + headers=e.headers or {}, + content_type="application/json", + ) + + # WebOb + @staticmethod + def parse_body(request): + # We use mimetype here since we don't need the other + # information provided by content_type + content_type = request.content_type + if content_type == "application/graphql": + return {"query": request.body.decode("utf8")} + + elif content_type == "application/json": + return load_json_body(request.body.decode("utf8")) + + elif content_type in ( + "application/x-www-form-urlencoded", + "multipart/form-data", + ): + return request.params + + return {} + + def should_display_graphiql(self, request): + if not self.graphiql or "raw" in request.params: + return False + + return self.request_wants_html() + + def request_wants_html(self): + best = self.request.accept.best_match(["application/json", "text/html"]) + return best == "text/html" diff --git a/graphql_server/webob/render_graphiql.py b/graphql_server/webob/render_graphiql.py new file mode 100644 index 0000000..5e9c735 --- /dev/null +++ b/graphql_server/webob/render_graphiql.py @@ -0,0 +1,172 @@ +import json +import re + +GRAPHIQL_VERSION = "0.17.5" + +TEMPLATE = """ + + + + + + + + + + + + + + +""" + + +def escape_js_value(value): + quotation = False + if value.startswith('"') and value.endswith('"'): + quotation = True + value = value[1 : len(value) - 1] + + value = value.replace("\\\\n", "\\\\\\n").replace("\\n", "\\\\n") + if quotation: + value = '"' + value.replace('\\\\"', '"').replace('"', '\\"') + '"' + + return value + + +def process_var(template, name, value, jsonify=False): + pattern = r"{{\s*" + name + r"(\s*|[^}]+)*\s*}}" + if jsonify and value not in ["null", "undefined"]: + value = json.dumps(value) + value = escape_js_value(value) + + return re.sub(pattern, value, template) + + +def simple_renderer(template, **values): + replace = ["graphiql_version"] + replace_jsonify = ["query", "result", "variables", "operation_name"] + + for r in replace: + template = process_var(template, r, values.get(r, "")) + + for r in replace_jsonify: + template = process_var(template, r, values.get(r, ""), True) + + return template + + +def render_graphiql( + graphiql_version=None, graphiql_template=None, params=None, result=None, +): + graphiql_version = graphiql_version or GRAPHIQL_VERSION + template = graphiql_template or TEMPLATE + + template_vars = { + "graphiql_version": graphiql_version, + "query": params and params.query, + "variables": params and params.variables, + "operation_name": params and params.operation_name, + "result": result, + } + + source = simple_renderer(template, **template_vars) + return source diff --git a/setup.py b/setup.py index 6135166..8977038 100644 --- a/setup.py +++ b/setup.py @@ -27,6 +27,10 @@ "sanic>=19.9.0,<20", ] +install_webob_requires = [ + "webob>=1.8.6,<2", +] + install_aiohttp_requires = [ "aiohttp>=3.5.0,<4", ] @@ -35,6 +39,7 @@ install_requires + \ install_flask_requires + \ install_sanic_requires + \ + install_webob_requires + \ install_aiohttp_requires setup( @@ -67,6 +72,7 @@ "dev": install_all_requires + dev_requires, "flask": install_flask_requires, "sanic": install_sanic_requires, + "webob": install_webob_requires, "aiohttp": install_aiohttp_requires, }, include_package_data=True, diff --git a/tests/aiohttp/test_graphiqlview.py b/tests/aiohttp/test_graphiqlview.py index 04a9b50..dfe442a 100644 --- a/tests/aiohttp/test_graphiqlview.py +++ b/tests/aiohttp/test_graphiqlview.py @@ -61,15 +61,22 @@ async def test_graphiql_simple_renderer(app, client, pretty_response): class TestJinjaEnv: @pytest.mark.asyncio @pytest.mark.parametrize( - "app", [create_app(graphiql=True, jinja_env=Environment())] + "app", [create_app(graphiql=True, jinja_env=Environment(enable_async=True))] ) - async def test_graphiql_jinja_renderer(self, app, client, pretty_response): + async def test_graphiql_jinja_renderer_async(self, app, client, pretty_response): response = await client.get( url_string(query="{test}"), headers={"Accept": "text/html"}, ) assert response.status == 200 assert pretty_response in await response.text() + async def test_graphiql_jinja_renderer_sync(self, app, client, pretty_response): + response = client.get( + url_string(query="{test}"), headers={"Accept": "text/html"}, + ) + assert response.status == 200 + assert pretty_response in response.text() + @pytest.mark.asyncio async def test_graphiql_html_is_not_accepted(client): diff --git a/tests/webob/__init__.py b/tests/webob/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/webob/app.py b/tests/webob/app.py new file mode 100644 index 0000000..c490515 --- /dev/null +++ b/tests/webob/app.py @@ -0,0 +1,46 @@ +from urllib.parse import urlencode + +from webob import Request + +from graphql_server.webob import GraphQLView +from tests.webob.schema import Schema + + +def url_string(**url_params): + string = "/graphql" + + if url_params: + string += "?" + urlencode(url_params) + + return string + + +class Client(object): + def __init__(self, **kwargs): + self.schema = kwargs.pop("schema", None) or Schema + self.settings = kwargs.pop("settings", None) or {} + + def get(self, url, **extra): + request = Request.blank(url, method="GET", **extra) + context = self.settings.pop("context", request) + response = GraphQLView( + request=request, schema=self.schema, context=context, **self.settings + ) + return response.dispatch_request(request) + + def post(self, url, **extra): + extra["POST"] = extra.pop("data") + request = Request.blank(url, method="POST", **extra) + context = self.settings.pop("context", request) + response = GraphQLView( + request=request, schema=self.schema, context=context, **self.settings + ) + return response.dispatch_request(request) + + def put(self, url, **extra): + request = Request.blank(url, method="PUT", **extra) + context = self.settings.pop("context", request) + response = GraphQLView( + request=request, schema=self.schema, context=context, **self.settings + ) + return response.dispatch_request(request) diff --git a/tests/webob/schema.py b/tests/webob/schema.py new file mode 100644 index 0000000..f00f14f --- /dev/null +++ b/tests/webob/schema.py @@ -0,0 +1,43 @@ +from graphql.type.definition import ( + GraphQLArgument, + GraphQLField, + GraphQLNonNull, + GraphQLObjectType, +) +from graphql.type.scalars import GraphQLString +from graphql.type.schema import GraphQLSchema + + +def resolve_raises(*_): + raise Exception("Throws!") + + +# Sync schema +QueryRootType = GraphQLObjectType( + name="QueryRoot", + fields={ + "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_raises), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"].params.get("q"), + ), + "context": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"], + ), + "test": GraphQLField( + type_=GraphQLString, + args={"who": GraphQLArgument(GraphQLString)}, + resolve=lambda obj, info, who=None: "Hello %s" % (who or "World"), + ), + }, +) + +MutationRootType = GraphQLObjectType( + name="MutationRoot", + fields={ + "writeTest": GraphQLField(type_=QueryRootType, resolve=lambda *_: QueryRootType) + }, +) + +Schema = GraphQLSchema(QueryRootType, MutationRootType) diff --git a/tests/webob/test_graphiqlview.py b/tests/webob/test_graphiqlview.py new file mode 100644 index 0000000..dbfe627 --- /dev/null +++ b/tests/webob/test_graphiqlview.py @@ -0,0 +1,43 @@ +import pytest + +from .app import Client, url_string + + +@pytest.fixture +def settings(): + return {} + + +@pytest.fixture +def client(settings): + return Client(settings=settings) + + +@pytest.fixture +def pretty_response(): + return ( + "{\n" + ' "data": {\n' + ' "test": "Hello World"\n' + " }\n" + "}".replace('"', '\\"').replace("\n", "\\n") + ) + + +@pytest.mark.parametrize("settings", [dict(graphiql=True)]) +def test_graphiql_is_enabled(client, settings): + response = client.get(url_string(query="{test}"), headers={"Accept": "text/html"}) + assert response.status_code == 200 + + +@pytest.mark.parametrize("settings", [dict(graphiql=True)]) +def test_graphiql_simple_renderer(client, settings, pretty_response): + response = client.get(url_string(query="{test}"), headers={"Accept": "text/html"}) + assert response.status_code == 200 + assert pretty_response in response.body.decode("utf-8") + + +@pytest.mark.parametrize("settings", [dict(graphiql=True)]) +def test_graphiql_html_is_not_accepted(client, settings): + response = client.get(url_string(), headers={"Accept": "application/json"}) + assert response.status_code == 400 diff --git a/tests/webob/test_graphqlview.py b/tests/webob/test_graphqlview.py new file mode 100644 index 0000000..6b5f37c --- /dev/null +++ b/tests/webob/test_graphqlview.py @@ -0,0 +1,571 @@ +import json +from urllib.parse import urlencode + +import pytest + +from .app import Client, url_string + + +@pytest.fixture +def settings(): + return {} + + +@pytest.fixture +def client(settings): + return Client(settings=settings) + + +def response_json(response): + return json.loads(response.body.decode()) + + +def json_dump_kwarg(**kwargs): + return json.dumps(kwargs) + + +def json_dump_kwarg_list(**kwargs): + return json.dumps([kwargs]) + + +def test_allows_get_with_query_param(client): + response = client.get(url_string(query="{test}")) + assert response.status_code == 200, response.status + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_allows_get_with_variable_values(client): + response = client.get( + url_string( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_allows_get_with_operation_name(client): + response = client.get( + url_string( + 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 response.status_code == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +def test_reports_validation_errors(client): + response = client.get(url_string(query="{ test, unknownOne, unknownTwo }")) + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 9}], + "path": None, + }, + { + "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 21}], + "path": None, + }, + ] + } + + +def test_errors_when_missing_operation_name(client): + response = client.get( + url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """ + ) + ) + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Must provide operation name if query contains multiple operations.", + "locations": None, + "path": None, + } + ] + } + + +def test_errors_when_sending_a_mutation_via_get(client): + response = client.get( + url_string( + query=""" + mutation TestMutation { writeTest { test } } + """ + ) + ) + assert response.status_code == 405 + assert response_json(response) == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + "locations": None, + "path": None, + } + ] + } + + +def test_errors_when_selecting_a_mutation_within_a_get(client): + response = client.get( + url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestMutation", + ) + ) + + assert response.status_code == 405 + assert response_json(response) == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + "locations": None, + "path": None, + } + ] + } + + +def test_allows_mutation_to_exist_within_a_get(client): + response = client.get( + url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestQuery", + ) + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_allows_post_with_json_encoding(client): + response = client.post( + url_string(), + data=json_dump_kwarg(query="{test}"), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_allows_sending_a_mutation_via_post(client): + response = client.post( + url_string(), + data=json_dump_kwarg(query="mutation TestMutation { writeTest { test } }"), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} + + +def test_allows_post_with_url_encoding(client): + response = client.post( + url_string(), + data=urlencode(dict(query="{test}")), + content_type="application/x-www-form-urlencoded", + ) + + # assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_supports_post_json_query_with_string_variables(client): + response = client.post( + url_string(), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_json_query_with_json_variables(client): + response = client.post( + url_string(), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_url_encoded_query_with_string_variables(client): + response = client.post( + url_string(), + data=urlencode( + dict( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + ), + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_json_quey_with_get_variable_values(client): + response = client.post( + url_string(variables=json.dumps({"who": "Dolly"})), + data=json_dump_kwarg(query="query helloWho($who: String){ test(who: $who) }",), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_post_url_encoded_query_with_get_variable_values(client): + response = client.post( + url_string(variables=json.dumps({"who": "Dolly"})), + data=urlencode(dict(query="query helloWho($who: String){ test(who: $who) }",)), + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_raw_text_query_with_get_variable_values(client): + response = client.post( + url_string(variables=json.dumps({"who": "Dolly"})), + data="query helloWho($who: String){ test(who: $who) }", + content_type="application/graphql", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_allows_post_with_operation_name(client): + response = client.post( + url_string(), + data=json_dump_kwarg( + 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", + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +def test_allows_post_with_get_operation_name(client): + response = client.post( + url_string(operationName="helloWorld"), + data=""" + 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") + } + """, + content_type="application/graphql", + ) + + assert response.status_code == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.parametrize("settings", [dict(pretty=True)]) +def test_supports_pretty_printing(client, settings): + response = client.get(url_string(query="{test}")) + + assert response.body.decode() == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) + + +@pytest.mark.parametrize("settings", [dict(pretty=False)]) +def test_not_pretty_by_default(client, settings): + response = client.get(url_string(query="{test}")) + + assert response.body.decode() == '{"data":{"test":"Hello World"}}' + + +def test_supports_pretty_printing_by_request(client): + response = client.get(url_string(query="{test}", pretty="1")) + + assert response.body.decode() == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) + + +def test_handles_field_errors_caught_by_graphql(client): + response = client.get(url_string(query="{thrower}")) + assert response.status_code == 200 + assert response_json(response) == { + "data": None, + "errors": [ + { + "message": "Throws!", + "locations": [{"column": 2, "line": 1}], + "path": ["thrower"], + } + ], + } + + +def test_handles_syntax_errors_caught_by_graphql(client): + response = client.get(url_string(query="syntaxerror")) + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Syntax Error: Unexpected Name 'syntaxerror'.", + "locations": [{"column": 1, "line": 1}], + "path": None, + } + ] + } + + +def test_handles_errors_caused_by_a_lack_of_query(client): + response = client.get(url_string()) + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + {"message": "Must provide query string.", "locations": None, "path": None} + ] + } + + +def test_handles_batch_correctly_if_is_disabled(client): + response = client.post(url_string(), data="[]", content_type="application/json") + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Batch GraphQL requests are not enabled.", + "locations": None, + "path": None, + } + ] + } + + +def test_handles_incomplete_json_bodies(client): + response = client.post( + url_string(), data='{"query":', content_type="application/json" + ) + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + {"message": "POST body sent invalid JSON.", "locations": None, "path": None} + ] + } + + +def test_handles_plain_post_text(client): + response = client.post( + url_string(variables=json.dumps({"who": "Dolly"})), + data="query helloWho($who: String){ test(who: $who) }", + content_type="text/plain", + ) + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + {"message": "Must provide query string.", "locations": None, "path": None} + ] + } + + +def test_handles_poorly_formed_variables(client): + response = client.get( + url_string( + query="query helloWho($who: String){ test(who: $who) }", variables="who:You" + ) + ) + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + {"message": "Variables are invalid JSON.", "locations": None, "path": None} + ] + } + + +def test_handles_unsupported_http_methods(client): + response = client.put(url_string(query="{test}")) + assert response.status_code == 405 + assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] + assert response_json(response) == { + "errors": [ + { + "message": "GraphQL only supports GET and POST requests.", + "locations": None, + "path": None, + } + ] + } + + +def test_passes_request_into_request_context(client): + response = client.get(url_string(query="{request}", q="testing")) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"request": "testing"}} + + +@pytest.mark.parametrize("settings", [dict(context="CUSTOM CONTEXT")]) +def test_supports_custom_context(client, settings): + response = client.get(url_string(query="{context}")) + + assert response.status_code == 200 + assert "data" in response_json(response) + assert ( + response_json(response)["data"]["context"] + == "GET /graphql?query=%7Bcontext%7D HTTP/1.0\r\nHost: localhost:80" + ) + + +def test_post_multipart_data(client): + query = "mutation TestMutation { writeTest { test } }" + data = ( + "------webobgraphql\r\n" + + 'Content-Disposition: form-data; name="query"\r\n' + + "\r\n" + + query + + "\r\n" + + "------webobgraphql--\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + 'Content-Disposition: form-data; name="file"; filename="text1.txt"; filename*=utf-8\'\'text1.txt\r\n' + + "\r\n" + + "\r\n" + + "------webobgraphql--\r\n" + ) + + response = client.post( + url_string(), + data=data, + content_type="multipart/form-data; boundary=----webobgraphql", + ) + + assert response.status_code == 200 + assert response_json(response) == { + "data": {u"writeTest": {u"test": u"Hello World"}} + } + + +@pytest.mark.parametrize("settings", [dict(batch=True)]) +def test_batch_allows_post_with_json_encoding(client, settings): + response = client.post( + url_string(), + data=json_dump_kwarg_list( + # id=1, + query="{test}" + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == [ + { + # 'id': 1, + "data": {"test": "Hello World"} + } + ] + + +@pytest.mark.parametrize("settings", [dict(batch=True)]) +def test_batch_supports_post_json_query_with_json_variables(client, settings): + response = client.post( + url_string(), + data=json_dump_kwarg_list( + # id=1, + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == [ + { + # 'id': 1, + "data": {"test": "Hello Dolly"} + } + ] + + +@pytest.mark.parametrize("settings", [dict(batch=True)]) +def test_batch_allows_post_with_operation_name(client, settings): + response = client.post( + url_string(), + data=json_dump_kwarg_list( + # id=1, + 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", + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == [ + { + # 'id': 1, + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + ] From accfef41bfabbfc4f00c1a8b6b04522ded036461 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Sat, 11 Jul 2020 09:12:06 -0500 Subject: [PATCH 032/108] refactor: graphiql template shared across servers (#49) * refactor: graphiql template shared across servers * chore: add typing-extensions to setup py * feat: add headers and should persist headers props * chore: mypy issues * fix: pass config to webob render graphiql * refactor: pass arguments instead of spread * chore: add pytest-asyncio and bump pytest dep --- graphql_server/aiohttp/graphqlview.py | 41 ++- graphql_server/aiohttp/render_graphiql.py | 208 -------------- graphql_server/flask/graphqlview.py | 44 ++- graphql_server/flask/render_graphiql.py | 148 ---------- graphql_server/render_graphiql.py | 330 ++++++++++++++++++++++ graphql_server/sanic/graphqlview.py | 43 ++- graphql_server/sanic/render_graphiql.py | 185 ------------ graphql_server/webob/graphqlview.py | 29 +- graphql_server/webob/render_graphiql.py | 172 ----------- setup.py | 4 +- tests/aiohttp/test_graphiqlview.py | 9 +- tox.ini | 2 +- 12 files changed, 448 insertions(+), 767 deletions(-) delete mode 100644 graphql_server/aiohttp/render_graphiql.py delete mode 100644 graphql_server/flask/render_graphiql.py create mode 100644 graphql_server/render_graphiql.py delete mode 100644 graphql_server/sanic/render_graphiql.py delete mode 100644 graphql_server/webob/render_graphiql.py diff --git a/graphql_server/aiohttp/graphqlview.py b/graphql_server/aiohttp/graphqlview.py index 9581e12..9d28f02 100644 --- a/graphql_server/aiohttp/graphqlview.py +++ b/graphql_server/aiohttp/graphqlview.py @@ -1,12 +1,14 @@ import copy from collections.abc import MutableMapping from functools import partial +from typing import List from aiohttp import web from graphql import GraphQLError from graphql.type.schema import GraphQLSchema from graphql_server import ( + GraphQLParams, HttpQueryError, encode_execution_results, format_error_default, @@ -14,8 +16,11 @@ load_json_body, run_http_query, ) - -from .render_graphiql import render_graphiql +from graphql_server.render_graphiql import ( + GraphiQLConfig, + GraphiQLData, + render_graphiql_async, +) class GraphQLView: @@ -26,12 +31,14 @@ class GraphQLView: graphiql = False graphiql_version = None graphiql_template = None + graphiql_html_title = None middleware = None batch = False jinja_env = None max_age = 86400 enable_async = False subscriptions = None + headers = None accepted_methods = ["GET", "POST", "PUT", "DELETE"] @@ -88,16 +95,6 @@ async def parse_body(self, request): return {} - def render_graphiql(self, params, result): - return render_graphiql( - jinja_env=self.jinja_env, - params=params, - result=result, - graphiql_version=self.graphiql_version, - graphiql_template=self.graphiql_template, - subscriptions=self.subscriptions, - ) - # TODO: # use this method to replace flask and sanic # checks as this is equivalent to `should_display_graphiql` and @@ -135,6 +132,7 @@ async def __call__(self, request): if request_method == "options": return self.process_preflight(request) + all_params: List[GraphQLParams] execution_results, all_params = run_http_query( self.schema, request_method, @@ -162,7 +160,24 @@ async def __call__(self, request): ) if is_graphiql: - return await self.render_graphiql(params=all_params[0], result=result) + graphiql_data = GraphiQLData( + result=result, + query=getattr(all_params[0], "query"), + variables=getattr(all_params[0], "variables"), + operation_name=getattr(all_params[0], "operation_name"), + subscription_url=self.subscriptions, + headers=self.headers, + ) + graphiql_config = GraphiQLConfig( + graphiql_version=self.graphiql_version, + graphiql_template=self.graphiql_template, + graphiql_html_title=self.graphiql_html_title, + jinja_env=self.jinja_env, + ) + source = await render_graphiql_async( + data=graphiql_data, config=graphiql_config + ) + return web.Response(text=source, content_type="text/html") return web.Response( text=result, status=status_code, content_type="application/json", diff --git a/graphql_server/aiohttp/render_graphiql.py b/graphql_server/aiohttp/render_graphiql.py deleted file mode 100644 index 9da47d3..0000000 --- a/graphql_server/aiohttp/render_graphiql.py +++ /dev/null @@ -1,208 +0,0 @@ -import json -import re - -from aiohttp import web - -GRAPHIQL_VERSION = "0.17.5" - -TEMPLATE = """ - - - - - - - - - - - - - - - - -""" - - -def escape_js_value(value): - quotation = False - if value.startswith('"') and value.endswith('"'): - quotation = True - value = value[1:-1] - - value = value.replace("\\\\n", "\\\\\\n").replace("\\n", "\\\\n") - if quotation: - value = '"' + value.replace('\\\\"', '"').replace('"', '\\"') + '"' - - return value - - -def process_var(template, name, value, jsonify=False): - pattern = r"{{\s*" + name + r"(\s*|[^}]+)*\s*}}" - if jsonify and value not in ["null", "undefined"]: - value = json.dumps(value) - value = escape_js_value(value) - - return re.sub(pattern, value, template) - - -def simple_renderer(template, **values): - replace = ["graphiql_version", "subscriptions"] - replace_jsonify = ["query", "result", "variables", "operation_name"] - - for rep in replace: - template = process_var(template, rep, values.get(rep, "")) - - for rep in replace_jsonify: - template = process_var(template, rep, values.get(rep, ""), True) - - return template - - -async def render_graphiql( - jinja_env=None, - graphiql_version=None, - graphiql_template=None, - params=None, - result=None, - subscriptions=None, -): - graphiql_version = graphiql_version or GRAPHIQL_VERSION - template = graphiql_template or TEMPLATE - template_vars = { - "graphiql_version": graphiql_version, - "query": params and params.query, - "variables": params and params.variables, - "operation_name": params and params.operation_name, - "result": result, - "subscriptions": subscriptions or "", - } - - if jinja_env: - template = jinja_env.from_string(template) - if jinja_env.is_async: - source = await template.render_async(**template_vars) - else: - source = template.render(**template_vars) - else: - source = simple_renderer(template, **template_vars) - - return web.Response(text=source, content_type="text/html") diff --git a/graphql_server/flask/graphqlview.py b/graphql_server/flask/graphqlview.py index 1a2f9af..9108a41 100644 --- a/graphql_server/flask/graphqlview.py +++ b/graphql_server/flask/graphqlview.py @@ -1,11 +1,13 @@ from functools import partial +from typing import List -from flask import Response, request +from flask import Response, render_template_string, request from flask.views import View from graphql.error import GraphQLError from graphql.type.schema import GraphQLSchema from graphql_server import ( + GraphQLParams, HttpQueryError, encode_execution_results, format_error_default, @@ -13,8 +15,11 @@ load_json_body, run_http_query, ) - -from .render_graphiql import render_graphiql +from graphql_server.render_graphiql import ( + GraphiQLConfig, + GraphiQLData, + render_graphiql_sync, +) class GraphQLView(View): @@ -27,6 +32,8 @@ class GraphQLView(View): graphiql_html_title = None middleware = None batch = False + subscriptions = None + headers = None methods = ["GET", "POST", "PUT", "DELETE"] @@ -50,15 +57,6 @@ def get_context_value(self): def get_middleware(self): return self.middleware - def render_graphiql(self, params, result): - return render_graphiql( - params=params, - result=result, - graphiql_version=self.graphiql_version, - graphiql_template=self.graphiql_template, - graphiql_html_title=self.graphiql_html_title, - ) - format_error = staticmethod(format_error_default) encode = staticmethod(json_encode) @@ -72,6 +70,7 @@ def dispatch_request(self): pretty = self.pretty or show_graphiql or request.args.get("pretty") + all_params: List[GraphQLParams] execution_results, all_params = run_http_query( self.schema, request_method, @@ -88,11 +87,28 @@ def dispatch_request(self): execution_results, is_batch=isinstance(data, list), format_error=self.format_error, - encode=partial(self.encode, pretty=pretty), + encode=partial(self.encode, pretty=pretty), # noqa ) if show_graphiql: - return self.render_graphiql(params=all_params[0], result=result) + graphiql_data = GraphiQLData( + result=result, + query=getattr(all_params[0], "query"), + variables=getattr(all_params[0], "variables"), + operation_name=getattr(all_params[0], "operation_name"), + subscription_url=self.subscriptions, + headers=self.headers, + ) + graphiql_config = GraphiQLConfig( + graphiql_version=self.graphiql_version, + graphiql_template=self.graphiql_template, + graphiql_html_title=self.graphiql_html_title, + jinja_env=None, + ) + source = render_graphiql_sync( + data=graphiql_data, config=graphiql_config + ) + return render_template_string(source) return Response(result, status=status_code, content_type="application/json") diff --git a/graphql_server/flask/render_graphiql.py b/graphql_server/flask/render_graphiql.py deleted file mode 100644 index d395d44..0000000 --- a/graphql_server/flask/render_graphiql.py +++ /dev/null @@ -1,148 +0,0 @@ -from flask import render_template_string - -GRAPHIQL_VERSION = "0.11.11" - -TEMPLATE = """ - - - - {{graphiql_html_title|default("GraphiQL", true)}} - - - - - - - - - - - -""" - - -def render_graphiql( - params, - result, - graphiql_version=None, - graphiql_template=None, - graphiql_html_title=None, -): - graphiql_version = graphiql_version or GRAPHIQL_VERSION - template = graphiql_template or TEMPLATE - - return render_template_string( - template, - graphiql_version=graphiql_version, - graphiql_html_title=graphiql_html_title, - result=result, - params=params, - ) diff --git a/graphql_server/render_graphiql.py b/graphql_server/render_graphiql.py new file mode 100644 index 0000000..8ae4107 --- /dev/null +++ b/graphql_server/render_graphiql.py @@ -0,0 +1,330 @@ +"""Based on (express-graphql)[https://github.com/graphql/express-graphql/blob/master/src/renderGraphiQL.js] and +(subscriptions-transport-ws)[https://github.com/apollographql/subscriptions-transport-ws]""" +import json +import re +from typing import Any, Dict, Optional, Tuple + +from jinja2 import Environment +from typing_extensions import TypedDict + +GRAPHIQL_VERSION = "1.0.3" + +GRAPHIQL_TEMPLATE = """ + + + + + {{graphiql_html_title}} + + + + + + + + + + + + + + +
Loading...
+ + +""" + + +class GraphiQLData(TypedDict): + """GraphiQL ReactDom Data + + Has the following attributes: + + subscription_url + The GraphiQL socket endpoint for using subscriptions in graphql-ws. + headers + An optional GraphQL string to use as the initial displayed request headers, + if None is provided, the stored headers will be used. + """ + + query: Optional[str] + variables: Optional[str] + operation_name: Optional[str] + result: Optional[str] + subscription_url: Optional[str] + headers: Optional[str] + + +class GraphiQLConfig(TypedDict): + """GraphiQL Extra Config + + Has the following attributes: + + graphiql_version + The version of the provided GraphiQL package. + graphiql_template + Inject a Jinja template string to customize GraphiQL. + graphiql_html_title + Replace the default html title on the 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. + """ + + graphiql_version: Optional[str] + graphiql_template: Optional[str] + graphiql_html_title: Optional[str] + jinja_env: Optional[Environment] + + +class GraphiQLOptions(TypedDict): + """GraphiQL options to display on the UI. + + Has the following attributes: + + default_query + An optional GraphQL string to use when no query is provided and no stored + query exists from a previous session. If undefined is 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. + """ + + default_query: Optional[str] + header_editor_enabled: Optional[bool] + should_persist_headers: Optional[bool] + + +def escape_js_value(value: Any) -> Any: + quotation = False + if value.startswith('"') and value.endswith('"'): + quotation = True + value = value[1 : len(value) - 1] + + value = value.replace("\\\\n", "\\\\\\n").replace("\\n", "\\\\n") + if quotation: + value = '"' + value.replace('\\\\"', '"').replace('"', '\\"') + '"' + + return value + + +def process_var(template: str, name: str, value: Any, jsonify=False) -> str: + pattern = r"{{\s*" + name + r"(\s*|[^}]+)*\s*}}" + if jsonify and value not in ["null", "undefined"]: + value = json.dumps(value) + value = escape_js_value(value) + + return re.sub(pattern, value, template) + + +def simple_renderer(template: str, **values: Dict[str, Any]) -> str: + replace = [ + "graphiql_version", + "graphiql_html_title", + "subscription_url", + "header_editor_enabled", + "should_persist_headers", + ] + replace_jsonify = [ + "query", + "result", + "variables", + "operation_name", + "default_query", + "headers", + ] + + for r in replace: + template = process_var(template, r, values.get(r, "")) + + for r in replace_jsonify: + template = process_var(template, r, values.get(r, ""), True) + + return template + + +def _render_graphiql( + data: GraphiQLData, + config: GraphiQLConfig, + options: Optional[GraphiQLOptions] = None, +) -> Tuple[str, Dict[str, Any]]: + """When render_graphiql receives a request which does not Accept JSON, but does + Accept HTML, it may present GraphiQL, the in-browser GraphQL explorer IDE. + When shown, it will be pre-populated with the result of having executed + the requested query. + """ + graphiql_version = config.get("graphiql_version") or GRAPHIQL_VERSION + graphiql_template = config.get("graphiql_template") or GRAPHIQL_TEMPLATE + graphiql_html_title = config.get("graphiql_html_title") or "GraphiQL" + + template_vars: Dict[str, Any] = { + "graphiql_version": graphiql_version, + "graphiql_html_title": graphiql_html_title, + "query": data.get("query"), + "variables": data.get("variables"), + "operation_name": data.get("operation_name"), + "result": data.get("result"), + "subscription_url": data.get("subscription_url") or "", + "headers": data.get("headers") or "", + "default_query": options and options.get("default_query") or "", + "header_editor_enabled": options + and options.get("header_editor_enabled") + or "true", + "should_persist_headers": options + and options.get("should_persist_headers") + or "false", + } + + return graphiql_template, template_vars + + +async def render_graphiql_async( + data: GraphiQLData, + config: GraphiQLConfig, + options: Optional[GraphiQLOptions] = None, +) -> str: + graphiql_template, template_vars = _render_graphiql(data, config, options) + jinja_env: Optional[Environment] = config.get("jinja_env") + + if jinja_env: + # This method returns a Template. See https://jinja.palletsprojects.com/en/2.11.x/api/#jinja2.Template + template = jinja_env.from_string(graphiql_template) + if jinja_env.is_async: # type: ignore + source = await template.render_async(**template_vars) + else: + source = template.render(**template_vars) + else: + source = simple_renderer(graphiql_template, **template_vars) + return source + + +def render_graphiql_sync( + data: GraphiQLData, + config: GraphiQLConfig, + options: Optional[GraphiQLOptions] = None, +) -> str: + graphiql_template, template_vars = _render_graphiql(data, config, options) + + source = simple_renderer(graphiql_template, **template_vars) + return source diff --git a/graphql_server/sanic/graphqlview.py b/graphql_server/sanic/graphqlview.py index fd22af2..8e2c7b8 100644 --- a/graphql_server/sanic/graphqlview.py +++ b/graphql_server/sanic/graphqlview.py @@ -2,13 +2,15 @@ from cgi import parse_header from collections.abc import MutableMapping from functools import partial +from typing import List from graphql import GraphQLError from graphql.type.schema import GraphQLSchema -from sanic.response import HTTPResponse +from sanic.response import HTTPResponse, html from sanic.views import HTTPMethodView from graphql_server import ( + GraphQLParams, HttpQueryError, encode_execution_results, format_error_default, @@ -16,8 +18,11 @@ load_json_body, run_http_query, ) - -from .render_graphiql import render_graphiql +from graphql_server.render_graphiql import ( + GraphiQLConfig, + GraphiQLData, + render_graphiql_async, +) class GraphQLView(HTTPMethodView): @@ -28,11 +33,14 @@ class GraphQLView(HTTPMethodView): graphiql = False graphiql_version = None graphiql_template = None + graphiql_html_title = None middleware = None batch = False jinja_env = None max_age = 86400 enable_async = False + subscriptions = None + headers = None methods = ["GET", "POST", "PUT", "DELETE"] @@ -62,15 +70,6 @@ def get_context(self, request): def get_middleware(self): return self.middleware - async def render_graphiql(self, params, result): - return await render_graphiql( - jinja_env=self.jinja_env, - params=params, - result=result, - graphiql_version=self.graphiql_version, - graphiql_template=self.graphiql_template, - ) - format_error = staticmethod(format_error_default) encode = staticmethod(json_encode) @@ -87,6 +86,7 @@ async def dispatch_request(self, request, *args, **kwargs): pretty = self.pretty or show_graphiql or request.args.get("pretty") if request_method != "options": + all_params: List[GraphQLParams] execution_results, all_params = run_http_query( self.schema, request_method, @@ -113,9 +113,24 @@ async def dispatch_request(self, request, *args, **kwargs): ) if show_graphiql: - return await self.render_graphiql( - params=all_params[0], result=result + graphiql_data = GraphiQLData( + result=result, + query=getattr(all_params[0], "query"), + variables=getattr(all_params[0], "variables"), + operation_name=getattr(all_params[0], "operation_name"), + subscription_url=self.subscriptions, + headers=self.headers, + ) + graphiql_config = GraphiQLConfig( + graphiql_version=self.graphiql_version, + graphiql_template=self.graphiql_template, + graphiql_html_title=self.graphiql_html_title, + jinja_env=self.jinja_env, + ) + source = await render_graphiql_async( + data=graphiql_data, config=graphiql_config ) + return html(source) return HTTPResponse( result, status=status_code, content_type="application/json" diff --git a/graphql_server/sanic/render_graphiql.py b/graphql_server/sanic/render_graphiql.py deleted file mode 100644 index ca21ee3..0000000 --- a/graphql_server/sanic/render_graphiql.py +++ /dev/null @@ -1,185 +0,0 @@ -import json -import re - -from sanic.response import html - -GRAPHIQL_VERSION = "0.7.1" - -TEMPLATE = """ - - - - - - - - - - - - - - -""" - - -def escape_js_value(value): - quotation = False - if value.startswith('"') and value.endswith('"'): - quotation = True - value = value[1 : len(value) - 1] - - value = value.replace("\\\\n", "\\\\\\n").replace("\\n", "\\\\n") - if quotation: - value = '"' + value.replace('\\\\"', '"').replace('"', '\\"') + '"' - - return value - - -def process_var(template, name, value, jsonify=False): - pattern = r"{{\s*" + name + r"(\s*|[^}]+)*\s*}}" - if jsonify and value not in ["null", "undefined"]: - value = json.dumps(value) - value = escape_js_value(value) - - return re.sub(pattern, value, template) - - -def simple_renderer(template, **values): - replace = ["graphiql_version"] - replace_jsonify = ["query", "result", "variables", "operation_name"] - - for r in replace: - template = process_var(template, r, values.get(r, "")) - - for r in replace_jsonify: - template = process_var(template, r, values.get(r, ""), True) - - return template - - -async def render_graphiql( - jinja_env=None, - graphiql_version=None, - graphiql_template=None, - params=None, - result=None, -): - graphiql_version = graphiql_version or GRAPHIQL_VERSION - template = graphiql_template or TEMPLATE - template_vars = { - "graphiql_version": graphiql_version, - "query": params and params.query, - "variables": params and params.variables, - "operation_name": params and params.operation_name, - "result": result, - } - - if jinja_env: - template = jinja_env.from_string(template) - if jinja_env.is_async: - source = await template.render_async(**template_vars) - else: - source = template.render(**template_vars) - else: - source = simple_renderer(template, **template_vars) - - return html(source) diff --git a/graphql_server/webob/graphqlview.py b/graphql_server/webob/graphqlview.py index a7cec7a..6a32c5b 100644 --- a/graphql_server/webob/graphqlview.py +++ b/graphql_server/webob/graphqlview.py @@ -1,12 +1,14 @@ import copy from collections.abc import MutableMapping from functools import partial +from typing import List from graphql.error import GraphQLError from graphql.type.schema import GraphQLSchema from webob import Response from graphql_server import ( + GraphQLParams, HttpQueryError, encode_execution_results, format_error_default, @@ -14,8 +16,11 @@ load_json_body, run_http_query, ) - -from .render_graphiql import render_graphiql +from graphql_server.render_graphiql import ( + GraphiQLConfig, + GraphiQLData, + render_graphiql_sync, +) class GraphQLView: @@ -27,9 +32,12 @@ class GraphQLView: graphiql = False graphiql_version = None graphiql_template = None + graphiql_html_title = None middleware = None batch = False enable_async = False + subscriptions = None + headers = None charset = "UTF-8" def __init__(self, **kwargs): @@ -73,6 +81,7 @@ def dispatch_request(self, request): pretty = self.pretty or show_graphiql or request.params.get("pretty") + all_params: List[GraphQLParams] execution_results, all_params = run_http_query( self.schema, request_method, @@ -94,8 +103,22 @@ def dispatch_request(self, request): ) if show_graphiql: + graphiql_data = GraphiQLData( + result=result, + query=getattr(all_params[0], "query"), + variables=getattr(all_params[0], "variables"), + operation_name=getattr(all_params[0], "operation_name"), + subscription_url=self.subscriptions, + headers=self.headers, + ) + graphiql_config = GraphiQLConfig( + graphiql_version=self.graphiql_version, + graphiql_template=self.graphiql_template, + graphiql_html_title=self.graphiql_html_title, + jinja_env=None, + ) return Response( - render_graphiql(params=all_params[0], result=result), + render_graphiql_sync(data=graphiql_data, config=graphiql_config), charset=self.charset, content_type="text/html", ) diff --git a/graphql_server/webob/render_graphiql.py b/graphql_server/webob/render_graphiql.py deleted file mode 100644 index 5e9c735..0000000 --- a/graphql_server/webob/render_graphiql.py +++ /dev/null @@ -1,172 +0,0 @@ -import json -import re - -GRAPHIQL_VERSION = "0.17.5" - -TEMPLATE = """ - - - - - - - - - - - - - - -""" - - -def escape_js_value(value): - quotation = False - if value.startswith('"') and value.endswith('"'): - quotation = True - value = value[1 : len(value) - 1] - - value = value.replace("\\\\n", "\\\\\\n").replace("\\n", "\\\\n") - if quotation: - value = '"' + value.replace('\\\\"', '"').replace('"', '\\"') + '"' - - return value - - -def process_var(template, name, value, jsonify=False): - pattern = r"{{\s*" + name + r"(\s*|[^}]+)*\s*}}" - if jsonify and value not in ["null", "undefined"]: - value = json.dumps(value) - value = escape_js_value(value) - - return re.sub(pattern, value, template) - - -def simple_renderer(template, **values): - replace = ["graphiql_version"] - replace_jsonify = ["query", "result", "variables", "operation_name"] - - for r in replace: - template = process_var(template, r, values.get(r, "")) - - for r in replace_jsonify: - template = process_var(template, r, values.get(r, ""), True) - - return template - - -def render_graphiql( - graphiql_version=None, graphiql_template=None, params=None, result=None, -): - graphiql_version = graphiql_version or GRAPHIQL_VERSION - template = graphiql_template or TEMPLATE - - template_vars = { - "graphiql_version": graphiql_version, - "query": params and params.query, - "variables": params and params.variables, - "operation_name": params and params.operation_name, - "result": result, - } - - source = simple_renderer(template, **template_vars) - return source diff --git a/setup.py b/setup.py index 8977038..4c6aa58 100644 --- a/setup.py +++ b/setup.py @@ -2,10 +2,12 @@ install_requires = [ "graphql-core>=3.1.0,<4", + "typing-extensions>=3.7.4,<4" ] tests_requires = [ - "pytest>=5.3,<5.4", + "pytest>=5.4,<5.5", + "pytest-asyncio>=0.11.0", "pytest-cov>=2.8,<3", "aiohttp>=3.5.0,<4", "Jinja2>=2.10.1,<3", diff --git a/tests/aiohttp/test_graphiqlview.py b/tests/aiohttp/test_graphiqlview.py index dfe442a..a4a7a26 100644 --- a/tests/aiohttp/test_graphiqlview.py +++ b/tests/aiohttp/test_graphiqlview.py @@ -70,13 +70,6 @@ async def test_graphiql_jinja_renderer_async(self, app, client, pretty_response) assert response.status == 200 assert pretty_response in await response.text() - async def test_graphiql_jinja_renderer_sync(self, app, client, pretty_response): - response = client.get( - url_string(query="{test}"), headers={"Accept": "text/html"}, - ) - assert response.status == 200 - assert pretty_response in response.text() - @pytest.mark.asyncio async def test_graphiql_html_is_not_accepted(client): @@ -97,7 +90,7 @@ async def test_graphiql_get_mutation(app, client): @pytest.mark.asyncio @pytest.mark.parametrize("app", [create_app(graphiql=True)]) -async def test_graphiql_get_subscriptions(client): +async def test_graphiql_get_subscriptions(app, client): response = await client.get( url_string( query="subscription TestSubscriptions { subscriptionsTest { test } }" diff --git a/tox.ini b/tox.ini index 2453c8b..35edfc5 100644 --- a/tox.ini +++ b/tox.ini @@ -14,7 +14,7 @@ whitelist_externals = python commands = pip install -U setuptools - pytest --cov-report=term-missing --cov=graphql_server tests {posargs} + pytest tests --cov-report=term-missing --cov=graphql_server {posargs} [testenv:black] basepython=python3.7 From 070a23d7cb9298d1f1f02c41e227f6521683a600 Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Sat, 11 Jul 2020 20:39:58 +0200 Subject: [PATCH 033/108] Run additional parse step only when necessary (#43) --- .gitignore | 1 + graphql_server/__init__.py | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 1789e38..642f015 100644 --- a/.gitignore +++ b/.gitignore @@ -132,6 +132,7 @@ pip-delete-this-directory.txt htmlcov/ .tox/ .nox/ +.venv/ .coverage .coverage.* .cache diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 4e5ad8f..369e62a 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -236,23 +236,23 @@ def get_response( if not params.query: raise HttpQueryError(400, "Must provide query string.") - # Parse document to trigger a new HttpQueryError if allow_only_query is True - try: - document = parse(params.query) - except GraphQLError as e: - return ExecutionResult(data=None, errors=[e]) - except Exception as e: - e = GraphQLError(str(e), original_error=e) - return ExecutionResult(data=None, errors=[e]) - if allow_only_query: + # Parse document to check that only query operations are used + try: + document = parse(params.query) + except GraphQLError as e: + return ExecutionResult(data=None, errors=[e]) + except Exception as e: + e = GraphQLError(str(e), original_error=e) + return ExecutionResult(data=None, errors=[e]) operation_ast = get_operation_ast(document, params.operation_name) if operation_ast: operation = operation_ast.operation.value if operation != OperationType.QUERY.value: raise HttpQueryError( 405, - f"Can only perform a {operation} operation from a POST request.", # noqa + f"Can only perform a {operation} operation" + " from a POST request.", headers={"Allow": "POST"}, ) From c7490304dba5099c136dd4530ce15e9dd48445e0 Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Sat, 11 Jul 2020 20:47:04 +0200 Subject: [PATCH 034/108] Run additional parse step only when necessary (#51) --- .gitignore | 1 + graphql_server/__init__.py | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 1789e38..642f015 100644 --- a/.gitignore +++ b/.gitignore @@ -132,6 +132,7 @@ pip-delete-this-directory.txt htmlcov/ .tox/ .nox/ +.venv/ .coverage .coverage.* .cache diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 4e5ad8f..369e62a 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -236,23 +236,23 @@ def get_response( if not params.query: raise HttpQueryError(400, "Must provide query string.") - # Parse document to trigger a new HttpQueryError if allow_only_query is True - try: - document = parse(params.query) - except GraphQLError as e: - return ExecutionResult(data=None, errors=[e]) - except Exception as e: - e = GraphQLError(str(e), original_error=e) - return ExecutionResult(data=None, errors=[e]) - if allow_only_query: + # Parse document to check that only query operations are used + try: + document = parse(params.query) + except GraphQLError as e: + return ExecutionResult(data=None, errors=[e]) + except Exception as e: + e = GraphQLError(str(e), original_error=e) + return ExecutionResult(data=None, errors=[e]) operation_ast = get_operation_ast(document, params.operation_name) if operation_ast: operation = operation_ast.operation.value if operation != OperationType.QUERY.value: raise HttpQueryError( 405, - f"Can only perform a {operation} operation from a POST request.", # noqa + f"Can only perform a {operation} operation" + " from a POST request.", headers={"Allow": "POST"}, ) From 351ca7a1648bdc8fd56645371f879d26a1797bc3 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Sat, 11 Jul 2020 14:39:24 -0500 Subject: [PATCH 035/108] Expose version number as __version__ (#50) --- graphql_server/__init__.py | 8 ++++ graphql_server/version.py | 44 +++++++++++++++++++++ setup.py | 11 +++++- tests/__init__.py | 2 +- tests/test_version.py | 78 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 graphql_server/version.py create mode 100644 tests/test_version.py diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 369e62a..99452b1 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -18,8 +18,16 @@ from graphql.pyutils import AwaitableOrValue from .error import HttpQueryError +from .version import version, version_info + +# The GraphQL-Server 3 version info. + +__version__ = version +__version_info__ = version_info __all__ = [ + "version", + "version_info", "run_http_query", "encode_execution_results", "load_json_body", diff --git a/graphql_server/version.py b/graphql_server/version.py new file mode 100644 index 0000000..f985b4d --- /dev/null +++ b/graphql_server/version.py @@ -0,0 +1,44 @@ +import re +from typing import NamedTuple + +__all__ = ["version", "version_info"] + + +version = "2.0.0" + +_re_version = re.compile(r"(\d+)\.(\d+)\.(\d+)(\D*)(\d*)") + + +class VersionInfo(NamedTuple): + major: int + minor: int + micro: int + releaselevel: str + serial: int + + @classmethod + def from_str(cls, v: str) -> "VersionInfo": + groups = _re_version.match(v).groups() # type: ignore + major, minor, micro = map(int, groups[:3]) + level = (groups[3] or "")[:1] + if level == "a": + level = "alpha" + elif level == "b": + level = "beta" + elif level in ("c", "r"): + level = "candidate" + else: + level = "final" + serial = groups[4] + serial = int(serial) if serial else 0 + return cls(major, minor, micro, level, serial) + + def __str__(self) -> str: + v = f"{self.major}.{self.minor}.{self.micro}" + level = self.releaselevel + if level and level != "final": + v = f"{v}{level[:1]}{self.serial}" + return v + + +version_info = VersionInfo.from_str(version) diff --git a/setup.py b/setup.py index 4c6aa58..72006bd 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,4 @@ +from re import search from setuptools import setup, find_packages install_requires = [ @@ -44,11 +45,17 @@ install_webob_requires + \ install_aiohttp_requires +with open("graphql_server/version.py") as version_file: + version = search('version = "(.*)"', version_file.read()).group(1) + +with open("README.md", encoding="utf-8") as readme_file: + readme = readme_file.read() + setup( name="graphql-server-core", - version="2.0.0", + version=version, description="GraphQL Server tools for powering your server", - long_description=open("README.md", encoding="utf-8").read(), + long_description=readme, 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", diff --git a/tests/__init__.py b/tests/__init__.py index 2a8fe60..ad617d8 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""GraphQL-Server-Core Tests""" +"""GraphQL-Server Tests""" diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..a69c95e --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,78 @@ +import re + +import graphql_server +from graphql_server.version import VersionInfo, version, version_info + +_re_version = re.compile(r"(\d+)\.(\d+)\.(\d+)(?:([abc])(\d+))?$") + + +def test_create_version_info_from_fields(): + v = VersionInfo(1, 2, 3, "alpha", 4) + assert v.major == 1 + assert v.minor == 2 + assert v.micro == 3 + assert v.releaselevel == "alpha" + assert v.serial == 4 + + +def test_create_version_info_from_str(): + v = VersionInfo.from_str("1.2.3") + assert v.major == 1 + assert v.minor == 2 + assert v.micro == 3 + assert v.releaselevel == "final" + assert v.serial == 0 + v = VersionInfo.from_str("1.2.3a4") + assert v.major == 1 + assert v.minor == 2 + assert v.micro == 3 + assert v.releaselevel == "alpha" + assert v.serial == 4 + v = VersionInfo.from_str("1.2.3beta4") + assert v.major == 1 + assert v.minor == 2 + assert v.micro == 3 + assert v.releaselevel == "beta" + assert v.serial == 4 + v = VersionInfo.from_str("12.34.56rc789") + assert v.major == 12 + assert v.minor == 34 + assert v.micro == 56 + assert v.releaselevel == "candidate" + assert v.serial == 789 + + +def test_serialize_as_str(): + v = VersionInfo(1, 2, 3, "final", 0) + assert str(v) == "1.2.3" + v = VersionInfo(1, 2, 3, "alpha", 4) + assert str(v) == "1.2.3a4" + + +def test_base_package_has_correct_version(): + assert graphql_server.__version__ == version + assert graphql_server.version == version + + +def test_base_package_has_correct_version_info(): + assert graphql_server.__version_info__ is version_info + assert graphql_server.version_info is version_info + + +def test_version_has_correct_format(): + assert isinstance(version, str) + assert _re_version.match(version) + + +def test_version_info_has_correct_fields(): + assert isinstance(version_info, tuple) + assert str(version_info) == version + groups = _re_version.match(version).groups() # type: ignore + assert version_info.major == int(groups[0]) + assert version_info.minor == int(groups[1]) + assert version_info.micro == int(groups[2]) + if groups[3] is None: # pragma: no cover + assert groups[4] is None + else: # pragma: no cover + assert version_info.releaselevel[:1] == groups[3] + assert version_info.serial == int(groups[4]) From 90cfb091c7a9339bdd120e6c3f75c42a1833661d Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Sat, 11 Jul 2020 22:51:28 +0200 Subject: [PATCH 036/108] Split parsing, validation and execution (#43) (#53) Instead of graphql()/graphql_sync() we now call execute() directly. This also allows adding custom validation rules and limiting the number of reported errors. --- graphql_server/__init__.py | 82 ++++++++++++++++++++++---------------- tests/test_query.py | 63 +++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 34 deletions(-) diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 99452b1..2148389 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -9,13 +9,16 @@ import json from collections import namedtuple from collections.abc import MutableMapping -from typing import Any, Callable, Dict, List, Optional, Type, Union +from typing import Any, Callable, Collection, Dict, List, Optional, Type, Union -from graphql import ExecutionResult, GraphQLError, GraphQLSchema, OperationType -from graphql import format_error as format_error_default -from graphql import get_operation_ast, parse -from graphql.graphql import graphql, graphql_sync +from graphql.error import GraphQLError +from graphql.error import format_error as format_error_default +from graphql.execution import ExecutionResult, execute +from graphql.language import OperationType, parse from graphql.pyutils import AwaitableOrValue +from graphql.type import GraphQLSchema, validate_schema +from graphql.utilities import get_operation_ast +from graphql.validation import ASTValidationRule, validate from .error import HttpQueryError from .version import version, version_info @@ -223,36 +226,48 @@ def load_json_variables(variables: Optional[Union[str, Dict]]) -> Optional[Dict] return variables # type: ignore +def assume_not_awaitable(_value: Any) -> bool: + """Replacement for isawaitable if everything is assumed to be synchronous.""" + return False + + def get_response( schema: GraphQLSchema, params: GraphQLParams, catch_exc: Type[BaseException], allow_only_query: bool = False, run_sync: bool = True, + validation_rules: Optional[Collection[Type[ASTValidationRule]]] = None, + max_errors: Optional[int] = None, **kwargs, ) -> Optional[AwaitableOrValue[ExecutionResult]]: """Get an individual execution result as response, with option to catch errors. - This does the same as graphql_impl() except that you can either - throw an error on the ExecutionResult if allow_only_query is set to True - or catch errors that belong to an exception class that you need to pass - as a parameter. + This will validate the schema (if the schema is used for the first time), + parse the query, check if this is a query if allow_only_query is set to True, + validate the query (optionally with additional validation rules and limiting + the number of errors), execute the request (asynchronously if run_sync is not + set to True), and return the ExecutionResult. You can also catch all errors that + belong to an exception class specified by catch_exc. """ - # noinspection PyBroadException try: if not params.query: raise HttpQueryError(400, "Must provide query string.") + schema_validation_errors = validate_schema(schema) + if schema_validation_errors: + return ExecutionResult(data=None, errors=schema_validation_errors) + + try: + document = parse(params.query) + except GraphQLError as e: + return ExecutionResult(data=None, errors=[e]) + except Exception as e: + e = GraphQLError(str(e), original_error=e) + return ExecutionResult(data=None, errors=[e]) + if allow_only_query: - # Parse document to check that only query operations are used - try: - document = parse(params.query) - except GraphQLError as e: - return ExecutionResult(data=None, errors=[e]) - except Exception as e: - e = GraphQLError(str(e), original_error=e) - return ExecutionResult(data=None, errors=[e]) operation_ast = get_operation_ast(document, params.operation_name) if operation_ast: operation = operation_ast.operation.value @@ -264,22 +279,21 @@ def get_response( headers={"Allow": "POST"}, ) - if run_sync: - execution_result = graphql_sync( - schema=schema, - source=params.query, - variable_values=params.variables, - operation_name=params.operation_name, - **kwargs, - ) - else: - execution_result = graphql( # type: ignore - schema=schema, - source=params.query, - variable_values=params.variables, - operation_name=params.operation_name, - **kwargs, - ) + validation_errors = validate( + schema, document, rules=validation_rules, max_errors=max_errors + ) + if validation_errors: + return ExecutionResult(data=None, errors=validation_errors) + + execution_result = execute( + schema, + document, + variable_values=params.variables, + operation_name=params.operation_name, + is_awaitable=assume_not_awaitable if run_sync else None, + **kwargs, + ) + except catch_exc: return None diff --git a/tests/test_query.py b/tests/test_query.py index 7f5ab6f..70f49ac 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -2,6 +2,7 @@ from graphql.error import GraphQLError from graphql.execution import ExecutionResult +from graphql.validation import ValidationRule from pytest import raises from graphql_server import ( @@ -123,6 +124,68 @@ def test_reports_validation_errors(): assert response.status_code == 400 +def test_reports_custom_validation_errors(): + class CustomValidationRule(ValidationRule): + def enter_field(self, node, *_args): + self.report_error(GraphQLError("Custom validation error.", node)) + + results, params = run_http_query( + schema, + "get", + {}, + query_data=dict(query="{ test }"), + validation_rules=[CustomValidationRule], + ) + + assert as_dicts(results) == [ + { + "data": None, + "errors": [ + { + "message": "Custom validation error.", + "locations": [{"line": 1, "column": 3}], + "path": None, + } + ], + } + ] + + response = encode_execution_results(results) + assert response.status_code == 400 + + +def test_reports_max_num_of_validation_errors(): + results, params = run_http_query( + schema, + "get", + {}, + query_data=dict(query="{ test, unknownOne, unknownTwo }"), + max_errors=1, + ) + + assert as_dicts(results) == [ + { + "data": None, + "errors": [ + { + "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 9}], + "path": None, + }, + { + "message": "Too many validation errors, error limit reached." + " Validation aborted.", + "locations": None, + "path": None, + }, + ], + } + ] + + response = encode_execution_results(results) + assert response.status_code == 400 + + def test_non_dict_params_in_non_batch_query(): with raises(HttpQueryError) as exc_info: # noinspection PyTypeChecker From 51dcc22c299c736cd3869b2e12c7a7d1cb6e08b6 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Wed, 22 Jul 2020 03:16:48 -0500 Subject: [PATCH 037/108] Docs about integration with each framework (#54) Co-authored-by: Jonathan Kim --- MANIFEST.in | 2 + README.md | 20 ++++--- docs/aiohttp.md | 73 ++++++++++++++++++++++++++ docs/flask.md | 81 +++++++++++++++++++++++++++++ docs/sanic.md | 74 ++++++++++++++++++++++++++ docs/webob.md | 61 ++++++++++++++++++++++ graphql_server/flask/graphqlview.py | 6 +-- graphql_server/sanic/graphqlview.py | 6 +-- graphql_server/webob/graphqlview.py | 6 +-- 9 files changed, 312 insertions(+), 17 deletions(-) create mode 100644 docs/aiohttp.md create mode 100644 docs/flask.md create mode 100644 docs/sanic.md create mode 100644 docs/webob.md diff --git a/MANIFEST.in b/MANIFEST.in index 12b4ad7..25673ee 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,6 +7,8 @@ include CONTRIBUTING.md include codecov.yml include tox.ini +recursive-include docs *.md + graft tests prune bin diff --git a/README.md b/README.md index 9e228f1..d4f717b 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,25 @@ -# GraphQL-Server-Core +# GraphQL-Server [![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) -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 | +| Server integration | Docs | |---|---| -| 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/) | +| Flask | [flask](docs/flask.md) | +| Sanic |[sanic](docs/sanic.md) | +| AIOHTTP | [aiohttp](docs/aiohttp.md) | +| WebOb (Pyramid, TurboGears) | [webob](docs/webob.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) | diff --git a/docs/aiohttp.md b/docs/aiohttp.md new file mode 100644 index 0000000..b99b78a --- /dev/null +++ b/docs/aiohttp.md @@ -0,0 +1,73 @@ +# 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-core[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 `GraphQLSchema` object that you want the view to execute when it gets a valid request. + * `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 **"1.0.3"**. + * `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/). + * `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..bb66176 --- /dev/null +++ b/docs/flask.md @@ -0,0 +1,81 @@ +# 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-core[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. + +### Special Note for Graphene v3 + +If you are using the `Schema` type of [Graphene](https://github.com/graphql-python/graphene) library, be sure to use the `graphql_schema` attribute to pass as schema on the `GraphQLView` view. Otherwise, the `GraphQLSchema` from `graphql-core` is the way to go. + +More info at [Graphene v3 release notes](https://github.com/graphql-python/graphene/wiki/v3-release-notes#graphene-schema-no-longer-subclasses-graphqlschema-type) and [GraphQL-core 3 usage](https://github.com/graphql-python/graphql-core#usage). + + +### Supported options for GraphQLView + + * `schema`: The `GraphQLSchema` object that you want the view to execute when it gets a valid request. + * `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 **"1.0.3"**. + * `graphiql_template`: Inject a Jinja template string to customize GraphiQL. + * `graphiql_html_title`: The graphiql title to display. Defaults to **"GraphiQL"**. + * `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/). + * `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) \ No newline at end of file diff --git a/docs/sanic.md b/docs/sanic.md new file mode 100644 index 0000000..f7fd278 --- /dev/null +++ b/docs/sanic.md @@ -0,0 +1,74 @@ +# 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-core[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 `GraphQLSchema` object that you want the view to execute when it gets a valid request. + * `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 **"1.0.3"**. + * `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/). + * `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) \ No newline at end of file diff --git a/docs/webob.md b/docs/webob.md new file mode 100644 index 0000000..afa7e8a --- /dev/null +++ b/docs/webob.md @@ -0,0 +1,61 @@ +# 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-core[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 `GraphQLSchema` object that you want the view to execute when it gets a valid request. + * `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 **"1.0.3"**. + * `graphiql_template`: Inject a Jinja template string to customize GraphiQL. + * `graphiql_html_title`: The graphiql title to display. Defaults to **"GraphiQL"**. + * `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/). + * `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) \ No newline at end of file diff --git a/graphql_server/flask/graphqlview.py b/graphql_server/flask/graphqlview.py index 9108a41..33467c9 100644 --- a/graphql_server/flask/graphqlview.py +++ b/graphql_server/flask/graphqlview.py @@ -37,6 +37,9 @@ class GraphQLView(View): methods = ["GET", "POST", "PUT", "DELETE"] + format_error = staticmethod(format_error_default) + encode = staticmethod(json_encode) + def __init__(self, **kwargs): super(GraphQLView, self).__init__() for key, value in kwargs.items(): @@ -57,9 +60,6 @@ def get_context_value(self): def get_middleware(self): return self.middleware - format_error = staticmethod(format_error_default) - encode = staticmethod(json_encode) - def dispatch_request(self): try: request_method = request.method.lower() diff --git a/graphql_server/sanic/graphqlview.py b/graphql_server/sanic/graphqlview.py index 8e2c7b8..d3fefaa 100644 --- a/graphql_server/sanic/graphqlview.py +++ b/graphql_server/sanic/graphqlview.py @@ -44,6 +44,9 @@ class GraphQLView(HTTPMethodView): methods = ["GET", "POST", "PUT", "DELETE"] + format_error = staticmethod(format_error_default) + encode = staticmethod(json_encode) + def __init__(self, **kwargs): super(GraphQLView, self).__init__() for key, value in kwargs.items(): @@ -70,9 +73,6 @@ def get_context(self, request): def get_middleware(self): return self.middleware - format_error = staticmethod(format_error_default) - encode = staticmethod(json_encode) - async def dispatch_request(self, request, *args, **kwargs): try: request_method = request.method.lower() diff --git a/graphql_server/webob/graphqlview.py b/graphql_server/webob/graphqlview.py index 6a32c5b..3801fee 100644 --- a/graphql_server/webob/graphqlview.py +++ b/graphql_server/webob/graphqlview.py @@ -40,6 +40,9 @@ class GraphQLView: headers = None charset = "UTF-8" + format_error = staticmethod(format_error_default) + encode = staticmethod(json_encode) + def __init__(self, **kwargs): super(GraphQLView, self).__init__() for key, value in kwargs.items(): @@ -66,9 +69,6 @@ def get_context(self, request): def get_middleware(self): return self.middleware - format_error = staticmethod(format_error_default) - encode = staticmethod(json_encode) - def dispatch_request(self, request): try: request_method = request.method.lower() From 0c7b59afe1f3ca219f44e7dabc099a18fb295d9d Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Thu, 23 Jul 2020 08:02:15 -0500 Subject: [PATCH 038/108] docs: add graphql-server logo (#57) * docs: add graphql-server logo * docs: add logo to manifest --- MANIFEST.in | 2 +- README.md | 38 +++++++++++++++------------- docs/_static/graphql-server-logo.svg | 1 + 3 files changed, 22 insertions(+), 19 deletions(-) create mode 100644 docs/_static/graphql-server-logo.svg diff --git a/MANIFEST.in b/MANIFEST.in index 25673ee..a6c003d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,7 +7,7 @@ include CONTRIBUTING.md include codecov.yml include tox.ini -recursive-include docs *.md +recursive-include docs *.md *.svg graft tests prune bin diff --git a/README.md b/README.md index d4f717b..b73e72f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# GraphQL-Server + [![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) @@ -10,34 +10,35 @@ for building GraphQL servers or integrations into existing web frameworks using ## Integrations built with GraphQL-Server -| Server integration | Docs | -|---|---| -| Flask | [flask](docs/flask.md) | -| Sanic |[sanic](docs/sanic.md) | -| AIOHTTP | [aiohttp](docs/aiohttp.md) | -| WebOb (Pyramid, TurboGears) | [webob](docs/webob.md) | +| Server integration | Docs | +| --------------------------- | -------------------------- | +| Flask | [flask](docs/flask.md) | +| Sanic | [sanic](docs/sanic.md) | +| AIOHTTP | [aiohttp](docs/aiohttp.md) | +| WebOb (Pyramid, TurboGears) | [webob](docs/webob.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) | +| 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` +- `run_http_query` +- `encode_execution_results` +- `load_json_body` +- `json_encode` +- `json_encode_pretty` **NOTE:** the `json_encode_pretty` is kept as backward compatibility change as it uses `json_encode` with `pretty` parameter set to `True`. @@ -50,4 +51,5 @@ 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) 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 From e8f3a89a64d75e8668f5f4762b87d34a1840d926 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Thu, 23 Jul 2020 08:52:40 -0500 Subject: [PATCH 039/108] chore: add GitHub Actions (#58) * chore: add GitHub Actions * chore: add deploy workflow * chore: only run actions on pull request --- .github/workflows/deploy.yml | 26 ++++++++++++++++++++++++++ .github/workflows/lint.yml | 22 ++++++++++++++++++++++ .github/workflows/tests.yml | 26 ++++++++++++++++++++++++++ .travis.yml | 25 ------------------------- tox.ini | 17 ++++++++++++----- 5 files changed, 86 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/tests.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..a580073 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,26 @@ +name: 🚀 Deploy to PyPI + +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Build wheel and source tarball + run: | + pip install wheel + python setup.py sdist + - name: Publish a Python distribution to PyPI + uses: pypa/gh-action-pypi-publish@v1.1.0 + with: + user: __token__ + password: ${{ secrets.pypi_password }} \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..b36ef4c --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,22 @@ +name: Lint + +on: [pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox + - name: Run lint and static type checks + run: tox + env: + TOXENV: flake8,black,import-order,mypy,manifest \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..03f92d6 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,26 @@ +name: Tests + +on: [pull_request] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + max-parallel: 4 + matrix: + python-version: ["3.6", "3.7", "3.8", "3.9-dev"] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + - name: Test with tox + run: tox + env: + TOXENV: ${{ matrix.toxenv }} \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 29bac19..0000000 --- a/.travis.yml +++ /dev/null @@ -1,25 +0,0 @@ -language: python -sudo: false -python: - - 3.6 - - 3.7 - - 3.8 - - 3.9-dev -matrix: - include: - - python: 3.7 - 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/tox.ini b/tox.ini index 35edfc5..813c610 100644 --- a/tox.ini +++ b/tox.ini @@ -4,6 +4,13 @@ envlist = py{36,37,38,39-dev} ; requires = tox-conda +[gh-actions] +python = + 3.6: py36 + 3.7: py37 + 3.8: py38 + 3.9: py39-dev + [testenv] passenv = * setenv = @@ -17,31 +24,31 @@ commands = pytest tests --cov-report=term-missing --cov=graphql_server {posargs} [testenv:black] -basepython=python3.7 +basepython = python3.8 deps = -e.[dev] commands = black --check graphql_server tests [testenv:flake8] -basepython=python3.7 +basepython = python3.8 deps = -e.[dev] commands = flake8 setup.py graphql_server tests [testenv:import-order] -basepython=python3.7 +basepython = python3.8 deps = -e.[dev] commands = isort -rc graphql_server/ tests/ [testenv:mypy] -basepython=python3.7 +basepython = python3.8 deps = -e.[dev] commands = mypy graphql_server tests --ignore-missing-imports [testenv:manifest] -basepython = python3.7 +basepython = python3.8 deps = -e.[dev] commands = check-manifest -v From cf6d1d41bff6cb9ef6b0cf4733fbc83b2c59e293 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Mon, 27 Jul 2020 18:14:37 -0500 Subject: [PATCH 040/108] Add graphiql options and missing flask context (#55) * Add graphiql options and missing flask context * Possible custom context test fix * Provide same context tests to other integration --- graphql_server/aiohttp/graphqlview.py | 11 +- graphql_server/flask/graphqlview.py | 28 ++++- graphql_server/render_graphiql.py | 2 +- graphql_server/sanic/graphqlview.py | 13 +- graphql_server/webob/graphqlview.py | 15 ++- tests/aiohttp/schema.py | 13 +- tests/aiohttp/test_graphqlview.py | 172 ++++++++++++++------------ tests/flask/schema.py | 14 ++- tests/flask/test_graphqlview.py | 26 +++- tests/sanic/schema.py | 13 +- tests/sanic/test_graphqlview.py | 29 ++++- tests/webob/schema.py | 13 +- tests/webob/test_graphqlview.py | 28 +++-- 13 files changed, 263 insertions(+), 114 deletions(-) diff --git a/graphql_server/aiohttp/graphqlview.py b/graphql_server/aiohttp/graphqlview.py index 9d28f02..84a5f11 100644 --- a/graphql_server/aiohttp/graphqlview.py +++ b/graphql_server/aiohttp/graphqlview.py @@ -19,6 +19,7 @@ from graphql_server.render_graphiql import ( GraphiQLConfig, GraphiQLData, + GraphiQLOptions, render_graphiql_async, ) @@ -39,6 +40,9 @@ class GraphQLView: enable_async = False subscriptions = None headers = None + default_query = None + header_editor_enabled = None + should_persist_headers = None accepted_methods = ["GET", "POST", "PUT", "DELETE"] @@ -174,8 +178,13 @@ async def __call__(self, request): graphiql_html_title=self.graphiql_html_title, jinja_env=self.jinja_env, ) + graphiql_options = GraphiQLOptions( + default_query=self.default_query, + header_editor_enabled=self.header_editor_enabled, + should_persist_headers=self.should_persist_headers, + ) source = await render_graphiql_async( - data=graphiql_data, config=graphiql_config + data=graphiql_data, config=graphiql_config, options=graphiql_options ) return web.Response(text=source, content_type="text/html") diff --git a/graphql_server/flask/graphqlview.py b/graphql_server/flask/graphqlview.py index 33467c9..1b33433 100644 --- a/graphql_server/flask/graphqlview.py +++ b/graphql_server/flask/graphqlview.py @@ -1,3 +1,5 @@ +import copy +from collections.abc import MutableMapping from functools import partial from typing import List @@ -18,6 +20,7 @@ from graphql_server.render_graphiql import ( GraphiQLConfig, GraphiQLData, + GraphiQLOptions, render_graphiql_sync, ) @@ -25,6 +28,7 @@ class GraphQLView(View): schema = None root_value = None + context = None pretty = False graphiql = False graphiql_version = None @@ -34,6 +38,9 @@ class GraphQLView(View): batch = False subscriptions = None headers = None + default_query = None + header_editor_enabled = None + should_persist_headers = None methods = ["GET", "POST", "PUT", "DELETE"] @@ -50,12 +57,18 @@ def __init__(self, **kwargs): self.schema, GraphQLSchema ), "A Schema is required to be provided to GraphQLView." - # noinspection PyUnusedLocal def get_root_value(self): return self.root_value - def get_context_value(self): - return request + def get_context(self): + context = ( + copy.copy(self.context) + if self.context and isinstance(self.context, MutableMapping) + else {} + ) + if isinstance(context, MutableMapping) and "request" not in context: + context.update({"request": request}) + return context def get_middleware(self): return self.middleware @@ -80,7 +93,7 @@ def dispatch_request(self): catch=catch, # Execute options root_value=self.get_root_value(), - context_value=self.get_context_value(), + context_value=self.get_context(), middleware=self.get_middleware(), ) result, status_code = encode_execution_results( @@ -105,8 +118,13 @@ def dispatch_request(self): graphiql_html_title=self.graphiql_html_title, jinja_env=None, ) + graphiql_options = GraphiQLOptions( + default_query=self.default_query, + header_editor_enabled=self.header_editor_enabled, + should_persist_headers=self.should_persist_headers, + ) source = render_graphiql_sync( - data=graphiql_data, config=graphiql_config + data=graphiql_data, config=graphiql_config, options=graphiql_options ) return render_template_string(source) diff --git a/graphql_server/render_graphiql.py b/graphql_server/render_graphiql.py index 8ae4107..c942300 100644 --- a/graphql_server/render_graphiql.py +++ b/graphql_server/render_graphiql.py @@ -201,7 +201,7 @@ class GraphiQLOptions(TypedDict): default_query An optional GraphQL string to use when no query is provided and no stored - query exists from a previous session. If undefined is provided, GraphiQL + query exists from a previous session. If None is provided, GraphiQL will use its own default query. header_editor_enabled An optional boolean which enables the header editor when true. diff --git a/graphql_server/sanic/graphqlview.py b/graphql_server/sanic/graphqlview.py index d3fefaa..110ea2e 100644 --- a/graphql_server/sanic/graphqlview.py +++ b/graphql_server/sanic/graphqlview.py @@ -21,6 +21,7 @@ from graphql_server.render_graphiql import ( GraphiQLConfig, GraphiQLData, + GraphiQLOptions, render_graphiql_async, ) @@ -41,6 +42,9 @@ class GraphQLView(HTTPMethodView): enable_async = False subscriptions = None headers = None + default_query = None + header_editor_enabled = None + should_persist_headers = None methods = ["GET", "POST", "PUT", "DELETE"] @@ -127,8 +131,15 @@ async def dispatch_request(self, request, *args, **kwargs): graphiql_html_title=self.graphiql_html_title, jinja_env=self.jinja_env, ) + graphiql_options = GraphiQLOptions( + default_query=self.default_query, + header_editor_enabled=self.header_editor_enabled, + should_persist_headers=self.should_persist_headers, + ) source = await render_graphiql_async( - data=graphiql_data, config=graphiql_config + data=graphiql_data, + config=graphiql_config, + options=graphiql_options, ) return html(source) diff --git a/graphql_server/webob/graphqlview.py b/graphql_server/webob/graphqlview.py index 3801fee..4eff242 100644 --- a/graphql_server/webob/graphqlview.py +++ b/graphql_server/webob/graphqlview.py @@ -19,6 +19,7 @@ from graphql_server.render_graphiql import ( GraphiQLConfig, GraphiQLData, + GraphiQLOptions, render_graphiql_sync, ) @@ -38,6 +39,9 @@ class GraphQLView: enable_async = False subscriptions = None headers = None + default_query = None + header_editor_enabled = None + should_persist_headers = None charset = "UTF-8" format_error = staticmethod(format_error_default) @@ -117,8 +121,17 @@ def dispatch_request(self, request): graphiql_html_title=self.graphiql_html_title, jinja_env=None, ) + graphiql_options = GraphiQLOptions( + default_query=self.default_query, + header_editor_enabled=self.header_editor_enabled, + should_persist_headers=self.should_persist_headers, + ) return Response( - render_graphiql_sync(data=graphiql_data, config=graphiql_config), + render_graphiql_sync( + data=graphiql_data, + config=graphiql_config, + options=graphiql_options, + ), charset=self.charset, content_type="text/html", ) diff --git a/tests/aiohttp/schema.py b/tests/aiohttp/schema.py index 9198b12..6e5495a 100644 --- a/tests/aiohttp/schema.py +++ b/tests/aiohttp/schema.py @@ -24,8 +24,17 @@ def resolve_raises(*_): resolve=lambda obj, info, *args: info.context["request"].query.get("q"), ), "context": GraphQLField( - GraphQLNonNull(GraphQLString), - resolve=lambda obj, info, *args: info.context["request"], + GraphQLObjectType( + name="context", + fields={ + "session": GraphQLField(GraphQLString), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"], + ), + }, + ), + resolve=lambda obj, info: info.context, ), "test": GraphQLField( type_=GraphQLString, diff --git a/tests/aiohttp/test_graphqlview.py b/tests/aiohttp/test_graphqlview.py index 0f6becb..0a940f9 100644 --- a/tests/aiohttp/test_graphqlview.py +++ b/tests/aiohttp/test_graphqlview.py @@ -521,8 +521,8 @@ async def test_handles_unsupported_http_methods(client): } -@pytest.mark.parametrize("app", [create_app()]) @pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app()]) async def test_passes_request_into_request_context(app, client): response = await client.get(url_string(query="{request}", q="testing")) @@ -532,27 +532,42 @@ async def test_passes_request_into_request_context(app, client): } -class TestCustomContext: - @pytest.mark.parametrize( - "app", [create_app(context="CUSTOM CONTEXT")], - ) - @pytest.mark.asyncio - async def test_context_remapped(self, app, client): - response = await client.get(url_string(query="{context}")) +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(context={"session": "CUSTOM CONTEXT"})]) +async def test_passes_custom_context_into_context(app, client): + response = await client.get(url_string(query="{context { session request }}")) + + _json = await response.json() + assert response.status == 200 + assert "data" in _json + assert "session" in _json["data"]["context"] + assert "request" in _json["data"]["context"] + assert "CUSTOM CONTEXT" in _json["data"]["context"]["session"] + assert "Request" in _json["data"]["context"]["request"] - _json = await response.json() - assert response.status == 200 - assert "Request" in _json["data"]["context"] - assert "CUSTOM CONTEXT" not in _json["data"]["context"] - @pytest.mark.parametrize("app", [create_app(context={"request": "test"})]) - @pytest.mark.asyncio - async def test_request_not_replaced(self, app, client): - response = await client.get(url_string(query="{context}")) +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(context="CUSTOM CONTEXT")]) +async def test_context_remapped_if_not_mapping(app, client): + response = await client.get(url_string(query="{context { session request }}")) - _json = await response.json() - assert response.status == 200 - assert _json["data"]["context"] == "test" + _json = await response.json() + assert response.status == 200 + assert "data" in _json + assert "session" in _json["data"]["context"] + assert "request" in _json["data"]["context"] + assert "CUSTOM CONTEXT" not in _json["data"]["context"]["request"] + assert "Request" in _json["data"]["context"]["request"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(context={"request": "test"})]) +async def test_request_not_replaced(app, client): + response = await client.get(url_string(query="{context { request }}")) + + _json = await response.json() + assert response.status == 200 + assert _json["data"]["context"]["request"] == "test" @pytest.mark.asyncio @@ -583,69 +598,68 @@ async def test_post_multipart_data(client): assert await response.json() == {"data": {u"writeTest": {u"test": u"Hello World"}}} -class TestBatchExecutor: - @pytest.mark.asyncio - @pytest.mark.parametrize("app", [create_app(batch=True)]) - async def test_batch_allows_post_with_json_encoding(self, app, client): - response = await client.post( - "/graphql", - data=json.dumps([dict(id=1, query="{test}")]), - headers={"content-type": "application/json"}, - ) +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(batch=True)]) +async def test_batch_allows_post_with_json_encoding(app, client): + response = await client.post( + "/graphql", + data=json.dumps([dict(id=1, query="{test}")]), + headers={"content-type": "application/json"}, + ) - assert response.status == 200 - assert await response.json() == [{"data": {"test": "Hello World"}}] - - @pytest.mark.asyncio - @pytest.mark.parametrize("app", [create_app(batch=True)]) - async def test_batch_supports_post_json_query_with_json_variables( - self, app, client - ): - response = await client.post( - "/graphql", - data=json.dumps( - [ - dict( - id=1, - query="query helloWho($who: String){ test(who: $who) }", - variables={"who": "Dolly"}, - ) - ] - ), - headers={"content-type": "application/json"}, - ) + assert response.status == 200 + assert await response.json() == [{"data": {"test": "Hello World"}}] - assert response.status == 200 - assert await response.json() == [{"data": {"test": "Hello Dolly"}}] - - @pytest.mark.asyncio - @pytest.mark.parametrize("app", [create_app(batch=True)]) - async def test_batch_allows_post_with_operation_name(self, app, client): - response = await client.post( - "/graphql", - data=json.dumps( - [ - dict( - id=1, - 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", - ) - ] - ), - headers={"content-type": "application/json"}, - ) - assert response.status == 200 - assert await response.json() == [ - {"data": {"test": "Hello World", "shared": "Hello Everyone"}} - ] +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(batch=True)]) +async def test_batch_supports_post_json_query_with_json_variables(app, client): + response = await client.post( + "/graphql", + data=json.dumps( + [ + dict( + id=1, + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ) + ] + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == [{"data": {"test": "Hello Dolly"}}] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(batch=True)]) +async def test_batch_allows_post_with_operation_name(app, client): + response = await client.post( + "/graphql", + data=json.dumps( + [ + dict( + id=1, + 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", + ) + ] + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == [ + {"data": {"test": "Hello World", "shared": "Hello Everyone"}} + ] @pytest.mark.asyncio diff --git a/tests/flask/schema.py b/tests/flask/schema.py index 5d4c52c..eb51e26 100644 --- a/tests/flask/schema.py +++ b/tests/flask/schema.py @@ -18,10 +18,20 @@ def resolve_raises(*_): "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_raises), "request": GraphQLField( GraphQLNonNull(GraphQLString), - resolve=lambda obj, info: info.context.args.get("q"), + resolve=lambda obj, info: info.context["request"].args.get("q"), ), "context": GraphQLField( - GraphQLNonNull(GraphQLString), resolve=lambda obj, info: info.context + GraphQLObjectType( + name="context", + fields={ + "session": GraphQLField(GraphQLString), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"], + ), + }, + ), + resolve=lambda obj, info: info.context, ), "test": GraphQLField( type_=GraphQLString, diff --git a/tests/flask/test_graphqlview.py b/tests/flask/test_graphqlview.py index d2f478d..961a8e0 100644 --- a/tests/flask/test_graphqlview.py +++ b/tests/flask/test_graphqlview.py @@ -489,14 +489,30 @@ def test_passes_request_into_request_context(app, client): assert response_json(response) == {"data": {"request": "testing"}} -@pytest.mark.parametrize( - "app", [create_app(get_context_value=lambda: "CUSTOM CONTEXT")] -) +@pytest.mark.parametrize("app", [create_app(context={"session": "CUSTOM CONTEXT"})]) def test_passes_custom_context_into_context(app, client): - response = client.get(url_string(app, query="{context}")) + response = client.get(url_string(app, query="{context { session request }}")) assert response.status_code == 200 - assert response_json(response) == {"data": {"context": "CUSTOM CONTEXT"}} + res = response_json(response) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" in res["data"]["context"]["session"] + assert "Request" in res["data"]["context"]["request"] + + +@pytest.mark.parametrize("app", [create_app(context="CUSTOM CONTEXT")]) +def test_context_remapped_if_not_mapping(app, client): + response = client.get(url_string(app, query="{context { session request }}")) + + assert response.status_code == 200 + res = response_json(response) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" not in res["data"]["context"]["request"] + assert "Request" in res["data"]["context"]["request"] def test_post_multipart_data(app, client): diff --git a/tests/sanic/schema.py b/tests/sanic/schema.py index a129d92..f827c2b 100644 --- a/tests/sanic/schema.py +++ b/tests/sanic/schema.py @@ -24,8 +24,17 @@ def resolve_raises(*_): resolve=lambda obj, info: info.context["request"].args.get("q"), ), "context": GraphQLField( - GraphQLNonNull(GraphQLString), - resolve=lambda obj, info: info.context["request"], + GraphQLObjectType( + name="context", + fields={ + "session": GraphQLField(GraphQLString), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"], + ), + }, + ), + resolve=lambda obj, info: info.context, ), "test": GraphQLField( type_=GraphQLString, diff --git a/tests/sanic/test_graphqlview.py b/tests/sanic/test_graphqlview.py index 7325e6d..740697c 100644 --- a/tests/sanic/test_graphqlview.py +++ b/tests/sanic/test_graphqlview.py @@ -491,13 +491,30 @@ def test_passes_request_into_request_context(app): assert response_json(response) == {"data": {"request": "testing"}} -@pytest.mark.parametrize("app", [create_app(context="CUSTOM CONTEXT")]) -def test_supports_pretty_printing_on_custom_context_response(app): - _, response = app.client.get(uri=url_string(query="{context}")) +@pytest.mark.parametrize("app", [create_app(context={"session": "CUSTOM CONTEXT"})]) +def test_passes_custom_context_into_context(app): + _, response = app.client.get(uri=url_string(query="{context { session request }}")) - assert response.status == 200 - assert "data" in response_json(response) - assert response_json(response)["data"]["context"] == "" + assert response.status_code == 200 + res = response_json(response) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" in res["data"]["context"]["session"] + assert "Request" in res["data"]["context"]["request"] + + +@pytest.mark.parametrize("app", [create_app(context="CUSTOM CONTEXT")]) +def test_context_remapped_if_not_mapping(app): + _, response = app.client.get(uri=url_string(query="{context { session request }}")) + + assert response.status_code == 200 + res = response_json(response) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" not in res["data"]["context"]["request"] + assert "Request" in res["data"]["context"]["request"] @pytest.mark.parametrize("app", [create_app()]) diff --git a/tests/webob/schema.py b/tests/webob/schema.py index f00f14f..e6aa93f 100644 --- a/tests/webob/schema.py +++ b/tests/webob/schema.py @@ -22,8 +22,17 @@ def resolve_raises(*_): resolve=lambda obj, info: info.context["request"].params.get("q"), ), "context": GraphQLField( - GraphQLNonNull(GraphQLString), - resolve=lambda obj, info: info.context["request"], + GraphQLObjectType( + name="context", + fields={ + "session": GraphQLField(GraphQLString), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"], + ), + }, + ), + resolve=lambda obj, info: info.context, ), "test": GraphQLField( type_=GraphQLString, diff --git a/tests/webob/test_graphqlview.py b/tests/webob/test_graphqlview.py index 6b5f37c..456b5f1 100644 --- a/tests/webob/test_graphqlview.py +++ b/tests/webob/test_graphqlview.py @@ -462,16 +462,30 @@ def test_passes_request_into_request_context(client): assert response_json(response) == {"data": {"request": "testing"}} +@pytest.mark.parametrize("settings", [dict(context={"session": "CUSTOM CONTEXT"})]) +def test_passes_custom_context_into_context(client, settings): + response = client.get(url_string(query="{context { session request }}")) + + assert response.status_code == 200 + res = response_json(response) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" in res["data"]["context"]["session"] + assert "request" in res["data"]["context"]["request"] + + @pytest.mark.parametrize("settings", [dict(context="CUSTOM CONTEXT")]) -def test_supports_custom_context(client, settings): - response = client.get(url_string(query="{context}")) +def test_context_remapped_if_not_mapping(client, settings): + response = client.get(url_string(query="{context { session request }}")) assert response.status_code == 200 - assert "data" in response_json(response) - assert ( - response_json(response)["data"]["context"] - == "GET /graphql?query=%7Bcontext%7D HTTP/1.0\r\nHost: localhost:80" - ) + res = response_json(response) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" not in res["data"]["context"]["request"] + assert "request" in res["data"]["context"]["request"] def test_post_multipart_data(client): From f5e8302d1320b013b441844df059e90ae83d04a0 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Tue, 28 Jul 2020 13:57:34 -0500 Subject: [PATCH 041/108] chore: rename to graphql-server and bump version (#59) --- CONTRIBUTING.md | 8 ++++---- README.md | 5 ++--- docs/aiohttp.md | 2 +- docs/flask.md | 2 +- docs/sanic.md | 2 +- docs/webob.md | 2 +- graphql_server/__init__.py | 4 ++-- graphql_server/version.py | 2 +- setup.py | 6 +++--- 9 files changed, 16 insertions(+), 17 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c573f21..98f59f0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Thanks for helping to make graphql-server-core awesome! +Thanks for helping to make graphql-server awesome! We welcome all kinds of contributions: @@ -12,7 +12,7 @@ We welcome all kinds of contributions: ## Getting started -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. +If you have a specific contribution in mind, be sure to check the [issues](https://github.com/graphql-python/graphql-server/issues) and [pull requests](https://github.com/graphql-python/graphql-server/pulls) in progress - someone could already be working on something similar and you can help out. ## Project setup @@ -22,7 +22,7 @@ If you have a specific contribution in mind, be sure to check the [issues](https After cloning this repo, create a virtualenv: ```console -virtualenv graphql-server-core-dev +virtualenv graphql-server-dev ``` Activate the virtualenv and install dependencies by running: @@ -57,7 +57,7 @@ And you ready to start development! After developing, the full test suite can be evaluated by running: ```sh -pytest tests --cov=graphql-server-core -vv +pytest tests --cov=graphql-server -vv ``` If you are using Linux or MacOS, you can make use of Makefile command diff --git a/README.md b/README.md index b73e72f..cba2e4b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ -[![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 is a base library that serves as a helper for building GraphQL servers or integrations into existing web frameworks using diff --git a/docs/aiohttp.md b/docs/aiohttp.md index b99b78a..35f7fbf 100644 --- a/docs/aiohttp.md +++ b/docs/aiohttp.md @@ -6,7 +6,7 @@ Adds GraphQL support to your aiohttp application. To install the integration with aiohttp, run the below command on your terminal. -`pip install graphql-server-core[aiohttp]` +`pip install graphql-server[aiohttp]` ## Usage diff --git a/docs/flask.md b/docs/flask.md index bb66176..80bab4f 100644 --- a/docs/flask.md +++ b/docs/flask.md @@ -6,7 +6,7 @@ Adds GraphQL support to your Flask application. To install the integration with Flask, run the below command on your terminal. -`pip install graphql-server-core[flask]` +`pip install graphql-server[flask]` ## Usage diff --git a/docs/sanic.md b/docs/sanic.md index f7fd278..0b5ec35 100644 --- a/docs/sanic.md +++ b/docs/sanic.md @@ -6,7 +6,7 @@ Adds GraphQL support to your Sanic application. To install the integration with Sanic, run the below command on your terminal. -`pip install graphql-server-core[sanic]` +`pip install graphql-server[sanic]` ## Usage diff --git a/docs/webob.md b/docs/webob.md index afa7e8a..5203c2c 100644 --- a/docs/webob.md +++ b/docs/webob.md @@ -6,7 +6,7 @@ Adds GraphQL support to your WebOb (Pyramid, Pylons, ...) application. To install the integration with WebOb, run the below command on your terminal. -`pip install graphql-server-core[webob]` +`pip install graphql-server[webob]` ## Usage diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 2148389..8942332 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -1,8 +1,8 @@ """ -GraphQL-Server-Core +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). """ diff --git a/graphql_server/version.py b/graphql_server/version.py index f985b4d..1eb6190 100644 --- a/graphql_server/version.py +++ b/graphql_server/version.py @@ -4,7 +4,7 @@ __all__ = ["version", "version_info"] -version = "2.0.0" +version = "3.0.0b1" _re_version = re.compile(r"(\d+)\.(\d+)\.(\d+)(\D*)(\d*)") diff --git a/setup.py b/setup.py index 72006bd..ea5ea65 100644 --- a/setup.py +++ b/setup.py @@ -52,13 +52,13 @@ readme = readme_file.read() setup( - name="graphql-server-core", + name="graphql-server", version=version, description="GraphQL Server tools for powering your server", long_description=readme, 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", + url="https://github.com/graphql-python/graphql-server", + download_url="https://github.com/graphql-python/graphql-server/releases", author="Syrus Akbary", author_email="me@syrusakbary.com", license="MIT", From 482f21bd862838ef1cf779a577d00d10489e112c Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Sun, 2 Aug 2020 14:38:54 -0500 Subject: [PATCH 042/108] docs: update links on readme (#60) --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index cba2e4b..3e4588d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - + [![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) @@ -9,12 +9,12 @@ for building GraphQL servers or integrations into existing web frameworks using ## Integrations built with GraphQL-Server -| Server integration | Docs | -| --------------------------- | -------------------------- | -| Flask | [flask](docs/flask.md) | -| Sanic | [sanic](docs/sanic.md) | -| AIOHTTP | [aiohttp](docs/aiohttp.md) | -| WebOb (Pyramid, TurboGears) | [webob](docs/webob.md) | +| Server integration | Docs | +| --------------------------- | --------------------------------------------------------------------------------------- | +| Flask | [flask](https://github.com/graphql-python/graphql-server/blob/master/docs/flask.md) | +| Sanic | [sanic](https://github.com/graphql-python/graphql-server/blob/master/docs/sanic.md) | +| AIOHTTP | [aiohttp](https://github.com/graphql-python/graphql-server/blob/master/docs/aiohttp.md) | +| WebOb (Pyramid, TurboGears) | [webob](https://github.com/graphql-python/graphql-server/blob/master/docs/webob.md) | ## Other integrations built with GraphQL-Server @@ -51,4 +51,4 @@ 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) From 49f73c3aaa8d00054aef54524908d09828952b3b Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Sat, 17 Oct 2020 13:19:10 -0500 Subject: [PATCH 043/108] chore: submit coverage to codecov (#63) * chore: submit coverage to codecov * chore: add correct package name on gh action * chore: add windows to os matrix for tests action workflow --- .github/workflows/lint.yml | 2 +- .github/workflows/tests.yml | 56 +++++++++++++++++++++++++++---------- 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b36ef4c..252a382 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,6 +1,6 @@ name: Lint -on: [pull_request] +on: [push, pull_request] jobs: build: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 03f92d6..3373733 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,26 +1,52 @@ name: Tests -on: [pull_request] +on: [push, pull_request] jobs: build: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: max-parallel: 4 matrix: python-version: ["3.6", "3.7", "3.8", "3.9-dev"] + os: [ubuntu-latest, windows-latest] + exclude: + - os: windows-latest + python-version: "3.6" + - os: windows-latest + python-version: "3.7" + - os: windows-latest + python-version: "3.9-dev" + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + - name: Test with tox + run: tox + env: + TOXENV: ${{ matrix.toxenv }} + + coverage: + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install tox tox-gh-actions - - name: Test with tox - run: tox - env: - TOXENV: ${{ matrix.toxenv }} \ No newline at end of file + - uses: actions/checkout@v2 + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Install test dependencies + run: | + python -m pip install --upgrade pip + pip install .[test] + - name: Test with coverage + run: pytest --cov=graphql_server --cov-report=xml tests + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v1 From e39398adae2a3b01ba4a33e271338d3e71c58c0a Mon Sep 17 00:00:00 2001 From: Russell Owen Date: Sat, 17 Oct 2020 13:28:15 -0700 Subject: [PATCH 044/108] Fix enable_async for aiohttp and sanic if graphiql is enabled (#67) * Fix enable_async=True in aiohttp Apply the fix suggested by ketanbshah in https://github.com/graphql-python/graphql-server/issues/64 * Apply the same fix to sanic * tests: add tests for graphiql enabled plus async Co-authored-by: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Co-authored-by: KingDarBoja --- graphql_server/aiohttp/graphqlview.py | 7 ++-- graphql_server/sanic/graphqlview.py | 9 +++-- tests/aiohttp/schema.py | 18 ++++++++++ tests/aiohttp/test_graphiqlview.py | 48 ++++++++++++++++++++++++--- tests/sanic/schema.py | 18 ++++++++++ tests/sanic/test_graphiqlview.py | 31 +++++++++++++++-- 6 files changed, 120 insertions(+), 11 deletions(-) diff --git a/graphql_server/aiohttp/graphqlview.py b/graphql_server/aiohttp/graphqlview.py index 84a5f11..61d2a3d 100644 --- a/graphql_server/aiohttp/graphqlview.py +++ b/graphql_server/aiohttp/graphqlview.py @@ -4,7 +4,7 @@ from typing import List from aiohttp import web -from graphql import GraphQLError +from graphql import ExecutionResult, GraphQLError from graphql.type.schema import GraphQLSchema from graphql_server import ( @@ -152,7 +152,10 @@ async def __call__(self, request): ) exec_res = ( - [await ex for ex in execution_results] + [ + ex if ex is None or isinstance(ex, ExecutionResult) else await ex + for ex in execution_results + ] if self.enable_async else execution_results ) diff --git a/graphql_server/sanic/graphqlview.py b/graphql_server/sanic/graphqlview.py index 110ea2e..29548e9 100644 --- a/graphql_server/sanic/graphqlview.py +++ b/graphql_server/sanic/graphqlview.py @@ -4,7 +4,7 @@ from functools import partial from typing import List -from graphql import GraphQLError +from graphql import ExecutionResult, GraphQLError from graphql.type.schema import GraphQLSchema from sanic.response import HTTPResponse, html from sanic.views import HTTPMethodView @@ -105,7 +105,12 @@ async def dispatch_request(self, request, *args, **kwargs): middleware=self.get_middleware(), ) exec_res = ( - [await ex for ex in execution_results] + [ + ex + if ex is None or isinstance(ex, ExecutionResult) + else await ex + for ex in execution_results + ] if self.enable_async else execution_results ) diff --git a/tests/aiohttp/schema.py b/tests/aiohttp/schema.py index 6e5495a..7673180 100644 --- a/tests/aiohttp/schema.py +++ b/tests/aiohttp/schema.py @@ -91,4 +91,22 @@ def resolver_field_sync(_obj, info): ) +def resolver_field_sync_1(_obj, info): + return "synced_one" + + +def resolver_field_sync_2(_obj, info): + return "synced_two" + + +SyncQueryType = GraphQLObjectType( + "SyncQueryType", + { + "a": GraphQLField(GraphQLString, resolve=resolver_field_sync_1), + "b": GraphQLField(GraphQLString, resolve=resolver_field_sync_2), + }, +) + + AsyncSchema = GraphQLSchema(AsyncQueryType) +SyncSchema = GraphQLSchema(SyncQueryType) diff --git a/tests/aiohttp/test_graphiqlview.py b/tests/aiohttp/test_graphiqlview.py index a4a7a26..111b603 100644 --- a/tests/aiohttp/test_graphiqlview.py +++ b/tests/aiohttp/test_graphiqlview.py @@ -3,7 +3,7 @@ from jinja2 import Environment from tests.aiohttp.app import create_app, url_string -from tests.aiohttp.schema import AsyncSchema, Schema +from tests.aiohttp.schema import AsyncSchema, Schema, SyncSchema @pytest.fixture @@ -102,11 +102,51 @@ async def test_graphiql_get_subscriptions(app, client): @pytest.mark.asyncio -@pytest.mark.parametrize("app", [create_app(schema=AsyncSchema, enable_async=True)]) -async def test_graphiql_async_schema(app, client): +@pytest.mark.parametrize( + "app", [create_app(schema=AsyncSchema, enable_async=True, graphiql=True)] +) +async def test_graphiql_enabled_async_schema(app, client): response = await client.get( url_string(query="{a,b,c}"), headers={"Accept": "text/html"}, ) + expected_response = ( + ( + "{\n" + ' "data": {\n' + ' "a": "hey",\n' + ' "b": "hey2",\n' + ' "c": "hey3"\n' + " }\n" + "}" + ) + .replace('"', '\\"') + .replace("\n", "\\n") + ) + assert response.status == 200 + assert expected_response in await response.text() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "app", [create_app(schema=SyncSchema, enable_async=True, graphiql=True)] +) +async def test_graphiql_enabled_sync_schema(app, client): + response = await client.get( + url_string(query="{a,b}"), headers={"Accept": "text/html"}, + ) + + expected_response = ( + ( + "{\n" + ' "data": {\n' + ' "a": "synced_one",\n' + ' "b": "synced_two"\n' + " }\n" + "}" + ) + .replace('"', '\\"') + .replace("\n", "\\n") + ) assert response.status == 200 - assert await response.json() == {"data": {"a": "hey", "b": "hey2", "c": "hey3"}} + assert expected_response in await response.text() diff --git a/tests/sanic/schema.py b/tests/sanic/schema.py index f827c2b..3c3298f 100644 --- a/tests/sanic/schema.py +++ b/tests/sanic/schema.py @@ -78,4 +78,22 @@ def resolver_field_sync(_obj, info): }, ) + +def resolver_field_sync_1(_obj, info): + return "synced_one" + + +def resolver_field_sync_2(_obj, info): + return "synced_two" + + +SyncQueryType = GraphQLObjectType( + "SyncQueryType", + { + "a": GraphQLField(GraphQLString, resolve=resolver_field_sync_1), + "b": GraphQLField(GraphQLString, resolve=resolver_field_sync_2), + }, +) + AsyncSchema = GraphQLSchema(AsyncQueryType) +SyncSchema = GraphQLSchema(SyncQueryType) diff --git a/tests/sanic/test_graphiqlview.py b/tests/sanic/test_graphiqlview.py index 60ecc75..91711f0 100644 --- a/tests/sanic/test_graphiqlview.py +++ b/tests/sanic/test_graphiqlview.py @@ -2,7 +2,7 @@ from jinja2 import Environment from .app import create_app, url_string -from .schema import AsyncSchema +from .schema import AsyncSchema, SyncSchema @pytest.fixture @@ -62,9 +62,9 @@ def test_graphiql_html_is_not_accepted(app): @pytest.mark.parametrize( - "app", [create_app(graphiql=True, schema=AsyncSchema, enable_async=True)] + "app", [create_app(schema=AsyncSchema, enable_async=True, graphiql=True)] ) -def test_graphiql_asyncio_schema(app): +def test_graphiql_enabled_async_schema(app): query = "{a,b,c}" _, response = app.client.get( uri=url_string(query=query), headers={"Accept": "text/html"} @@ -86,3 +86,28 @@ def test_graphiql_asyncio_schema(app): assert response.status == 200 assert expected_response in response.body.decode("utf-8") + + +@pytest.mark.parametrize( + "app", [create_app(schema=SyncSchema, enable_async=True, graphiql=True)] +) +def test_graphiql_enabled_sync_schema(app): + query = "{a,b}" + _, response = app.client.get( + uri=url_string(query=query), headers={"Accept": "text/html"} + ) + + expected_response = ( + ( + "{\n" + ' "data": {\n' + ' "a": "synced_one",\n' + ' "b": "synced_two"\n' + " }\n" + "}" + ) + .replace('"', '\\"') + .replace("\n", "\\n") + ) + assert response.status == 200 + assert expected_response in response.body.decode("utf-8") From 60e9171446ce78d02337f4824d04c1aeb4c06e6c Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Tue, 27 Oct 2020 13:33:17 -0500 Subject: [PATCH 045/108] chore: stable Python 3.9 support and bump version (#71) --- .github/workflows/tests.yml | 4 ++-- graphql_server/version.py | 2 +- setup.py | 3 ++- tox.ini | 8 +++++--- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3373733..4110dae 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ["3.6", "3.7", "3.8", "3.9-dev"] + python-version: ["3.6", "3.7", "3.8", "3.9"] os: [ubuntu-latest, windows-latest] exclude: - os: windows-latest @@ -16,7 +16,7 @@ jobs: - os: windows-latest python-version: "3.7" - os: windows-latest - python-version: "3.9-dev" + python-version: "3.9" steps: - uses: actions/checkout@v2 diff --git a/graphql_server/version.py b/graphql_server/version.py index 1eb6190..5536d02 100644 --- a/graphql_server/version.py +++ b/graphql_server/version.py @@ -4,7 +4,7 @@ __all__ = ["version", "version_info"] -version = "3.0.0b1" +version = "3.0.0b2" _re_version = re.compile(r"(\d+)\.(\d+)\.(\d+)(\D*)(\d*)") diff --git a/setup.py b/setup.py index ea5ea65..6295b99 100644 --- a/setup.py +++ b/setup.py @@ -63,12 +63,13 @@ author_email="me@syrusakbary.com", license="MIT", classifiers=[ - "Development Status :: 5 - Production/Stable", + "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Topic :: Software Development :: Libraries", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", "License :: OSI Approved :: MIT License", ], keywords="api graphql protocol rest", diff --git a/tox.ini b/tox.ini index 813c610..e374ee0 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = black,flake8,import-order,mypy,manifest, - py{36,37,38,39-dev} + py{36,37,38,39} ; requires = tox-conda [gh-actions] @@ -9,9 +9,10 @@ python = 3.6: py36 3.7: py37 3.8: py38 - 3.9: py39-dev + 3.9: py39 [testenv] +conda_channels = conda-forge passenv = * setenv = PYTHONPATH = {toxinidir} @@ -21,7 +22,8 @@ whitelist_externals = python commands = pip install -U setuptools - pytest tests --cov-report=term-missing --cov=graphql_server {posargs} + py{36,37,39}: pytest tests {posargs} + py{38}: pytest tests --cov-report=term-missing --cov=graphql_server {posargs} [testenv:black] basepython = python3.8 From f89d93caab2ed47eeac5c4435b1c28f3af564669 Mon Sep 17 00:00:00 2001 From: Shiny Brar Date: Sat, 31 Oct 2020 14:30:15 -0400 Subject: [PATCH 046/108] Update Sanic dependency to support 20.3.0 and above (#73) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6295b99..c590303 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ ] install_sanic_requires = [ - "sanic>=19.9.0,<20", + "sanic>=20.3.0", ] install_webob_requires = [ From 5b7f5de42efd3d8034b69c9dcc70f506a2247d55 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Sun, 1 Nov 2020 15:54:33 -0500 Subject: [PATCH 047/108] feat: Quart Server Integration (#70) * feat: Quart Server Integration * chore: change quart version constraint * tests: check py version for test_request_context * tests: refactor graphiqlview test suite * tests: properly match py36 quart API * fix: manually get accept mime types for py36 --- graphql_server/aiohttp/graphqlview.py | 4 +- graphql_server/flask/graphqlview.py | 7 +- graphql_server/quart/__init__.py | 3 + graphql_server/quart/graphqlview.py | 201 +++++++ setup.py | 8 +- tests/flask/app.py | 8 +- tests/flask/test_graphqlview.py | 30 +- tests/quart/__init__.py | 0 tests/quart/app.py | 18 + tests/quart/schema.py | 51 ++ tests/quart/test_graphiqlview.py | 87 +++ tests/quart/test_graphqlview.py | 732 ++++++++++++++++++++++++++ 12 files changed, 1115 insertions(+), 34 deletions(-) create mode 100644 graphql_server/quart/__init__.py create mode 100644 graphql_server/quart/graphqlview.py create mode 100644 tests/quart/__init__.py create mode 100644 tests/quart/app.py create mode 100644 tests/quart/schema.py create mode 100644 tests/quart/test_graphiqlview.py create mode 100644 tests/quart/test_graphqlview.py diff --git a/graphql_server/aiohttp/graphqlview.py b/graphql_server/aiohttp/graphqlview.py index 61d2a3d..a3db1d6 100644 --- a/graphql_server/aiohttp/graphqlview.py +++ b/graphql_server/aiohttp/graphqlview.py @@ -75,8 +75,8 @@ def get_context(self, request): def get_middleware(self): return self.middleware - # This method can be static - async def parse_body(self, request): + @staticmethod + async def parse_body(request): content_type = request.content_type # request.text() is the aiohttp equivalent to # request.body.decode("utf8") diff --git a/graphql_server/flask/graphqlview.py b/graphql_server/flask/graphqlview.py index 1b33433..a417406 100644 --- a/graphql_server/flask/graphqlview.py +++ b/graphql_server/flask/graphqlview.py @@ -139,8 +139,8 @@ def dispatch_request(self): content_type="application/json", ) - # Flask - def parse_body(self): + @staticmethod + def parse_body(): # We use mimetype here since we don't need the other # information provided by content_type content_type = request.mimetype @@ -164,7 +164,8 @@ def should_display_graphiql(self): return self.request_wants_html() - def request_wants_html(self): + @staticmethod + def request_wants_html(): best = request.accept_mimetypes.best_match(["application/json", "text/html"]) return ( best == "text/html" diff --git a/graphql_server/quart/__init__.py b/graphql_server/quart/__init__.py new file mode 100644 index 0000000..8f5beaf --- /dev/null +++ b/graphql_server/quart/__init__.py @@ -0,0 +1,3 @@ +from .graphqlview import GraphQLView + +__all__ = ["GraphQLView"] diff --git a/graphql_server/quart/graphqlview.py b/graphql_server/quart/graphqlview.py new file mode 100644 index 0000000..9993998 --- /dev/null +++ b/graphql_server/quart/graphqlview.py @@ -0,0 +1,201 @@ +import copy +import sys +from collections.abc import MutableMapping +from functools import partial +from typing import List + +from graphql import ExecutionResult +from graphql.error import GraphQLError +from graphql.type.schema import GraphQLSchema +from quart import Response, render_template_string, request +from quart.views import View + +from graphql_server import ( + GraphQLParams, + HttpQueryError, + encode_execution_results, + format_error_default, + json_encode, + load_json_body, + run_http_query, +) +from graphql_server.render_graphiql import ( + GraphiQLConfig, + GraphiQLData, + GraphiQLOptions, + render_graphiql_sync, +) + + +class GraphQLView(View): + schema = None + root_value = None + context = None + pretty = False + graphiql = False + graphiql_version = None + graphiql_template = None + graphiql_html_title = None + middleware = None + batch = False + enable_async = False + subscriptions = None + headers = None + default_query = None + header_editor_enabled = None + should_persist_headers = None + + methods = ["GET", "POST", "PUT", "DELETE"] + + format_error = staticmethod(format_error_default) + encode = staticmethod(json_encode) + + def __init__(self, **kwargs): + super(GraphQLView, self).__init__() + for key, value in kwargs.items(): + if hasattr(self, key): + setattr(self, key, value) + + assert isinstance( + self.schema, GraphQLSchema + ), "A Schema is required to be provided to GraphQLView." + + def get_root_value(self): + return self.root_value + + def get_context(self): + context = ( + copy.copy(self.context) + if self.context and isinstance(self.context, MutableMapping) + else {} + ) + if isinstance(context, MutableMapping) and "request" not in context: + context.update({"request": request}) + return context + + def get_middleware(self): + return self.middleware + + async def dispatch_request(self): + try: + request_method = request.method.lower() + data = await self.parse_body() + + show_graphiql = request_method == "get" and self.should_display_graphiql() + catch = show_graphiql + + pretty = self.pretty or show_graphiql or request.args.get("pretty") + all_params: List[GraphQLParams] + execution_results, all_params = run_http_query( + self.schema, + request_method, + data, + query_data=request.args, + batch_enabled=self.batch, + catch=catch, + # Execute options + run_sync=not self.enable_async, + root_value=self.get_root_value(), + context_value=self.get_context(), + middleware=self.get_middleware(), + ) + exec_res = ( + [ + ex if ex is None or isinstance(ex, ExecutionResult) else await ex + for ex in execution_results + ] + if self.enable_async + else execution_results + ) + result, status_code = encode_execution_results( + exec_res, + is_batch=isinstance(data, list), + format_error=self.format_error, + encode=partial(self.encode, pretty=pretty), # noqa + ) + + if show_graphiql: + graphiql_data = GraphiQLData( + result=result, + query=getattr(all_params[0], "query"), + variables=getattr(all_params[0], "variables"), + operation_name=getattr(all_params[0], "operation_name"), + subscription_url=self.subscriptions, + headers=self.headers, + ) + graphiql_config = GraphiQLConfig( + graphiql_version=self.graphiql_version, + graphiql_template=self.graphiql_template, + graphiql_html_title=self.graphiql_html_title, + jinja_env=None, + ) + graphiql_options = GraphiQLOptions( + default_query=self.default_query, + header_editor_enabled=self.header_editor_enabled, + should_persist_headers=self.should_persist_headers, + ) + source = render_graphiql_sync( + data=graphiql_data, config=graphiql_config, options=graphiql_options + ) + return await render_template_string(source) + + return Response(result, status=status_code, content_type="application/json") + + except HttpQueryError as e: + parsed_error = GraphQLError(e.message) + return Response( + self.encode(dict(errors=[self.format_error(parsed_error)])), + status=e.status_code, + headers=e.headers, + content_type="application/json", + ) + + @staticmethod + async def parse_body(): + # We use mimetype here since we don't need the other + # information provided by content_type + content_type = request.mimetype + if content_type == "application/graphql": + refined_data = await request.get_data(raw=False) + return {"query": refined_data} + + elif content_type == "application/json": + refined_data = await request.get_data(raw=False) + return load_json_body(refined_data) + + elif content_type == "application/x-www-form-urlencoded": + return await request.form + + # TODO: Fix this check + elif content_type == "multipart/form-data": + return await request.files + + return {} + + def should_display_graphiql(self): + if not self.graphiql or "raw" in request.args: + return False + + return self.request_wants_html() + + @staticmethod + def request_wants_html(): + best = request.accept_mimetypes.best_match(["application/json", "text/html"]) + + # Needed as this was introduced at Quart 0.8.0: https://gitlab.com/pgjones/quart/-/issues/189 + def _quality(accept, key: str) -> float: + for option in accept.options: + if accept._values_match(key, option.value): + return option.quality + return 0.0 + + if sys.version_info >= (3, 7): + return ( + best == "text/html" + and request.accept_mimetypes[best] + > request.accept_mimetypes["application/json"] + ) + else: + return best == "text/html" and _quality( + request.accept_mimetypes, best + ) > _quality(request.accept_mimetypes, "application/json") diff --git a/setup.py b/setup.py index c590303..e16e61b 100644 --- a/setup.py +++ b/setup.py @@ -38,12 +38,17 @@ "aiohttp>=3.5.0,<4", ] +install_quart_requires = [ + "quart>=0.6.15" +] + install_all_requires = \ install_requires + \ install_flask_requires + \ install_sanic_requires + \ install_webob_requires + \ - install_aiohttp_requires + install_aiohttp_requires + \ + install_quart_requires with open("graphql_server/version.py") as version_file: version = search('version = "(.*)"', version_file.read()).group(1) @@ -84,6 +89,7 @@ "sanic": install_sanic_requires, "webob": install_webob_requires, "aiohttp": install_aiohttp_requires, + "quart": install_quart_requires, }, include_package_data=True, zip_safe=False, diff --git a/tests/flask/app.py b/tests/flask/app.py index 01f6fa8..ec9e9d0 100644 --- a/tests/flask/app.py +++ b/tests/flask/app.py @@ -5,12 +5,12 @@ def create_app(path="/graphql", **kwargs): - app = Flask(__name__) - app.debug = True - app.add_url_rule( + server = Flask(__name__) + server.debug = True + server.add_url_rule( path, view_func=GraphQLView.as_view("graphql", schema=Schema, **kwargs) ) - return app + return server if __name__ == "__main__": diff --git a/tests/flask/test_graphqlview.py b/tests/flask/test_graphqlview.py index 961a8e0..d8d60b0 100644 --- a/tests/flask/test_graphqlview.py +++ b/tests/flask/test_graphqlview.py @@ -9,7 +9,7 @@ @pytest.fixture -def app(request): +def app(): # import app factory pattern app = create_app() @@ -269,7 +269,7 @@ def test_supports_post_url_encoded_query_with_string_variables(app, client): assert response_json(response) == {"data": {"test": "Hello Dolly"}} -def test_supports_post_json_quey_with_get_variable_values(app, client): +def test_supports_post_json_query_with_get_variable_values(app, client): response = client.post( url_string(app, variables=json.dumps({"who": "Dolly"})), data=json_dump_kwarg(query="query helloWho($who: String){ test(who: $who) }",), @@ -533,20 +533,12 @@ def test_post_multipart_data(app, client): def test_batch_allows_post_with_json_encoding(app, client): response = client.post( url_string(app), - data=json_dump_kwarg_list( - # id=1, - query="{test}" - ), + data=json_dump_kwarg_list(query="{test}"), content_type="application/json", ) assert response.status_code == 200 - assert response_json(response) == [ - { - # 'id': 1, - "data": {"test": "Hello World"} - } - ] + assert response_json(response) == [{"data": {"test": "Hello World"}}] @pytest.mark.parametrize("app", [create_app(batch=True)]) @@ -554,7 +546,6 @@ def test_batch_supports_post_json_query_with_json_variables(app, client): response = client.post( url_string(app), data=json_dump_kwarg_list( - # id=1, query="query helloWho($who: String){ test(who: $who) }", variables={"who": "Dolly"}, ), @@ -562,12 +553,7 @@ def test_batch_supports_post_json_query_with_json_variables(app, client): ) assert response.status_code == 200 - assert response_json(response) == [ - { - # 'id': 1, - "data": {"test": "Hello Dolly"} - } - ] + assert response_json(response) == [{"data": {"test": "Hello Dolly"}}] @pytest.mark.parametrize("app", [create_app(batch=True)]) @@ -575,7 +561,6 @@ def test_batch_allows_post_with_operation_name(app, client): response = client.post( url_string(app), data=json_dump_kwarg_list( - # id=1, query=""" query helloYou { test(who: "You"), ...shared } query helloWorld { test(who: "World"), ...shared } @@ -591,8 +576,5 @@ def test_batch_allows_post_with_operation_name(app, client): assert response.status_code == 200 assert response_json(response) == [ - { - # 'id': 1, - "data": {"test": "Hello World", "shared": "Hello Everyone"} - } + {"data": {"test": "Hello World", "shared": "Hello Everyone"}} ] diff --git a/tests/quart/__init__.py b/tests/quart/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/quart/app.py b/tests/quart/app.py new file mode 100644 index 0000000..2313f99 --- /dev/null +++ b/tests/quart/app.py @@ -0,0 +1,18 @@ +from quart import Quart + +from graphql_server.quart import GraphQLView +from tests.quart.schema import Schema + + +def create_app(path="/graphql", **kwargs): + server = Quart(__name__) + server.debug = True + server.add_url_rule( + path, view_func=GraphQLView.as_view("graphql", schema=Schema, **kwargs) + ) + return server + + +if __name__ == "__main__": + app = create_app(graphiql=True) + app.run() diff --git a/tests/quart/schema.py b/tests/quart/schema.py new file mode 100644 index 0000000..eb51e26 --- /dev/null +++ b/tests/quart/schema.py @@ -0,0 +1,51 @@ +from graphql.type.definition import ( + GraphQLArgument, + GraphQLField, + GraphQLNonNull, + GraphQLObjectType, +) +from graphql.type.scalars import GraphQLString +from graphql.type.schema import GraphQLSchema + + +def resolve_raises(*_): + raise Exception("Throws!") + + +QueryRootType = GraphQLObjectType( + name="QueryRoot", + fields={ + "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_raises), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"].args.get("q"), + ), + "context": GraphQLField( + GraphQLObjectType( + name="context", + fields={ + "session": GraphQLField(GraphQLString), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"], + ), + }, + ), + resolve=lambda obj, info: info.context, + ), + "test": GraphQLField( + type_=GraphQLString, + args={"who": GraphQLArgument(GraphQLString)}, + resolve=lambda obj, info, who="World": "Hello %s" % who, + ), + }, +) + +MutationRootType = GraphQLObjectType( + name="MutationRoot", + fields={ + "writeTest": GraphQLField(type_=QueryRootType, resolve=lambda *_: QueryRootType) + }, +) + +Schema = GraphQLSchema(QueryRootType, MutationRootType) diff --git a/tests/quart/test_graphiqlview.py b/tests/quart/test_graphiqlview.py new file mode 100644 index 0000000..12b001f --- /dev/null +++ b/tests/quart/test_graphiqlview.py @@ -0,0 +1,87 @@ +import sys + +import pytest +from quart import Quart, Response, url_for +from quart.testing import QuartClient +from werkzeug.datastructures import Headers + +from .app import create_app + + +@pytest.fixture +def app() -> Quart: + # import app factory pattern + app = create_app(graphiql=True) + + # pushes an application context manually + # ctx = app.app_context() + # await ctx.push() + return app + + +@pytest.fixture +def client(app: Quart) -> QuartClient: + return app.test_client() + + +@pytest.mark.asyncio +async def execute_client( + app: Quart, + client: QuartClient, + method: str = "GET", + headers: Headers = None, + **extra_params +) -> Response: + if sys.version_info >= (3, 7): + test_request_context = app.test_request_context("/", method=method) + else: + test_request_context = app.test_request_context(method, "/") + async with test_request_context: + string = url_for("graphql", **extra_params) + return await client.get(string, headers=headers) + + +@pytest.mark.asyncio +async def test_graphiql_is_enabled(app: Quart, client: QuartClient): + response = await execute_client( + app, client, headers=Headers({"Accept": "text/html"}), externals=False + ) + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_graphiql_renders_pretty(app: Quart, client: QuartClient): + response = await execute_client( + app, client, headers=Headers({"Accept": "text/html"}), query="{test}" + ) + assert response.status_code == 200 + pretty_response = ( + "{\n" + ' "data": {\n' + ' "test": "Hello World"\n' + " }\n" + "}".replace('"', '\\"').replace("\n", "\\n") + ) + result = await response.get_data(raw=False) + assert pretty_response in result + + +@pytest.mark.asyncio +async def test_graphiql_default_title(app: Quart, client: QuartClient): + response = await execute_client( + app, client, headers=Headers({"Accept": "text/html"}) + ) + result = await response.get_data(raw=False) + assert "GraphiQL" in result + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "app", [create_app(graphiql=True, graphiql_html_title="Awesome")] +) +async def test_graphiql_custom_title(app: Quart, client: QuartClient): + response = await execute_client( + app, client, headers=Headers({"Accept": "text/html"}) + ) + result = await response.get_data(raw=False) + assert "Awesome" in result diff --git a/tests/quart/test_graphqlview.py b/tests/quart/test_graphqlview.py new file mode 100644 index 0000000..4a24ace --- /dev/null +++ b/tests/quart/test_graphqlview.py @@ -0,0 +1,732 @@ +import json +import sys + +# from io import StringIO +from urllib.parse import urlencode + +import pytest +from quart import Quart, Response, url_for +from quart.testing import QuartClient +from werkzeug.datastructures import Headers + +from .app import create_app + + +@pytest.fixture +def app() -> Quart: + # import app factory pattern + app = create_app(graphiql=True) + + # pushes an application context manually + # ctx = app.app_context() + # await ctx.push() + return app + + +@pytest.fixture +def client(app: Quart) -> QuartClient: + return app.test_client() + + +@pytest.mark.asyncio +async def execute_client( + app: Quart, + client: QuartClient, + method: str = "GET", + data: str = None, + headers: Headers = None, + **url_params +) -> Response: + if sys.version_info >= (3, 7): + test_request_context = app.test_request_context("/", method=method) + else: + test_request_context = app.test_request_context(method, "/") + async with test_request_context: + string = url_for("graphql") + + if url_params: + string += "?" + urlencode(url_params) + + if method == "POST": + return await client.post(string, data=data, headers=headers) + elif method == "PUT": + return await client.put(string, data=data, headers=headers) + else: + return await client.get(string) + + +def response_json(result): + return json.loads(result) + + +def json_dump_kwarg(**kwargs) -> str: + return json.dumps(kwargs) + + +def json_dump_kwarg_list(**kwargs): + return json.dumps([kwargs]) + + +@pytest.mark.asyncio +async def test_allows_get_with_query_param(app: Quart, client: QuartClient): + response = await execute_client(app, client, query="{test}") + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"test": "Hello World"}} + + +@pytest.mark.asyncio +async def test_allows_get_with_variable_values(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_allows_get_with_operation_name(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + 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 response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.asyncio +async def test_reports_validation_errors(app: Quart, client: QuartClient): + response = await execute_client( + app, client, query="{ test, unknownOne, unknownTwo }" + ) + + assert response.status_code == 400 + result = await response.get_data(raw=False) + assert response_json(result) == { + "errors": [ + { + "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 9}], + "path": None, + }, + { + "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 21}], + "path": None, + }, + ] + } + + +@pytest.mark.asyncio +async def test_errors_when_missing_operation_name(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + ) + + assert response.status_code == 400 + result = await response.get_data(raw=False) + assert response_json(result) == { + "errors": [ + { + "message": "Must provide operation name if query contains multiple operations.", # noqa: E501 + "locations": None, + "path": None, + } + ] + } + + +@pytest.mark.asyncio +async def test_errors_when_sending_a_mutation_via_get(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + query=""" + mutation TestMutation { writeTest { test } } + """, + ) + assert response.status_code == 405 + result = await response.get_data(raw=False) + assert response_json(result) == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + "locations": None, + "path": None, + } + ] + } + + +@pytest.mark.asyncio +async def test_errors_when_selecting_a_mutation_within_a_get( + app: Quart, client: QuartClient +): + response = await execute_client( + app, + client, + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestMutation", + ) + + assert response.status_code == 405 + result = await response.get_data(raw=False) + assert response_json(result) == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + "locations": None, + "path": None, + } + ] + } + + +@pytest.mark.asyncio +async def test_allows_mutation_to_exist_within_a_get(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestQuery", + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"test": "Hello World"}} + + +@pytest.mark.asyncio +async def test_allows_post_with_json_encoding(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg(query="{test}"), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"test": "Hello World"}} + + +@pytest.mark.asyncio +async def test_allows_sending_a_mutation_via_post(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg(query="mutation TestMutation { writeTest { test } }"), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"writeTest": {"test": "Hello World"}}} + + +@pytest.mark.asyncio +async def test_allows_post_with_url_encoding(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + method="POST", + data=urlencode(dict(query="{test}")), + headers=Headers({"Content-Type": "application/x-www-form-urlencoded"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"test": "Hello World"}} + + +@pytest.mark.asyncio +async def test_supports_post_json_query_with_string_variables( + app: Quart, client: QuartClient +): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_json_query_with_json_variables( + app: Quart, client: QuartClient +): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_url_encoded_query_with_string_variables( + app: Quart, client: QuartClient +): + response = await execute_client( + app, + client, + method="POST", + data=urlencode( + dict( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + ), + headers=Headers({"Content-Type": "application/x-www-form-urlencoded"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_json_query_with_get_variable_values( + app: Quart, client: QuartClient +): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg(query="query helloWho($who: String){ test(who: $who) }",), + headers=Headers({"Content-Type": "application/json"}), + variables=json.dumps({"who": "Dolly"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_post_url_encoded_query_with_get_variable_values( + app: Quart, client: QuartClient +): + response = await execute_client( + app, + client, + method="POST", + data=urlencode(dict(query="query helloWho($who: String){ test(who: $who) }",)), + headers=Headers({"Content-Type": "application/x-www-form-urlencoded"}), + variables=json.dumps({"who": "Dolly"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_raw_text_query_with_get_variable_values( + app: Quart, client: QuartClient +): + response = await execute_client( + app, + client=client, + method="POST", + data="query helloWho($who: String){ test(who: $who) }", + headers=Headers({"Content-Type": "application/graphql"}), + variables=json.dumps({"who": "Dolly"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_allows_post_with_operation_name(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg( + 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", + ), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.asyncio +async def test_allows_post_with_get_operation_name(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + method="POST", + data=""" + 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") + } + """, + headers=Headers({"Content-Type": "application/graphql"}), + operationName="helloWorld", + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(pretty=True)]) +async def test_supports_pretty_printing(app: Quart, client: QuartClient): + response = await execute_client(app, client, query="{test}") + + result = await response.get_data(raw=False) + assert result == ("{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(pretty=False)]) +async def test_not_pretty_by_default(app: Quart, client: QuartClient): + response = await execute_client(app, client, query="{test}") + + result = await response.get_data(raw=False) + assert result == '{"data":{"test":"Hello World"}}' + + +@pytest.mark.asyncio +async def test_supports_pretty_printing_by_request(app: Quart, client: QuartClient): + response = await execute_client(app, client, query="{test}", pretty="1") + + result = await response.get_data(raw=False) + assert result == ("{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}") + + +@pytest.mark.asyncio +async def test_handles_field_errors_caught_by_graphql(app: Quart, client: QuartClient): + response = await execute_client(app, client, query="{thrower}") + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == { + "errors": [ + { + "locations": [{"column": 2, "line": 1}], + "path": ["thrower"], + "message": "Throws!", + } + ], + "data": None, + } + + +@pytest.mark.asyncio +async def test_handles_syntax_errors_caught_by_graphql(app: Quart, client: QuartClient): + response = await execute_client(app, client, query="syntaxerror") + assert response.status_code == 400 + result = await response.get_data(raw=False) + assert response_json(result) == { + "errors": [ + { + "locations": [{"column": 1, "line": 1}], + "message": "Syntax Error: Unexpected Name 'syntaxerror'.", + "path": None, + } + ] + } + + +@pytest.mark.asyncio +async def test_handles_errors_caused_by_a_lack_of_query( + app: Quart, client: QuartClient +): + response = await execute_client(app, client) + + assert response.status_code == 400 + result = await response.get_data(raw=False) + assert response_json(result) == { + "errors": [ + {"message": "Must provide query string.", "locations": None, "path": None} + ] + } + + +@pytest.mark.asyncio +async def test_handles_batch_correctly_if_is_disabled(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + method="POST", + data="[]", + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 400 + result = await response.get_data(raw=False) + assert response_json(result) == { + "errors": [ + { + "message": "Batch GraphQL requests are not enabled.", + "locations": None, + "path": None, + } + ] + } + + +@pytest.mark.asyncio +async def test_handles_incomplete_json_bodies(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + method="POST", + data='{"query":', + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 400 + result = await response.get_data(raw=False) + assert response_json(result) == { + "errors": [ + {"message": "POST body sent invalid JSON.", "locations": None, "path": None} + ] + } + + +@pytest.mark.asyncio +async def test_handles_plain_post_text(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + method="POST", + data="query helloWho($who: String){ test(who: $who) }", + headers=Headers({"Content-Type": "text/plain"}), + variables=json.dumps({"who": "Dolly"}), + ) + assert response.status_code == 400 + result = await response.get_data(raw=False) + assert response_json(result) == { + "errors": [ + {"message": "Must provide query string.", "locations": None, "path": None} + ] + } + + +@pytest.mark.asyncio +async def test_handles_poorly_formed_variables(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + query="query helloWho($who: String){ test(who: $who) }", + variables="who:You", + ) + assert response.status_code == 400 + result = await response.get_data(raw=False) + assert response_json(result) == { + "errors": [ + {"message": "Variables are invalid JSON.", "locations": None, "path": None} + ] + } + + +@pytest.mark.asyncio +async def test_handles_unsupported_http_methods(app: Quart, client: QuartClient): + response = await execute_client(app, client, method="PUT", query="{test}") + assert response.status_code == 405 + result = await response.get_data(raw=False) + assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] + assert response_json(result) == { + "errors": [ + { + "message": "GraphQL only supports GET and POST requests.", + "locations": None, + "path": None, + } + ] + } + + +@pytest.mark.asyncio +async def test_passes_request_into_request_context(app: Quart, client: QuartClient): + response = await execute_client(app, client, query="{request}", q="testing") + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"request": "testing"}} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(context={"session": "CUSTOM CONTEXT"})]) +async def test_passes_custom_context_into_context(app: Quart, client: QuartClient): + response = await execute_client(app, client, query="{context { session request }}") + + assert response.status_code == 200 + result = await response.get_data(raw=False) + res = response_json(result) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" in res["data"]["context"]["session"] + assert "Request" in res["data"]["context"]["request"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(context="CUSTOM CONTEXT")]) +async def test_context_remapped_if_not_mapping(app: Quart, client: QuartClient): + response = await execute_client(app, client, query="{context { session request }}") + + assert response.status_code == 200 + result = await response.get_data(raw=False) + res = response_json(result) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" not in res["data"]["context"]["request"] + assert "Request" in res["data"]["context"]["request"] + + +# @pytest.mark.asyncio +# async def test_post_multipart_data(app: Quart, client: QuartClient): +# query = "mutation TestMutation { writeTest { test } }" +# response = await execute_client( +# app, +# client, +# method='POST', +# data={"query": query, "file": (StringIO(), "text1.txt")}, +# headers=Headers({"Content-Type": "multipart/form-data"}) +# ) +# +# assert response.status_code == 200 +# result = await response.get_data() +# assert response_json(result) == { +# "data": {u"writeTest": {u"test": u"Hello World"}} +# } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(batch=True)]) +async def test_batch_allows_post_with_json_encoding(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg_list(query="{test}"), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == [{"data": {"test": "Hello World"}}] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(batch=True)]) +async def test_batch_supports_post_json_query_with_json_variables( + app: Quart, client: QuartClient +): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg_list( + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == [{"data": {"test": "Hello Dolly"}}] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(batch=True)]) +async def test_batch_allows_post_with_operation_name(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg_list( + # id=1, + 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", + ), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == [ + {"data": {"test": "Hello World", "shared": "Hello Everyone"}} + ] From 9815d26bb3b67afc93466befbc18398da3afd22e Mon Sep 17 00:00:00 2001 From: Rainer Koirikivi Date: Sat, 28 Nov 2020 19:12:54 +0200 Subject: [PATCH 048/108] Prevent including test directory when installing package (#75) * Prevent including test directory when installing package * chore: include only graphql_server package Co-authored-by: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e16e61b..3758a20 100644 --- a/setup.py +++ b/setup.py @@ -78,7 +78,7 @@ "License :: OSI Approved :: MIT License", ], keywords="api graphql protocol rest", - packages=find_packages(exclude=["tests"]), + packages=find_packages(include=["graphql_server*"]), install_requires=install_requires, tests_require=install_all_requires + tests_requires, extras_require={ From c03e1a4177233b0a053948c96ce862314d52e7bf Mon Sep 17 00:00:00 2001 From: KingDarBoja Date: Sat, 28 Nov 2020 12:28:57 -0500 Subject: [PATCH 049/108] chore: bump version to v3.0.0b3 --- graphql_server/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphql_server/version.py b/graphql_server/version.py index 5536d02..46cd5e1 100644 --- a/graphql_server/version.py +++ b/graphql_server/version.py @@ -4,7 +4,7 @@ __all__ = ["version", "version_info"] -version = "3.0.0b2" +version = "3.0.0b3" _re_version = re.compile(r"(\d+)\.(\d+)\.(\d+)(\D*)(\d*)") From b8705c2ca910d330120fc91d4b8a7a9a9adfefbd Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Mon, 9 Aug 2021 17:04:25 +0200 Subject: [PATCH 050/108] Fix tests (#84) --- graphql_server/__init__.py | 4 ++++ setup.py | 28 ++++++++++++---------------- tests/sanic/app.py | 3 +++ tests/test_query.py | 6 ++++-- 4 files changed, 23 insertions(+), 18 deletions(-) diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 8942332..239a1d4 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -255,6 +255,10 @@ def get_response( if not params.query: raise HttpQueryError(400, "Must provide query string.") + # Sanity check query + if not isinstance(params.query, str): + raise HttpQueryError(400, "Unexpected query type.") + schema_validation_errors = validate_schema(schema) if schema_validation_errors: return ExecutionResult(data=None, errors=schema_validation_errors) diff --git a/setup.py b/setup.py index 3758a20..e3f769e 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,7 @@ from re import search from setuptools import setup, find_packages -install_requires = [ - "graphql-core>=3.1.0,<4", - "typing-extensions>=3.7.4,<4" -] +install_requires = ["graphql-core>=3.1.0,<4", "typing-extensions>=3.7.4,<4"] tests_requires = [ "pytest>=5.4,<5.5", @@ -23,11 +20,11 @@ ] + tests_requires install_flask_requires = [ - "flask>=0.7.0", + "flask>=0.7.0<1", ] install_sanic_requires = [ - "sanic>=20.3.0", + "sanic>=20.3.0,<21", ] install_webob_requires = [ @@ -38,17 +35,16 @@ "aiohttp>=3.5.0,<4", ] -install_quart_requires = [ - "quart>=0.6.15" -] +install_quart_requires = ["quart>=0.6.15,<1"] -install_all_requires = \ - install_requires + \ - install_flask_requires + \ - install_sanic_requires + \ - install_webob_requires + \ - install_aiohttp_requires + \ - install_quart_requires +install_all_requires = ( + install_requires + + install_flask_requires + + install_sanic_requires + + install_webob_requires + + install_aiohttp_requires + + install_quart_requires +) with open("graphql_server/version.py") as version_file: version = search('version = "(.*)"', version_file.read()).group(1) diff --git a/tests/sanic/app.py b/tests/sanic/app.py index f5a74cf..6966b1e 100644 --- a/tests/sanic/app.py +++ b/tests/sanic/app.py @@ -8,6 +8,9 @@ from .schema import Schema +Sanic.test_mode = True + + def create_app(path="/graphql", **kwargs): app = Sanic(__name__) app.debug = True diff --git a/tests/test_query.py b/tests/test_query.py index 70f49ac..c4f6a43 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -495,8 +495,10 @@ def test_handles_errors_caused_by_a_lack_of_query(): def test_handles_errors_caused_by_invalid_query_type(): - results, params = run_http_query(schema, "get", dict(query=42)) - assert results == [(None, [{"message": "Must provide Source. Received: 42."}])] + with raises(HttpQueryError) as exc_info: + results, params = run_http_query(schema, "get", dict(query=42)) + + assert exc_info.value == HttpQueryError(400, "Unexpected query type.") def test_handles_batch_correctly_if_is_disabled(): From 86b7926f16c547b1a0f2e50096eb92ce1dd327af Mon Sep 17 00:00:00 2001 From: Aryan Iyappan <69184573+codebyaryan@users.noreply.github.com> Date: Tue, 10 Aug 2021 17:42:32 +0530 Subject: [PATCH 051/108] add support for validation rules (#83) Co-authored-by: Aryan Iyappan <69184573+aryan340@users.noreply.github.com> Co-authored-by: Jonathan Kim --- .gitignore | 3 +++ docs/aiohttp.md | 1 + docs/flask.md | 1 + docs/sanic.md | 1 + docs/webob.md | 1 + graphql_server/aiohttp/graphqlview.py | 9 ++++++++- graphql_server/flask/graphqlview.py | 8 ++++++++ graphql_server/quart/graphqlview.py | 9 ++++++++- graphql_server/sanic/graphqlview.py | 9 ++++++++- graphql_server/webob/graphqlview.py | 8 ++++++++ 10 files changed, 47 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 642f015..bfac963 100644 --- a/.gitignore +++ b/.gitignore @@ -158,6 +158,9 @@ 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 diff --git a/docs/aiohttp.md b/docs/aiohttp.md index 35f7fbf..d65bcb8 100644 --- a/docs/aiohttp.md +++ b/docs/aiohttp.md @@ -59,6 +59,7 @@ gql_view(request) # <-- the instance is callable and expects a `aiohttp.web.Req `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. * `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`. diff --git a/docs/flask.md b/docs/flask.md index 80bab4f..f3a36e7 100644 --- a/docs/flask.md +++ b/docs/flask.md @@ -58,6 +58,7 @@ More info at [Graphene v3 release notes](https://github.com/graphql-python/graph * `graphiql_html_title`: The graphiql title to display. Defaults to **"GraphiQL"**. * `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. * `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. diff --git a/docs/sanic.md b/docs/sanic.md index 0b5ec35..e922598 100644 --- a/docs/sanic.md +++ b/docs/sanic.md @@ -51,6 +51,7 @@ This will add `/graphql` endpoint to your app and enable the GraphiQL IDE. `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. * `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`. diff --git a/docs/webob.md b/docs/webob.md index 5203c2c..41c0ad1 100644 --- a/docs/webob.md +++ b/docs/webob.md @@ -48,6 +48,7 @@ This will add `/graphql` endpoint to your app and enable the GraphiQL IDE. * `graphiql_html_title`: The graphiql title to display. Defaults to **"GraphiQL"**. * `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. * `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. diff --git a/graphql_server/aiohttp/graphqlview.py b/graphql_server/aiohttp/graphqlview.py index a3db1d6..0081174 100644 --- a/graphql_server/aiohttp/graphqlview.py +++ b/graphql_server/aiohttp/graphqlview.py @@ -4,7 +4,7 @@ from typing import List from aiohttp import web -from graphql import ExecutionResult, GraphQLError +from graphql import ExecutionResult, GraphQLError, specified_rules from graphql.type.schema import GraphQLSchema from graphql_server import ( @@ -34,6 +34,7 @@ class GraphQLView: graphiql_template = None graphiql_html_title = None middleware = None + validation_rules = None batch = False jinja_env = None max_age = 86400 @@ -75,6 +76,11 @@ def get_context(self, request): def get_middleware(self): return self.middleware + def get_validation_rules(self): + if self.validation_rules is None: + return specified_rules + return self.validation_rules + @staticmethod async def parse_body(request): content_type = request.content_type @@ -149,6 +155,7 @@ async def __call__(self, request): root_value=self.get_root_value(), context_value=self.get_context(request), middleware=self.get_middleware(), + validation_rules=self.get_validation_rules(), ) exec_res = ( diff --git a/graphql_server/flask/graphqlview.py b/graphql_server/flask/graphqlview.py index a417406..59097d9 100644 --- a/graphql_server/flask/graphqlview.py +++ b/graphql_server/flask/graphqlview.py @@ -7,6 +7,7 @@ from flask.views import View from graphql.error import GraphQLError from graphql.type.schema import GraphQLSchema +from graphql import specified_rules from graphql_server import ( GraphQLParams, @@ -35,6 +36,7 @@ class GraphQLView(View): graphiql_template = None graphiql_html_title = None middleware = None + validation_rules = None batch = False subscriptions = None headers = None @@ -73,6 +75,11 @@ def get_context(self): def get_middleware(self): return self.middleware + def get_validation_rules(self): + if self.validation_rules is None: + return specified_rules + return self.validation_rules + def dispatch_request(self): try: request_method = request.method.lower() @@ -95,6 +102,7 @@ def dispatch_request(self): root_value=self.get_root_value(), context_value=self.get_context(), middleware=self.get_middleware(), + validation_rules=self.get_validation_rules(), ) result, status_code = encode_execution_results( execution_results, diff --git a/graphql_server/quart/graphqlview.py b/graphql_server/quart/graphqlview.py index 9993998..3f01edc 100644 --- a/graphql_server/quart/graphqlview.py +++ b/graphql_server/quart/graphqlview.py @@ -4,7 +4,7 @@ from functools import partial from typing import List -from graphql import ExecutionResult +from graphql import ExecutionResult, specified_rules from graphql.error import GraphQLError from graphql.type.schema import GraphQLSchema from quart import Response, render_template_string, request @@ -37,6 +37,7 @@ class GraphQLView(View): graphiql_template = None graphiql_html_title = None middleware = None + validation_rules = None batch = False enable_async = False subscriptions = None @@ -76,6 +77,11 @@ def get_context(self): def get_middleware(self): return self.middleware + def get_validation_rules(self): + if self.validation_rules is None: + return specified_rules + return self.validation_rules + async def dispatch_request(self): try: request_method = request.method.lower() @@ -98,6 +104,7 @@ async def dispatch_request(self): root_value=self.get_root_value(), context_value=self.get_context(), middleware=self.get_middleware(), + validation_rules=self.get_validation_rules(), ) exec_res = ( [ diff --git a/graphql_server/sanic/graphqlview.py b/graphql_server/sanic/graphqlview.py index 29548e9..e184143 100644 --- a/graphql_server/sanic/graphqlview.py +++ b/graphql_server/sanic/graphqlview.py @@ -4,7 +4,7 @@ from functools import partial from typing import List -from graphql import ExecutionResult, GraphQLError +from graphql import ExecutionResult, GraphQLError, specified_rules from graphql.type.schema import GraphQLSchema from sanic.response import HTTPResponse, html from sanic.views import HTTPMethodView @@ -36,6 +36,7 @@ class GraphQLView(HTTPMethodView): graphiql_template = None graphiql_html_title = None middleware = None + validation_rules = None batch = False jinja_env = None max_age = 86400 @@ -77,6 +78,11 @@ def get_context(self, request): def get_middleware(self): return self.middleware + def get_validation_rules(self): + if self.validation_rules is None: + return specified_rules + return self.validation_rules + async def dispatch_request(self, request, *args, **kwargs): try: request_method = request.method.lower() @@ -103,6 +109,7 @@ async def dispatch_request(self, request, *args, **kwargs): root_value=self.get_root_value(), context_value=self.get_context(request), middleware=self.get_middleware(), + validation_rules=self.get_validation_rules(), ) exec_res = ( [ diff --git a/graphql_server/webob/graphqlview.py b/graphql_server/webob/graphqlview.py index 4eff242..ba54599 100644 --- a/graphql_server/webob/graphqlview.py +++ b/graphql_server/webob/graphqlview.py @@ -5,6 +5,7 @@ from graphql.error import GraphQLError from graphql.type.schema import GraphQLSchema +from graphql import specified_rules from webob import Response from graphql_server import ( @@ -35,6 +36,7 @@ class GraphQLView: graphiql_template = None graphiql_html_title = None middleware = None + validation_rules = None batch = False enable_async = False subscriptions = None @@ -73,6 +75,11 @@ def get_context(self, request): def get_middleware(self): return self.middleware + def get_validation_rules(self): + if self.validation_rules is None: + return specified_rules + return self.validation_rules + def dispatch_request(self, request): try: request_method = request.method.lower() @@ -98,6 +105,7 @@ def dispatch_request(self, request): root_value=self.get_root_value(), context_value=self.get_context(request), middleware=self.get_middleware(), + validation_rules=self.get_validation_rules(), ) result, status_code = encode_execution_results( execution_results, From 1ccebee8c6102f2855bcf64024d84091d8547f08 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Tue, 10 Aug 2021 14:13:35 +0200 Subject: [PATCH 052/108] v3.0.0b4 --- graphql_server/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphql_server/version.py b/graphql_server/version.py index 46cd5e1..2ee7c44 100644 --- a/graphql_server/version.py +++ b/graphql_server/version.py @@ -4,7 +4,7 @@ __all__ = ["version", "version_info"] -version = "3.0.0b3" +version = "3.0.0b4" _re_version = re.compile(r"(\d+)\.(\d+)\.(\d+)(\D*)(\d*)") From 476edf370099df050289f9c0b8d70007e7dc8ecc Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Fri, 24 Dec 2021 14:28:48 +0100 Subject: [PATCH 053/108] Accept Graphene wrapped GraphQL schemas --- graphql_server/aiohttp/graphqlview.py | 8 +++++--- graphql_server/flask/graphqlview.py | 8 +++++--- graphql_server/quart/graphqlview.py | 8 +++++--- graphql_server/sanic/graphqlview.py | 8 +++++--- graphql_server/webob/graphqlview.py | 8 +++++--- 5 files changed, 25 insertions(+), 15 deletions(-) diff --git a/graphql_server/aiohttp/graphqlview.py b/graphql_server/aiohttp/graphqlview.py index 0081174..deb6522 100644 --- a/graphql_server/aiohttp/graphqlview.py +++ b/graphql_server/aiohttp/graphqlview.py @@ -56,9 +56,11 @@ def __init__(self, **kwargs): if hasattr(self, key): setattr(self, key, value) - assert isinstance( - self.schema, GraphQLSchema - ), "A Schema is required to be provided to GraphQLView." + if not isinstance(self.schema, GraphQLSchema): + # maybe the GraphQL schema is wrapped in a Graphene schema + self.schema = getattr(self.schema, "graphql_schema", None) + if not isinstance(self.schema, GraphQLSchema): + raise TypeError("A Schema is required to be provided to GraphQLView.") def get_root_value(self): return self.root_value diff --git a/graphql_server/flask/graphqlview.py b/graphql_server/flask/graphqlview.py index 59097d9..2a9e451 100644 --- a/graphql_server/flask/graphqlview.py +++ b/graphql_server/flask/graphqlview.py @@ -55,9 +55,11 @@ def __init__(self, **kwargs): if hasattr(self, key): setattr(self, key, value) - assert isinstance( - self.schema, GraphQLSchema - ), "A Schema is required to be provided to GraphQLView." + if not isinstance(self.schema, GraphQLSchema): + # maybe the GraphQL schema is wrapped in a Graphene schema + self.schema = getattr(self.schema, "graphql_schema", None) + if not isinstance(self.schema, GraphQLSchema): + raise TypeError("A Schema is required to be provided to GraphQLView.") def get_root_value(self): return self.root_value diff --git a/graphql_server/quart/graphqlview.py b/graphql_server/quart/graphqlview.py index 3f01edc..ff737ec 100644 --- a/graphql_server/quart/graphqlview.py +++ b/graphql_server/quart/graphqlview.py @@ -57,9 +57,11 @@ def __init__(self, **kwargs): if hasattr(self, key): setattr(self, key, value) - assert isinstance( - self.schema, GraphQLSchema - ), "A Schema is required to be provided to GraphQLView." + if not isinstance(self.schema, GraphQLSchema): + # maybe the GraphQL schema is wrapped in a Graphene schema + self.schema = getattr(self.schema, "graphql_schema", None) + if not isinstance(self.schema, GraphQLSchema): + raise TypeError("A Schema is required to be provided to GraphQLView.") def get_root_value(self): return self.root_value diff --git a/graphql_server/sanic/graphqlview.py b/graphql_server/sanic/graphqlview.py index e184143..c7a3b75 100644 --- a/graphql_server/sanic/graphqlview.py +++ b/graphql_server/sanic/graphqlview.py @@ -58,9 +58,11 @@ def __init__(self, **kwargs): if hasattr(self, key): setattr(self, key, value) - assert isinstance( - self.schema, GraphQLSchema - ), "A Schema is required to be provided to GraphQLView." + if not isinstance(self.schema, GraphQLSchema): + # maybe the GraphQL schema is wrapped in a Graphene schema + self.schema = getattr(self.schema, "graphql_schema", None) + if not isinstance(self.schema, GraphQLSchema): + raise TypeError("A Schema is required to be provided to GraphQLView.") def get_root_value(self): return self.root_value diff --git a/graphql_server/webob/graphqlview.py b/graphql_server/webob/graphqlview.py index ba54599..0aa08c6 100644 --- a/graphql_server/webob/graphqlview.py +++ b/graphql_server/webob/graphqlview.py @@ -55,9 +55,11 @@ def __init__(self, **kwargs): if hasattr(self, key): setattr(self, key, value) - assert isinstance( - self.schema, GraphQLSchema - ), "A Schema is required to be provided to GraphQLView." + if not isinstance(self.schema, GraphQLSchema): + # maybe the GraphQL schema is wrapped in a Graphene schema + self.schema = getattr(self.schema, "graphql_schema", None) + if not isinstance(self.schema, GraphQLSchema): + raise TypeError("A Schema is required to be provided to GraphQLView.") def get_root_value(self): return self.root_value From 384ae78d257f0bb8bd86c581b7f01eb395378d6f Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Mon, 17 Jan 2022 11:52:54 +0100 Subject: [PATCH 054/108] Update GraphQL-core from 3.1 to 3.2 (#85) --- graphql_server/__init__.py | 6 +++++- setup.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 239a1d4..5ae4acd 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -12,7 +12,6 @@ from typing import Any, Callable, Collection, Dict, List, Optional, Type, Union from graphql.error import GraphQLError -from graphql.error import format_error as format_error_default from graphql.execution import ExecutionResult, execute from graphql.language import OperationType, parse from graphql.pyutils import AwaitableOrValue @@ -55,6 +54,11 @@ # The public helper functions +def format_error_default(error: GraphQLError) -> Dict: + """The default function for converting GraphQLError to a dictionary.""" + return error.formatted + + def run_http_query( schema: GraphQLSchema, request_method: str, diff --git a/setup.py b/setup.py index e3f769e..91786c3 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ from re import search from setuptools import setup, find_packages -install_requires = ["graphql-core>=3.1.0,<4", "typing-extensions>=3.7.4,<4"] +install_requires = ["graphql-core>=3.2,<3.3", "typing-extensions>=4,<5"] tests_requires = [ "pytest>=5.4,<5.5", From bc74eedab7e15b98aff4891dc1c74eb0528634f6 Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Mon, 17 Jan 2022 13:12:26 +0100 Subject: [PATCH 055/108] Empty fields are not contained in formatted errors any more --- setup.py | 8 +-- tests/aiohttp/test_graphqlview.py | 86 ++++++++++++++----------------- tests/flask/test_graphqlview.py | 46 ++++++++--------- tests/quart/test_graphqlview.py | 52 ++++++------------- tests/sanic/test_graphqlview.py | 46 ++++++----------- tests/test_query.py | 13 +---- tests/webob/test_graphqlview.py | 52 ++++++------------- 7 files changed, 116 insertions(+), 187 deletions(-) diff --git a/setup.py b/setup.py index 91786c3..6bb761e 100644 --- a/setup.py +++ b/setup.py @@ -12,9 +12,9 @@ ] dev_requires = [ - "flake8>=3.7,<4", - "isort>=4,<5", - "black==19.10b0", + "flake8>=4,<5", + "isort>=5,<6", + "black>=19.10b0", "mypy>=0.761,<0.770", "check-manifest>=0.40,<1", ] + tests_requires @@ -28,7 +28,7 @@ ] install_webob_requires = [ - "webob>=1.8.6,<2", + "webob>=1.8.7,<2", ] install_aiohttp_requires = [ diff --git a/tests/aiohttp/test_graphqlview.py b/tests/aiohttp/test_graphqlview.py index 0a940f9..815d23d 100644 --- a/tests/aiohttp/test_graphqlview.py +++ b/tests/aiohttp/test_graphqlview.py @@ -76,12 +76,10 @@ async def test_reports_validation_errors(client): { "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 9}], - "path": None, }, { "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 21}], - "path": None, }, ], } @@ -107,8 +105,6 @@ async def test_errors_when_missing_operation_name(client): "Must provide operation name if query contains multiple " "operations." ), - "locations": None, - "path": None, }, ] } @@ -128,8 +124,6 @@ async def test_errors_when_sending_a_mutation_via_get(client): "errors": [ { "message": "Can only perform a mutation operation from a POST request.", - "locations": None, - "path": None, }, ], } @@ -152,8 +146,6 @@ async def test_errors_when_selecting_a_mutation_within_a_get(client): "errors": [ { "message": "Can only perform a mutation operation from a POST request.", - "locations": None, - "path": None, }, ], } @@ -174,10 +166,8 @@ async def test_errors_when_selecting_a_subscription_within_a_get(client): assert await response.json() == { "errors": [ { - "message": "Can only perform a subscription operation from a POST " - "request.", - "locations": None, - "path": None, + "message": "Can only perform a subscription operation" + " from a POST request.", }, ], } @@ -215,7 +205,11 @@ async def test_allows_post_with_json_encoding(client): async def test_allows_sending_a_mutation_via_post(client): response = await client.post( "/graphql", - data=json.dumps(dict(query="mutation TestMutation { writeTest { test } }",)), + data=json.dumps( + dict( + query="mutation TestMutation { writeTest { test } }", + ) + ), headers={"content-type": "application/json"}, ) @@ -292,7 +286,11 @@ async def test_supports_post_url_encoded_query_with_string_variables(client): async def test_supports_post_json_quey_with_get_variable_values(client): response = await client.post( url_string(variables=json.dumps({"who": "Dolly"})), - data=json.dumps(dict(query="query helloWho($who: String){ test(who: $who) }",)), + data=json.dumps( + dict( + query="query helloWho($who: String){ test(who: $who) }", + ) + ), headers={"content-type": "application/json"}, ) @@ -304,7 +302,11 @@ async def test_supports_post_json_quey_with_get_variable_values(client): async def test_post_url_encoded_query_with_get_variable_values(client): response = await client.post( url_string(variables=json.dumps({"who": "Dolly"})), - data=urlencode(dict(query="query helloWho($who: String){ test(who: $who) }",)), + data=urlencode( + dict( + query="query helloWho($who: String){ test(who: $who) }", + ) + ), headers={"content-type": "application/x-www-form-urlencoded"}, ) @@ -421,7 +423,6 @@ async def test_handles_syntax_errors_caught_by_graphql(client): { "locations": [{"column": 1, "line": 1}], "message": "Syntax Error: Unexpected Name 'syntaxerror'.", - "path": None, }, ], } @@ -433,16 +434,16 @@ async def test_handles_errors_caused_by_a_lack_of_query(client): assert response.status == 400 assert await response.json() == { - "errors": [ - {"message": "Must provide query string.", "locations": None, "path": None} - ] + "errors": [{"message": "Must provide query string."}] } @pytest.mark.asyncio async def test_handles_batch_correctly_if_is_disabled(client): response = await client.post( - "/graphql", data="[]", headers={"content-type": "application/json"}, + "/graphql", + data="[]", + headers={"content-type": "application/json"}, ) assert response.status == 400 @@ -450,8 +451,6 @@ async def test_handles_batch_correctly_if_is_disabled(client): "errors": [ { "message": "Batch GraphQL requests are not enabled.", - "locations": None, - "path": None, } ] } @@ -460,7 +459,9 @@ async def test_handles_batch_correctly_if_is_disabled(client): @pytest.mark.asyncio async def test_handles_incomplete_json_bodies(client): response = await client.post( - "/graphql", data='{"query":', headers={"content-type": "application/json"}, + "/graphql", + data='{"query":', + headers={"content-type": "application/json"}, ) assert response.status == 400 @@ -468,8 +469,6 @@ async def test_handles_incomplete_json_bodies(client): "errors": [ { "message": "POST body sent invalid JSON.", - "locations": None, - "path": None, } ] } @@ -484,9 +483,7 @@ async def test_handles_plain_post_text(client): ) assert response.status == 400 assert await response.json() == { - "errors": [ - {"message": "Must provide query string.", "locations": None, "path": None} - ] + "errors": [{"message": "Must provide query string."}] } @@ -499,9 +496,7 @@ async def test_handles_poorly_formed_variables(client): ) assert response.status == 400 assert await response.json() == { - "errors": [ - {"message": "Variables are invalid JSON.", "locations": None, "path": None} - ] + "errors": [{"message": "Variables are invalid JSON."}] } @@ -514,8 +509,6 @@ async def test_handles_unsupported_http_methods(client): "errors": [ { "message": "GraphQL only supports GET and POST requests.", - "locations": None, - "path": None, } ] } @@ -576,16 +569,15 @@ async def test_post_multipart_data(client): data = ( "------aiohttpgraphql\r\n" - + 'Content-Disposition: form-data; name="query"\r\n' - + "\r\n" - + query - + "\r\n" - + "------aiohttpgraphql--\r\n" - + "Content-Type: text/plain; charset=utf-8\r\n" - + 'Content-Disposition: form-data; name="file"; filename="text1.txt"; filename*=utf-8\'\'text1.txt\r\n' # noqa: ignore - + "\r\n" - + "\r\n" - + "------aiohttpgraphql--\r\n" + 'Content-Disposition: form-data; name="query"\r\n' + "\r\n" + query + "\r\n" + "------aiohttpgraphql--\r\n" + "Content-Type: text/plain; charset=utf-8\r\n" + 'Content-Disposition: form-data; name="file"; filename="text1.txt";' + " filename*=utf-8''text1.txt\r\n" + "\r\n" + "\r\n" + "------aiohttpgraphql--\r\n" ) response = await client.post( @@ -595,7 +587,7 @@ async def test_post_multipart_data(client): ) assert response.status == 200 - assert await response.json() == {"data": {u"writeTest": {u"test": u"Hello World"}}} + assert await response.json() == {"data": {"writeTest": {"test": "Hello World"}}} @pytest.mark.asyncio @@ -674,7 +666,8 @@ async def test_async_schema(app, client): @pytest.mark.asyncio async def test_preflight_request(client): response = await client.options( - "/graphql", headers={"Access-Control-Request-Method": "POST"}, + "/graphql", + headers={"Access-Control-Request-Method": "POST"}, ) assert response.status == 200 @@ -683,7 +676,8 @@ async def test_preflight_request(client): @pytest.mark.asyncio async def test_preflight_incorrect_request(client): response = await client.options( - "/graphql", headers={"Access-Control-Request-Method": "OPTIONS"}, + "/graphql", + headers={"Access-Control-Request-Method": "OPTIONS"}, ) assert response.status == 400 diff --git a/tests/flask/test_graphqlview.py b/tests/flask/test_graphqlview.py index d8d60b0..9b388f9 100644 --- a/tests/flask/test_graphqlview.py +++ b/tests/flask/test_graphqlview.py @@ -97,12 +97,10 @@ def test_reports_validation_errors(app, client): { "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 9}], - "path": None, }, { "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 21}], - "path": None, }, ] } @@ -123,9 +121,8 @@ def test_errors_when_missing_operation_name(app, client): assert response_json(response) == { "errors": [ { - "message": "Must provide operation name if query contains multiple operations.", # noqa: E501 - "locations": None, - "path": None, + "message": "Must provide operation name" + " if query contains multiple operations.", } ] } @@ -145,8 +142,6 @@ def test_errors_when_sending_a_mutation_via_get(app, client): "errors": [ { "message": "Can only perform a mutation operation from a POST request.", - "locations": None, - "path": None, } ] } @@ -169,8 +164,6 @@ def test_errors_when_selecting_a_mutation_within_a_get(app, client): "errors": [ { "message": "Can only perform a mutation operation from a POST request.", - "locations": None, - "path": None, } ] } @@ -272,7 +265,9 @@ def test_supports_post_url_encoded_query_with_string_variables(app, client): def test_supports_post_json_query_with_get_variable_values(app, client): response = client.post( url_string(app, variables=json.dumps({"who": "Dolly"})), - data=json_dump_kwarg(query="query helloWho($who: String){ test(who: $who) }",), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + ), content_type="application/json", ) @@ -283,7 +278,11 @@ def test_supports_post_json_query_with_get_variable_values(app, client): def test_post_url_encoded_query_with_get_variable_values(app, client): response = client.post( url_string(app, variables=json.dumps({"who": "Dolly"})), - data=urlencode(dict(query="query helloWho($who: String){ test(who: $who) }",)), + data=urlencode( + dict( + query="query helloWho($who: String){ test(who: $who) }", + ) + ), content_type="application/x-www-form-urlencoded", ) @@ -392,7 +391,6 @@ def test_handles_syntax_errors_caught_by_graphql(app, client): { "locations": [{"column": 1, "line": 1}], "message": "Syntax Error: Unexpected Name 'syntaxerror'.", - "path": None, } ] } @@ -404,7 +402,9 @@ def test_handles_errors_caused_by_a_lack_of_query(app, client): assert response.status_code == 400 assert response_json(response) == { "errors": [ - {"message": "Must provide query string.", "locations": None, "path": None} + { + "message": "Must provide query string.", + } ] } @@ -417,8 +417,6 @@ def test_handles_batch_correctly_if_is_disabled(app, client): "errors": [ { "message": "Batch GraphQL requests are not enabled.", - "locations": None, - "path": None, } ] } @@ -432,7 +430,9 @@ def test_handles_incomplete_json_bodies(app, client): assert response.status_code == 400 assert response_json(response) == { "errors": [ - {"message": "POST body sent invalid JSON.", "locations": None, "path": None} + { + "message": "POST body sent invalid JSON.", + } ] } @@ -446,7 +446,9 @@ def test_handles_plain_post_text(app, client): assert response.status_code == 400 assert response_json(response) == { "errors": [ - {"message": "Must provide query string.", "locations": None, "path": None} + { + "message": "Must provide query string.", + } ] } @@ -462,7 +464,9 @@ def test_handles_poorly_formed_variables(app, client): assert response.status_code == 400 assert response_json(response) == { "errors": [ - {"message": "Variables are invalid JSON.", "locations": None, "path": None} + { + "message": "Variables are invalid JSON.", + } ] } @@ -475,8 +479,6 @@ def test_handles_unsupported_http_methods(app, client): "errors": [ { "message": "GraphQL only supports GET and POST requests.", - "locations": None, - "path": None, } ] } @@ -524,9 +526,7 @@ def test_post_multipart_data(app, client): ) assert response.status_code == 200 - assert response_json(response) == { - "data": {u"writeTest": {u"test": u"Hello World"}} - } + assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} @pytest.mark.parametrize("app", [create_app(batch=True)]) diff --git a/tests/quart/test_graphqlview.py b/tests/quart/test_graphqlview.py index 4a24ace..429b4ef 100644 --- a/tests/quart/test_graphqlview.py +++ b/tests/quart/test_graphqlview.py @@ -35,7 +35,7 @@ async def execute_client( method: str = "GET", data: str = None, headers: Headers = None, - **url_params + **url_params, ) -> Response: if sys.version_info >= (3, 7): test_request_context = app.test_request_context("/", method=method) @@ -126,12 +126,10 @@ async def test_reports_validation_errors(app: Quart, client: QuartClient): { "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 9}], - "path": None, }, { "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 21}], - "path": None, }, ] } @@ -153,9 +151,8 @@ async def test_errors_when_missing_operation_name(app: Quart, client: QuartClien assert response_json(result) == { "errors": [ { - "message": "Must provide operation name if query contains multiple operations.", # noqa: E501 - "locations": None, - "path": None, + "message": "Must provide operation name" + " if query contains multiple operations.", } ] } @@ -176,8 +173,6 @@ async def test_errors_when_sending_a_mutation_via_get(app: Quart, client: QuartC "errors": [ { "message": "Can only perform a mutation operation from a POST request.", - "locations": None, - "path": None, } ] } @@ -203,8 +198,6 @@ async def test_errors_when_selecting_a_mutation_within_a_get( "errors": [ { "message": "Can only perform a mutation operation from a POST request.", - "locations": None, - "path": None, } ] } @@ -342,7 +335,9 @@ async def test_supports_post_json_query_with_get_variable_values( app, client, method="POST", - data=json_dump_kwarg(query="query helloWho($who: String){ test(who: $who) }",), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + ), headers=Headers({"Content-Type": "application/json"}), variables=json.dumps({"who": "Dolly"}), ) @@ -360,7 +355,11 @@ async def test_post_url_encoded_query_with_get_variable_values( app, client, method="POST", - data=urlencode(dict(query="query helloWho($who: String){ test(who: $who) }",)), + data=urlencode( + dict( + query="query helloWho($who: String){ test(who: $who) }", + ) + ), headers=Headers({"Content-Type": "application/x-www-form-urlencoded"}), variables=json.dumps({"who": "Dolly"}), ) @@ -463,7 +462,7 @@ async def test_supports_pretty_printing_by_request(app: Quart, client: QuartClie response = await execute_client(app, client, query="{test}", pretty="1") result = await response.get_data(raw=False) - assert result == ("{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}") + assert result == "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" @pytest.mark.asyncio @@ -493,7 +492,6 @@ async def test_handles_syntax_errors_caught_by_graphql(app: Quart, client: Quart { "locations": [{"column": 1, "line": 1}], "message": "Syntax Error: Unexpected Name 'syntaxerror'.", - "path": None, } ] } @@ -508,9 +506,7 @@ async def test_handles_errors_caused_by_a_lack_of_query( assert response.status_code == 400 result = await response.get_data(raw=False) assert response_json(result) == { - "errors": [ - {"message": "Must provide query string.", "locations": None, "path": None} - ] + "errors": [{"message": "Must provide query string."}] } @@ -530,8 +526,6 @@ async def test_handles_batch_correctly_if_is_disabled(app: Quart, client: QuartC "errors": [ { "message": "Batch GraphQL requests are not enabled.", - "locations": None, - "path": None, } ] } @@ -550,9 +544,7 @@ async def test_handles_incomplete_json_bodies(app: Quart, client: QuartClient): assert response.status_code == 400 result = await response.get_data(raw=False) assert response_json(result) == { - "errors": [ - {"message": "POST body sent invalid JSON.", "locations": None, "path": None} - ] + "errors": [{"message": "POST body sent invalid JSON."}] } @@ -569,9 +561,7 @@ async def test_handles_plain_post_text(app: Quart, client: QuartClient): assert response.status_code == 400 result = await response.get_data(raw=False) assert response_json(result) == { - "errors": [ - {"message": "Must provide query string.", "locations": None, "path": None} - ] + "errors": [{"message": "Must provide query string."}] } @@ -586,9 +576,7 @@ async def test_handles_poorly_formed_variables(app: Quart, client: QuartClient): assert response.status_code == 400 result = await response.get_data(raw=False) assert response_json(result) == { - "errors": [ - {"message": "Variables are invalid JSON.", "locations": None, "path": None} - ] + "errors": [{"message": "Variables are invalid JSON."}] } @@ -599,13 +587,7 @@ async def test_handles_unsupported_http_methods(app: Quart, client: QuartClient) result = await response.get_data(raw=False) assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] assert response_json(result) == { - "errors": [ - { - "message": "GraphQL only supports GET and POST requests.", - "locations": None, - "path": None, - } - ] + "errors": [{"message": "GraphQL only supports GET and POST requests."}] } diff --git a/tests/sanic/test_graphqlview.py b/tests/sanic/test_graphqlview.py index 740697c..7152150 100644 --- a/tests/sanic/test_graphqlview.py +++ b/tests/sanic/test_graphqlview.py @@ -74,12 +74,10 @@ def test_reports_validation_errors(app): { "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 9}], - "path": None, }, { "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 21}], - "path": None, }, ] } @@ -100,9 +98,8 @@ def test_errors_when_missing_operation_name(app): assert response_json(response) == { "errors": [ { - "locations": None, - "message": "Must provide operation name if query contains multiple operations.", - "path": None, + "message": "Must provide operation name" + " if query contains multiple operations.", } ] } @@ -121,9 +118,7 @@ def test_errors_when_sending_a_mutation_via_get(app): assert response_json(response) == { "errors": [ { - "locations": None, "message": "Can only perform a mutation operation from a POST request.", - "path": None, } ] } @@ -145,9 +140,7 @@ def test_errors_when_selecting_a_mutation_within_a_get(app): assert response_json(response) == { "errors": [ { - "locations": None, "message": "Can only perform a mutation operation from a POST request.", - "path": None, } ] } @@ -260,7 +253,9 @@ def test_supports_post_url_encoded_query_with_string_variables(app): def test_supports_post_json_query_with_get_variable_values(app): _, response = app.client.post( uri=url_string(variables=json.dumps({"who": "Dolly"})), - data=json_dump_kwarg(query="query helloWho($who: String){ test(who: $who) }",), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + ), headers={"content-type": "application/json"}, ) @@ -272,7 +267,11 @@ def test_supports_post_json_query_with_get_variable_values(app): def test_post_url_encoded_query_with_get_variable_values(app): _, response = app.client.post( uri=url_string(variables=json.dumps({"who": "Dolly"})), - data=urlencode(dict(query="query helloWho($who: String){ test(who: $who) }",)), + data=urlencode( + dict( + query="query helloWho($who: String){ test(who: $who) }", + ) + ), headers={"content-type": "application/x-www-form-urlencoded"}, ) @@ -387,7 +386,6 @@ def test_handles_syntax_errors_caught_by_graphql(app): { "locations": [{"column": 1, "line": 1}], "message": "Syntax Error: Unexpected Name 'syntaxerror'.", - "path": None, } ] } @@ -399,9 +397,7 @@ def test_handles_errors_caused_by_a_lack_of_query(app): assert response.status == 400 assert response_json(response) == { - "errors": [ - {"locations": None, "message": "Must provide query string.", "path": None} - ] + "errors": [{"message": "Must provide query string."}] } @@ -415,9 +411,7 @@ def test_handles_batch_correctly_if_is_disabled(app): assert response_json(response) == { "errors": [ { - "locations": None, "message": "Batch GraphQL requests are not enabled.", - "path": None, } ] } @@ -431,9 +425,7 @@ def test_handles_incomplete_json_bodies(app): assert response.status == 400 assert response_json(response) == { - "errors": [ - {"locations": None, "message": "POST body sent invalid JSON.", "path": None} - ] + "errors": [{"message": "POST body sent invalid JSON."}] } @@ -446,9 +438,7 @@ def test_handles_plain_post_text(app): ) assert response.status == 400 assert response_json(response) == { - "errors": [ - {"locations": None, "message": "Must provide query string.", "path": None} - ] + "errors": [{"message": "Must provide query string."}] } @@ -461,9 +451,7 @@ def test_handles_poorly_formed_variables(app): ) assert response.status == 400 assert response_json(response) == { - "errors": [ - {"locations": None, "message": "Variables are invalid JSON.", "path": None} - ] + "errors": [{"message": "Variables are invalid JSON."}] } @@ -475,9 +463,7 @@ def test_handles_unsupported_http_methods(app): assert response_json(response) == { "errors": [ { - "locations": None, "message": "GraphQL only supports GET and POST requests.", - "path": None, } ] } @@ -542,9 +528,7 @@ def test_post_multipart_data(app): ) assert response.status == 200 - assert response_json(response) == { - "data": {u"writeTest": {u"test": u"Hello World"}} - } + assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} @pytest.mark.parametrize("app", [create_app(batch=True)]) diff --git a/tests/test_query.py b/tests/test_query.py index c4f6a43..a1352cc 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -40,9 +40,7 @@ def test_validate_schema(): "data": None, "errors": [ { - "locations": None, "message": "Query root type must be provided.", - "path": None, } ], } @@ -109,12 +107,10 @@ def test_reports_validation_errors(): { "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 9}], - "path": None, }, { "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 21}], - "path": None, }, ], } @@ -144,7 +140,6 @@ def enter_field(self, node, *_args): { "message": "Custom validation error.", "locations": [{"line": 1, "column": 3}], - "path": None, } ], } @@ -170,13 +165,10 @@ def test_reports_max_num_of_validation_errors(): { "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 9}], - "path": None, }, { "message": "Too many validation errors, error limit reached." " Validation aborted.", - "locations": None, - "path": None, }, ], } @@ -223,12 +215,10 @@ def test_errors_when_missing_operation_name(): "data": None, "errors": [ { - "locations": None, "message": ( "Must provide operation name" " if query contains multiple operations." ), - "path": None, } ], } @@ -585,8 +575,7 @@ def test_encode_execution_results_batch(): results = [ExecutionResult(data, None), ExecutionResult(None, errors)] result = encode_execution_results(results, is_batch=True) assert result == ( - '[{"data":{"answer":42}},' - '{"errors":[{"message":"bad","locations":null,"path":null}]}]', + '[{"data":{"answer":42}},{"errors":[{"message":"bad"}]}]', 400, ) diff --git a/tests/webob/test_graphqlview.py b/tests/webob/test_graphqlview.py index 456b5f1..e1d783d 100644 --- a/tests/webob/test_graphqlview.py +++ b/tests/webob/test_graphqlview.py @@ -76,12 +76,10 @@ def test_reports_validation_errors(client): { "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 9}], - "path": None, }, { "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 21}], - "path": None, }, ] } @@ -101,9 +99,8 @@ def test_errors_when_missing_operation_name(client): assert response_json(response) == { "errors": [ { - "message": "Must provide operation name if query contains multiple operations.", - "locations": None, - "path": None, + "message": "Must provide operation name" + " if query contains multiple operations.", } ] } @@ -122,8 +119,6 @@ def test_errors_when_sending_a_mutation_via_get(client): "errors": [ { "message": "Can only perform a mutation operation from a POST request.", - "locations": None, - "path": None, } ] } @@ -145,8 +140,6 @@ def test_errors_when_selecting_a_mutation_within_a_get(client): "errors": [ { "message": "Can only perform a mutation operation from a POST request.", - "locations": None, - "path": None, } ] } @@ -247,7 +240,9 @@ def test_supports_post_url_encoded_query_with_string_variables(client): def test_supports_post_json_quey_with_get_variable_values(client): response = client.post( url_string(variables=json.dumps({"who": "Dolly"})), - data=json_dump_kwarg(query="query helloWho($who: String){ test(who: $who) }",), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + ), content_type="application/json", ) @@ -258,7 +253,11 @@ def test_supports_post_json_quey_with_get_variable_values(client): def test_post_url_encoded_query_with_get_variable_values(client): response = client.post( url_string(variables=json.dumps({"who": "Dolly"})), - data=urlencode(dict(query="query helloWho($who: String){ test(who: $who) }",)), + data=urlencode( + dict( + query="query helloWho($who: String){ test(who: $who) }", + ) + ), content_type="application/x-www-form-urlencoded", ) @@ -367,7 +366,6 @@ def test_handles_syntax_errors_caught_by_graphql(client): { "message": "Syntax Error: Unexpected Name 'syntaxerror'.", "locations": [{"column": 1, "line": 1}], - "path": None, } ] } @@ -378,9 +376,7 @@ def test_handles_errors_caused_by_a_lack_of_query(client): assert response.status_code == 400 assert response_json(response) == { - "errors": [ - {"message": "Must provide query string.", "locations": None, "path": None} - ] + "errors": [{"message": "Must provide query string."}] } @@ -392,8 +388,6 @@ def test_handles_batch_correctly_if_is_disabled(client): "errors": [ { "message": "Batch GraphQL requests are not enabled.", - "locations": None, - "path": None, } ] } @@ -406,9 +400,7 @@ def test_handles_incomplete_json_bodies(client): assert response.status_code == 400 assert response_json(response) == { - "errors": [ - {"message": "POST body sent invalid JSON.", "locations": None, "path": None} - ] + "errors": [{"message": "POST body sent invalid JSON."}] } @@ -420,9 +412,7 @@ def test_handles_plain_post_text(client): ) assert response.status_code == 400 assert response_json(response) == { - "errors": [ - {"message": "Must provide query string.", "locations": None, "path": None} - ] + "errors": [{"message": "Must provide query string."}] } @@ -434,9 +424,7 @@ def test_handles_poorly_formed_variables(client): ) assert response.status_code == 400 assert response_json(response) == { - "errors": [ - {"message": "Variables are invalid JSON.", "locations": None, "path": None} - ] + "errors": [{"message": "Variables are invalid JSON."}] } @@ -445,13 +433,7 @@ def test_handles_unsupported_http_methods(client): assert response.status_code == 405 assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] assert response_json(response) == { - "errors": [ - { - "message": "GraphQL only supports GET and POST requests.", - "locations": None, - "path": None, - } - ] + "errors": [{"message": "GraphQL only supports GET and POST requests."}] } @@ -511,9 +493,7 @@ def test_post_multipart_data(client): ) assert response.status_code == 200 - assert response_json(response) == { - "data": {u"writeTest": {u"test": u"Hello World"}} - } + assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} @pytest.mark.parametrize("settings", [dict(batch=True)]) From bda6a87bb987625908159a80a5563b1a1e7f05e5 Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Mon, 17 Jan 2022 13:20:10 +0100 Subject: [PATCH 056/108] Update dependencies --- setup.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/setup.py b/setup.py index 6bb761e..18294bf 100644 --- a/setup.py +++ b/setup.py @@ -4,27 +4,27 @@ install_requires = ["graphql-core>=3.2,<3.3", "typing-extensions>=4,<5"] tests_requires = [ - "pytest>=5.4,<5.5", - "pytest-asyncio>=0.11.0", - "pytest-cov>=2.8,<3", - "aiohttp>=3.5.0,<4", - "Jinja2>=2.10.1,<3", + "pytest>=6.2,<6.3", + "pytest-asyncio>=0.17,<1", + "pytest-cov>=3,<4", + "aiohttp>=3.8,<4", + "Jinja2>=2.11,<3", ] dev_requires = [ "flake8>=4,<5", "isort>=5,<6", "black>=19.10b0", - "mypy>=0.761,<0.770", - "check-manifest>=0.40,<1", + "mypy>=0.931,<1", + "check-manifest>=0.47,<1", ] + tests_requires install_flask_requires = [ - "flask>=0.7.0<1", + "flask>=1,<2", ] install_sanic_requires = [ - "sanic>=20.3.0,<21", + "sanic>=21,<22", ] install_webob_requires = [ @@ -32,7 +32,7 @@ ] install_aiohttp_requires = [ - "aiohttp>=3.5.0,<4", + "aiohttp>=3.8,<4", ] install_quart_requires = ["quart>=0.6.15,<1"] From ec4ed15046c7b133907c9250a8101b01fe94eaaf Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Mon, 17 Jan 2022 14:13:52 +0100 Subject: [PATCH 057/108] Support Python 3.10 Also restrict web frameworks to supported versions --- .github/workflows/deploy.yml | 6 +++--- .github/workflows/lint.yml | 6 +++--- .github/workflows/tests.yml | 10 ++++++---- graphql_server/__init__.py | 14 ++++++++++++-- graphql_server/aiohttp/graphqlview.py | 4 +++- graphql_server/flask/graphqlview.py | 2 +- graphql_server/sanic/graphqlview.py | 4 ++-- graphql_server/webob/graphqlview.py | 2 +- setup.py | 5 +++-- tests/aiohttp/schema.py | 5 ++++- tests/aiohttp/test_graphiqlview.py | 17 ++++++++++++----- tests/quart/test_graphiqlview.py | 7 +------ tests/quart/test_graphqlview.py | 8 +------- tests/sanic/app.py | 1 - tox.ini | 17 +++++++++-------- 15 files changed, 61 insertions(+), 47 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a580073..6a34bba 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -11,10 +11,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Build wheel and source tarball run: | pip install wheel @@ -23,4 +23,4 @@ jobs: uses: pypa/gh-action-pypi-publish@v1.1.0 with: user: __token__ - password: ${{ secrets.pypi_password }} \ No newline at end of file + password: ${{ secrets.pypi_password }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 252a382..90ba2a1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,10 +8,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Install dependencies run: | python -m pip install --upgrade pip @@ -19,4 +19,4 @@ jobs: - name: Run lint and static type checks run: tox env: - TOXENV: flake8,black,import-order,mypy,manifest \ No newline at end of file + TOXENV: flake8,black,import-order,mypy,manifest diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4110dae..31616ec 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ["3.6", "3.7", "3.8", "3.9"] + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] os: [ubuntu-latest, windows-latest] exclude: - os: windows-latest @@ -16,7 +16,9 @@ jobs: - os: windows-latest python-version: "3.7" - os: windows-latest - python-version: "3.9" + python-version: "3.8" + - os: windows-latest + python-version: "3.10" steps: - uses: actions/checkout@v2 @@ -38,10 +40,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Install test dependencies run: | python -m pip install --upgrade pip diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 5ae4acd..ee54cdb 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -9,7 +9,17 @@ import json from collections import namedtuple from collections.abc import MutableMapping -from typing import Any, Callable, Collection, Dict, List, Optional, Type, Union +from typing import ( + Any, + Callable, + Collection, + Dict, + List, + Optional, + Type, + Union, + cast, +) from graphql.error import GraphQLError from graphql.execution import ExecutionResult, execute @@ -56,7 +66,7 @@ def format_error_default(error: GraphQLError) -> Dict: """The default function for converting GraphQLError to a dictionary.""" - return error.formatted + return cast(Dict, error.formatted) def run_http_query( diff --git a/graphql_server/aiohttp/graphqlview.py b/graphql_server/aiohttp/graphqlview.py index deb6522..d98becd 100644 --- a/graphql_server/aiohttp/graphqlview.py +++ b/graphql_server/aiohttp/graphqlview.py @@ -201,7 +201,9 @@ async def __call__(self, request): return web.Response(text=source, content_type="text/html") return web.Response( - text=result, status=status_code, content_type="application/json", + text=result, + status=status_code, + content_type="application/json", ) except HttpQueryError as err: diff --git a/graphql_server/flask/graphqlview.py b/graphql_server/flask/graphqlview.py index 2a9e451..063a67a 100644 --- a/graphql_server/flask/graphqlview.py +++ b/graphql_server/flask/graphqlview.py @@ -5,9 +5,9 @@ from flask import Response, render_template_string, request from flask.views import View +from graphql import specified_rules from graphql.error import GraphQLError from graphql.type.schema import GraphQLSchema -from graphql import specified_rules from graphql_server import ( GraphQLParams, diff --git a/graphql_server/sanic/graphqlview.py b/graphql_server/sanic/graphqlview.py index c7a3b75..569db53 100644 --- a/graphql_server/sanic/graphqlview.py +++ b/graphql_server/sanic/graphqlview.py @@ -212,8 +212,8 @@ def request_wants_html(request): return "text/html" in accept or "*/*" in accept def process_preflight(self, request): - """ Preflight request support for apollo-client - https://www.w3.org/TR/cors/#resource-preflight-requests """ + """Preflight request support for apollo-client + https://www.w3.org/TR/cors/#resource-preflight-requests""" origin = request.headers.get("Origin", "") method = request.headers.get("Access-Control-Request-Method", "").upper() diff --git a/graphql_server/webob/graphqlview.py b/graphql_server/webob/graphqlview.py index 0aa08c6..36725f3 100644 --- a/graphql_server/webob/graphqlview.py +++ b/graphql_server/webob/graphqlview.py @@ -3,9 +3,9 @@ from functools import partial from typing import List +from graphql import specified_rules from graphql.error import GraphQLError from graphql.type.schema import GraphQLSchema -from graphql import specified_rules from webob import Response from graphql_server import ( diff --git a/setup.py b/setup.py index 18294bf..bb98728 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ ] install_sanic_requires = [ - "sanic>=21,<22", + "sanic>=20.3,<21", ] install_webob_requires = [ @@ -35,7 +35,7 @@ "aiohttp>=3.8,<4", ] -install_quart_requires = ["quart>=0.6.15,<1"] +install_quart_requires = ["quart>=0.6.15,<0.15"] install_all_requires = ( install_requires @@ -71,6 +71,7 @@ "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "License :: OSI Approved :: MIT License", ], keywords="api graphql protocol rest", diff --git a/tests/aiohttp/schema.py b/tests/aiohttp/schema.py index 7673180..54e0d10 100644 --- a/tests/aiohttp/schema.py +++ b/tests/aiohttp/schema.py @@ -18,7 +18,10 @@ def resolve_raises(*_): QueryRootType = GraphQLObjectType( name="QueryRoot", fields={ - "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_raises,), + "thrower": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=resolve_raises, + ), "request": GraphQLField( GraphQLNonNull(GraphQLString), resolve=lambda obj, info, *args: info.context["request"].query.get("q"), diff --git a/tests/aiohttp/test_graphiqlview.py b/tests/aiohttp/test_graphiqlview.py index 111b603..4e5bd32 100644 --- a/tests/aiohttp/test_graphiqlview.py +++ b/tests/aiohttp/test_graphiqlview.py @@ -52,7 +52,8 @@ async def test_graphiql_is_enabled(app, client): @pytest.mark.parametrize("app", [create_app(graphiql=True)]) async def test_graphiql_simple_renderer(app, client, pretty_response): response = await client.get( - url_string(query="{test}"), headers={"Accept": "text/html"}, + url_string(query="{test}"), + headers={"Accept": "text/html"}, ) assert response.status == 200 assert pretty_response in await response.text() @@ -65,7 +66,8 @@ class TestJinjaEnv: ) async def test_graphiql_jinja_renderer_async(self, app, client, pretty_response): response = await client.get( - url_string(query="{test}"), headers={"Accept": "text/html"}, + url_string(query="{test}"), + headers={"Accept": "text/html"}, ) assert response.status == 200 assert pretty_response in await response.text() @@ -73,7 +75,10 @@ async def test_graphiql_jinja_renderer_async(self, app, client, pretty_response) @pytest.mark.asyncio async def test_graphiql_html_is_not_accepted(client): - response = await client.get("/graphql", headers={"Accept": "application/json"},) + response = await client.get( + "/graphql", + headers={"Accept": "application/json"}, + ) assert response.status == 400 @@ -107,7 +112,8 @@ async def test_graphiql_get_subscriptions(app, client): ) async def test_graphiql_enabled_async_schema(app, client): response = await client.get( - url_string(query="{a,b,c}"), headers={"Accept": "text/html"}, + url_string(query="{a,b,c}"), + headers={"Accept": "text/html"}, ) expected_response = ( @@ -133,7 +139,8 @@ async def test_graphiql_enabled_async_schema(app, client): ) async def test_graphiql_enabled_sync_schema(app, client): response = await client.get( - url_string(query="{a,b}"), headers={"Accept": "text/html"}, + url_string(query="{a,b}"), + headers={"Accept": "text/html"}, ) expected_response = ( diff --git a/tests/quart/test_graphiqlview.py b/tests/quart/test_graphiqlview.py index 12b001f..1d8d7e3 100644 --- a/tests/quart/test_graphiqlview.py +++ b/tests/quart/test_graphiqlview.py @@ -1,5 +1,3 @@ -import sys - import pytest from quart import Quart, Response, url_for from quart.testing import QuartClient @@ -32,10 +30,7 @@ async def execute_client( headers: Headers = None, **extra_params ) -> Response: - if sys.version_info >= (3, 7): - test_request_context = app.test_request_context("/", method=method) - else: - test_request_context = app.test_request_context(method, "/") + test_request_context = app.test_request_context(path="/", method=method) async with test_request_context: string = url_for("graphql", **extra_params) return await client.get(string, headers=headers) diff --git a/tests/quart/test_graphqlview.py b/tests/quart/test_graphqlview.py index 429b4ef..79d1f73 100644 --- a/tests/quart/test_graphqlview.py +++ b/tests/quart/test_graphqlview.py @@ -1,7 +1,4 @@ import json -import sys - -# from io import StringIO from urllib.parse import urlencode import pytest @@ -37,10 +34,7 @@ async def execute_client( headers: Headers = None, **url_params, ) -> Response: - if sys.version_info >= (3, 7): - test_request_context = app.test_request_context("/", method=method) - else: - test_request_context = app.test_request_context(method, "/") + test_request_context = app.test_request_context(path="/", method=method) async with test_request_context: string = url_for("graphql") diff --git a/tests/sanic/app.py b/tests/sanic/app.py index 6966b1e..84269cc 100644 --- a/tests/sanic/app.py +++ b/tests/sanic/app.py @@ -7,7 +7,6 @@ from .schema import Schema - Sanic.test_mode = True diff --git a/tox.ini b/tox.ini index e374ee0..047d8a6 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] -envlist = +envlist = black,flake8,import-order,mypy,manifest, - py{36,37,38,39} + py{36,37,38,39,310} ; requires = tox-conda [gh-actions] @@ -10,6 +10,7 @@ python = 3.7: py37 3.8: py38 3.9: py39 + 3.10: py310 [testenv] conda_channels = conda-forge @@ -26,31 +27,31 @@ commands = py{38}: pytest tests --cov-report=term-missing --cov=graphql_server {posargs} [testenv:black] -basepython = python3.8 +basepython = python3.9 deps = -e.[dev] commands = black --check graphql_server tests [testenv:flake8] -basepython = python3.8 +basepython = python3.9 deps = -e.[dev] commands = flake8 setup.py graphql_server tests [testenv:import-order] -basepython = python3.8 +basepython = python3.9 deps = -e.[dev] commands = - isort -rc graphql_server/ tests/ + isort graphql_server/ tests/ [testenv:mypy] -basepython = python3.8 +basepython = python3.9 deps = -e.[dev] commands = mypy graphql_server tests --ignore-missing-imports [testenv:manifest] -basepython = python3.8 +basepython = python3.9 deps = -e.[dev] commands = check-manifest -v From eec3d3331413c0b3da4a4dca5e77dc2df7c74090 Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Mon, 17 Jan 2022 14:24:07 +0100 Subject: [PATCH 058/108] Make teste work with Python 3.6 again Note that pytest-asyncio 0.17 is not supported for Python 3.6. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index bb98728..e2dfcaf 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ tests_requires = [ "pytest>=6.2,<6.3", - "pytest-asyncio>=0.17,<1", + "pytest-asyncio>=0.16,<1", "pytest-cov>=3,<4", "aiohttp>=3.8,<4", "Jinja2>=2.11,<3", From 8dec731311a653f6a3ebd5b91c51ad0ff9bb4bab Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Mon, 17 Jan 2022 14:28:52 +0100 Subject: [PATCH 059/108] Release a new beta version --- graphql_server/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphql_server/version.py b/graphql_server/version.py index 2ee7c44..d159828 100644 --- a/graphql_server/version.py +++ b/graphql_server/version.py @@ -4,7 +4,7 @@ __all__ = ["version", "version_info"] -version = "3.0.0b4" +version = "3.0.0b5" _re_version = re.compile(r"(\d+)\.(\d+)\.(\d+)(\D*)(\d*)") From 184ba72578101ad7b11a2008e544d5432f627146 Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Mon, 26 Dec 2022 02:59:20 +0800 Subject: [PATCH 060/108] chore: update dependencies (#99) * Update dependencies * Relax flask dependency to allow flask 2 * Fixes for quart >=0.15 Fix quart.request.get_data signature QuartClient -> TestClientProtocol * Lint * Fix aiohttp tests * Update sanic to v22.6 * Make sanic v22.9 work * Fix deprecation warnings DeprecationWarning: Use 'content=<...>' to upload raw bytes/text content. * Update graphiql to 1.4.7 for security reason "All versions of graphiql < 1.4.7 are vulnerable to an XSS attack." https://github.com/graphql/graphiql/blob/ab2b52f06213bd9bf90c905c1b460b6939f3d856/docs/security/2021-introspection-schema-xss.md * Fix webob graphiql check Was working by accident before * Fix quart PytestCollectionWarning cannot collect test class 'TestClientProtocol' because it has a __init__ constructor * Make Jinja2 optional * Add python 3.11 and remove 3.6 * Tweak quart for python 3.7 to 3.11 * Fix test for python 3.11 Co-authored-by: Giovanni Campagna Co-authored-by: Choongkyu Kim --- .github/workflows/deploy.yml | 8 +- .github/workflows/lint.yml | 8 +- .github/workflows/tests.yml | 20 +-- graphql_server/__init__.py | 12 +- graphql_server/quart/graphqlview.py | 27 ++-- graphql_server/render_graphiql.py | 17 ++- graphql_server/sanic/graphqlview.py | 4 +- graphql_server/webob/graphqlview.py | 8 +- setup.cfg | 1 + setup.py | 27 ++-- tests/aiohttp/test_graphiqlview.py | 3 +- tests/aiohttp/test_graphqlview.py | 3 +- tests/quart/conftest.py | 3 + tests/quart/test_graphiqlview.py | 24 ++-- tests/quart/test_graphqlview.py | 183 ++++++++++++++++------------ tests/sanic/app.py | 9 +- tests/sanic/test_graphiqlview.py | 14 +-- tests/sanic/test_graphqlview.py | 127 ++++++++++--------- tests/test_asyncio.py | 13 +- tox.ini | 18 +-- 20 files changed, 271 insertions(+), 258 deletions(-) create mode 100644 tests/quart/conftest.py diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6a34bba..29bb7d1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,11 +10,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.9 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: "3.10" - name: Build wheel and source tarball run: | pip install wheel diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 90ba2a1..454ab1b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,11 +7,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.9 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: "3.10" - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 31616ec..7e58bb5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,22 +8,22 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] os: [ubuntu-latest, windows-latest] exclude: - - os: windows-latest - python-version: "3.6" - os: windows-latest python-version: "3.7" - os: windows-latest python-version: "3.8" - os: windows-latest - python-version: "3.10" + python-version: "3.9" + - os: windows-latest + python-version: "3.11" steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -39,11 +39,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.9 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: "3.10" - name: Install test dependencies run: | python -m pip install --upgrade pip diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index ee54cdb..9a58a9f 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -9,17 +9,7 @@ import json from collections import namedtuple from collections.abc import MutableMapping -from typing import ( - Any, - Callable, - Collection, - Dict, - List, - Optional, - Type, - Union, - cast, -) +from typing import Any, Callable, Collection, Dict, List, Optional, Type, Union, cast from graphql.error import GraphQLError from graphql.execution import ExecutionResult, execute diff --git a/graphql_server/quart/graphqlview.py b/graphql_server/quart/graphqlview.py index ff737ec..107cfdc 100644 --- a/graphql_server/quart/graphqlview.py +++ b/graphql_server/quart/graphqlview.py @@ -1,5 +1,4 @@ import copy -import sys from collections.abc import MutableMapping from functools import partial from typing import List @@ -165,11 +164,11 @@ async def parse_body(): # information provided by content_type content_type = request.mimetype if content_type == "application/graphql": - refined_data = await request.get_data(raw=False) + refined_data = await request.get_data(as_text=True) return {"query": refined_data} elif content_type == "application/json": - refined_data = await request.get_data(raw=False) + refined_data = await request.get_data(as_text=True) return load_json_body(refined_data) elif content_type == "application/x-www-form-urlencoded": @@ -191,20 +190,8 @@ def should_display_graphiql(self): def request_wants_html(): best = request.accept_mimetypes.best_match(["application/json", "text/html"]) - # Needed as this was introduced at Quart 0.8.0: https://gitlab.com/pgjones/quart/-/issues/189 - def _quality(accept, key: str) -> float: - for option in accept.options: - if accept._values_match(key, option.value): - return option.quality - return 0.0 - - if sys.version_info >= (3, 7): - return ( - best == "text/html" - and request.accept_mimetypes[best] - > request.accept_mimetypes["application/json"] - ) - else: - return best == "text/html" and _quality( - request.accept_mimetypes, best - ) > _quality(request.accept_mimetypes, "application/json") + return ( + best == "text/html" + and request.accept_mimetypes[best] + > request.accept_mimetypes["application/json"] + ) diff --git a/graphql_server/render_graphiql.py b/graphql_server/render_graphiql.py index c942300..498f53b 100644 --- a/graphql_server/render_graphiql.py +++ b/graphql_server/render_graphiql.py @@ -1,4 +1,4 @@ -"""Based on (express-graphql)[https://github.com/graphql/express-graphql/blob/master/src/renderGraphiQL.js] and +"""Based on (express-graphql)[https://github.com/graphql/express-graphql/blob/main/src/renderGraphiQL.ts] and (subscriptions-transport-ws)[https://github.com/apollographql/subscriptions-transport-ws]""" import json import re @@ -7,7 +7,7 @@ from jinja2 import Environment from typing_extensions import TypedDict -GRAPHIQL_VERSION = "1.0.3" +GRAPHIQL_VERSION = "1.4.7" GRAPHIQL_TEMPLATE = """ - - - - - {{graphiql_html_title}} - - - - - - - - - - - - - -
Loading...
- - -""" - - -class GraphiQLData(TypedDict): - """GraphiQL ReactDom Data - - Has the following attributes: - - subscription_url - The GraphiQL socket endpoint for using subscriptions in graphql-ws. - headers - An optional GraphQL string to use as the initial displayed request headers, - if None is provided, the stored headers will be used. - """ - - query: Optional[str] - variables: Optional[str] - operation_name: Optional[str] - result: Optional[str] - subscription_url: Optional[str] - headers: Optional[str] - - -class GraphiQLConfig(TypedDict): - """GraphiQL Extra Config - - Has the following attributes: - - graphiql_version - The version of the provided GraphiQL package. - graphiql_template - Inject a Jinja template string to customize GraphiQL. - graphiql_html_title - Replace the default html title on the 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. - """ - - graphiql_version: Optional[str] - graphiql_template: Optional[str] - graphiql_html_title: Optional[str] - jinja_env: Optional[Environment] - - -class GraphiQLOptions(TypedDict): - """GraphiQL options to display on the UI. - - Has the following attributes: - - default_query - An optional GraphQL string to use when no query is provided and no stored - query exists from a previous session. If None is 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. - """ - - default_query: Optional[str] - header_editor_enabled: Optional[bool] - should_persist_headers: Optional[bool] - - -def process_var(template: str, name: str, value: Any, jsonify=False) -> str: - pattern = r"{{\s*" + name.replace("\\", r"\\") + r"(\s*|[^}]+)*\s*}}" - if jsonify and value not in ["null", "undefined"]: - value = json.dumps(value) - - value = value.replace("\\", r"\\") - - return re.sub(pattern, value, template) - - -def simple_renderer(template: str, **values: Dict[str, Any]) -> str: - replace = [ - "graphiql_version", - "graphiql_html_title", - "subscription_url", - "header_editor_enabled", - "should_persist_headers", - ] - replace_jsonify = [ - "query", - "result", - "variables", - "operation_name", - "default_query", - "headers", - ] - - for r in replace: - template = process_var(template, r, values.get(r, "")) - - for r in replace_jsonify: - template = process_var(template, r, values.get(r, ""), True) - - return template - - -def _render_graphiql( - data: GraphiQLData, - config: GraphiQLConfig, - options: Optional[GraphiQLOptions] = None, -) -> Tuple[str, Dict[str, Any]]: - """When render_graphiql receives a request which does not Accept JSON, but does - Accept HTML, it may present GraphiQL, the in-browser GraphQL explorer IDE. - When shown, it will be pre-populated with the result of having executed - the requested query. - """ - graphiql_version = config.get("graphiql_version") or GRAPHIQL_VERSION - graphiql_template = config.get("graphiql_template") or GRAPHIQL_TEMPLATE - graphiql_html_title = config.get("graphiql_html_title") or "GraphiQL" - - template_vars: Dict[str, Any] = { - "graphiql_version": graphiql_version, - "graphiql_html_title": graphiql_html_title, - "query": data.get("query"), - "variables": data.get("variables"), - "operation_name": data.get("operation_name"), - "result": data.get("result"), - "subscription_url": data.get("subscription_url") or "", - "headers": data.get("headers") or "", - "default_query": options and options.get("default_query") or "", - "header_editor_enabled": options - and options.get("header_editor_enabled") - or "true", - "should_persist_headers": options - and options.get("should_persist_headers") - or "false", - } - - if template_vars["result"] in ("null"): - template_vars["result"] = None - - return graphiql_template, template_vars - - -async def render_graphiql_async( - data: GraphiQLData, - config: GraphiQLConfig, - options: Optional[GraphiQLOptions] = None, -) -> str: - graphiql_template, template_vars = _render_graphiql(data, config, options) - jinja_env = config.get("jinja_env") - - if jinja_env: - template = jinja_env.from_string(graphiql_template) - if jinja_env.is_async: - source = await template.render_async(**template_vars) - else: - source = template.render(**template_vars) - else: - source = simple_renderer(graphiql_template, **template_vars) - return source - - -def render_graphiql_sync( - data: GraphiQLData, - config: GraphiQLConfig, - options: Optional[GraphiQLOptions] = None, -) -> str: - graphiql_template, template_vars = _render_graphiql(data, config, options) - jinja_env = config.get("jinja_env") - - if jinja_env: - template = jinja_env.from_string(graphiql_template) - source = template.render(**template_vars) - else: - source = simple_renderer(graphiql_template, **template_vars) - return source diff --git a/graphql_server/sanic/__init__.py b/graphql_server/sanic/__init__.py deleted file mode 100644 index 8f5beaf..0000000 --- a/graphql_server/sanic/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .graphqlview import GraphQLView - -__all__ = ["GraphQLView"] diff --git a/graphql_server/sanic/graphqlview.py b/graphql_server/sanic/graphqlview.py deleted file mode 100644 index 37e0e0c..0000000 --- a/graphql_server/sanic/graphqlview.py +++ /dev/null @@ -1,246 +0,0 @@ -import asyncio -import copy -from collections.abc import MutableMapping -from functools import partial -from typing import List - -from graphql import GraphQLError, specified_rules -from graphql.pyutils import is_awaitable -from graphql.type.schema import GraphQLSchema -from sanic.headers import parse_content_header -from sanic.response import HTTPResponse, html -from sanic.views import HTTPMethodView - -from graphql_server import ( - GraphQLParams, - HttpQueryError, - _check_jinja, - encode_execution_results, - format_error_default, - json_encode, - load_json_body, - run_http_query, -) -from graphql_server.render_graphiql import ( - GraphiQLConfig, - GraphiQLData, - GraphiQLOptions, - render_graphiql_async, -) -from graphql_server.utils import wrap_in_async - - -class GraphQLView(HTTPMethodView): - schema = None - root_value = None - context = None - pretty = False - graphiql = False - graphiql_version = None - graphiql_template = None - graphiql_html_title = None - middleware = None - validation_rules = None - execution_context_class = None - batch = False - jinja_env = None - max_age = 86400 - enable_async = False - subscriptions = None - headers = None - default_query = None - header_editor_enabled = None - should_persist_headers = None - - methods = ["GET", "POST", "PUT", "DELETE"] - - format_error = staticmethod(format_error_default) - encode = staticmethod(json_encode) - - def __init__(self, **kwargs): - super().__init__() - for key, value in kwargs.items(): - if hasattr(self, key): - setattr(self, key, value) - - if not isinstance(self.schema, GraphQLSchema): - # maybe the GraphQL schema is wrapped in a Graphene schema - self.schema = getattr(self.schema, "graphql_schema", None) - if not isinstance(self.schema, GraphQLSchema): - raise TypeError("A Schema is required to be provided to GraphQLView.") - - if self.jinja_env is not None: - _check_jinja(self.jinja_env) - - def get_root_value(self): - return self.root_value - - def get_context(self, request): - context = ( - copy.copy(self.context) - if self.context is not None and isinstance(self.context, MutableMapping) - else {} - ) - if isinstance(context, MutableMapping) and "request" not in context: - context.update({"request": request}) - return context - - def get_middleware(self): - return self.middleware - - def get_validation_rules(self): - if self.validation_rules is None: - return specified_rules - return self.validation_rules - - def get_execution_context_class(self): - return self.execution_context_class - - async def __handle_request(self, request, *args, **kwargs): - try: - request_method = request.method.lower() - data = self.parse_body(request) - - show_graphiql = request_method == "get" and self.should_display_graphiql( - request - ) - catch = show_graphiql - - pretty = self.pretty or show_graphiql or request.args.get("pretty") - - if request_method != "options": - all_params: List[GraphQLParams] - execution_results, all_params = run_http_query( - self.schema, - request_method, - data, - query_data=request.args, - batch_enabled=self.batch, - catch=catch, - # Execute options - run_sync=not self.enable_async, - root_value=self.get_root_value(), - context_value=self.get_context(request), - middleware=self.get_middleware(), - validation_rules=self.get_validation_rules(), - execution_context_class=self.get_execution_context_class(), - ) - exec_res = ( - await asyncio.gather( - *( - ex - if ex is not None and is_awaitable(ex) - else wrap_in_async(lambda x: x)(ex) - for ex in execution_results - ) - ) - if self.enable_async - else execution_results - ) - result, status_code = encode_execution_results( - exec_res, - is_batch=isinstance(data, list), - format_error=self.format_error, - encode=partial(self.encode, pretty=pretty), - ) - - if show_graphiql: - graphiql_data = GraphiQLData( - result=result, - query=all_params[0].query, - variables=all_params[0].variables, - operation_name=all_params[0].operation_name, - subscription_url=self.subscriptions, - headers=self.headers, - ) - graphiql_config = GraphiQLConfig( - graphiql_version=self.graphiql_version, - graphiql_template=self.graphiql_template, - graphiql_html_title=self.graphiql_html_title, - jinja_env=self.jinja_env, - ) - graphiql_options = GraphiQLOptions( - default_query=self.default_query, - header_editor_enabled=self.header_editor_enabled, - should_persist_headers=self.should_persist_headers, - ) - source = await render_graphiql_async( - data=graphiql_data, - config=graphiql_config, - options=graphiql_options, - ) - return html(source) - - return HTTPResponse( - result, status=status_code, content_type="application/json" - ) - - else: - return self.process_preflight(request) - - except HttpQueryError as e: - parsed_error = GraphQLError(e.message) - return HTTPResponse( - self.encode({"errors": [self.format_error(parsed_error)]}), - status=e.status_code, - headers=e.headers, - content_type="application/json", - ) - - get = post = put = head = options = patch = delete = __handle_request - - # noinspection PyBroadException - def parse_body(self, request): - content_type = self.get_mime_type(request) - if content_type == "application/graphql": - return {"query": request.body.decode("utf8")} - - elif content_type == "application/json": - return load_json_body(request.body.decode("utf8")) - - elif content_type in ( - "application/x-www-form-urlencoded", - "multipart/form-data", - ): - return request.form - - return {} - - @staticmethod - def get_mime_type(request): - # We use mime type here since we don't need the other - # information provided by content_type - if "content-type" not in request.headers: - return None - - mime_type, _ = parse_content_header(request.headers["content-type"]) - return mime_type - - def should_display_graphiql(self, request): - if not self.graphiql or "raw" in request.args: - return False - - return self.request_wants_html(request) - - @staticmethod - def request_wants_html(request): - accept = request.headers.get("accept", {}) - return "text/html" in accept or "*/*" in accept - - def process_preflight(self, request): - """Preflight request support for apollo-client - https://www.w3.org/TR/cors/#resource-preflight-requests""" - origin = request.headers.get("Origin", "") - method = request.headers.get("Access-Control-Request-Method", "").upper() - - if method and method in self.methods: - return HTTPResponse( - status=200, - headers={ - "Access-Control-Allow-Origin": origin, - "Access-Control-Allow-Methods": ", ".join(self.methods), - "Access-Control-Max-Age": str(self.max_age), - }, - ) - else: - return HTTPResponse(status=400) diff --git a/graphql_server/utils.py b/graphql_server/utils.py deleted file mode 100644 index d1920d2..0000000 --- a/graphql_server/utils.py +++ /dev/null @@ -1,25 +0,0 @@ -import sys -from typing import Awaitable, Callable, TypeVar - -if sys.version_info >= (3, 10): - from typing import ParamSpec -else: # pragma: no cover - from typing_extensions import ParamSpec - - -__all__ = ["wrap_in_async"] - -P = ParamSpec("P") -R = TypeVar("R") - - -def wrap_in_async(f: Callable[P, R]) -> Callable[P, Awaitable[R]]: - """Convert a sync callable (normal def or lambda) to a coroutine (async def). - - This is similar to asyncio.coroutine which was deprecated in Python 3.8. - """ - - async def f_async(*args: P.args, **kwargs: P.kwargs) -> R: - return f(*args, **kwargs) - - return f_async diff --git a/graphql_server/version.py b/graphql_server/version.py deleted file mode 100644 index 138d67d..0000000 --- a/graphql_server/version.py +++ /dev/null @@ -1,15 +0,0 @@ -__all__ = ["version", "version_info"] - - -version = "3.0.0b7" -version_info = (3, 0, 0, "beta", 7) -# version_info has the same format as django.VERSION -# https://github.com/django/django/blob/4a5048b036fd9e965515e31fdd70b0af72655cba/django/utils/version.py#L22 -# -# examples -# "3.0.0" -> (3, 0, 0, "final", 0) -# "3.0.0rc1" -> (3, 0, 0, "rc", 1) -# "3.0.0b7" -> (3, 0, 0, "beta", 7) -# "3.0.0a2" -> (3, 0, 0, "alpha", 2) -# -# also see tests/test_version.py diff --git a/graphql_server/webob/__init__.py b/graphql_server/webob/__init__.py deleted file mode 100644 index 8f5beaf..0000000 --- a/graphql_server/webob/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .graphqlview import GraphQLView - -__all__ = ["GraphQLView"] diff --git a/graphql_server/webob/graphqlview.py b/graphql_server/webob/graphqlview.py deleted file mode 100644 index 78ff48a..0000000 --- a/graphql_server/webob/graphqlview.py +++ /dev/null @@ -1,204 +0,0 @@ -import copy -from collections.abc import MutableMapping -from functools import partial -from typing import List - -from graphql import specified_rules -from graphql.error import GraphQLError -from graphql.type.schema import GraphQLSchema -from webob import Response - -from graphql_server import ( - GraphQLParams, - HttpQueryError, - _check_jinja, - encode_execution_results, - format_error_default, - json_encode, - load_json_body, - run_http_query, -) -from graphql_server.render_graphiql import ( - GraphiQLConfig, - GraphiQLData, - GraphiQLOptions, - render_graphiql_sync, -) - - -class GraphQLView: - schema = None - root_value = None - context = None - pretty = False - graphiql = False - graphiql_version = None - graphiql_template = None - graphiql_html_title = None - middleware = None - validation_rules = None - execution_context_class = None - batch = False - jinja_env = None - enable_async = False - subscriptions = None - headers = None - default_query = None - header_editor_enabled = None - should_persist_headers = None - charset = "UTF-8" - - format_error = staticmethod(format_error_default) - encode = staticmethod(json_encode) - - def __init__(self, **kwargs): - super().__init__() - for key, value in kwargs.items(): - if hasattr(self, key): - setattr(self, key, value) - - if not isinstance(self.schema, GraphQLSchema): - # maybe the GraphQL schema is wrapped in a Graphene schema - self.schema = getattr(self.schema, "graphql_schema", None) - if not isinstance(self.schema, GraphQLSchema): - raise TypeError("A Schema is required to be provided to GraphQLView.") - - if self.jinja_env is not None: - _check_jinja(self.jinja_env) - - def get_root_value(self): - return self.root_value - - def get_context(self, request): - context = ( - copy.copy(self.context) - if self.context is not None and isinstance(self.context, MutableMapping) - else {} - ) - if isinstance(context, MutableMapping) and "request" not in context: - context.update({"request": request}) - return context - - def get_middleware(self): - return self.middleware - - def get_validation_rules(self): - if self.validation_rules is None: - return specified_rules - return self.validation_rules - - def get_execution_context_class(self): - return self.execution_context_class - - def dispatch_request(self, request): - try: - request_method = request.method.lower() - data = self.parse_body(request) - - show_graphiql = request_method == "get" and self.should_display_graphiql( - request - ) - catch = show_graphiql - - pretty = self.pretty or show_graphiql or request.params.get("pretty") - - all_params: List[GraphQLParams] - execution_results, all_params = run_http_query( - self.schema, - request_method, - data, - query_data=request.params, - batch_enabled=self.batch, - catch=catch, - # Execute options - run_sync=not self.enable_async, - root_value=self.get_root_value(), - context_value=self.get_context(request), - middleware=self.get_middleware(), - validation_rules=self.get_validation_rules(), - execution_context_class=self.get_execution_context_class(), - ) - result, status_code = encode_execution_results( - execution_results, - is_batch=isinstance(data, list), - format_error=self.format_error, - encode=partial(self.encode, pretty=pretty), - ) - - if show_graphiql: - graphiql_data = GraphiQLData( - result=result, - query=all_params[0].query, - variables=all_params[0].variables, - operation_name=all_params[0].operation_name, - subscription_url=self.subscriptions, - headers=self.headers, - ) - graphiql_config = GraphiQLConfig( - graphiql_version=self.graphiql_version, - graphiql_template=self.graphiql_template, - graphiql_html_title=self.graphiql_html_title, - jinja_env=self.jinja_env, - ) - graphiql_options = GraphiQLOptions( - default_query=self.default_query, - header_editor_enabled=self.header_editor_enabled, - should_persist_headers=self.should_persist_headers, - ) - return Response( - render_graphiql_sync( - data=graphiql_data, - config=graphiql_config, - options=graphiql_options, - ), - charset=self.charset, - content_type="text/html", - ) - - return Response( - result, - status=status_code, - charset=self.charset, - content_type="application/json", - ) - - except HttpQueryError as e: - parsed_error = GraphQLError(e.message) - return Response( - self.encode({"errors": [self.format_error(parsed_error)]}), - status=e.status_code, - charset=self.charset, - headers=e.headers or {}, - content_type="application/json", - ) - - # WebOb - @staticmethod - def parse_body(request): - # We use mimetype here since we don't need the other - # information provided by content_type - content_type = request.content_type - if content_type == "application/graphql": - return {"query": request.body.decode("utf8")} - - elif content_type == "application/json": - return load_json_body(request.body.decode("utf8")) - - elif content_type in ( - "application/x-www-form-urlencoded", - "multipart/form-data", - ): - return request.params - - return {} - - def should_display_graphiql(self, request): - if not self.graphiql or "raw" in request.params: - return False - - return self.request_wants_html(request) - - @staticmethod - def request_wants_html(request): - best = request.accept.best_match(["application/json", "text/html"]) - return best == "text/html" 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..27c79b2 --- /dev/null +++ b/noxfile.py @@ -0,0 +1,139 @@ +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.2.3", + # "3.3.0a8", +] + +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", + "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", + "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..081666e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,341 @@ +[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", "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"] + +[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", + "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 9888367..0000000 --- a/setup.cfg +++ /dev/null @@ -1,6 +0,0 @@ -[tool:pytest] -norecursedirs = venv .venv .tox .git .cache .mypy_cache .pytest_cache -markers = asyncio - -[bdist_wheel] -universal=1 diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index 3531daa..373d560 --- a/setup.py +++ b/setup.py @@ -1,94 +1,9 @@ -from re import search +#!/usr/bin/env python -from setuptools import find_packages, setup +# we use poetry for our build, but this file seems to be required +# in order to get GitHub dependencies graph to work -install_requires = [ - "graphql-core>=3.2,<3.3", - "typing-extensions>=4,<5", -] +import setuptools -tests_requires = [ - "pytest>=7.2,<8", - "pytest-asyncio>=0.20,<1", - "pytest-cov>=4,<5", - "Jinja2>=3.1,<4", - "sanic-testing>=22.3,<24", - "packaging==23.2", -] - -dev_requires = [ - "mypy>=1.6,<1.7", -] + tests_requires - -install_flask_requires = [ - "flask>=1,<4", -] - -install_sanic_requires = [ - "sanic>=21.12,<24", -] - -install_webob_requires = [ - "webob>=1.8.7,<2", -] - -install_aiohttp_requires = [ - "aiohttp>=3.8,<4", -] - -install_quart_requires = ["quart>=0.15,<1"] - -install_all_requires = ( - install_requires - + install_flask_requires - + install_sanic_requires - + install_webob_requires - + install_aiohttp_requires - + install_quart_requires -) - -with open("graphql_server/version.py") as version_file: - version = search('version = "(.*)"', version_file.read()).group(1) - -with open("README.md", encoding="utf-8") as readme_file: - readme = readme_file.read() - -setup( - name="graphql-server", - version=version, - description="GraphQL Server tools for powering your server", - long_description=readme, - long_description_content_type="text/markdown", - url="https://github.com/graphql-python/graphql-server", - download_url="https://github.com/graphql-python/graphql-server/releases", - author="Syrus Akbary", - author_email="me@syrusakbary.com", - license="MIT", - classifiers=[ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "Topic :: Software Development :: Libraries", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "License :: OSI Approved :: MIT License", - ], - keywords="api graphql protocol rest", - packages=find_packages(include=["graphql_server*"]), - install_requires=install_requires, - tests_require=install_all_requires + tests_requires, - extras_require={ - "all": install_all_requires, - "test": install_all_requires + tests_requires, - "dev": install_all_requires + dev_requires, - "flask": install_flask_requires, - "sanic": install_sanic_requires, - "webob": install_webob_requires, - "aiohttp": install_aiohttp_requires, - "quart": install_quart_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..e9f4a4e --- /dev/null +++ b/src/graphql_server/__init__.py @@ -0,0 +1,34 @@ +""" +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, + subscribe, + validate_document, + process_errors, + introspect, +) +from .version import version, version_info + +# The GraphQL-Server 3 version info. + +__version__ = version +__version_info__ = version_info + +__all__ = [ + "version", + "version_info", + "execute", + "execute_sync", + "subscribe", + "validate_document", + "process_errors", + "introspect", +] diff --git a/tests/flask/__init__.py b/src/graphql_server/aiohttp/__init__.py similarity index 100% rename from tests/flask/__init__.py rename to src/graphql_server/aiohttp/__init__.py 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..93aa01d --- /dev/null +++ b/src/graphql_server/aiohttp/views.py @@ -0,0 +1,249 @@ +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.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) -> web.Response: + return web.Response(text=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 + ) -> web.Response: + sub_response.text = self.encode_json(response_data) + sub_response.content_type = "application/json" + + return sub_response + + 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..2ad6223 --- /dev/null +++ b/src/graphql_server/asgi/__init__.py @@ -0,0 +1,260 @@ +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.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) -> Response: + return HTMLResponse(self.graphql_ide_html) + + def create_response( + self, response_data: GraphQLHTTPResponse, sub_response: Response + ) -> Response: + response = Response( + self.encode_json(response_data), + status_code=status.HTTP_200_OK, + media_type="application/json", + ) + + response.headers.raw.extend(sub_response.headers.raw) + + if sub_response.background: + 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 | 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/tests/quart/__init__.py b/src/graphql_server/chalice/__init__.py similarity index 100% rename from tests/quart/__init__.py rename to src/graphql_server/chalice/__init__.py diff --git a/src/graphql_server/chalice/views.py b/src/graphql_server/chalice/views.py new file mode 100644 index 0000000..c3d8361 --- /dev/null +++ b/src/graphql_server/chalice/views.py @@ -0,0 +1,155 @@ +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.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) -> Response: + return Response( + 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 + ) -> Response: + status_code = 200 + + if sub_response.status_code != 200: + status_code = sub_response.status_code + + return Response( + body=self.encode_json(response_data), + status_code=status_code, + headers={ + "Content-Type": "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/tests/sanic/__init__.py b/src/graphql_server/channels/handlers/__init__.py similarity index 100% rename from tests/sanic/__init__.py rename to src/graphql_server/channels/handlers/__init__.py 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..15a2e51 --- /dev/null +++ b/src/graphql_server/channels/handlers/http_handler.py @@ -0,0 +1,373 @@ +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.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 + ) -> ChannelsResponse: + return ChannelsResponse( + content=json.dumps(response_data).encode(), + status=sub_response.status_code, + 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 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)), + ]), + }) + ``` + """ + + 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) -> ChannelsResponse: + return ChannelsResponse( + content=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) -> ChannelsResponse: + return ChannelsResponse( + content=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..7187f8f --- /dev/null +++ b/src/graphql_server/channels/handlers/ws_handler.py @@ -0,0 +1,201 @@ +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.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 + ) -> GraphQLWSConsumer: + raise NotImplementedError + + async def render_graphql_ide(self, request: GraphQLWSConsumer) -> 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..0ebf00f --- /dev/null +++ b/src/graphql_server/channels/router.py @@ -0,0 +1,69 @@ +"""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", + ) -> None: + http_urls = [re_path(url_pattern, GraphQLHTTPConsumer.as_asgi(schema=schema))] + 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, GraphQLWSConsumer.as_asgi(schema=schema)), + ] + ), + } + ) + + +__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/tests/webob/__init__.py b/src/graphql_server/django/__init__.py similarity index 100% rename from tests/webob/__init__.py rename to src/graphql_server/django/__init__.py 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..1a7d8f9 --- /dev/null +++ b/src/graphql_server/django/views.py @@ -0,0 +1,323 @@ +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.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 + ) -> HttpResponseBase: + data = self.encode_json(response_data) + + response = HttpResponse( + data, + content_type="application/json", + ) + + for name, value in sub_response.items(): + response[name] = value + + if sub_response.status_code: + response.status_code = sub_response.status_code + + 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) -> HttpResponse: + try: + content = render_to_string("graphql/graphiql.html", request=request) + except TemplateDoesNotExist: + content = 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) -> HttpResponse: + try: + content = render_to_string("graphql/graphiql.html", request=request) + except TemplateDoesNotExist: + content = 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..472c484 --- /dev/null +++ b/src/graphql_server/fastapi/router.py @@ -0,0 +1,327 @@ +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.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.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) -> HTMLResponse: + return HTMLResponse(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 + ) -> Response: + response = Response( + self.encode_json(response_data), + media_type="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..8ea0578 --- /dev/null +++ b/src/graphql_server/flask/views.py @@ -0,0 +1,214 @@ +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.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 + ) -> Response: + sub_response.set_data(self.encode_json(response_data)) # type: ignore + + 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) -> Response: + return render_template_string(self.graphql_ide_html) # 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) -> Response: + content = render_template_string(self.graphql_ide_html) + 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..a257a85 --- /dev/null +++ b/src/graphql_server/http/__init__.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Optional +from typing_extensions import Literal, TypedDict + +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) -> GraphQLHTTPResponse: + 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 + + +@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] + variables: Optional[dict[str, Any]] + operation_name: Optional[str] + extensions: Optional[dict[str, Any]] + protocol: Literal["http", "multipart-subscription"] = "http" + + +__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..9038aaf --- /dev/null +++ b/src/graphql_server/http/async_base_view.py @@ -0,0 +1,572 @@ +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 connection_params is None: + return + + 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 + ) -> Response: ... + + @abc.abstractmethod + async def render_graphql_ide(self, request: Request) -> 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: Request, context: Context, root_value: Optional[RootValue] + ) -> ExecutionResult: + request_adapter = self.request_adapter_class(request) + + try: + request_data = await self.parse_http_body(request_adapter) + 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 + + allowed_operation_types = operation_type_from_http(request_adapter.method) + + if not self.allow_queries_via_get and request_adapter.method == "GET": + allowed_operation_types = allowed_operation_types - {OperationType.QUERY} + + assert self.schema + + if request_data.protocol == "multipart-subscription": + return await subscribe( + schema=self.schema, + query=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.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]: + root_value = ( + await self.get_root_value(request) if root_value is UNSET else root_value + ) + + 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) + + 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) + sub_response = await self.get_sub_response(request) + 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.") + + if self.should_render_graphql_ide(request_adapter): + if self.graphql_ide: + return await self.render_graphql_ide(request) + raise HTTPException(404, "Not Found") + + try: + result = await self.execute_operation( + request=request, context=context, root_value=root_value + ) + except GraphQLValidationError as e: + 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) + + if result.errors: + self._handle_errors(result.errors, response_data) + + return self.create_response( + response_data=response_data, sub_response=sub_response + ) + + 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 parse_http_body( + self, request: AsyncHTTPRequestAdapter + ) -> 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", "") + + protocol: Literal["http", "multipart-subscription"] = "http" + + if self._is_multipart_subscriptions(*parse_content_type(accept)): + protocol = "multipart-subscription" + + 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 GraphQLRequestData( + query=data.get("query"), + variables=data.get("variables"), + operation_name=data.get("operationName"), + extensions=data.get("extensions"), + protocol=protocol, + ) + + async def process_result( + self, request: Request, result: ExecutionResult + ) -> GraphQLHTTPResponse: + return process_result(result) + + 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..f9eb22a --- /dev/null +++ b/src/graphql_server/http/base.py @@ -0,0 +1,86 @@ +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 request.query_params.get("query") is None + 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..4117a95 --- /dev/null +++ b/src/graphql_server/http/sync_base_view.py @@ -0,0 +1,223 @@ +import abc +import json +from collections.abc import Mapping +from typing import ( + Any, + Callable, + Generic, + 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 + ) -> Response: ... + + @abc.abstractmethod + def render_graphql_ide(self, request: Request) -> Response: ... + + def execute_operation( + self, request: Request, context: Context, root_value: Optional[RootValue] + ) -> ExecutionResult: + request_adapter = self.request_adapter_class(request) + + try: + request_data = self.parse_http_body(request_adapter) + 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 + + allowed_operation_types = operation_type_from_http(request_adapter.method) + + if not self.allow_queries_via_get and request_adapter.method == "GET": + allowed_operation_types = allowed_operation_types - {OperationType.QUERY} + + assert self.schema + + return execute_sync( + schema=self.schema, + query=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 parse_http_body(self, request: SyncHTTPRequestAdapter) -> GraphQLRequestData: + content_type, params = parse_content_type(request.content_type or "") + + 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 GraphQLRequestData( + query=data.get("query"), + variables=data.get("variables"), + operation_name=data.get("operationName"), + extensions=data.get("extensions"), + ) + + 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 not self.is_request_allowed(request_adapter): + raise HTTPException(405, "GraphQL only supports GET and POST requests.") + + if self.should_render_graphql_ide(request_adapter): + if self.graphql_ide: + return self.render_graphql_ide(request) + raise HTTPException(404, "Not Found") + + sub_response = self.get_sub_response(request) + context = ( + self.get_context(request, response=sub_response) + if context is UNSET + else context + ) + root_value = self.get_root_value(request) if root_value is UNSET else root_value + + try: + result = self.execute_operation( + request=request, + context=context, + root_value=root_value, + ) + except HTTPException: + raise + except GraphQLValidationError as e: + 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) + + if result.errors: + self._handle_errors(result.errors, response_data) + + return self.create_response( + response_data=response_data, sub_response=sub_response + ) + + def process_result( + self, request: Request, result: ExecutionResult + ) -> GraphQLHTTPResponse: + return process_result(result) + + +__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..8c93a54 --- /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] = 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..5f5ccd1 --- /dev/null +++ b/src/graphql_server/litestar/controller.py @@ -0,0 +1,477 @@ +"""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.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] + ) -> Response[str]: + return Response(self.graphql_ide_html, media_type=MediaType.HTML) + + def create_response( + self, response_data: GraphQLHTTPResponse, sub_response: Response[bytes] + ) -> Response[bytes]: + response = Response( + self.encode_json(response_data).encode(), + status_code=HTTP_200_OK, + media_type=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..783b3f4 --- /dev/null +++ b/src/graphql_server/quart/views.py @@ -0,0 +1,220 @@ +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.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) -> Response: + return Response(self.graphql_ide_html) + + def create_response( + self, response_data: "GraphQLHTTPResponse", sub_response: Response + ) -> Response: + sub_response.set_data(self.encode_json(response_data)) + + 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..a958a05 --- /dev/null +++ b/src/graphql_server/runtime.py @@ -0,0 +1,459 @@ +from __future__ import annotations + +import warnings +from asyncio import ensure_future +from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Iterable +from functools import cached_property, lru_cache +from inspect import isawaitable +from typing import ( + TYPE_CHECKING, + Any, + Callable, + NamedTuple, + Optional, + Set, + Union, + cast, +) + +from graphql import ( + ExecutionContext, + ExecutionResult, + FieldNode, + FragmentDefinitionNode, + GraphQLBoolean, + GraphQLError, + GraphQLField, + GraphQLNamedType, + GraphQLNonNull, + GraphQLObjectType, + GraphQLOutputType, + GraphQLSchema, + OperationDefinitionNode, + get_introspection_query, + parse, + print_schema, + validate_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 OperationType +from graphql.type import GraphQLSchema +from graphql.type.directives import specified_directives +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 collections.abc import Iterable, Mapping + from typing_extensions import TypeAlias + + from graphql.execution.collect_fields import FieldGroup # type: ignore + from graphql.language import DocumentNode + from graphql.pyutils import Path + from graphql.type import GraphQLResolveInfo + 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, + # 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") + + try: + if isinstance(query, str): + document_node = parse(query) + else: + document_node = query + 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(): + _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, +) -> 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, + ) + + # 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, +) -> 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, + ) + + # 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, +) -> AsyncGenerator[ExecutionResult, None]: + allowed_operation_types = { + OperationType.SUBSCRIPTION, + } + graphql_document = _parse_and_validate( + schema, + query, + allowed_operation_types, + validation_rules, + operation_name, + ) + 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..7c2b952 --- /dev/null +++ b/src/graphql_server/sanic/views.py @@ -0,0 +1,232 @@ +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.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) -> HTTPResponse: + return html(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 + ) -> HTTPResponse: + status_code = sub_response.status_code + + data = self.encode_json(response_data) + + return HTTPResponse( + data, + status=status_code, + content_type="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 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..583d5b8 --- /dev/null +++ b/src/graphql_server/static/graphiql.html @@ -0,0 +1,163 @@ + + + + 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..6bad4ab --- /dev/null +++ b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py @@ -0,0 +1,441 @@ +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.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 + + try: + graphql_document = parse(message["payload"]["query"]) + except GraphQLSyntaxError as exc: + await self.websocket.close(code=4400, reason=exc.message) + return + + operation_name = message["payload"].get("operationName") + + 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( + message["payload"].get("operationName"), + message["payload"]["query"], + message["payload"].get("variables"), + ) + + operation = Operation( + self, + message["id"], + operation_type, + message["payload"]["query"], + message["payload"].get("variables"), + message["payload"].get("operationName"), + ) + + 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.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.query, + variable_values=operation.variables, + context_value=self.context, + root_value=self.root_value, + operation_name=operation.operation_name, + ) + + # TODO: maybe change PreExecutionError to an exception that can be caught + + if isinstance(result_source, ExecutionResult): + # if isinstance(result_source, PreExecutionError): + # assert result_source.errors + # await operation.send_initial_errors(result_source.errors) + # else: + await operation.send_next(result_source) + else: + is_first_result = True + async for result in result_source: + print("RESULT SOURCE", result, is_first_result) + 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 + task = asyncio.current_task() + 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_name", + "operation_type", + "query", + "task", + "variables", + ] + + def __init__( + self, + handler: BaseGraphQLTransportWSHandler[Context, RootValue], + id: str, + operation_type: OperationType, + query: str, + variables: Optional[dict[str, object]], + operation_name: Optional[str], + ) -> None: + self.handler = handler + self.id = id + self.operation_type = operation_type + self.query = query + self.variables = variables + self.operation_name = operation_name + self.completed = False + self.task: Optional[asyncio.Task] = None + + 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..128729a --- /dev/null +++ b/src/graphql_server/subscriptions/protocols/graphql_ws/handlers.py @@ -0,0 +1,256 @@ +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) + await self.send_message( + ErrorMessage( + type="error", + id=operation_id, + payload={"message": str(e)}, + ) + ) + except asyncio.CancelledError: + await self.send_message(CompleteMessage(type="complete", id=operation_id)) + + 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/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..b528735 --- /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 + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return await super().process_result(request, result) + + +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..212ff72 --- /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 + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return await super().process_result(request, result) + + +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..0617844 --- /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 + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return await super().process_result(request, result) + + +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..2e86d30 --- /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 + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return await super().process_result(request, result) + + +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..b1baf44 --- /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 + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return super().process_result(request, result) + + +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..4f786fc --- /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 + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return await super().process_result(request, result) + + +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 + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return super().process_result(request, result) + + +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..796c76d --- /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 + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return super().process_result(request, result) + + +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..88be77a --- /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 + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return await super().process_result(request, result) + + +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..78a9e17 --- /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 + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return super().process_result(request, result) + + +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..7117c66 --- /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 + ) -> GraphQLHTTPResponse: + if result_override: + return result_override(result) + + return await super().process_result(request, result) + + 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..fe92093 --- /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 + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return await super().process_result(request, result) + + +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..d49344b --- /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 + ) -> GraphQLHTTPResponse: + if self.result_override: + return self.result_override(result) + + return await super().process_result(request, result) + + +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/conftest.py b/src/tests/http/conftest.py new file mode 100644 index 0000000..cd8b8b6 --- /dev/null +++ b/src/tests/http/conftest.py @@ -0,0 +1,56 @@ +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]), + ("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..8e84aae --- /dev/null +++ b/src/tests/http/test_async_base_view.py @@ -0,0 +1,80 @@ +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..85d19e7 --- /dev/null +++ b/src/tests/http/test_graphql_ide.py @@ -0,0 +1,100 @@ +from typing import Union +from typing_extensions import Literal + +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"}) + + # THIS might need to be changed to 404 + + assert response.status_code == 400 + + +@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 == 404 + + +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 == 404 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..d4360fe --- /dev/null +++ b/src/tests/http/test_graphql_over_http_spec.py @@ -0,0 +1,778 @@ +""" +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) + + +@pytest.mark.xfail( + reason="Our integrations currently only return application/json", + raises=AssertionError, +) +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 + + +@pytest.mark.xfail( + reason="OPTIONAL - currently supported by Channels, Chalice, Django, and Sanic", + raises=AssertionError, +) +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={}, + json={"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.xfail( + reason="OPTIONAL - Currently results in lots of TypeErrors", + raises=AssertionError, +) +@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.xfail( + reason="OPTIONAL - Currently results in lots of TypeErrors", raises=AssertionError +) +@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.xfail( + reason="OPTIONAL - Currently not supported by GraphQL-Server", raises=AssertionError +) +@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 + + +@pytest.mark.xfail( + reason="Currently results in status 200 with GraphQL errors", raises=AssertionError +) +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 + + +@pytest.mark.xfail( + reason="Currently results in status 200 with GraphQL errors", raises=AssertionError +) +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 + + +@pytest.mark.xfail( + reason="Currently results in status 200 with GraphQL errors", raises=AssertionError +) +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 + + +@pytest.mark.xfail( + reason="Currently results in status 200 with GraphQL errors", raises=AssertionError +) +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 + + +@pytest.mark.xfail( + reason="Currently results in status 200 with GraphQL errors", raises=AssertionError +) +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 + + +@pytest.mark.xfail( + reason="Currently results in status 200 with GraphQL errors", raises=AssertionError +) +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 + + +@pytest.mark.xfail( + reason="Currently results in status 200 with GraphQL errors", raises=AssertionError +) +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..e7e0f53 --- /dev/null +++ b/src/tests/http/test_http.py @@ -0,0 +1,32 @@ +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 diff --git a/src/tests/http/test_multipart_subscription.py b/src/tests/http/test_multipart_subscription.py new file mode 100644 index 0000000..ec6861f --- /dev/null +++ b/src/tests/http/test_multipart_subscription.py @@ -0,0 +1,113 @@ +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 + + # TODO: why do we have a sync channels client? + 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..a76a2c7 --- /dev/null +++ b/src/tests/litestar/app.py @@ -0,0 +1,35 @@ +from typing import Any + +from graphql_server.litestar import make_graphql_controller +from litestar import Litestar, Request +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, + ) + + return Litestar( + route_handlers=[GraphQLController], + dependencies={ + "app_dependency": Provide(custom_context_dependency, sync_to_thread=True) + }, + ) 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..ff0d53e --- /dev/null +++ b/src/tests/websockets/test_graphql_transport_ws.py @@ -0,0 +1,1224 @@ +from __future__ import annotations + +import asyncio +import contextlib +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}} + ) + + +@pytest.mark.xfail(reason="This test is flaky") +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" + + +# timings can sometimes fail currently. Until this test is rewritten when +# generator based subscriptions are implemented, mark it as flaky +@pytest.mark.xfail(reason="This test is flaky, see comment above") +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 + + +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=2) + assert Subscription.active_infinity_subscriptions == 1 + + await ws.close() + await asyncio.sleep(1) + + 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..c4054e1 --- /dev/null +++ b/src/tests/websockets/test_graphql_ws.py @@ -0,0 +1,868 @@ +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, +) + +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=2) # 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"] 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(1) + + 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 ad617d8..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""GraphQL-Server Tests""" diff --git a/tests/aiohttp/__init__.py b/tests/aiohttp/__init__.py deleted file mode 100644 index 943d58f..0000000 --- a/tests/aiohttp/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# aiohttp-graphql tests diff --git a/tests/aiohttp/app.py b/tests/aiohttp/app.py deleted file mode 100644 index c4b5110..0000000 --- a/tests/aiohttp/app.py +++ /dev/null @@ -1,18 +0,0 @@ -from urllib.parse import urlencode - -from aiohttp import web - -from graphql_server.aiohttp import GraphQLView - -from .schema import Schema - - -def create_app(schema=Schema, **kwargs): - app = web.Application() - # Only needed to silence aiohttp deprecation warnings - GraphQLView.attach(app, schema=schema, **kwargs) - return app - - -def url_string(url="/graphql", **url_params): - return f"{url}?{urlencode(url_params)}" if url_params else url diff --git a/tests/aiohttp/conftest.py b/tests/aiohttp/conftest.py deleted file mode 100644 index 8adfd0a..0000000 --- a/tests/aiohttp/conftest.py +++ /dev/null @@ -1,18 +0,0 @@ -import pytest -import pytest_asyncio -from aiohttp.test_utils import TestClient, TestServer - -from .app import create_app - - -@pytest.fixture -def app(): - return create_app() - - -@pytest_asyncio.fixture -async def client(app): - client = TestClient(TestServer(app)) - await client.start_server() - yield client - await client.close() diff --git a/tests/aiohttp/schema.py b/tests/aiohttp/schema.py deleted file mode 100644 index e94088f..0000000 --- a/tests/aiohttp/schema.py +++ /dev/null @@ -1,118 +0,0 @@ -import asyncio - -from graphql.type.definition import ( - GraphQLArgument, - GraphQLField, - GraphQLNonNull, - GraphQLObjectType, -) -from graphql.type.scalars import GraphQLString -from graphql.type.schema import GraphQLSchema - - -def resolve_raises(*_): - raise Exception("Throws!") - - -# Sync schema -QueryRootType = GraphQLObjectType( - name="QueryRoot", - fields={ - "thrower": GraphQLField( - GraphQLNonNull(GraphQLString), - resolve=resolve_raises, - ), - "request": GraphQLField( - GraphQLNonNull(GraphQLString), - resolve=lambda obj, info, *args: info.context["request"].query.get("q"), - ), - "context": GraphQLField( - GraphQLObjectType( - name="context", - fields={ - "session": GraphQLField(GraphQLString), - "request": GraphQLField( - GraphQLNonNull(GraphQLString), - resolve=lambda obj, info: info.context["request"], - ), - "property": GraphQLField( - GraphQLString, resolve=lambda obj, info: info.context.property - ), - }, - ), - resolve=lambda obj, info: info.context, - ), - "test": GraphQLField( - type_=GraphQLString, - args={"who": GraphQLArgument(GraphQLString)}, - resolve=lambda obj, info, who=None: "Hello %s" % (who or "World"), - ), - }, -) - - -MutationRootType = GraphQLObjectType( - name="MutationRoot", - fields={ - "writeTest": GraphQLField( - type_=QueryRootType, resolve=lambda *args: QueryRootType - ) - }, -) - -SubscriptionsRootType = GraphQLObjectType( - name="SubscriptionsRoot", - fields={ - "subscriptionsTest": GraphQLField( - type_=QueryRootType, resolve=lambda *args: QueryRootType - ) - }, -) - -Schema = GraphQLSchema(QueryRootType, MutationRootType, SubscriptionsRootType) - - -# Schema with async methods -async def resolver_field_async_1(_obj, info): - await asyncio.sleep(0.001) - return "hey" - - -async def resolver_field_async_2(_obj, info): - await asyncio.sleep(0.003) - return "hey2" - - -def resolver_field_sync(_obj, info): - return "hey3" - - -AsyncQueryType = GraphQLObjectType( - "AsyncQueryType", - { - "a": GraphQLField(GraphQLString, resolve=resolver_field_async_1), - "b": GraphQLField(GraphQLString, resolve=resolver_field_async_2), - "c": GraphQLField(GraphQLString, resolve=resolver_field_sync), - }, -) - - -def resolver_field_sync_1(_obj, info): - return "synced_one" - - -def resolver_field_sync_2(_obj, info): - return "synced_two" - - -SyncQueryType = GraphQLObjectType( - "SyncQueryType", - { - "a": GraphQLField(GraphQLString, resolve=resolver_field_sync_1), - "b": GraphQLField(GraphQLString, resolve=resolver_field_sync_2), - }, -) - - -AsyncSchema = GraphQLSchema(AsyncQueryType) -SyncSchema = GraphQLSchema(SyncQueryType) diff --git a/tests/aiohttp/test_graphiqlview.py b/tests/aiohttp/test_graphiqlview.py deleted file mode 100644 index b3f39cd..0000000 --- a/tests/aiohttp/test_graphiqlview.py +++ /dev/null @@ -1,139 +0,0 @@ -import pytest -from jinja2 import Environment - -from .app import create_app, url_string -from .schema import AsyncSchema, SyncSchema - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "app", - [ - create_app(graphiql=True), - create_app(graphiql=True, jinja_env=Environment()), - create_app(graphiql=True, jinja_env=Environment(enable_async=True)), - ], -) -async def test_graphiql_is_enabled(app, client): - response = await client.get( - url_string(query="{test}"), - headers={"Accept": "text/html"}, - ) - assert response.status == 200 - - pretty_response = ( - "{\n" - ' "data": {\n' - ' "test": "Hello World"\n' - " }\n" - "}".replace('"', '\\"').replace("\n", "\\n") - ) # fmt: skip - - assert pretty_response in await response.text() - - -@pytest.mark.asyncio -async def test_graphiql_html_is_not_accepted(client): - response = await client.get( - "/graphql", - headers={"Accept": "application/json"}, - ) - assert response.status == 400 - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "app", - [create_app(graphiql=True), create_app(graphiql=True, jinja_env=Environment())], -) -async def test_graphiql_get_mutation(app, client): - response = await client.get( - url_string(query="mutation TestMutation { writeTest { test } }"), - headers={"Accept": "text/html"}, - ) - assert response.status == 200 - assert "response: null" in await response.text() - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "app", - [create_app(graphiql=True), create_app(graphiql=True, jinja_env=Environment())], -) -async def test_graphiql_get_subscriptions(app, client): - response = await client.get( - url_string( - query="subscription TestSubscriptions { subscriptionsTest { test } }" - ), - headers={"Accept": "text/html"}, - ) - assert response.status == 200 - assert "response: null" in await response.text() - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "app", - [ - create_app(schema=AsyncSchema, enable_async=True, graphiql=True), - create_app( - schema=AsyncSchema, - enable_async=True, - graphiql=True, - jinja_env=Environment(), - ), - ], -) -async def test_graphiql_enabled_async_schema(app, client): - response = await client.get( - url_string(query="{a,b,c}"), - headers={"Accept": "text/html"}, - ) - - expected_response = ( - ( - "{\n" - ' "data": {\n' - ' "a": "hey",\n' - ' "b": "hey2",\n' - ' "c": "hey3"\n' - " }\n" - "}" - ) - .replace('"', '\\"') - .replace("\n", "\\n") - ) - assert response.status == 200 - assert expected_response in await response.text() - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "app", - [ - create_app(schema=SyncSchema, enable_async=True, graphiql=True), - create_app( - schema=SyncSchema, enable_async=True, graphiql=True, jinja_env=Environment() - ), - ], -) -async def test_graphiql_enabled_sync_schema(app, client): - response = await client.get( - url_string(query="{a,b}"), - headers={"Accept": "text/html"}, - ) - - expected_response = ( - ( - "{\n" - ' "data": {\n' - ' "a": "synced_one",\n' - ' "b": "synced_two"\n' - " }\n" - "}" - ) - .replace('"', '\\"') - .replace("\n", "\\n") - ) - assert response.status == 200 - assert expected_response in await response.text() diff --git a/tests/aiohttp/test_graphqlview.py b/tests/aiohttp/test_graphqlview.py deleted file mode 100644 index 3009426..0000000 --- a/tests/aiohttp/test_graphqlview.py +++ /dev/null @@ -1,702 +0,0 @@ -import json -from urllib.parse import urlencode - -import pytest -from aiohttp import FormData - -from ..utils import RepeatExecutionContext -from .app import create_app, url_string -from .schema import AsyncSchema - - -@pytest.mark.asyncio -async def test_allows_get_with_query_param(client): - response = await client.get(url_string(query="{test}")) - - assert response.status == 200 - assert await response.json() == {"data": {"test": "Hello World"}} - - -@pytest.mark.asyncio -async def test_allows_get_with_variable_values(client): - response = await client.get( - url_string( - query="query helloWho($who: String) { test(who: $who) }", - variables=json.dumps({"who": "Dolly"}), - ) - ) - - assert response.status == 200 - assert await response.json() == {"data": {"test": "Hello Dolly"}} - - -@pytest.mark.asyncio -async def test_allows_get_with_operation_name(client): - response = await client.get( - url_string( - 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 response.status == 200 - assert await response.json() == { - "data": {"test": "Hello World", "shared": "Hello Everyone"} - } - - -@pytest.mark.asyncio -async def test_reports_validation_errors(client): - response = await client.get(url_string(query="{ test, unknownOne, unknownTwo }")) - - assert response.status == 400 - assert await response.json() == { - "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}], - }, - ], - } - - -@pytest.mark.asyncio -async def test_errors_when_missing_operation_name(client): - response = await client.get( - url_string( - query=""" - query TestQuery { test } - mutation TestMutation { writeTest { test } } - subscription TestSubscriptions { subscriptionsTest { test } } - """ - ) - ) - - assert response.status == 400 - assert await response.json() == { - "errors": [ - { - "message": ( - "Must provide operation name if query contains multiple " - "operations." - ), - }, - ] - } - - -@pytest.mark.asyncio -async def test_errors_when_sending_a_mutation_via_get(client): - response = await client.get( - url_string( - query=""" - mutation TestMutation { writeTest { test } } - """ - ) - ) - assert response.status == 405 - assert await response.json() == { - "errors": [ - { - "message": "Can only perform a mutation operation from a POST request.", - }, - ], - } - - -@pytest.mark.asyncio -async def test_errors_when_selecting_a_mutation_within_a_get(client): - response = await client.get( - url_string( - query=""" - query TestQuery { test } - mutation TestMutation { writeTest { test } } - """, - operationName="TestMutation", - ) - ) - - assert response.status == 405 - assert await response.json() == { - "errors": [ - { - "message": "Can only perform a mutation operation from a POST request.", - }, - ], - } - - -@pytest.mark.asyncio -async def test_errors_when_selecting_a_subscription_within_a_get(client): - response = await client.get( - url_string( - query=""" - subscription TestSubscriptions { subscriptionsTest { test } } - """, - operationName="TestSubscriptions", - ) - ) - - assert response.status == 405 - assert await response.json() == { - "errors": [ - { - "message": "Can only perform a subscription operation" - " from a POST request.", - }, - ], - } - - -@pytest.mark.asyncio -async def test_allows_mutation_to_exist_within_a_get(client): - response = await client.get( - url_string( - query=""" - query TestQuery { test } - mutation TestMutation { writeTest { test } } - """, - operationName="TestQuery", - ) - ) - - assert response.status == 200 - assert await response.json() == {"data": {"test": "Hello World"}} - - -@pytest.mark.asyncio -async def test_allows_post_with_json_encoding(client): - response = await client.post( - "/graphql", - data=json.dumps({"query": "{test}"}), - headers={"content-type": "application/json"}, - ) - - assert await response.json() == {"data": {"test": "Hello World"}} - assert response.status == 200 - - -@pytest.mark.asyncio -async def test_allows_sending_a_mutation_via_post(client): - response = await client.post( - "/graphql", - data=json.dumps( - { - "query": "mutation TestMutation { writeTest { test } }", - } - ), - headers={"content-type": "application/json"}, - ) - - assert response.status == 200 - assert await response.json() == {"data": {"writeTest": {"test": "Hello World"}}} - - -@pytest.mark.asyncio -async def test_allows_post_with_url_encoding(client): - data = FormData() - data.add_field("query", "{test}") - response = await client.post( - "/graphql", - data=data(), - headers={"content-type": "application/x-www-form-urlencoded"}, - ) - - assert await response.json() == {"data": {"test": "Hello World"}} - assert response.status == 200 - - -@pytest.mark.asyncio -async def test_supports_post_json_query_with_string_variables(client): - response = await client.post( - "/graphql", - data=json.dumps( - { - "query": "query helloWho($who: String){ test(who: $who) }", - "variables": json.dumps({"who": "Dolly"}), - } - ), - headers={"content-type": "application/json"}, - ) - - assert response.status == 200 - assert await response.json() == {"data": {"test": "Hello Dolly"}} - - -@pytest.mark.asyncio -async def test_supports_post_json_query_with_json_variables(client): - response = await client.post( - "/graphql", - data=json.dumps( - { - "query": "query helloWho($who: String){ test(who: $who) }", - "variables": {"who": "Dolly"}, - } - ), - headers={"content-type": "application/json"}, - ) - - assert response.status == 200 - assert await response.json() == {"data": {"test": "Hello Dolly"}} - - -@pytest.mark.asyncio -async def test_supports_post_url_encoded_query_with_string_variables(client): - response = await client.post( - "/graphql", - data=urlencode( - { - "query": "query helloWho($who: String){ test(who: $who) }", - "variables": json.dumps({"who": "Dolly"}), - }, - ), - headers={"content-type": "application/x-www-form-urlencoded"}, - ) - - assert response.status == 200 - assert await response.json() == {"data": {"test": "Hello Dolly"}} - - -@pytest.mark.asyncio -async def test_supports_post_json_quey_with_get_variable_values(client): - response = await client.post( - url_string(variables=json.dumps({"who": "Dolly"})), - data=json.dumps( - { - "query": "query helloWho($who: String){ test(who: $who) }", - } - ), - headers={"content-type": "application/json"}, - ) - - assert response.status == 200 - assert await response.json() == {"data": {"test": "Hello Dolly"}} - - -@pytest.mark.asyncio -async def test_post_url_encoded_query_with_get_variable_values(client): - response = await client.post( - url_string(variables=json.dumps({"who": "Dolly"})), - data=urlencode( - { - "query": "query helloWho($who: String){ test(who: $who) }", - } - ), - headers={"content-type": "application/x-www-form-urlencoded"}, - ) - - assert response.status == 200 - assert await response.json() == {"data": {"test": "Hello Dolly"}} - - -@pytest.mark.asyncio -async def test_supports_post_raw_text_query_with_get_variable_values(client): - response = await client.post( - url_string(variables=json.dumps({"who": "Dolly"})), - data="query helloWho($who: String){ test(who: $who) }", - headers={"content-type": "application/graphql"}, - ) - - assert response.status == 200 - assert await response.json() == {"data": {"test": "Hello Dolly"}} - - -@pytest.mark.asyncio -async def test_allows_post_with_operation_name(client): - response = await client.post( - "/graphql", - data=json.dumps( - { - "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", - } - ), - headers={"content-type": "application/json"}, - ) - - assert response.status == 200 - assert await response.json() == { - "data": {"test": "Hello World", "shared": "Hello Everyone"} - } - - -@pytest.mark.asyncio -async def test_allows_post_with_get_operation_name(client): - response = await client.post( - url_string(operationName="helloWorld"), - data=""" - 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") - } - """, - headers={"content-type": "application/graphql"}, - ) - - assert response.status == 200 - assert await response.json() == { - "data": {"test": "Hello World", "shared": "Hello Everyone"} - } - - -@pytest.mark.asyncio -async def test_supports_pretty_printing(client): - response = await client.get(url_string(query="{test}", pretty="1")) - - text = await response.text() - assert text == "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" - - -@pytest.mark.asyncio -async def test_not_pretty_by_default(client): - response = await client.get(url_string(query="{test}")) - - assert await response.text() == '{"data":{"test":"Hello World"}}' - - -@pytest.mark.asyncio -async def test_supports_pretty_printing_by_request(client): - response = await client.get(url_string(query="{test}", pretty="1")) - - assert await response.text() == ( - "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" - ) - - -@pytest.mark.asyncio -async def test_handles_field_errors_caught_by_graphql(client): - response = await client.get(url_string(query="{thrower}")) - assert response.status == 200 - assert await response.json() == { - "data": None, - "errors": [ - { - "locations": [{"column": 2, "line": 1}], - "message": "Throws!", - "path": ["thrower"], - } - ], - } - - -@pytest.mark.asyncio -async def test_handles_syntax_errors_caught_by_graphql(client): - response = await client.get(url_string(query="syntaxerror")) - - assert response.status == 400 - assert await response.json() == { - "errors": [ - { - "locations": [{"column": 1, "line": 1}], - "message": "Syntax Error: Unexpected Name 'syntaxerror'.", - }, - ], - } - - -@pytest.mark.asyncio -async def test_handles_errors_caused_by_a_lack_of_query(client): - response = await client.get("/graphql") - - assert response.status == 400 - assert await response.json() == { - "errors": [{"message": "Must provide query string."}] - } - - -@pytest.mark.asyncio -async def test_handles_batch_correctly_if_is_disabled(client): - response = await client.post( - "/graphql", - data="[]", - headers={"content-type": "application/json"}, - ) - - assert response.status == 400 - assert await response.json() == { - "errors": [ - { - "message": "Batch GraphQL requests are not enabled.", - } - ] - } - - -@pytest.mark.asyncio -async def test_handles_incomplete_json_bodies(client): - response = await client.post( - "/graphql", - data='{"query":', - headers={"content-type": "application/json"}, - ) - - assert response.status == 400 - assert await response.json() == { - "errors": [ - { - "message": "POST body sent invalid JSON.", - } - ] - } - - -@pytest.mark.asyncio -async def test_handles_plain_post_text(client): - response = await client.post( - url_string(variables=json.dumps({"who": "Dolly"})), - data="query helloWho($who: String){ test(who: $who) }", - headers={"content-type": "text/plain"}, - ) - assert response.status == 400 - assert await response.json() == { - "errors": [{"message": "Must provide query string."}] - } - - -@pytest.mark.asyncio -async def test_handles_poorly_formed_variables(client): - response = await client.get( - url_string( - query="query helloWho($who: String){ test(who: $who) }", variables="who:You" - ), - ) - assert response.status == 400 - assert await response.json() == { - "errors": [{"message": "Variables are invalid JSON."}] - } - - -@pytest.mark.asyncio -async def test_handles_unsupported_http_methods(client): - response = await client.put(url_string(query="{test}")) - assert response.status == 405 - assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] - assert await response.json() == { - "errors": [ - { - "message": "GraphQL only supports GET and POST requests.", - } - ] - } - - -@pytest.mark.asyncio -@pytest.mark.parametrize("app", [create_app()]) -async def test_passes_request_into_request_context(app, client): - response = await client.get(url_string(query="{request}", q="testing")) - - assert response.status == 200 - assert await response.json() == { - "data": {"request": "testing"}, - } - - -@pytest.mark.asyncio -@pytest.mark.parametrize("app", [create_app(context={"session": "CUSTOM CONTEXT"})]) -async def test_passes_custom_context_into_context(app, client): - response = await client.get(url_string(query="{context { session request }}")) - - _json = await response.json() - assert response.status == 200 - assert "data" in _json - assert "session" in _json["data"]["context"] - assert "request" in _json["data"]["context"] - assert "CUSTOM CONTEXT" in _json["data"]["context"]["session"] - assert "Request" in _json["data"]["context"]["request"] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("app", [create_app(context="CUSTOM CONTEXT")]) -async def test_context_remapped_if_not_mapping(app, client): - response = await client.get(url_string(query="{context { session request }}")) - - _json = await response.json() - assert response.status == 200 - assert "data" in _json - assert "session" in _json["data"]["context"] - assert "request" in _json["data"]["context"] - assert "CUSTOM CONTEXT" not in _json["data"]["context"]["request"] - assert "Request" in _json["data"]["context"]["request"] - - -class CustomContext(dict): - property = "A custom property" - - -@pytest.mark.asyncio -@pytest.mark.parametrize("app", [create_app(context=CustomContext())]) -async def test_allow_empty_custom_context(app, client): - response = await client.get(url_string(query="{context { property request }}")) - - _json = await response.json() - assert response.status == 200 - assert "data" in _json - assert "request" in _json["data"]["context"] - assert "property" in _json["data"]["context"] - assert "A custom property" == _json["data"]["context"]["property"] - assert "Request" in _json["data"]["context"]["request"] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("app", [create_app(context={"request": "test"})]) -async def test_request_not_replaced(app, client): - response = await client.get(url_string(query="{context { request }}")) - - _json = await response.json() - assert response.status == 200 - assert _json["data"]["context"]["request"] == "test" - - -@pytest.mark.asyncio -async def test_post_multipart_data(client): - query = "mutation TestMutation { writeTest { test } }" - - data = ( - "------aiohttpgraphql\r\n" - 'Content-Disposition: form-data; name="query"\r\n' - "\r\n" + query + "\r\n" - "------aiohttpgraphql--\r\n" - "Content-Type: text/plain; charset=utf-8\r\n" - 'Content-Disposition: form-data; name="file"; filename="text1.txt";' - " filename*=utf-8''text1.txt\r\n" - "\r\n" - "\r\n" - "------aiohttpgraphql--\r\n" - ) - - response = await client.post( - "/graphql", - data=data, - headers={"content-type": "multipart/form-data; boundary=----aiohttpgraphql"}, - ) - - assert response.status == 200 - assert await response.json() == {"data": {"writeTest": {"test": "Hello World"}}} - - -@pytest.mark.asyncio -@pytest.mark.parametrize("app", [create_app(batch=True)]) -async def test_batch_allows_post_with_json_encoding(app, client): - response = await client.post( - "/graphql", - data=json.dumps([{"id": 1, "query": "{test}"}]), - headers={"content-type": "application/json"}, - ) - - assert response.status == 200 - assert await response.json() == [{"data": {"test": "Hello World"}}] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("app", [create_app(batch=True)]) -async def test_batch_supports_post_json_query_with_json_variables(app, client): - response = await client.post( - "/graphql", - data=json.dumps( - [ - { - "id": 1, - "query": "query helloWho($who: String){ test(who: $who) }", - "variables": {"who": "Dolly"}, - } - ] - ), - headers={"content-type": "application/json"}, - ) - - assert response.status == 200 - assert await response.json() == [{"data": {"test": "Hello Dolly"}}] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("app", [create_app(batch=True)]) -async def test_batch_allows_post_with_operation_name(app, client): - response = await client.post( - "/graphql", - data=json.dumps( - [ - { - "id": 1, - "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", - } - ] - ), - headers={"content-type": "application/json"}, - ) - - assert response.status == 200 - assert await response.json() == [ - {"data": {"test": "Hello World", "shared": "Hello Everyone"}} - ] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("app", [create_app(schema=AsyncSchema, enable_async=True)]) -async def test_async_schema(app, client): - response = await client.get(url_string(query="{a,b,c}")) - - assert response.status == 200 - assert await response.json() == {"data": {"a": "hey", "b": "hey2", "c": "hey3"}} - - -@pytest.mark.asyncio -async def test_preflight_request(client): - response = await client.options( - "/graphql", - headers={"Access-Control-Request-Method": "POST"}, - ) - - assert response.status == 200 - - -@pytest.mark.asyncio -async def test_preflight_incorrect_request(client): - response = await client.options( - "/graphql", - headers={"Access-Control-Request-Method": "OPTIONS"}, - ) - - assert response.status == 400 - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "app", [create_app(execution_context_class=RepeatExecutionContext)] -) -async def test_custom_execution_context_class(client): - response = await client.post( - "/graphql", - data=json.dumps({"query": "{test}"}), - headers={"content-type": "application/json"}, - ) - - assert response.status == 200 - assert await response.json() == {"data": {"test": "Hello WorldHello World"}} diff --git a/tests/flask/app.py b/tests/flask/app.py deleted file mode 100644 index ec9e9d0..0000000 --- a/tests/flask/app.py +++ /dev/null @@ -1,18 +0,0 @@ -from flask import Flask - -from graphql_server.flask import GraphQLView -from tests.flask.schema import Schema - - -def create_app(path="/graphql", **kwargs): - server = Flask(__name__) - server.debug = True - server.add_url_rule( - path, view_func=GraphQLView.as_view("graphql", schema=Schema, **kwargs) - ) - return server - - -if __name__ == "__main__": - app = create_app(graphiql=True) - app.run() diff --git a/tests/flask/conftest.py b/tests/flask/conftest.py deleted file mode 100644 index 5944b2e..0000000 --- a/tests/flask/conftest.py +++ /dev/null @@ -1,19 +0,0 @@ -import pytest - -from .app import create_app - - -@pytest.fixture -def app(): - # import app factory pattern - app = create_app() - - # pushes an application context manually - ctx = app.app_context() - ctx.push() - return app - - -@pytest.fixture -def client(app): - return app.test_client() diff --git a/tests/flask/schema.py b/tests/flask/schema.py deleted file mode 100644 index fc056fa..0000000 --- a/tests/flask/schema.py +++ /dev/null @@ -1,54 +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_raises(*_): - raise Exception("Throws!") - - -QueryRootType = GraphQLObjectType( - name="QueryRoot", - fields={ - "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_raises), - "request": GraphQLField( - GraphQLNonNull(GraphQLString), - resolve=lambda obj, info: info.context["request"].args.get("q"), - ), - "context": GraphQLField( - GraphQLObjectType( - name="context", - fields={ - "session": GraphQLField(GraphQLString), - "request": GraphQLField( - GraphQLNonNull(GraphQLString), - resolve=lambda obj, info: info.context["request"], - ), - "property": GraphQLField( - GraphQLString, resolve=lambda obj, info: info.context.property - ), - }, - ), - resolve=lambda obj, info: info.context, - ), - "test": GraphQLField( - type_=GraphQLString, - args={"who": GraphQLArgument(GraphQLString)}, - resolve=lambda obj, info, who="World": "Hello %s" % who, - ), - }, -) - -MutationRootType = GraphQLObjectType( - name="MutationRoot", - fields={ - "writeTest": GraphQLField(type_=QueryRootType, resolve=lambda *_: QueryRootType) - }, -) - -Schema = GraphQLSchema(QueryRootType, MutationRootType) diff --git a/tests/flask/test_graphiqlview.py b/tests/flask/test_graphiqlview.py deleted file mode 100644 index bb398f6..0000000 --- a/tests/flask/test_graphiqlview.py +++ /dev/null @@ -1,63 +0,0 @@ -import pytest -from flask import url_for -from jinja2 import Environment - -from .app import create_app - - -@pytest.mark.parametrize( - "app", - [create_app(graphiql=True), create_app(graphiql=True, jinja_env=Environment())], -) -def test_graphiql_is_enabled(app, client): - with app.test_request_context(): - response = client.get( - url_for("graphql", externals=False), headers={"Accept": "text/html"} - ) - assert response.status_code == 200 - - -@pytest.mark.parametrize( - "app", - [create_app(graphiql=True), create_app(graphiql=True, jinja_env=Environment())], -) -def test_graphiql_renders_pretty(app, client): - with app.test_request_context(): - response = client.get( - url_for("graphql", query="{test}"), headers={"Accept": "text/html"} - ) - assert response.status_code == 200 - pretty_response = ( - "{\n" - ' "data": {\n' - ' "test": "Hello World"\n' - " }\n" - "}".replace('"', '\\"').replace("\n", "\\n") - ) # fmt: skip - - assert pretty_response in response.data.decode("utf-8") - - -@pytest.mark.parametrize( - "app", - [create_app(graphiql=True), create_app(graphiql=True, jinja_env=Environment())], -) -def test_graphiql_default_title(app, client): - with app.test_request_context(): - response = client.get(url_for("graphql"), headers={"Accept": "text/html"}) - assert "GraphiQL" in response.data.decode("utf-8") - - -@pytest.mark.parametrize( - "app", - [ - create_app(graphiql=True, graphiql_html_title="Awesome"), - create_app( - graphiql=True, graphiql_html_title="Awesome", jinja_env=Environment() - ), - ], -) -def test_graphiql_custom_title(app, client): - with app.test_request_context(): - response = client.get(url_for("graphql"), headers={"Accept": "text/html"}) - assert "Awesome" in response.data.decode("utf-8") diff --git a/tests/flask/test_graphqlview.py b/tests/flask/test_graphqlview.py deleted file mode 100644 index 3a6b87c..0000000 --- a/tests/flask/test_graphqlview.py +++ /dev/null @@ -1,606 +0,0 @@ -import json -from urllib.parse import urlencode - -import pytest -from flask import url_for - -from ..utils import RepeatExecutionContext -from .app import create_app - - -def url_string(app, **url_params): - with app.test_request_context(): - url = url_for("graphql") - - return f"{url}?{urlencode(url_params)}" if url_params else url - - -def response_json(response): - return json.loads(response.data.decode()) - - -def json_dump_kwarg(**kwargs): - return json.dumps(kwargs) - - -def json_dump_kwarg_list(**kwargs): - return json.dumps([kwargs]) - - -def test_allows_get_with_query_param(app, client): - response = client.get(url_string(app, query="{test}")) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello World"}} - - -def test_allows_get_with_variable_values(app, client): - response = client.get( - url_string( - app, - query="query helloWho($who: String){ test(who: $who) }", - variables=json.dumps({"who": "Dolly"}), - ) - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello Dolly"}} - - -def test_allows_get_with_operation_name(app, client): - response = client.get( - url_string( - app, - 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 response.status_code == 200 - assert response_json(response) == { - "data": {"test": "Hello World", "shared": "Hello Everyone"} - } - - -def test_reports_validation_errors(app, client): - response = client.get(url_string(app, query="{ test, unknownOne, unknownTwo }")) - - assert response.status_code == 400 - assert response_json(response) == { - "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_errors_when_missing_operation_name(app, client): - response = client.get( - url_string( - app, - query=""" - query TestQuery { test } - mutation TestMutation { writeTest { test } } - """, - ) - ) - - assert response.status_code == 400 - assert response_json(response) == { - "errors": [ - { - "message": "Must provide operation name" - " if query contains multiple operations.", - } - ] - } - - -def test_errors_when_sending_a_mutation_via_get(app, client): - response = client.get( - url_string( - app, - query=""" - mutation TestMutation { writeTest { test } } - """, - ) - ) - assert response.status_code == 405 - assert response_json(response) == { - "errors": [ - { - "message": "Can only perform a mutation operation from a POST request.", - } - ] - } - - -def test_errors_when_selecting_a_mutation_within_a_get(app, client): - response = client.get( - url_string( - app, - query=""" - query TestQuery { test } - mutation TestMutation { writeTest { test } } - """, - operationName="TestMutation", - ) - ) - - assert response.status_code == 405 - assert response_json(response) == { - "errors": [ - { - "message": "Can only perform a mutation operation from a POST request.", - } - ] - } - - -def test_allows_mutation_to_exist_within_a_get(app, client): - response = client.get( - url_string( - app, - query=""" - query TestQuery { test } - mutation TestMutation { writeTest { test } } - """, - operationName="TestQuery", - ) - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello World"}} - - -def test_allows_post_with_json_encoding(app, client): - response = client.post( - url_string(app), - data=json_dump_kwarg(query="{test}"), - content_type="application/json", - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello World"}} - - -def test_allows_sending_a_mutation_via_post(app, client): - response = client.post( - url_string(app), - data=json_dump_kwarg(query="mutation TestMutation { writeTest { test } }"), - content_type="application/json", - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} - - -def test_allows_post_with_url_encoding(app, client): - response = client.post( - url_string(app), - data=urlencode({"query": "{test}"}), - content_type="application/x-www-form-urlencoded", - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello World"}} - - -def test_supports_post_json_query_with_string_variables(app, client): - response = client.post( - url_string(app), - data=json_dump_kwarg( - query="query helloWho($who: String){ test(who: $who) }", - variables=json.dumps({"who": "Dolly"}), - ), - content_type="application/json", - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello Dolly"}} - - -def test_supports_post_json_query_with_json_variables(app, client): - response = client.post( - url_string(app), - data=json_dump_kwarg( - query="query helloWho($who: String){ test(who: $who) }", - variables={"who": "Dolly"}, - ), - content_type="application/json", - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello Dolly"}} - - -def test_supports_post_url_encoded_query_with_string_variables(app, client): - response = client.post( - url_string(app), - data=urlencode( - { - "query": "query helloWho($who: String){ test(who: $who) }", - "variables": json.dumps({"who": "Dolly"}), - } - ), - content_type="application/x-www-form-urlencoded", - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello Dolly"}} - - -def test_supports_post_json_query_with_get_variable_values(app, client): - response = client.post( - url_string(app, variables=json.dumps({"who": "Dolly"})), - data=json_dump_kwarg( - query="query helloWho($who: String){ test(who: $who) }", - ), - content_type="application/json", - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello Dolly"}} - - -def test_post_url_encoded_query_with_get_variable_values(app, client): - response = client.post( - url_string(app, variables=json.dumps({"who": "Dolly"})), - data=urlencode( - { - "query": "query helloWho($who: String){ test(who: $who) }", - } - ), - content_type="application/x-www-form-urlencoded", - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello Dolly"}} - - -def test_supports_post_raw_text_query_with_get_variable_values(app, client): - response = client.post( - url_string(app, variables=json.dumps({"who": "Dolly"})), - data="query helloWho($who: String){ test(who: $who) }", - content_type="application/graphql", - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello Dolly"}} - - -def test_allows_post_with_operation_name(app, client): - response = client.post( - url_string(app), - data=json_dump_kwarg( - 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", - ), - content_type="application/json", - ) - - assert response.status_code == 200 - assert response_json(response) == { - "data": {"test": "Hello World", "shared": "Hello Everyone"} - } - - -def test_allows_post_with_get_operation_name(app, client): - response = client.post( - url_string(app, operationName="helloWorld"), - data=""" - 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") - } - """, - content_type="application/graphql", - ) - - assert response.status_code == 200 - assert response_json(response) == { - "data": {"test": "Hello World", "shared": "Hello Everyone"} - } - - -@pytest.mark.parametrize("app", [create_app(pretty=True)]) -def test_supports_pretty_printing(app, client): - response = client.get(url_string(app, query="{test}")) - - assert response.data.decode() == ( - "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" - ) - - -@pytest.mark.parametrize("app", [create_app(pretty=False)]) -def test_not_pretty_by_default(app, client): - response = client.get(url_string(app, query="{test}")) - - assert response.data.decode() == '{"data":{"test":"Hello World"}}' - - -def test_supports_pretty_printing_by_request(app, client): - response = client.get(url_string(app, query="{test}", pretty="1")) - - assert response.data.decode() == ( - "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" - ) - - -def test_handles_field_errors_caught_by_graphql(app, client): - response = client.get(url_string(app, query="{thrower}")) - assert response.status_code == 200 - assert response_json(response) == { - "errors": [ - { - "locations": [{"column": 2, "line": 1}], - "path": ["thrower"], - "message": "Throws!", - } - ], - "data": None, - } - - -def test_handles_syntax_errors_caught_by_graphql(app, client): - response = client.get(url_string(app, query="syntaxerror")) - assert response.status_code == 400 - assert response_json(response) == { - "errors": [ - { - "locations": [{"column": 1, "line": 1}], - "message": "Syntax Error: Unexpected Name 'syntaxerror'.", - } - ] - } - - -def test_handles_errors_caused_by_a_lack_of_query(app, client): - response = client.get(url_string(app)) - - assert response.status_code == 400 - assert response_json(response) == { - "errors": [ - { - "message": "Must provide query string.", - } - ] - } - - -def test_handles_batch_correctly_if_is_disabled(app, client): - response = client.post(url_string(app), data="[]", content_type="application/json") - - assert response.status_code == 400 - assert response_json(response) == { - "errors": [ - { - "message": "Batch GraphQL requests are not enabled.", - } - ] - } - - -def test_handles_incomplete_json_bodies(app, client): - response = client.post( - url_string(app), data='{"query":', content_type="application/json" - ) - - assert response.status_code == 400 - assert response_json(response) == { - "errors": [ - { - "message": "POST body sent invalid JSON.", - } - ] - } - - -def test_handles_plain_post_text(app, client): - response = client.post( - url_string(app, variables=json.dumps({"who": "Dolly"})), - data="query helloWho($who: String){ test(who: $who) }", - content_type="text/plain", - ) - assert response.status_code == 400 - assert response_json(response) == { - "errors": [ - { - "message": "Must provide query string.", - } - ] - } - - -def test_handles_poorly_formed_variables(app, client): - response = client.get( - url_string( - app, - query="query helloWho($who: String){ test(who: $who) }", - variables="who:You", - ) - ) - assert response.status_code == 400 - assert response_json(response) == { - "errors": [ - { - "message": "Variables are invalid JSON.", - } - ] - } - - -def test_handles_unsupported_http_methods(app, client): - response = client.put(url_string(app, query="{test}")) - assert response.status_code == 405 - assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] - assert response_json(response) == { - "errors": [ - { - "message": "GraphQL only supports GET and POST requests.", - } - ] - } - - -def test_passes_request_into_request_context(app, client): - response = client.get(url_string(app, query="{request}", q="testing")) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"request": "testing"}} - - -@pytest.mark.parametrize("app", [create_app(context={"session": "CUSTOM CONTEXT"})]) -def test_passes_custom_context_into_context(app, client): - response = client.get(url_string(app, query="{context { session request }}")) - - assert response.status_code == 200 - res = response_json(response) - assert "data" in res - assert "session" in res["data"]["context"] - assert "request" in res["data"]["context"] - assert "CUSTOM CONTEXT" in res["data"]["context"]["session"] - assert "Request" in res["data"]["context"]["request"] - - -@pytest.mark.parametrize("app", [create_app(context="CUSTOM CONTEXT")]) -def test_context_remapped_if_not_mapping(app, client): - response = client.get(url_string(app, query="{context { session request }}")) - - assert response.status_code == 200 - res = response_json(response) - assert "data" in res - assert "session" in res["data"]["context"] - assert "request" in res["data"]["context"] - assert "CUSTOM CONTEXT" not in res["data"]["context"]["request"] - assert "Request" in res["data"]["context"]["request"] - - -class CustomContext(dict): - property = "A custom property" - - -@pytest.mark.parametrize("app", [create_app(context=CustomContext())]) -def test_allow_empty_custom_context(app, client): - response = client.get(url_string(app, query="{context { property request }}")) - - assert response.status_code == 200 - res = response_json(response) - assert "data" in res - assert "request" in res["data"]["context"] - assert "property" in res["data"]["context"] - assert "A custom property" == res["data"]["context"]["property"] - assert "Request" in res["data"]["context"]["request"] - - -def test_post_multipart_data(app, client): - query = "mutation TestMutation { writeTest { test } }" - - data = ( - "------flaskgraphql\r\n" - 'Content-Disposition: form-data; name="query"\r\n' - "\r\n" + query + "\r\n" - "------flaskgraphql--\r\n" - "Content-Type: text/plain; charset=utf-8\r\n" - 'Content-Disposition: form-data; name="file"; filename="text1.txt";' - " filename*=utf-8''text1.txt\r\n" - "\r\n" - "\r\n" - "------flaskgraphql--\r\n" - ) - - response = client.post( - url_string(app), - data=data, - content_type="multipart/form-data; boundary=----flaskgraphql", - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} - - -@pytest.mark.parametrize("app", [create_app(batch=True)]) -def test_batch_allows_post_with_json_encoding(app, client): - response = client.post( - url_string(app), - data=json_dump_kwarg_list(query="{test}"), - content_type="application/json", - ) - - assert response.status_code == 200 - assert response_json(response) == [{"data": {"test": "Hello World"}}] - - -@pytest.mark.parametrize("app", [create_app(batch=True)]) -def test_batch_supports_post_json_query_with_json_variables(app, client): - response = client.post( - url_string(app), - data=json_dump_kwarg_list( - query="query helloWho($who: String){ test(who: $who) }", - variables={"who": "Dolly"}, - ), - content_type="application/json", - ) - - assert response.status_code == 200 - assert response_json(response) == [{"data": {"test": "Hello Dolly"}}] - - -@pytest.mark.parametrize("app", [create_app(batch=True)]) -def test_batch_allows_post_with_operation_name(app, client): - response = client.post( - url_string(app), - data=json_dump_kwarg_list( - 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", - ), - content_type="application/json", - ) - - assert response.status_code == 200 - assert response_json(response) == [ - {"data": {"test": "Hello World", "shared": "Hello Everyone"}} - ] - - -@pytest.mark.parametrize( - "app", [create_app(execution_context_class=RepeatExecutionContext)] -) -def test_custom_execution_context_class(app, client): - response = client.post( - url_string(app), - data=json_dump_kwarg(query="{test}"), - content_type="application/json", - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello WorldHello World"}} diff --git a/tests/quart/app.py b/tests/quart/app.py deleted file mode 100644 index adfce41..0000000 --- a/tests/quart/app.py +++ /dev/null @@ -1,18 +0,0 @@ -from quart import Quart - -from graphql_server.quart import GraphQLView -from tests.quart.schema import Schema - - -def create_app(path="/graphql", schema=Schema, **kwargs): - server = Quart(__name__) - server.debug = True - server.add_url_rule( - path, view_func=GraphQLView.as_view("graphql", schema=schema, **kwargs) - ) - return server - - -if __name__ == "__main__": - app = create_app(graphiql=True) - app.run() diff --git a/tests/quart/conftest.py b/tests/quart/conftest.py deleted file mode 100644 index bb5a38c..0000000 --- a/tests/quart/conftest.py +++ /dev/null @@ -1,23 +0,0 @@ -import pytest -from quart import Quart -from quart.typing import TestClientProtocol - -from .app import create_app - -TestClientProtocol.__test__ = False # type: ignore - - -@pytest.fixture -def app() -> Quart: - # import app factory pattern - app = create_app() - - # pushes an application context manually - # ctx = app.app_context() - # await ctx.push() - return app - - -@pytest.fixture -def client(app: Quart) -> TestClientProtocol: - return app.test_client() diff --git a/tests/quart/schema.py b/tests/quart/schema.py deleted file mode 100644 index d3804ea..0000000 --- a/tests/quart/schema.py +++ /dev/null @@ -1,102 +0,0 @@ -import asyncio - -from graphql.type.definition import ( - GraphQLArgument, - GraphQLField, - GraphQLNonNull, - GraphQLObjectType, -) -from graphql.type.scalars import GraphQLString -from graphql.type.schema import GraphQLSchema - - -def resolve_raises(*_): - raise Exception("Throws!") - - -# Sync schema -QueryRootType = GraphQLObjectType( - name="QueryRoot", - fields={ - "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_raises), - "request": GraphQLField( - GraphQLNonNull(GraphQLString), - resolve=lambda obj, info: info.context["request"].args.get("q"), - ), - "context": GraphQLField( - GraphQLObjectType( - name="context", - fields={ - "session": GraphQLField(GraphQLString), - "request": GraphQLField( - GraphQLNonNull(GraphQLString), - resolve=lambda obj, info: info.context["request"], - ), - "property": GraphQLField( - GraphQLString, resolve=lambda obj, info: info.context.property - ), - }, - ), - resolve=lambda obj, info: info.context, - ), - "test": GraphQLField( - type_=GraphQLString, - args={"who": GraphQLArgument(GraphQLString)}, - resolve=lambda obj, info, who="World": f"Hello {who}", - ), - }, -) - -MutationRootType = GraphQLObjectType( - name="MutationRoot", - fields={ - "writeTest": GraphQLField(type_=QueryRootType, resolve=lambda *_: QueryRootType) - }, -) - -Schema = GraphQLSchema(QueryRootType, MutationRootType) - - -# Schema with async methods -async def resolver_field_async_1(_obj, info): - await asyncio.sleep(0.001) - return "hey" - - -async def resolver_field_async_2(_obj, info): - await asyncio.sleep(0.003) - return "hey2" - - -def resolver_field_sync(_obj, info): - return "hey3" - - -AsyncQueryType = GraphQLObjectType( - name="AsyncQueryType", - fields={ - "a": GraphQLField(GraphQLString, resolve=resolver_field_async_1), - "b": GraphQLField(GraphQLString, resolve=resolver_field_async_2), - "c": GraphQLField(GraphQLString, resolve=resolver_field_sync), - }, -) - - -def resolver_field_sync_1(_obj, info): - return "synced_one" - - -def resolver_field_sync_2(_obj, info): - return "synced_two" - - -SyncQueryType = GraphQLObjectType( - "SyncQueryType", - { - "a": GraphQLField(GraphQLString, resolve=resolver_field_sync_1), - "b": GraphQLField(GraphQLString, resolve=resolver_field_sync_2), - }, -) - -AsyncSchema = GraphQLSchema(AsyncQueryType) -SyncSchema = GraphQLSchema(SyncQueryType) diff --git a/tests/quart/test_graphiqlview.py b/tests/quart/test_graphiqlview.py deleted file mode 100644 index 4b78ec2..0000000 --- a/tests/quart/test_graphiqlview.py +++ /dev/null @@ -1,87 +0,0 @@ -from typing import Optional - -import pytest -from jinja2 import Environment -from quart import Quart, Response, url_for -from quart.typing import TestClientProtocol -from werkzeug.datastructures import Headers - -from .app import create_app - - -@pytest.mark.asyncio -async def execute_client( - app: Quart, - client: TestClientProtocol, - method: str = "GET", - headers: Optional[Headers] = None, - **extra_params, -) -> Response: - test_request_context = app.test_request_context(path="/", method=method) - async with test_request_context: - string = url_for("graphql", **extra_params) - return await client.get(string, headers=headers) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "app", - [create_app(graphiql=True), create_app(graphiql=True, jinja_env=Environment())], -) -async def test_graphiql_is_enabled(app: Quart, client: TestClientProtocol): - response = await execute_client( - app, client, headers=Headers({"Accept": "text/html"}), externals=False - ) - assert response.status_code == 200 - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "app", - [create_app(graphiql=True), create_app(graphiql=True, jinja_env=Environment())], -) -async def test_graphiql_renders_pretty(app: Quart, client: TestClientProtocol): - response = await execute_client( - app, client, headers=Headers({"Accept": "text/html"}), query="{test}" - ) - assert response.status_code == 200 - pretty_response = ( - "{\n" - ' "data": {\n' - ' "test": "Hello World"\n' - " }\n" - "}".replace('"', '\\"').replace("\n", "\\n") - ) # fmt: skip - result = await response.get_data(as_text=True) - assert pretty_response in result - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "app", - [create_app(graphiql=True), create_app(graphiql=True, jinja_env=Environment())], -) -async def test_graphiql_default_title(app: Quart, client: TestClientProtocol): - response = await execute_client( - app, client, headers=Headers({"Accept": "text/html"}) - ) - result = await response.get_data(as_text=True) - assert "GraphiQL" in result - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "app", - [ - create_app(graphiql=True, graphiql_html_title="Awesome"), - create_app( - graphiql=True, graphiql_html_title="Awesome", jinja_env=Environment() - ), - ], -) -async def test_graphiql_custom_title(app: Quart, client: TestClientProtocol): - response = await execute_client( - app, client, headers=Headers({"Accept": "text/html"}) - ) - result = await response.get_data(as_text=True) - assert "Awesome" in result diff --git a/tests/quart/test_graphqlview.py b/tests/quart/test_graphqlview.py deleted file mode 100644 index 17d12cc..0000000 --- a/tests/quart/test_graphqlview.py +++ /dev/null @@ -1,772 +0,0 @@ -import json -from typing import Optional -from urllib.parse import urlencode - -import pytest -from quart import Quart, Response, url_for -from quart.typing import TestClientProtocol -from werkzeug.datastructures import Headers - -from ..utils import RepeatExecutionContext -from .app import create_app -from .schema import AsyncSchema - - -@pytest.mark.asyncio -async def execute_client( - app: Quart, - client: TestClientProtocol, - method: str = "GET", - data: Optional[str] = None, - headers: Optional[Headers] = None, - **url_params, -) -> Response: - test_request_context = app.test_request_context(path="/", method=method) - async with test_request_context: - string = url_for("graphql") - - if url_params: - string += "?" + urlencode(url_params) - - if method == "POST": - return await client.post(string, data=data, headers=headers) - elif method == "PUT": - return await client.put(string, data=data, headers=headers) - else: - return await client.get(string) - - -def response_json(result): - return json.loads(result) - - -def json_dump_kwarg(**kwargs) -> str: - return json.dumps(kwargs) - - -def json_dump_kwarg_list(**kwargs): - return json.dumps([kwargs]) - - -@pytest.mark.asyncio -async def test_allows_get_with_query_param(app: Quart, client: TestClientProtocol): - response = await execute_client(app, client, query="{test}") - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - assert response_json(result) == {"data": {"test": "Hello World"}} - - -@pytest.mark.asyncio -async def test_allows_get_with_variable_values(app: Quart, client: TestClientProtocol): - response = await execute_client( - app, - client, - query="query helloWho($who: String){ test(who: $who) }", - variables=json.dumps({"who": "Dolly"}), - ) - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - assert response_json(result) == {"data": {"test": "Hello Dolly"}} - - -@pytest.mark.asyncio -async def test_allows_get_with_operation_name(app: Quart, client: TestClientProtocol): - response = await execute_client( - app, - client, - 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 response.status_code == 200 - result = await response.get_data(as_text=True) - assert response_json(result) == { - "data": {"test": "Hello World", "shared": "Hello Everyone"} - } - - -@pytest.mark.asyncio -async def test_reports_validation_errors(app: Quart, client: TestClientProtocol): - response = await execute_client( - app, client, query="{ test, unknownOne, unknownTwo }" - ) - - assert response.status_code == 400 - result = await response.get_data(as_text=True) - assert response_json(result) == { - "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}], - }, - ] - } - - -@pytest.mark.asyncio -async def test_errors_when_missing_operation_name( - app: Quart, client: TestClientProtocol -): - response = await execute_client( - app, - client, - query=""" - query TestQuery { test } - mutation TestMutation { writeTest { test } } - """, - ) - - assert response.status_code == 400 - result = await response.get_data(as_text=True) - assert response_json(result) == { - "errors": [ - { - "message": "Must provide operation name" - " if query contains multiple operations.", - } - ] - } - - -@pytest.mark.asyncio -async def test_errors_when_sending_a_mutation_via_get( - app: Quart, client: TestClientProtocol -): - response = await execute_client( - app, - client, - query=""" - mutation TestMutation { writeTest { test } } - """, - ) - assert response.status_code == 405 - result = await response.get_data(as_text=True) - assert response_json(result) == { - "errors": [ - { - "message": "Can only perform a mutation operation from a POST request.", - } - ] - } - - -@pytest.mark.asyncio -async def test_errors_when_selecting_a_mutation_within_a_get( - app: Quart, client: TestClientProtocol -): - response = await execute_client( - app, - client, - query=""" - query TestQuery { test } - mutation TestMutation { writeTest { test } } - """, - operationName="TestMutation", - ) - - assert response.status_code == 405 - result = await response.get_data(as_text=True) - assert response_json(result) == { - "errors": [ - { - "message": "Can only perform a mutation operation from a POST request.", - } - ] - } - - -@pytest.mark.asyncio -async def test_allows_mutation_to_exist_within_a_get( - app: Quart, client: TestClientProtocol -): - response = await execute_client( - app, - client, - query=""" - query TestQuery { test } - mutation TestMutation { writeTest { test } } - """, - operationName="TestQuery", - ) - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - assert response_json(result) == {"data": {"test": "Hello World"}} - - -@pytest.mark.asyncio -async def test_allows_post_with_json_encoding(app: Quart, client: TestClientProtocol): - response = await execute_client( - app, - client, - method="POST", - data=json_dump_kwarg(query="{test}"), - headers=Headers({"Content-Type": "application/json"}), - ) - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - assert response_json(result) == {"data": {"test": "Hello World"}} - - -@pytest.mark.asyncio -async def test_allows_sending_a_mutation_via_post( - app: Quart, client: TestClientProtocol -): - response = await execute_client( - app, - client, - method="POST", - data=json_dump_kwarg(query="mutation TestMutation { writeTest { test } }"), - headers=Headers({"Content-Type": "application/json"}), - ) - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - assert response_json(result) == {"data": {"writeTest": {"test": "Hello World"}}} - - -@pytest.mark.asyncio -async def test_allows_post_with_url_encoding(app: Quart, client: TestClientProtocol): - response = await execute_client( - app, - client, - method="POST", - data=urlencode({"query": "{test}"}), - headers=Headers({"Content-Type": "application/x-www-form-urlencoded"}), - ) - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - assert response_json(result) == {"data": {"test": "Hello World"}} - - -@pytest.mark.asyncio -async def test_supports_post_json_query_with_string_variables( - app: Quart, client: TestClientProtocol -): - response = await execute_client( - app, - client, - method="POST", - data=json_dump_kwarg( - query="query helloWho($who: String){ test(who: $who) }", - variables=json.dumps({"who": "Dolly"}), - ), - headers=Headers({"Content-Type": "application/json"}), - ) - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - assert response_json(result) == {"data": {"test": "Hello Dolly"}} - - -@pytest.mark.asyncio -async def test_supports_post_json_query_with_json_variables( - app: Quart, client: TestClientProtocol -): - response = await execute_client( - app, - client, - method="POST", - data=json_dump_kwarg( - query="query helloWho($who: String){ test(who: $who) }", - variables={"who": "Dolly"}, - ), - headers=Headers({"Content-Type": "application/json"}), - ) - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - assert response_json(result) == {"data": {"test": "Hello Dolly"}} - - -@pytest.mark.asyncio -async def test_supports_post_url_encoded_query_with_string_variables( - app: Quart, client: TestClientProtocol -): - response = await execute_client( - app, - client, - method="POST", - data=urlencode( - { - "query": "query helloWho($who: String){ test(who: $who) }", - "variables": json.dumps({"who": "Dolly"}), - } - ), - headers=Headers({"Content-Type": "application/x-www-form-urlencoded"}), - ) - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - assert response_json(result) == {"data": {"test": "Hello Dolly"}} - - -@pytest.mark.asyncio -async def test_supports_post_json_query_with_get_variable_values( - app: Quart, client: TestClientProtocol -): - response = await execute_client( - app, - client, - method="POST", - data=json_dump_kwarg( - query="query helloWho($who: String){ test(who: $who) }", - ), - headers=Headers({"Content-Type": "application/json"}), - variables=json.dumps({"who": "Dolly"}), - ) - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - assert response_json(result) == {"data": {"test": "Hello Dolly"}} - - -@pytest.mark.asyncio -async def test_post_url_encoded_query_with_get_variable_values( - app: Quart, client: TestClientProtocol -): - response = await execute_client( - app, - client, - method="POST", - data=urlencode( - { - "query": "query helloWho($who: String){ test(who: $who) }", - } - ), - headers=Headers({"Content-Type": "application/x-www-form-urlencoded"}), - variables=json.dumps({"who": "Dolly"}), - ) - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - assert response_json(result) == {"data": {"test": "Hello Dolly"}} - - -@pytest.mark.asyncio -async def test_supports_post_raw_text_query_with_get_variable_values( - app: Quart, client: TestClientProtocol -): - response = await execute_client( - app, - client=client, - method="POST", - data="query helloWho($who: String){ test(who: $who) }", - headers=Headers({"Content-Type": "application/graphql"}), - variables=json.dumps({"who": "Dolly"}), - ) - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - assert response_json(result) == {"data": {"test": "Hello Dolly"}} - - -@pytest.mark.asyncio -async def test_allows_post_with_operation_name(app: Quart, client: TestClientProtocol): - response = await execute_client( - app, - client, - method="POST", - data=json_dump_kwarg( - 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", - ), - headers=Headers({"Content-Type": "application/json"}), - ) - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - assert response_json(result) == { - "data": {"test": "Hello World", "shared": "Hello Everyone"} - } - - -@pytest.mark.asyncio -async def test_allows_post_with_get_operation_name( - app: Quart, client: TestClientProtocol -): - response = await execute_client( - app, - client, - method="POST", - data=""" - 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") - } - """, - headers=Headers({"Content-Type": "application/graphql"}), - operationName="helloWorld", - ) - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - assert response_json(result) == { - "data": {"test": "Hello World", "shared": "Hello Everyone"} - } - - -@pytest.mark.asyncio -@pytest.mark.parametrize("app", [create_app(pretty=True)]) -async def test_supports_pretty_printing(app: Quart, client: TestClientProtocol): - response = await execute_client(app, client, query="{test}") - - result = await response.get_data(as_text=True) - assert result == ("{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}") - - -@pytest.mark.asyncio -@pytest.mark.parametrize("app", [create_app(pretty=False)]) -async def test_not_pretty_by_default(app: Quart, client: TestClientProtocol): - response = await execute_client(app, client, query="{test}") - - result = await response.get_data(as_text=True) - assert result == '{"data":{"test":"Hello World"}}' - - -@pytest.mark.asyncio -async def test_supports_pretty_printing_by_request( - app: Quart, client: TestClientProtocol -): - response = await execute_client(app, client, query="{test}", pretty="1") - - result = await response.get_data(as_text=True) - assert result == "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" - - -@pytest.mark.asyncio -async def test_handles_field_errors_caught_by_graphql( - app: Quart, client: TestClientProtocol -): - response = await execute_client(app, client, query="{thrower}") - assert response.status_code == 200 - result = await response.get_data(as_text=True) - assert response_json(result) == { - "errors": [ - { - "locations": [{"column": 2, "line": 1}], - "path": ["thrower"], - "message": "Throws!", - } - ], - "data": None, - } - - -@pytest.mark.asyncio -async def test_handles_syntax_errors_caught_by_graphql( - app: Quart, client: TestClientProtocol -): - response = await execute_client(app, client, query="syntaxerror") - assert response.status_code == 400 - result = await response.get_data(as_text=True) - assert response_json(result) == { - "errors": [ - { - "locations": [{"column": 1, "line": 1}], - "message": "Syntax Error: Unexpected Name 'syntaxerror'.", - } - ] - } - - -@pytest.mark.asyncio -async def test_handles_errors_caused_by_a_lack_of_query( - app: Quart, client: TestClientProtocol -): - response = await execute_client(app, client) - - assert response.status_code == 400 - result = await response.get_data(as_text=True) - assert response_json(result) == { - "errors": [{"message": "Must provide query string."}] - } - - -@pytest.mark.asyncio -async def test_handles_batch_correctly_if_is_disabled( - app: Quart, client: TestClientProtocol -): - response = await execute_client( - app, - client, - method="POST", - data="[]", - headers=Headers({"Content-Type": "application/json"}), - ) - - assert response.status_code == 400 - result = await response.get_data(as_text=True) - assert response_json(result) == { - "errors": [ - { - "message": "Batch GraphQL requests are not enabled.", - } - ] - } - - -@pytest.mark.asyncio -async def test_handles_incomplete_json_bodies(app: Quart, client: TestClientProtocol): - response = await execute_client( - app, - client, - method="POST", - data='{"query":', - headers=Headers({"Content-Type": "application/json"}), - ) - - assert response.status_code == 400 - result = await response.get_data(as_text=True) - assert response_json(result) == { - "errors": [{"message": "POST body sent invalid JSON."}] - } - - -@pytest.mark.asyncio -async def test_handles_plain_post_text(app: Quart, client: TestClientProtocol): - response = await execute_client( - app, - client, - method="POST", - data="query helloWho($who: String){ test(who: $who) }", - headers=Headers({"Content-Type": "text/plain"}), - variables=json.dumps({"who": "Dolly"}), - ) - assert response.status_code == 400 - result = await response.get_data(as_text=True) - assert response_json(result) == { - "errors": [{"message": "Must provide query string."}] - } - - -@pytest.mark.asyncio -async def test_handles_poorly_formed_variables(app: Quart, client: TestClientProtocol): - response = await execute_client( - app, - client, - query="query helloWho($who: String){ test(who: $who) }", - variables="who:You", - ) - assert response.status_code == 400 - result = await response.get_data(as_text=True) - assert response_json(result) == { - "errors": [{"message": "Variables are invalid JSON."}] - } - - -@pytest.mark.asyncio -async def test_handles_unsupported_http_methods(app: Quart, client: TestClientProtocol): - response = await execute_client(app, client, method="PUT", query="{test}") - assert response.status_code == 405 - result = await response.get_data(as_text=True) - assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] - assert response_json(result) == { - "errors": [{"message": "GraphQL only supports GET and POST requests."}] - } - - -@pytest.mark.asyncio -async def test_passes_request_into_request_context( - app: Quart, client: TestClientProtocol -): - response = await execute_client(app, client, query="{request}", q="testing") - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - assert response_json(result) == {"data": {"request": "testing"}} - - -@pytest.mark.asyncio -@pytest.mark.parametrize("app", [create_app(context={"session": "CUSTOM CONTEXT"})]) -async def test_passes_custom_context_into_context( - app: Quart, client: TestClientProtocol -): - response = await execute_client(app, client, query="{context { session request }}") - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - res = response_json(result) - assert "data" in res - assert "session" in res["data"]["context"] - assert "request" in res["data"]["context"] - assert "CUSTOM CONTEXT" in res["data"]["context"]["session"] - assert "Request" in res["data"]["context"]["request"] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("app", [create_app(context="CUSTOM CONTEXT")]) -async def test_context_remapped_if_not_mapping(app: Quart, client: TestClientProtocol): - response = await execute_client(app, client, query="{context { session request }}") - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - res = response_json(result) - assert "data" in res - assert "session" in res["data"]["context"] - assert "request" in res["data"]["context"] - assert "CUSTOM CONTEXT" not in res["data"]["context"]["request"] - assert "Request" in res["data"]["context"]["request"] - - -class CustomContext(dict): - property = "A custom property" - - -@pytest.mark.asyncio -@pytest.mark.parametrize("app", [create_app(context=CustomContext())]) -async def test_allow_empty_custom_context(app: Quart, client: TestClientProtocol): - response = await execute_client(app, client, query="{context { property request }}") - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - res = response_json(result) - assert "data" in res - assert "request" in res["data"]["context"] - assert "property" in res["data"]["context"] - assert "A custom property" == res["data"]["context"]["property"] - assert "Request" in res["data"]["context"]["request"] - - -# @pytest.mark.asyncio -# async def test_post_multipart_data(app: Quart, client: TestClientProtocol): -# query = "mutation TestMutation { writeTest { test } }" -# response = await execute_client( -# app, -# client, -# method='POST', -# data={"query": query, "file": (StringIO(), "text1.txt")}, -# headers=Headers({"Content-Type": "multipart/form-data"}) -# ) -# -# assert response.status_code == 200 -# result = await response.get_data() -# assert response_json(result) == { -# "data": {u"writeTest": {u"test": u"Hello World"}} -# } - - -@pytest.mark.asyncio -@pytest.mark.parametrize("app", [create_app(batch=True)]) -async def test_batch_allows_post_with_json_encoding( - app: Quart, client: TestClientProtocol -): - response = await execute_client( - app, - client, - method="POST", - data=json_dump_kwarg_list(query="{test}"), - headers=Headers({"Content-Type": "application/json"}), - ) - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - assert response_json(result) == [{"data": {"test": "Hello World"}}] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("app", [create_app(batch=True)]) -async def test_batch_supports_post_json_query_with_json_variables( - app: Quart, client: TestClientProtocol -): - response = await execute_client( - app, - client, - method="POST", - data=json_dump_kwarg_list( - query="query helloWho($who: String){ test(who: $who) }", - variables={"who": "Dolly"}, - ), - headers=Headers({"Content-Type": "application/json"}), - ) - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - assert response_json(result) == [{"data": {"test": "Hello Dolly"}}] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("app", [create_app(batch=True)]) -async def test_batch_allows_post_with_operation_name( - app: Quart, client: TestClientProtocol -): - response = await execute_client( - app, - client, - method="POST", - data=json_dump_kwarg_list( - # id=1, - 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", - ), - headers=Headers({"Content-Type": "application/json"}), - ) - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - assert response_json(result) == [ - {"data": {"test": "Hello World", "shared": "Hello Everyone"}} - ] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("app", [create_app(schema=AsyncSchema, enable_async=True)]) -async def test_async_schema(app, client): - response = await execute_client( - app, - client, - query="{a,b,c}", - ) - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - assert response_json(result) == {"data": {"a": "hey", "b": "hey2", "c": "hey3"}} - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "app", [create_app(execution_context_class=RepeatExecutionContext)] -) -async def test_custom_execution_context_class(app: Quart, client: TestClientProtocol): - response = await execute_client( - app, - client, - method="POST", - data=json_dump_kwarg(query="{test}"), - headers=Headers({"Content-Type": "application/json"}), - ) - - assert response.status_code == 200 - result = await response.get_data(as_text=True) - assert response_json(result) == {"data": {"test": "Hello WorldHello World"}} diff --git a/tests/sanic/app.py b/tests/sanic/app.py deleted file mode 100644 index 2cabd73..0000000 --- a/tests/sanic/app.py +++ /dev/null @@ -1,21 +0,0 @@ -import uuid -from urllib.parse import urlencode - -from sanic import Sanic - -from graphql_server.sanic import GraphQLView - -from .schema import Schema - - -def create_app(path="/graphql", schema=Schema, **kwargs): - random_valid_app_name = f"App{uuid.uuid4().hex}" - app = Sanic(random_valid_app_name) - - app.add_route(GraphQLView.as_view(schema=schema, **kwargs), path) - - return app - - -def url_string(url="/graphql", **url_params): - return f"{url}?{urlencode(url_params)}" if url_params else url diff --git a/tests/sanic/conftest.py b/tests/sanic/conftest.py deleted file mode 100644 index 2ba7d3c..0000000 --- a/tests/sanic/conftest.py +++ /dev/null @@ -1,8 +0,0 @@ -import pytest - -from .app import create_app - - -@pytest.fixture -def app(): - return create_app() diff --git a/tests/sanic/schema.py b/tests/sanic/schema.py deleted file mode 100644 index 5df9a20..0000000 --- a/tests/sanic/schema.py +++ /dev/null @@ -1,102 +0,0 @@ -import asyncio - -from graphql.type.definition import ( - GraphQLArgument, - GraphQLField, - GraphQLNonNull, - GraphQLObjectType, -) -from graphql.type.scalars import GraphQLString -from graphql.type.schema import GraphQLSchema - - -def resolve_raises(*_): - raise Exception("Throws!") - - -# Sync schema -QueryRootType = GraphQLObjectType( - name="QueryRoot", - fields={ - "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_raises), - "request": GraphQLField( - GraphQLNonNull(GraphQLString), - resolve=lambda obj, info: info.context["request"].args.get("q"), - ), - "context": GraphQLField( - GraphQLObjectType( - name="context", - fields={ - "session": GraphQLField(GraphQLString), - "request": GraphQLField( - GraphQLNonNull(GraphQLString), - resolve=lambda obj, info: info.context["request"], - ), - "property": GraphQLField( - GraphQLString, resolve=lambda obj, info: info.context.property - ), - }, - ), - resolve=lambda obj, info: info.context, - ), - "test": GraphQLField( - type_=GraphQLString, - args={"who": GraphQLArgument(GraphQLString)}, - resolve=lambda obj, info, who=None: "Hello %s" % (who or "World"), - ), - }, -) - -MutationRootType = GraphQLObjectType( - name="MutationRoot", - fields={ - "writeTest": GraphQLField(type_=QueryRootType, resolve=lambda *_: QueryRootType) - }, -) - -Schema = GraphQLSchema(QueryRootType, MutationRootType) - - -# Schema with async methods -async def resolver_field_async_1(_obj, info): - await asyncio.sleep(0.001) - return "hey" - - -async def resolver_field_async_2(_obj, info): - await asyncio.sleep(0.003) - return "hey2" - - -def resolver_field_sync(_obj, info): - return "hey3" - - -AsyncQueryType = GraphQLObjectType( - name="AsyncQueryType", - fields={ - "a": GraphQLField(GraphQLString, resolve=resolver_field_async_1), - "b": GraphQLField(GraphQLString, resolve=resolver_field_async_2), - "c": GraphQLField(GraphQLString, resolve=resolver_field_sync), - }, -) - - -def resolver_field_sync_1(_obj, info): - return "synced_one" - - -def resolver_field_sync_2(_obj, info): - return "synced_two" - - -SyncQueryType = GraphQLObjectType( - "SyncQueryType", - { - "a": GraphQLField(GraphQLString, resolve=resolver_field_sync_1), - "b": GraphQLField(GraphQLString, resolve=resolver_field_sync_2), - }, -) - -AsyncSchema = GraphQLSchema(AsyncQueryType) -SyncSchema = GraphQLSchema(SyncQueryType) diff --git a/tests/sanic/test_graphiqlview.py b/tests/sanic/test_graphiqlview.py deleted file mode 100644 index b5c877b..0000000 --- a/tests/sanic/test_graphiqlview.py +++ /dev/null @@ -1,91 +0,0 @@ -import pytest -from jinja2 import Environment - -from .app import create_app, url_string -from .schema import AsyncSchema, SyncSchema - - -@pytest.mark.parametrize( - "app", - [ - create_app(graphiql=True), - create_app(graphiql=True, jinja_env=Environment()), - create_app(graphiql=True, jinja_env=Environment(enable_async=True)), - ], -) -def test_graphiql_is_enabled(app): - _, response = app.test_client.get( - uri=url_string(query="{test}"), headers={"Accept": "text/html"} - ) - - assert response.status == 200 - - pretty_response = ( - "{\n" - ' "data": {\n' - ' "test": "Hello World"\n' - " }\n" - "}".replace('"', '\\"').replace("\n", "\\n") - ) # fmt: skip - - assert pretty_response in response.body.decode("utf-8") - - -@pytest.mark.parametrize("app", [create_app(graphiql=True)]) -def test_graphiql_html_is_not_accepted(app): - _, response = app.test_client.get( - uri=url_string(), headers={"Accept": "application/json"} - ) - assert response.status == 400 - - -@pytest.mark.parametrize( - "app", [create_app(schema=AsyncSchema, enable_async=True, graphiql=True)] -) -def test_graphiql_enabled_async_schema(app): - query = "{a,b,c}" - _, response = app.test_client.get( - uri=url_string(query=query), headers={"Accept": "text/html"} - ) - - expected_response = ( - ( - "{\n" - ' "data": {\n' - ' "a": "hey",\n' - ' "b": "hey2",\n' - ' "c": "hey3"\n' - " }\n" - "}" - ) - .replace('"', '\\"') - .replace("\n", "\\n") - ) - - assert response.status == 200 - assert expected_response in response.body.decode("utf-8") - - -@pytest.mark.parametrize( - "app", [create_app(schema=SyncSchema, enable_async=True, graphiql=True)] -) -def test_graphiql_enabled_sync_schema(app): - query = "{a,b}" - _, response = app.test_client.get( - uri=url_string(query=query), headers={"Accept": "text/html"} - ) - - expected_response = ( - ( - "{\n" - ' "data": {\n' - ' "a": "synced_one",\n' - ' "b": "synced_two"\n' - " }\n" - "}" - ) - .replace('"', '\\"') - .replace("\n", "\\n") - ) - assert response.status == 200 - assert expected_response in response.body.decode("utf-8") diff --git a/tests/sanic/test_graphqlview.py b/tests/sanic/test_graphqlview.py deleted file mode 100644 index 24f2a92..0000000 --- a/tests/sanic/test_graphqlview.py +++ /dev/null @@ -1,622 +0,0 @@ -import json -from urllib.parse import urlencode - -import pytest - -from ..utils import RepeatExecutionContext -from .app import create_app, url_string -from .schema import AsyncSchema - - -def response_json(response): - return json.loads(response.body.decode()) - - -def json_dump_kwarg(**kwargs): - return json.dumps(kwargs) - - -def json_dump_kwarg_list(**kwargs): - return json.dumps([kwargs]) - - -def test_allows_get_with_query_param(app): - _, response = app.test_client.get(uri=url_string(query="{test}")) - - assert response.status == 200 - assert response_json(response) == {"data": {"test": "Hello World"}} - - -def test_allows_get_with_variable_values(app): - _, response = app.test_client.get( - uri=url_string( - query="query helloWho($who: String){ test(who: $who) }", - variables=json.dumps({"who": "Dolly"}), - ) - ) - - assert response.status == 200 - assert response_json(response) == {"data": {"test": "Hello Dolly"}} - - -def test_allows_get_with_operation_name(app): - _, response = app.test_client.get( - uri=url_string( - 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 response.status == 200 - assert response_json(response) == { - "data": {"test": "Hello World", "shared": "Hello Everyone"} - } - - -def test_reports_validation_errors(app): - _, response = app.test_client.get( - uri=url_string(query="{ test, unknownOne, unknownTwo }") - ) - - assert response.status == 400 - assert response_json(response) == { - "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_errors_when_missing_operation_name(app): - _, response = app.test_client.get( - uri=url_string( - query=""" - query TestQuery { test } - mutation TestMutation { writeTest { test } } - """ - ) - ) - - assert response.status == 400 - assert response_json(response) == { - "errors": [ - { - "message": "Must provide operation name" - " if query contains multiple operations.", - } - ] - } - - -def test_errors_when_sending_a_mutation_via_get(app): - _, response = app.test_client.get( - uri=url_string( - query=""" - mutation TestMutation { writeTest { test } } - """ - ) - ) - assert response.status == 405 - assert response_json(response) == { - "errors": [ - { - "message": "Can only perform a mutation operation from a POST request.", - } - ] - } - - -def test_errors_when_selecting_a_mutation_within_a_get(app): - _, response = app.test_client.get( - uri=url_string( - query=""" - query TestQuery { test } - mutation TestMutation { writeTest { test } } - """, - operationName="TestMutation", - ) - ) - - assert response.status == 405 - assert response_json(response) == { - "errors": [ - { - "message": "Can only perform a mutation operation from a POST request.", - } - ] - } - - -def test_allows_mutation_to_exist_within_a_get(app): - _, response = app.test_client.get( - uri=url_string( - query=""" - query TestQuery { test } - mutation TestMutation { writeTest { test } } - """, - operationName="TestQuery", - ) - ) - - assert response.status == 200 - assert response_json(response) == {"data": {"test": "Hello World"}} - - -def test_allows_post_with_json_encoding(app): - _, response = app.test_client.post( - uri=url_string(), - content=json_dump_kwarg(query="{test}"), - headers={"content-type": "application/json"}, - ) - - assert response.status == 200 - assert response_json(response) == {"data": {"test": "Hello World"}} - - -def test_allows_sending_a_mutation_via_post(app): - _, response = app.test_client.post( - uri=url_string(), - content=json_dump_kwarg(query="mutation TestMutation { writeTest { test } }"), - headers={"content-type": "application/json"}, - ) - - assert response.status == 200 - assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} - - -def test_allows_post_with_url_encoding(app): - # Example of how sanic does send data using url enconding - # can be found at their repo. - # https://github.com/huge-success/sanic/blob/master/tests/test_requests.py#L927 - payload = "query={test}" - _, response = app.test_client.post( - uri=url_string(), - content=payload, - headers={"content-type": "application/x-www-form-urlencoded"}, - ) - - assert response.status == 200 - assert response_json(response) == {"data": {"test": "Hello World"}} - - -def test_supports_post_json_query_with_string_variables(app): - _, response = app.test_client.post( - uri=url_string(), - content=json_dump_kwarg( - query="query helloWho($who: String){ test(who: $who) }", - variables=json.dumps({"who": "Dolly"}), - ), - headers={"content-type": "application/json"}, - ) - - assert response.status == 200 - assert response_json(response) == {"data": {"test": "Hello Dolly"}} - - -def test_supports_post_json_query_with_json_variables(app): - _, response = app.test_client.post( - uri=url_string(), - content=json_dump_kwarg( - query="query helloWho($who: String){ test(who: $who) }", - variables={"who": "Dolly"}, - ), - headers={"content-type": "application/json"}, - ) - - assert response.status == 200 - assert response_json(response) == {"data": {"test": "Hello Dolly"}} - - -def test_supports_post_url_encoded_query_with_string_variables(app): - _, response = app.test_client.post( - uri=url_string(), - content=urlencode( - { - "query": "query helloWho($who: String){ test(who: $who) }", - "variables": json.dumps({"who": "Dolly"}), - } - ), - headers={"content-type": "application/x-www-form-urlencoded"}, - ) - - assert response.status == 200 - assert response_json(response) == {"data": {"test": "Hello Dolly"}} - - -def test_supports_post_json_query_with_get_variable_values(app): - _, response = app.test_client.post( - uri=url_string(variables=json.dumps({"who": "Dolly"})), - content=json_dump_kwarg( - query="query helloWho($who: String){ test(who: $who) }", - ), - headers={"content-type": "application/json"}, - ) - - assert response.status == 200 - assert response_json(response) == {"data": {"test": "Hello Dolly"}} - - -def test_post_url_encoded_query_with_get_variable_values(app): - _, response = app.test_client.post( - uri=url_string(variables=json.dumps({"who": "Dolly"})), - content=urlencode( - { - "query": "query helloWho($who: String){ test(who: $who) }", - } - ), - headers={"content-type": "application/x-www-form-urlencoded"}, - ) - - assert response.status == 200 - assert response_json(response) == {"data": {"test": "Hello Dolly"}} - - -def test_supports_post_raw_text_query_with_get_variable_values(app): - _, response = app.test_client.post( - uri=url_string(variables=json.dumps({"who": "Dolly"})), - content="query helloWho($who: String){ test(who: $who) }", - headers={"content-type": "application/graphql"}, - ) - - assert response.status == 200 - assert response_json(response) == {"data": {"test": "Hello Dolly"}} - - -def test_allows_post_with_operation_name(app): - _, response = app.test_client.post( - uri=url_string(), - content=json_dump_kwarg( - 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", - ), - headers={"content-type": "application/json"}, - ) - - assert response.status == 200 - assert response_json(response) == { - "data": {"test": "Hello World", "shared": "Hello Everyone"} - } - - -def test_allows_post_with_get_operation_name(app): - _, response = app.test_client.post( - uri=url_string(operationName="helloWorld"), - content=""" - 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") - } - """, - headers={"content-type": "application/graphql"}, - ) - - assert response.status == 200 - assert response_json(response) == { - "data": {"test": "Hello World", "shared": "Hello Everyone"} - } - - -@pytest.mark.parametrize("app", [create_app(pretty=True)]) -def test_supports_pretty_printing(app): - _, response = app.test_client.get(uri=url_string(query="{test}")) - - assert response.body.decode() == ( - "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" - ) - - -@pytest.mark.parametrize("app", [create_app(pretty=False)]) -def test_not_pretty_by_default(app): - _, response = app.test_client.get(url_string(query="{test}")) - - assert response.body.decode() == '{"data":{"test":"Hello World"}}' - - -def test_supports_pretty_printing_by_request(app): - _, response = app.test_client.get(uri=url_string(query="{test}", pretty="1")) - - assert response.body.decode() == ( - "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" - ) - - -def test_handles_field_errors_caught_by_graphql(app): - _, response = app.test_client.get(uri=url_string(query="{thrower}")) - assert response.status == 200 - assert response_json(response) == { - "data": None, - "errors": [ - { - "locations": [{"column": 2, "line": 1}], - "message": "Throws!", - "path": ["thrower"], - } - ], - } - - -def test_handles_syntax_errors_caught_by_graphql(app): - _, response = app.test_client.get(uri=url_string(query="syntaxerror")) - assert response.status == 400 - assert response_json(response) == { - "errors": [ - { - "locations": [{"column": 1, "line": 1}], - "message": "Syntax Error: Unexpected Name 'syntaxerror'.", - } - ] - } - - -def test_handles_errors_caused_by_a_lack_of_query(app): - _, response = app.test_client.get(uri=url_string()) - - assert response.status == 400 - assert response_json(response) == { - "errors": [{"message": "Must provide query string."}] - } - - -def test_handles_batch_correctly_if_is_disabled(app): - _, response = app.test_client.post( - uri=url_string(), content="[]", headers={"content-type": "application/json"} - ) - - assert response.status == 400 - assert response_json(response) == { - "errors": [ - { - "message": "Batch GraphQL requests are not enabled.", - } - ] - } - - -def test_handles_incomplete_json_bodies(app): - _, response = app.test_client.post( - uri=url_string(), - content='{"query":', - headers={"content-type": "application/json"}, - ) - - assert response.status == 400 - assert response_json(response) == { - "errors": [{"message": "POST body sent invalid JSON."}] - } - - -def test_handles_plain_post_text(app): - _, response = app.test_client.post( - uri=url_string(variables=json.dumps({"who": "Dolly"})), - content="query helloWho($who: String){ test(who: $who) }", - headers={"content-type": "text/plain"}, - ) - assert response.status == 400 - assert response_json(response) == { - "errors": [{"message": "Must provide query string."}] - } - - -def test_handles_poorly_formed_variables(app): - _, response = app.test_client.get( - uri=url_string( - query="query helloWho($who: String){ test(who: $who) }", variables="who:You" - ) - ) - assert response.status == 400 - assert response_json(response) == { - "errors": [{"message": "Variables are invalid JSON."}] - } - - -def test_handles_unsupported_http_methods(app): - _, response = app.test_client.put(uri=url_string(query="{test}")) - assert response.status == 405 - allowed_methods = { - method.strip() for method in response.headers["Allow"].split(",") - } - assert allowed_methods in [{"GET", "POST"}, {"HEAD", "GET", "POST", "OPTIONS"}] - assert response_json(response) == { - "errors": [ - { - "message": "GraphQL only supports GET and POST requests.", - } - ] - } - - -def test_passes_request_into_request_context(app): - _, response = app.test_client.get(uri=url_string(query="{request}", q="testing")) - - assert response.status == 200 - assert response_json(response) == {"data": {"request": "testing"}} - - -@pytest.mark.parametrize("app", [create_app(context={"session": "CUSTOM CONTEXT"})]) -def test_passes_custom_context_into_context(app): - _, response = app.test_client.get( - uri=url_string(query="{context { session request }}") - ) - - assert response.status_code == 200 - res = response_json(response) - assert "data" in res - assert "session" in res["data"]["context"] - assert "request" in res["data"]["context"] - assert "CUSTOM CONTEXT" in res["data"]["context"]["session"] - assert "Request" in res["data"]["context"]["request"] - - -class CustomContext(dict): - property = "A custom property" - - -@pytest.mark.parametrize("app", [create_app(context=CustomContext())]) -def test_allow_empty_custom_context(app): - _, response = app.test_client.get( - uri=url_string(query="{context { property request }}") - ) - - assert response.status_code == 200 - res = response_json(response) - assert "data" in res - assert "request" in res["data"]["context"] - assert "property" in res["data"]["context"] - assert "A custom property" == res["data"]["context"]["property"] - assert "Request" in res["data"]["context"]["request"] - - -@pytest.mark.parametrize("app", [create_app(context="CUSTOM CONTEXT")]) -def test_context_remapped_if_not_mapping(app): - _, response = app.test_client.get( - uri=url_string(query="{context { session request }}") - ) - - assert response.status_code == 200 - res = response_json(response) - assert "data" in res - assert "session" in res["data"]["context"] - assert "request" in res["data"]["context"] - assert "CUSTOM CONTEXT" not in res["data"]["context"]["request"] - assert "Request" in res["data"]["context"]["request"] - - -def test_post_multipart_data(app): - query = "mutation TestMutation { writeTest { test } }" - - data = ( - "------sanicgraphql\r\n" - + 'Content-Disposition: form-data; name="query"\r\n' - + "\r\n" - + query - + "\r\n" - + "------sanicgraphql--\r\n" - + "Content-Type: text/plain; charset=utf-8\r\n" - + 'Content-Disposition: form-data; name="file"; filename="text1.txt"; filename*=utf-8\'\'text1.txt\r\n' - + "\r\n" - + "\r\n" - + "------sanicgraphql--\r\n" - ) - - _, response = app.test_client.post( - uri=url_string(), - content=data, - headers={"content-type": "multipart/form-data; boundary=----sanicgraphql"}, - ) - - assert response.status == 200 - assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} - - -@pytest.mark.parametrize("app", [create_app(batch=True)]) -def test_batch_allows_post_with_json_encoding(app): - _, response = app.test_client.post( - uri=url_string(), - content=json_dump_kwarg_list(id=1, query="{test}"), - headers={"content-type": "application/json"}, - ) - - assert response.status == 200 - assert response_json(response) == [{"data": {"test": "Hello World"}}] - - -@pytest.mark.parametrize("app", [create_app(batch=True)]) -def test_batch_supports_post_json_query_with_json_variables(app): - _, response = app.test_client.post( - uri=url_string(), - content=json_dump_kwarg_list( - id=1, - query="query helloWho($who: String){ test(who: $who) }", - variables={"who": "Dolly"}, - ), - headers={"content-type": "application/json"}, - ) - - assert response.status == 200 - assert response_json(response) == [{"data": {"test": "Hello Dolly"}}] - - -@pytest.mark.parametrize("app", [create_app(batch=True)]) -def test_batch_allows_post_with_operation_name(app): - _, response = app.test_client.post( - uri=url_string(), - content=json_dump_kwarg_list( - id=1, - 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", - ), - headers={"content-type": "application/json"}, - ) - - assert response.status == 200 - assert response_json(response) == [ - {"data": {"test": "Hello World", "shared": "Hello Everyone"}} - ] - - -@pytest.mark.parametrize("app", [create_app(schema=AsyncSchema, enable_async=True)]) -def test_async_schema(app): - query = "{a,b,c}" - _, response = app.test_client.get(uri=url_string(query=query)) - - assert response.status == 200 - assert response_json(response) == {"data": {"a": "hey", "b": "hey2", "c": "hey3"}} - - -def test_preflight_request(app): - _, response = app.test_client.options( - uri=url_string(), headers={"Access-Control-Request-Method": "POST"} - ) - - assert response.status == 200 - - -def test_preflight_incorrect_request(app): - _, response = app.test_client.options( - uri=url_string(), headers={"Access-Control-Request-Method": "OPTIONS"} - ) - - assert response.status == 400 - - -@pytest.mark.parametrize( - "app", [create_app(execution_context_class=RepeatExecutionContext)] -) -def test_custom_execution_context_class(app): - _, response = app.test_client.post( - uri=url_string(), - content=json_dump_kwarg(query="{test}"), - headers={"content-type": "application/json"}, - ) - - assert response.status == 200 - assert response_json(response) == {"data": {"test": "Hello WorldHello World"}} diff --git a/tests/schema.py b/tests/schema.py deleted file mode 100644 index c7665ba..0000000 --- a/tests/schema.py +++ /dev/null @@ -1,45 +0,0 @@ -from graphql import ( - GraphQLArgument, - GraphQLField, - GraphQLNonNull, - GraphQLObjectType, - GraphQLSchema, - GraphQLString, -) - - -def resolve_thrower(*_args): - raise Exception("Throws!") - - -def resolve_request(_obj, info): - return info.context.get("q") - - -def resolve_context(_obj, info): - return str(info.context) - - -QueryRootType = GraphQLObjectType( - name="QueryRoot", - fields={ - "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_thrower), - "request": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_request), - "context": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_context), - "test": GraphQLField( - type_=GraphQLString, - args={"who": GraphQLArgument(GraphQLString)}, - resolve=lambda obj, info, who="World": "Hello %s" % who, - ), - }, -) - -MutationRootType = GraphQLObjectType( - name="MutationRoot", - fields={ - "writeTest": GraphQLField(type_=QueryRootType, resolve=lambda *_: QueryRootType) - }, -) - -schema = GraphQLSchema(QueryRootType, MutationRootType) -invalid_schema = GraphQLSchema() diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py deleted file mode 100644 index a0845e4..0000000 --- a/tests/test_asyncio.py +++ /dev/null @@ -1,62 +0,0 @@ -import asyncio - -from graphql.type.definition import GraphQLField, GraphQLNonNull, GraphQLObjectType -from graphql.type.scalars import GraphQLString -from graphql.type.schema import GraphQLSchema - -from graphql_server import GraphQLParams, 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, resolve=resolve_error_sync), - "errorAsync": GraphQLField(NonNullString, resolve=resolve_error_async), - "fieldSync": GraphQLField(NonNullString, resolve=resolve_field_sync), - "fieldAsync": GraphQLField(NonNullString, resolve=resolve_field_async), - }, -) - -schema = GraphQLSchema(QueryRootType) - - -def test_get_responses_using_asyncio_executor(): - query = "{fieldSync fieldAsync}" - - async def get_results(): - result_promises, params = run_http_query( - schema, "get", {}, {"query": query}, run_sync=False - ) - res = [await result for result in result_promises] - return res, params - - results, params = asyncio.run(get_results()) - - expected_results = [ - {"data": {"fieldSync": "sync", "fieldAsync": "async"}, "errors": None} - ] - - assert as_dicts(results) == expected_results - assert params == [GraphQLParams(query=query, variables=None, operation_name=None)] diff --git a/tests/test_error.py b/tests/test_error.py deleted file mode 100644 index 4dfdc93..0000000 --- a/tests/test_error.py +++ /dev/null @@ -1,34 +0,0 @@ -from graphql_server import HttpQueryError - - -def test_can_create_http_query_error(): - error = HttpQueryError(400, "Bad error") - assert error.status_code == 400 - assert error.message == "Bad error" - assert not error.is_graphql_error - assert error.headers is None - - -def test_compare_http_query_errors(): - error = HttpQueryError(400, "Bad error") - assert error == error - same_error = HttpQueryError(400, "Bad error") - assert error == same_error - different_error = HttpQueryError(400, "Not really bad error") - assert error != different_error - different_error = HttpQueryError(405, "Bad error") - assert error != different_error - different_error = HttpQueryError(400, "Bad error", headers={"Allow": "ALL"}) - assert error != different_error - - -def test_hash_http_query_errors(): - errors = { - HttpQueryError(400, "Bad error 1"), - HttpQueryError(400, "Bad error 2"), - HttpQueryError(403, "Bad error 1"), - } - assert HttpQueryError(400, "Bad error 1") in errors - assert HttpQueryError(400, "Bad error 2") in errors - assert HttpQueryError(403, "Bad error 1") in errors - assert HttpQueryError(403, "Bad error 2") not in errors diff --git a/tests/test_helpers.py b/tests/test_helpers.py deleted file mode 100644 index 30a0b2e..0000000 --- a/tests/test_helpers.py +++ /dev/null @@ -1,222 +0,0 @@ -import json - -from graphql import Source -from graphql.error import GraphQLError -from graphql.execution import ExecutionResult -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_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", - source=Source(body="Some error"), - positions=[1], - 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) == { - "errors": [ - { - "message": "Some error", - "locations": [{"line": 1, "column": 2}], - "path": ["somePath"], - } - ], - "data": None, - } - assert output.status_code == 200 - - -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", - source=Source("Some msg"), - positions=[1], - path=["some", "path"], - ) - ], - ) - ] - - def format_error(error): - return { - "msg": error.message, - "loc": f"{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) == { - "errors": [{"msg": "Some msg", "loc": "1:2", "pth": "some/path"}], - "data": None, - } - 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_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_encode(): - 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 e0994db..0000000 --- a/tests/test_query.py +++ /dev/null @@ -1,676 +0,0 @@ -import json - -from graphql.error import GraphQLError -from graphql.execution import ExecutionResult -from graphql.validation import ValidationRule -from pytest import raises - -from graphql_server import ( - GraphQLParams, - GraphQLResponse, - HttpQueryError, - encode_execution_results, - format_execution_result, - json_encode, - load_json_body, - run_http_query, -) -from graphql_server.render_graphiql import ( - GraphiQLConfig, - GraphiQLData, - render_graphiql_sync, -) - -from .schema import invalid_schema, schema -from .utils import as_dicts - - -def test_request_params(): - assert issubclass(GraphQLParams, tuple) - # noinspection PyUnresolvedReferences - assert GraphQLParams._fields == ("query", "variables", "operation_name") - - -def test_server_results(): - assert issubclass(GraphQLResponse, tuple) - # noinspection PyUnresolvedReferences - assert GraphQLResponse._fields == ("results", "params") - - -def test_validate_schema(): - query = "{test}" - results, params = run_http_query(invalid_schema, "get", {}, {"query": query}) - assert as_dicts(results) == [ - { - "data": None, - "errors": [ - { - "message": "Query root type must be provided.", - } - ], - } - ] - - -def test_allows_get_with_query_param(): - query = "{test}" - results, params = run_http_query(schema, "get", {}, {"query": query}) - - assert as_dicts(results) == [{"data": {"test": "Hello World"}, "errors": None}] - assert params == [GraphQLParams(query=query, variables=None, operation_name=None)] - - -def test_allows_get_with_variable_values(): - results, params = run_http_query( - schema, - "get", - {}, - { - "query": "query helloWho($who: String){ test(who: $who) }", - "variables": json.dumps({"who": "Dolly"}), - }, - ) - - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}, "errors": None}] - - -def test_allows_get_with_operation_name(): - results, params = run_http_query( - schema, - "get", - {}, - query_data={ - "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"}, "errors": None} - ] - - response = encode_execution_results(results) - assert response.status_code == 200 - - -def test_reports_validation_errors(): - results, params = run_http_query( - schema, "get", {}, query_data={"query": "{ test, unknownOne, unknownTwo }"} - ) - - assert as_dicts(results) == [ - { - "data": None, - "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}], - }, - ], - } - ] - - response = encode_execution_results(results) - assert response.status_code == 400 - - -def test_reports_custom_validation_errors(): - class CustomValidationRule(ValidationRule): - def enter_field(self, node, *_args): - self.report_error(GraphQLError("Custom validation error.", node)) - - results, params = run_http_query( - schema, - "get", - {}, - query_data={"query": "{ test }"}, - validation_rules=[CustomValidationRule], - ) - - assert as_dicts(results) == [ - { - "data": None, - "errors": [ - { - "message": "Custom validation error.", - "locations": [{"line": 1, "column": 3}], - } - ], - } - ] - - response = encode_execution_results(results) - assert response.status_code == 400 - - -def test_reports_max_num_of_validation_errors(): - results, params = run_http_query( - schema, - "get", - {}, - query_data={"query": "{ test, unknownOne, unknownTwo }"}, - max_errors=1, - ) - - assert as_dicts(results) == [ - { - "data": None, - "errors": [ - { - "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", - "locations": [{"line": 1, "column": 9}], - }, - { - "message": "Too many validation errors, error limit reached." - " Validation aborted.", - }, - ], - } - ] - - response = encode_execution_results(results) - assert response.status_code == 400 - - -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={ - "query": """ - query TestQuery { test } - mutation TestMutation { writeTest { test } } - """ - }, - ) - - assert as_dicts(results) == [ - { - "data": None, - "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={ - "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={ - "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={ - "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={ - "query": """ - query TestQuery { test } - mutation TestMutation { writeTest { test } } - """, - "operationName": "TestQuery", - }, - ) - - assert as_dicts(results) == [{"data": {"test": "Hello World"}, "errors": None}] - - -def test_allows_sending_a_mutation_via_post(): - results, params = run_http_query( - schema, - "post", - {}, - query_data={"query": "mutation TestMutation { writeTest { test } }"}, - ) - - assert results == [({"writeTest": {"test": "Hello World"}}, None)] - - -def test_allows_post_with_url_encoding(): - results, params = run_http_query(schema, "post", {}, query_data={"query": "{test}"}) - - assert results == [({"test": "Hello World"}, None)] - - -def test_supports_post_json_query_with_string_variables(): - results, params = run_http_query( - schema, - "post", - {}, - query_data={ - "query": "query helloWho($who: String){ test(who: $who) }", - "variables": '{"who": "Dolly"}', - }, - ) - - assert results == [({"test": "Hello Dolly"}, None)] - - -def test_supports_post_json_query_with_json_variables(): - result = load_json_body( - """ - { - "query": "query helloWho($who: String){ test(who: $who) }", - "variables": {"who": "Dolly"} - } - """ - ) - - assert result["variables"] == {"who": "Dolly"} - - -def test_supports_post_url_encoded_query_with_string_variables(): - results, params = run_http_query( - schema, - "post", - {}, - query_data={ - "query": "query helloWho($who: String){ test(who: $who) }", - "variables": '{"who": "Dolly"}', - }, - ) - - assert results == [({"test": "Hello Dolly"}, None)] - - -def test_supports_post_json_query_with_get_variable_values(): - results, params = run_http_query( - schema, - "post", - data={"query": "query helloWho($who: String){ test(who: $who) }"}, - query_data={"variables": {"who": "Dolly"}}, - ) - - assert results == [({"test": "Hello Dolly"}, None)] - - -def test_post_url_encoded_query_with_get_variable_values(): - results, params = run_http_query( - schema, - "get", - data={"query": "query helloWho($who: String){ test(who: $who) }"}, - query_data={"variables": '{"who": "Dolly"}'}, - ) - - assert results == [({"test": "Hello Dolly"}, None)] - - -def test_supports_post_raw_text_query_with_get_variable_values(): - results, params = run_http_query( - schema, - "get", - data={"query": "query helloWho($who: String){ test(who: $who) }"}, - query_data={"variables": '{"who": "Dolly"}'}, - ) - - assert results == [({"test": "Hello Dolly"}, None)] - - -def test_allows_post_with_operation_name(): - results, params = run_http_query( - schema, - "get", - data={ - "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 results == [({"test": "Hello World", "shared": "Hello Everyone"}, None)] - - -def test_allows_post_with_get_operation_name(): - results, params = run_http_query( - schema, - "get", - data={ - "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={"operationName": "helloWorld"}, - ) - - assert results == [({"test": "Hello World", "shared": "Hello Everyone"}, None)] - - -def test_supports_pretty_printing_data(): - results, params = run_http_query(schema, "get", data={"query": "{test}"}) - result = {"data": results[0].data} - - assert json_encode(result, pretty=True) == ( - "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" - ) - - -def test_not_pretty_data_by_default(): - results, params = run_http_query(schema, "get", data={"query": "{test}"}) - result = {"data": results[0].data} - - assert json_encode(result) == '{"data":{"test":"Hello World"}}' - - -def test_handles_field_errors_caught_by_graphql(): - results, params = run_http_query(schema, "get", data={"query": "{thrower}"}) - - assert results == [ - (None, [{"message": "Throws!", "locations": [(1, 2)], "path": ["thrower"]}]) - ] - - response = encode_execution_results(results) - assert response.status_code == 200 - - -def test_handles_syntax_errors_caught_by_graphql(): - results, params = run_http_query(schema, "get", data={"query": "syntaxerror"}) - - assert results == [ - ( - None, - [ - { - "locations": [(1, 1)], - "message": "Syntax Error: Unexpected Name 'syntaxerror'.", - } - ], - ) - ] - - -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(): - with raises(HttpQueryError) as exc_info: - results, params = run_http_query(schema, "get", {"query": 42}) - - assert exc_info.value == HttpQueryError(400, "Unexpected query type.") - - -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", - {}, - { - "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", {}) # type: ignore - - assert str(exc_info.value) == ( - "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_format_execution_result(): - result = format_execution_result(None) - assert result == GraphQLResponse(None, 200) - data = {"answer": 42} - result = format_execution_result(ExecutionResult(data, None)) - assert result == GraphQLResponse({"data": data}, 200) - errors = [GraphQLError("bad")] - result = format_execution_result(ExecutionResult(None, errors)) - assert result == GraphQLResponse({"errors": errors}, 400) - - -def test_encode_execution_results(): - data = {"answer": 42} - errors = [GraphQLError("bad")] - results = [ExecutionResult(data, None), ExecutionResult(None, errors)] - result = encode_execution_results(results) - assert result == ('{"data":{"answer":42}}', 400) - - -def test_encode_execution_results_batch(): - data = {"answer": 42} - errors = [GraphQLError("bad")] - results = [ExecutionResult(data, None), ExecutionResult(None, errors)] - result = encode_execution_results(results, is_batch=True) - assert result == ( - '[{"data":{"answer":42}},{"errors":[{"message":"bad"}]}]', - 400, - ) - - -def test_encode_execution_results_not_encoded(): - data = {"answer": 42} - results = [ExecutionResult(data, None)] - result = encode_execution_results(results, encode=lambda r: r) - assert result == ({"data": data}, 200) - - -def test_passes_request_into_request_context(): - results, params = run_http_query( - schema, - "get", - {}, - query_data={"query": "{request}"}, - context_value={"q": "testing"}, - ) - - assert results == [({"request": "testing"}, None)] - - -def test_supports_pretty_printing_context(): - class Context: - def __str__(self): - return "CUSTOM CONTEXT" - - results, params = run_http_query( - schema, "get", {}, query_data={"query": "{context}"}, context_value=Context() - ) - - assert results == [({"context": "CUSTOM CONTEXT"}, None)] - - -def test_post_multipart_data(): - query = "mutation TestMutation { writeTest { test } }" - results, params = run_http_query(schema, "post", {}, query_data={"query": query}) - - assert results == [({"writeTest": {"test": "Hello World"}}, None)] - - -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 results == [({"test": "Hello World"}, None)] - - -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 results == [({"test": "Hello Dolly"}, None)] - - -def test_batch_allows_post_with_operation_name(): - data = [ - { - "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 results == [({"test": "Hello World", "shared": "Hello Everyone"}, None)] - - -def test_graphiql_render_umlaut(): - results, params = run_http_query( - schema, - "get", - data={"query": "query helloWho($who: String){ test(who: $who) }"}, - query_data={"variables": '{"who": "Björn"}'}, - catch=True, - ) - result, status_code = encode_execution_results(results) - - assert status_code == 200 - - graphiql_data = GraphiQLData(result=result, query=params[0].query) - source = render_graphiql_sync(data=graphiql_data, config=GraphiQLConfig()) - - assert "Hello Bj\\\\u00f6rn" in source diff --git a/tests/test_version.py b/tests/test_version.py deleted file mode 100644 index 0ac89e5..0000000 --- a/tests/test_version.py +++ /dev/null @@ -1,50 +0,0 @@ -import packaging -from packaging.version import Version - -from graphql_server.version import version, version_info - -RELEASE_LEVEL = {"alpha": "a", "beta": "b", "rc": "rc", "final": None} - - -parsed_version = Version(version) - - -def test_valid_version() -> None: - packaging.version.parse(version) - - -def test_valid_version_info() -> None: - """version_info has to be a tuple[int, int, int, str, int]""" - assert isinstance(version_info, tuple) - assert len(version_info) == 5 - - major, minor, micro, release_level, serial = version_info - assert isinstance(major, int) - assert isinstance(minor, int) - assert isinstance(micro, int) - assert isinstance(release_level, str) - assert isinstance(serial, int) - - -def test_valid_version_release_level() -> None: - if parsed_version.pre is not None: - valid_release_levels = {v for v in RELEASE_LEVEL.values() if v is not None} - assert parsed_version.pre[0] in valid_release_levels - - -def test_valid_version_info_release_level() -> None: - assert version_info[3] in RELEASE_LEVEL.keys() - - -def test_version_same_as_version_info() -> None: - assert ( - parsed_version.major, - parsed_version.minor, - parsed_version.micro, - ) == version_info[:3] - - release_level, serial = version_info[-2:] - if parsed_version.is_prerelease: - assert (RELEASE_LEVEL[release_level], serial) == parsed_version.pre - else: - assert (release_level, serial) == ("final", 0) diff --git a/tests/utils.py b/tests/utils.py deleted file mode 100644 index ee082d4..0000000 --- a/tests/utils.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import List - -from graphql import ExecutionResult -from graphql.execution import ExecutionContext - - -def as_dicts(results: List[ExecutionResult]): - """Convert execution results to a list of tuples of dicts for better comparison.""" - return [ - { - "data": result.data, - "errors": [error.formatted for error in result.errors] - if result.errors - else result.errors, - } - for result in results - ] - - -class RepeatExecutionContext(ExecutionContext): - def execute_field(self, parent_type, source, field_nodes, path): - result = super().execute_field(parent_type, source, field_nodes, path) - return result * 2 diff --git a/tests/webob/app.py b/tests/webob/app.py deleted file mode 100644 index fffbc88..0000000 --- a/tests/webob/app.py +++ /dev/null @@ -1,42 +0,0 @@ -from urllib.parse import urlencode - -from webob import Request - -from graphql_server.webob import GraphQLView - -from .schema import Schema - - -def url_string(url="/graphql", **url_params): - return f"{url}?{urlencode(url_params)}" if url_params else url - - -class Client: - def __init__(self, **kwargs): - self.schema = kwargs.pop("schema", None) or Schema - self.settings = kwargs.pop("settings", None) or {} - - def get(self, url, **extra): - request = Request.blank(url, method="GET", **extra) - context = self.settings.pop("context", request) - response = GraphQLView( - request=request, schema=self.schema, context=context, **self.settings - ) - return response.dispatch_request(request) - - def post(self, url, **extra): - extra["POST"] = extra.pop("data") - request = Request.blank(url, method="POST", **extra) - context = self.settings.pop("context", request) - response = GraphQLView( - request=request, schema=self.schema, context=context, **self.settings - ) - return response.dispatch_request(request) - - def put(self, url, **extra): - request = Request.blank(url, method="PUT", **extra) - context = self.settings.pop("context", request) - response = GraphQLView( - request=request, schema=self.schema, context=context, **self.settings - ) - return response.dispatch_request(request) diff --git a/tests/webob/conftest.py b/tests/webob/conftest.py deleted file mode 100644 index 12897d1..0000000 --- a/tests/webob/conftest.py +++ /dev/null @@ -1,13 +0,0 @@ -import pytest - -from .app import Client - - -@pytest.fixture -def settings(): - return {} - - -@pytest.fixture -def client(settings): - return Client(settings=settings) diff --git a/tests/webob/schema.py b/tests/webob/schema.py deleted file mode 100644 index e94f596..0000000 --- a/tests/webob/schema.py +++ /dev/null @@ -1,55 +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_raises(*_): - raise Exception("Throws!") - - -# Sync schema -QueryRootType = GraphQLObjectType( - name="QueryRoot", - fields={ - "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_raises), - "request": GraphQLField( - GraphQLNonNull(GraphQLString), - resolve=lambda obj, info: info.context["request"].params.get("q"), - ), - "context": GraphQLField( - GraphQLObjectType( - name="context", - fields={ - "session": GraphQLField(GraphQLString), - "request": GraphQLField( - GraphQLNonNull(GraphQLString), - resolve=lambda obj, info: info.context["request"], - ), - "property": GraphQLField( - GraphQLString, resolve=lambda obj, info: info.context.property - ), - }, - ), - resolve=lambda obj, info: info.context, - ), - "test": GraphQLField( - type_=GraphQLString, - args={"who": GraphQLArgument(GraphQLString)}, - resolve=lambda obj, info, who=None: "Hello %s" % (who or "World"), - ), - }, -) - -MutationRootType = GraphQLObjectType( - name="MutationRoot", - fields={ - "writeTest": GraphQLField(type_=QueryRootType, resolve=lambda *_: QueryRootType) - }, -) - -Schema = GraphQLSchema(QueryRootType, MutationRootType) diff --git a/tests/webob/test_graphiqlview.py b/tests/webob/test_graphiqlview.py deleted file mode 100644 index 3e6ead2..0000000 --- a/tests/webob/test_graphiqlview.py +++ /dev/null @@ -1,36 +0,0 @@ -import pytest -from jinja2 import Environment - -from .app import url_string - - -@pytest.mark.parametrize( - "settings", [{"graphiql": True}, {"graphiql": True, "jinja_env": Environment()}] -) -def test_graphiql_is_enabled(client): - response = client.get(url_string(query="{test}"), headers={"Accept": "text/html"}) - assert response.status_code == 200 - - -@pytest.mark.parametrize( - "settings", [{"graphiql": True}, {"graphiql": True, "jinja_env": Environment()}] -) -def test_graphiql_simple_renderer(client): - response = client.get(url_string(query="{test}"), headers={"Accept": "text/html"}) - assert response.status_code == 200 - pretty_response = ( - "{\n" - ' "data": {\n' - ' "test": "Hello World"\n' - " }\n" - "}".replace('"', '\\"').replace("\n", "\\n") - ) # fmt: skip - assert pretty_response in response.body.decode("utf-8") - - -@pytest.mark.parametrize( - "settings", [{"graphiql": True}, {"graphiql": True, "jinja_env": Environment()}] -) -def test_graphiql_html_is_not_accepted(client): - response = client.get(url_string(), headers={"Accept": "application/json"}) - assert response.status_code == 400 diff --git a/tests/webob/test_graphqlview.py b/tests/webob/test_graphqlview.py deleted file mode 100644 index 5acacd3..0000000 --- a/tests/webob/test_graphqlview.py +++ /dev/null @@ -1,587 +0,0 @@ -import json -from urllib.parse import urlencode - -import pytest - -from ..utils import RepeatExecutionContext -from .app import url_string - - -def response_json(response): - return json.loads(response.body.decode()) - - -def json_dump_kwarg(**kwargs): - return json.dumps(kwargs) - - -def json_dump_kwarg_list(**kwargs): - return json.dumps([kwargs]) - - -def test_allows_get_with_query_param(client): - response = client.get(url_string(query="{test}")) - assert response.status_code == 200, response.status - assert response_json(response) == {"data": {"test": "Hello World"}} - - -def test_allows_get_with_variable_values(client): - response = client.get( - url_string( - query="query helloWho($who: String){ test(who: $who) }", - variables=json.dumps({"who": "Dolly"}), - ) - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello Dolly"}} - - -def test_allows_get_with_operation_name(client): - response = client.get( - url_string( - 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 response.status_code == 200 - assert response_json(response) == { - "data": {"test": "Hello World", "shared": "Hello Everyone"} - } - - -def test_reports_validation_errors(client): - response = client.get(url_string(query="{ test, unknownOne, unknownTwo }")) - - assert response.status_code == 400 - assert response_json(response) == { - "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_errors_when_missing_operation_name(client): - response = client.get( - url_string( - query=""" - query TestQuery { test } - mutation TestMutation { writeTest { test } } - """ - ) - ) - - assert response.status_code == 400 - assert response_json(response) == { - "errors": [ - { - "message": "Must provide operation name" - " if query contains multiple operations.", - } - ] - } - - -def test_errors_when_sending_a_mutation_via_get(client): - response = client.get( - url_string( - query=""" - mutation TestMutation { writeTest { test } } - """ - ) - ) - assert response.status_code == 405 - assert response_json(response) == { - "errors": [ - { - "message": "Can only perform a mutation operation from a POST request.", - } - ] - } - - -def test_errors_when_selecting_a_mutation_within_a_get(client): - response = client.get( - url_string( - query=""" - query TestQuery { test } - mutation TestMutation { writeTest { test } } - """, - operationName="TestMutation", - ) - ) - - assert response.status_code == 405 - assert response_json(response) == { - "errors": [ - { - "message": "Can only perform a mutation operation from a POST request.", - } - ] - } - - -def test_allows_mutation_to_exist_within_a_get(client): - response = client.get( - url_string( - query=""" - query TestQuery { test } - mutation TestMutation { writeTest { test } } - """, - operationName="TestQuery", - ) - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello World"}} - - -def test_allows_post_with_json_encoding(client): - response = client.post( - url_string(), - data=json_dump_kwarg(query="{test}"), - content_type="application/json", - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello World"}} - - -def test_allows_sending_a_mutation_via_post(client): - response = client.post( - url_string(), - data=json_dump_kwarg(query="mutation TestMutation { writeTest { test } }"), - content_type="application/json", - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} - - -def test_allows_post_with_url_encoding(client): - response = client.post( - url_string(), - data=urlencode({"query": "{test}"}), - content_type="application/x-www-form-urlencoded", - ) - - # assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello World"}} - - -def test_supports_post_json_query_with_string_variables(client): - response = client.post( - url_string(), - data=json_dump_kwarg( - query="query helloWho($who: String){ test(who: $who) }", - variables=json.dumps({"who": "Dolly"}), - ), - content_type="application/json", - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello Dolly"}} - - -def test_supports_post_json_query_with_json_variables(client): - response = client.post( - url_string(), - data=json_dump_kwarg( - query="query helloWho($who: String){ test(who: $who) }", - variables={"who": "Dolly"}, - ), - content_type="application/json", - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello Dolly"}} - - -def test_supports_post_url_encoded_query_with_string_variables(client): - response = client.post( - url_string(), - data=urlencode( - { - "query": "query helloWho($who: String){ test(who: $who) }", - "variables": json.dumps({"who": "Dolly"}), - } - ), - content_type="application/x-www-form-urlencoded", - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello Dolly"}} - - -def test_supports_post_json_quey_with_get_variable_values(client): - response = client.post( - url_string(variables=json.dumps({"who": "Dolly"})), - data=json_dump_kwarg( - query="query helloWho($who: String){ test(who: $who) }", - ), - content_type="application/json", - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello Dolly"}} - - -def test_post_url_encoded_query_with_get_variable_values(client): - response = client.post( - url_string(variables=json.dumps({"who": "Dolly"})), - data=urlencode( - { - "query": "query helloWho($who: String){ test(who: $who) }", - } - ), - content_type="application/x-www-form-urlencoded", - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello Dolly"}} - - -def test_supports_post_raw_text_query_with_get_variable_values(client): - response = client.post( - url_string(variables=json.dumps({"who": "Dolly"})), - data="query helloWho($who: String){ test(who: $who) }", - content_type="application/graphql", - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello Dolly"}} - - -def test_allows_post_with_operation_name(client): - response = client.post( - url_string(), - data=json_dump_kwarg( - 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", - ), - content_type="application/json", - ) - - assert response.status_code == 200 - assert response_json(response) == { - "data": {"test": "Hello World", "shared": "Hello Everyone"} - } - - -def test_allows_post_with_get_operation_name(client): - response = client.post( - url_string(operationName="helloWorld"), - data=""" - 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") - } - """, - content_type="application/graphql", - ) - - assert response.status_code == 200 - assert response_json(response) == { - "data": {"test": "Hello World", "shared": "Hello Everyone"} - } - - -@pytest.mark.parametrize("settings", [{"pretty": True}]) -def test_supports_pretty_printing(client, settings): - response = client.get(url_string(query="{test}")) - - assert response.body.decode() == ( - "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" - ) - - -@pytest.mark.parametrize("settings", [{"pretty": False}]) -def test_not_pretty_by_default(client, settings): - response = client.get(url_string(query="{test}")) - - assert response.body.decode() == '{"data":{"test":"Hello World"}}' - - -def test_supports_pretty_printing_by_request(client): - response = client.get(url_string(query="{test}", pretty="1")) - - assert response.body.decode() == ( - "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" - ) - - -def test_handles_field_errors_caught_by_graphql(client): - response = client.get(url_string(query="{thrower}")) - assert response.status_code == 200 - assert response_json(response) == { - "data": None, - "errors": [ - { - "message": "Throws!", - "locations": [{"column": 2, "line": 1}], - "path": ["thrower"], - } - ], - } - - -def test_handles_syntax_errors_caught_by_graphql(client): - response = client.get(url_string(query="syntaxerror")) - assert response.status_code == 400 - assert response_json(response) == { - "errors": [ - { - "message": "Syntax Error: Unexpected Name 'syntaxerror'.", - "locations": [{"column": 1, "line": 1}], - } - ] - } - - -def test_handles_errors_caused_by_a_lack_of_query(client): - response = client.get(url_string()) - - assert response.status_code == 400 - assert response_json(response) == { - "errors": [{"message": "Must provide query string."}] - } - - -def test_handles_batch_correctly_if_is_disabled(client): - response = client.post(url_string(), data="[]", content_type="application/json") - - assert response.status_code == 400 - assert response_json(response) == { - "errors": [ - { - "message": "Batch GraphQL requests are not enabled.", - } - ] - } - - -def test_handles_incomplete_json_bodies(client): - response = client.post( - url_string(), data='{"query":', content_type="application/json" - ) - - assert response.status_code == 400 - assert response_json(response) == { - "errors": [{"message": "POST body sent invalid JSON."}] - } - - -def test_handles_plain_post_text(client): - response = client.post( - url_string(variables=json.dumps({"who": "Dolly"})), - data="query helloWho($who: String){ test(who: $who) }", - content_type="text/plain", - ) - assert response.status_code == 400 - assert response_json(response) == { - "errors": [{"message": "Must provide query string."}] - } - - -def test_handles_poorly_formed_variables(client): - response = client.get( - url_string( - query="query helloWho($who: String){ test(who: $who) }", variables="who:You" - ) - ) - assert response.status_code == 400 - assert response_json(response) == { - "errors": [{"message": "Variables are invalid JSON."}] - } - - -def test_handles_unsupported_http_methods(client): - response = client.put(url_string(query="{test}")) - assert response.status_code == 405 - assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] - assert response_json(response) == { - "errors": [{"message": "GraphQL only supports GET and POST requests."}] - } - - -def test_passes_request_into_request_context(client): - response = client.get(url_string(query="{request}", q="testing")) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"request": "testing"}} - - -@pytest.mark.parametrize("settings", [{"context": {"session": "CUSTOM CONTEXT"}}]) -def test_passes_custom_context_into_context(client, settings): - response = client.get(url_string(query="{context { session request }}")) - - assert response.status_code == 200 - res = response_json(response) - assert "data" in res - assert "session" in res["data"]["context"] - assert "request" in res["data"]["context"] - assert "CUSTOM CONTEXT" in res["data"]["context"]["session"] - assert "request" in res["data"]["context"]["request"] - - -@pytest.mark.parametrize("settings", [{"context": "CUSTOM CONTEXT"}]) -def test_context_remapped_if_not_mapping(client, settings): - response = client.get(url_string(query="{context { session request }}")) - - assert response.status_code == 200 - res = response_json(response) - assert "data" in res - assert "session" in res["data"]["context"] - assert "request" in res["data"]["context"] - assert "CUSTOM CONTEXT" not in res["data"]["context"]["request"] - assert "request" in res["data"]["context"]["request"] - - -class CustomContext(dict): - property = "A custom property" - - -@pytest.mark.parametrize("settings", [{"context": CustomContext()}]) -def test_allow_empty_custom_context(client, settings): - response = client.get(url_string(query="{context { property request }}")) - - assert response.status_code == 200 - res = response_json(response) - assert "data" in res - assert "request" in res["data"]["context"] - assert "property" in res["data"]["context"] - assert "A custom property" == res["data"]["context"]["property"] - assert "request" in res["data"]["context"]["request"] - - -def test_post_multipart_data(client): - query = "mutation TestMutation { writeTest { test } }" - data = ( - "------webobgraphql\r\n" - + 'Content-Disposition: form-data; name="query"\r\n' - + "\r\n" - + query - + "\r\n" - + "------webobgraphql--\r\n" - + "Content-Type: text/plain; charset=utf-8\r\n" - + 'Content-Disposition: form-data; name="file"; filename="text1.txt"; filename*=utf-8\'\'text1.txt\r\n' - + "\r\n" - + "\r\n" - + "------webobgraphql--\r\n" - ) - - response = client.post( - url_string(), - data=data, - content_type="multipart/form-data; boundary=----webobgraphql", - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} - - -@pytest.mark.parametrize("settings", [{"batch": True}]) -def test_batch_allows_post_with_json_encoding(client, settings): - response = client.post( - url_string(), - data=json_dump_kwarg_list( - # id=1, - query="{test}" - ), - content_type="application/json", - ) - - assert response.status_code == 200 - assert response_json(response) == [ - { - # 'id': 1, - "data": {"test": "Hello World"} - } - ] - - -@pytest.mark.parametrize("settings", [{"batch": True}]) -def test_batch_supports_post_json_query_with_json_variables(client, settings): - response = client.post( - url_string(), - data=json_dump_kwarg_list( - # id=1, - query="query helloWho($who: String){ test(who: $who) }", - variables={"who": "Dolly"}, - ), - content_type="application/json", - ) - - assert response.status_code == 200 - assert response_json(response) == [ - { - # 'id': 1, - "data": {"test": "Hello Dolly"} - } - ] - - -@pytest.mark.parametrize("settings", [{"batch": True}]) -def test_batch_allows_post_with_operation_name(client, settings): - response = client.post( - url_string(), - data=json_dump_kwarg_list( - # id=1, - 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", - ), - content_type="application/json", - ) - - assert response.status_code == 200 - assert response_json(response) == [ - { - # 'id': 1, - "data": {"test": "Hello World", "shared": "Hello Everyone"} - } - ] - - -@pytest.mark.parametrize( - "settings", [{"execution_context_class": RepeatExecutionContext}] -) -def test_custom_execution_context_class(client): - response = client.post( - url_string(), - data=json_dump_kwarg(query="{test}"), - content_type="application/json", - ) - - assert response.status_code == 200 - assert response_json(response) == {"data": {"test": "Hello WorldHello World"}} diff --git a/tox.ini b/tox.ini deleted file mode 100644 index b44f66b..0000000 --- a/tox.ini +++ /dev/null @@ -1,38 +0,0 @@ -[tox] -envlist = - pre-commit,mypy, - py{38,39,310,311} -; requires = tox-conda - -[gh-actions] -python = - 3.8: py38 - 3.9: py39 - 3.10: py310 - 3.11: py311 - -[testenv] -conda_channels = conda-forge -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 - py{38,39,310}: pytest tests {posargs} - py{311}: pytest tests --cov-report=term-missing --cov=graphql_server {posargs} - -[testenv:pre-commit] -skip_install = true -deps = pre-commit -commands = - pre-commit run --all-files --show-diff-on-failure {posargs} - -[testenv:mypy] -basepython = python3.11 -deps = -e.[dev] -commands = - mypy graphql_server tests --ignore-missing-imports diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..c2cfff5 --- /dev/null +++ b/uv.lock @@ -0,0 +1,4670 @@ +version = 1 +revision = 2 +requires-python = ">=3.9, <4.0" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version >= '3.10' and python_full_version < '3.13'", + "python_full_version < '3.10'", +] + +[[package]] +name = "aiofiles" +version = "24.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.12.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/6e/ab88e7cb2a4058bed2f7870276454f85a7c56cd6da79349eb314fc7bbcaa/aiohttp-3.12.13.tar.gz", hash = "sha256:47e2da578528264a12e4e3dd8dd72a7289e5f812758fe086473fab037a10fcce", size = 7819160, upload-time = "2025-06-14T15:15:41.354Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/2d/27e4347660723738b01daa3f5769d56170f232bf4695dd4613340da135bb/aiohttp-3.12.13-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5421af8f22a98f640261ee48aae3a37f0c41371e99412d55eaf2f8a46d5dad29", size = 702090, upload-time = "2025-06-14T15:12:58.938Z" }, + { url = "https://files.pythonhosted.org/packages/10/0b/4a8e0468ee8f2b9aff3c05f2c3a6be1dfc40b03f68a91b31041d798a9510/aiohttp-3.12.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fcda86f6cb318ba36ed8f1396a6a4a3fd8f856f84d426584392083d10da4de0", size = 478440, upload-time = "2025-06-14T15:13:02.981Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/2086df2f9a842b13feb92d071edf756be89250f404f10966b7bc28317f17/aiohttp-3.12.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cd71c9fb92aceb5a23c4c39d8ecc80389c178eba9feab77f19274843eb9412d", size = 466215, upload-time = "2025-06-14T15:13:04.817Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3d/d23e5bd978bc8012a65853959b13bd3b55c6e5afc172d89c26ad6624c52b/aiohttp-3.12.13-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34ebf1aca12845066c963016655dac897651e1544f22a34c9b461ac3b4b1d3aa", size = 1648271, upload-time = "2025-06-14T15:13:06.532Z" }, + { url = "https://files.pythonhosted.org/packages/31/31/e00122447bb137591c202786062f26dd383574c9f5157144127077d5733e/aiohttp-3.12.13-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:893a4639694c5b7edd4bdd8141be296042b6806e27cc1d794e585c43010cc294", size = 1622329, upload-time = "2025-06-14T15:13:08.394Z" }, + { url = "https://files.pythonhosted.org/packages/04/01/caef70be3ac38986969045f21f5fb802ce517b3f371f0615206bf8aa6423/aiohttp-3.12.13-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:663d8ee3ffb3494502ebcccb49078faddbb84c1d870f9c1dd5a29e85d1f747ce", size = 1694734, upload-time = "2025-06-14T15:13:09.979Z" }, + { url = "https://files.pythonhosted.org/packages/3f/15/328b71fedecf69a9fd2306549b11c8966e420648a3938d75d3ed5bcb47f6/aiohttp-3.12.13-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0f8f6a85a0006ae2709aa4ce05749ba2cdcb4b43d6c21a16c8517c16593aabe", size = 1737049, upload-time = "2025-06-14T15:13:11.672Z" }, + { url = "https://files.pythonhosted.org/packages/e6/7a/d85866a642158e1147c7da5f93ad66b07e5452a84ec4258e5f06b9071e92/aiohttp-3.12.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1582745eb63df267c92d8b61ca655a0ce62105ef62542c00a74590f306be8cb5", size = 1641715, upload-time = "2025-06-14T15:13:13.548Z" }, + { url = "https://files.pythonhosted.org/packages/14/57/3588800d5d2f5f3e1cb6e7a72747d1abc1e67ba5048e8b845183259c2e9b/aiohttp-3.12.13-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d59227776ee2aa64226f7e086638baa645f4b044f2947dbf85c76ab11dcba073", size = 1581836, upload-time = "2025-06-14T15:13:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/2f/55/c913332899a916d85781aa74572f60fd98127449b156ad9c19e23135b0e4/aiohttp-3.12.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06b07c418bde1c8e737d8fa67741072bd3f5b0fb66cf8c0655172188c17e5fa6", size = 1625685, upload-time = "2025-06-14T15:13:17.163Z" }, + { url = "https://files.pythonhosted.org/packages/4c/34/26cded195f3bff128d6a6d58d7a0be2ae7d001ea029e0fe9008dcdc6a009/aiohttp-3.12.13-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:9445c1842680efac0f81d272fd8db7163acfcc2b1436e3f420f4c9a9c5a50795", size = 1636471, upload-time = "2025-06-14T15:13:19.086Z" }, + { url = "https://files.pythonhosted.org/packages/19/21/70629ca006820fccbcec07f3cd5966cbd966e2d853d6da55339af85555b9/aiohttp-3.12.13-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:09c4767af0b0b98c724f5d47f2bf33395c8986995b0a9dab0575ca81a554a8c0", size = 1611923, upload-time = "2025-06-14T15:13:20.997Z" }, + { url = "https://files.pythonhosted.org/packages/31/80/7fa3f3bebf533aa6ae6508b51ac0de9965e88f9654fa679cc1a29d335a79/aiohttp-3.12.13-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f3854fbde7a465318ad8d3fc5bef8f059e6d0a87e71a0d3360bb56c0bf87b18a", size = 1691511, upload-time = "2025-06-14T15:13:22.54Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7a/359974653a3cdd3e9cee8ca10072a662c3c0eb46a359c6a1f667b0296e2f/aiohttp-3.12.13-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2332b4c361c05ecd381edb99e2a33733f3db906739a83a483974b3df70a51b40", size = 1714751, upload-time = "2025-06-14T15:13:24.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/24/0aa03d522171ce19064347afeefadb008be31ace0bbb7d44ceb055700a14/aiohttp-3.12.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1561db63fa1b658cd94325d303933553ea7d89ae09ff21cc3bcd41b8521fbbb6", size = 1643090, upload-time = "2025-06-14T15:13:26.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/7d4b0026a41e4b467e143221c51b279083b7044a4b104054f5c6464082ff/aiohttp-3.12.13-cp310-cp310-win32.whl", hash = "sha256:a0be857f0b35177ba09d7c472825d1b711d11c6d0e8a2052804e3b93166de1ad", size = 427526, upload-time = "2025-06-14T15:13:27.988Z" }, + { url = "https://files.pythonhosted.org/packages/17/de/34d998da1e7f0de86382160d039131e9b0af1962eebfe53dda2b61d250e7/aiohttp-3.12.13-cp310-cp310-win_amd64.whl", hash = "sha256:fcc30ad4fb5cb41a33953292d45f54ef4066746d625992aeac33b8c681173178", size = 450734, upload-time = "2025-06-14T15:13:29.394Z" }, + { url = "https://files.pythonhosted.org/packages/6a/65/5566b49553bf20ffed6041c665a5504fb047cefdef1b701407b8ce1a47c4/aiohttp-3.12.13-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7c229b1437aa2576b99384e4be668af1db84b31a45305d02f61f5497cfa6f60c", size = 709401, upload-time = "2025-06-14T15:13:30.774Z" }, + { url = "https://files.pythonhosted.org/packages/14/b5/48e4cc61b54850bdfafa8fe0b641ab35ad53d8e5a65ab22b310e0902fa42/aiohttp-3.12.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04076d8c63471e51e3689c93940775dc3d12d855c0c80d18ac5a1c68f0904358", size = 481669, upload-time = "2025-06-14T15:13:32.316Z" }, + { url = "https://files.pythonhosted.org/packages/04/4f/e3f95c8b2a20a0437d51d41d5ccc4a02970d8ad59352efb43ea2841bd08e/aiohttp-3.12.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55683615813ce3601640cfaa1041174dc956d28ba0511c8cbd75273eb0587014", size = 469933, upload-time = "2025-06-14T15:13:34.104Z" }, + { url = "https://files.pythonhosted.org/packages/41/c9/c5269f3b6453b1cfbd2cfbb6a777d718c5f086a3727f576c51a468b03ae2/aiohttp-3.12.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:921bc91e602d7506d37643e77819cb0b840d4ebb5f8d6408423af3d3bf79a7b7", size = 1740128, upload-time = "2025-06-14T15:13:35.604Z" }, + { url = "https://files.pythonhosted.org/packages/6f/49/a3f76caa62773d33d0cfaa842bdf5789a78749dbfe697df38ab1badff369/aiohttp-3.12.13-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e72d17fe0974ddeae8ed86db297e23dba39c7ac36d84acdbb53df2e18505a013", size = 1688796, upload-time = "2025-06-14T15:13:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e4/556fccc4576dc22bf18554b64cc873b1a3e5429a5bdb7bbef7f5d0bc7664/aiohttp-3.12.13-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0653d15587909a52e024a261943cf1c5bdc69acb71f411b0dd5966d065a51a47", size = 1787589, upload-time = "2025-06-14T15:13:38.745Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3d/d81b13ed48e1a46734f848e26d55a7391708421a80336e341d2aef3b6db2/aiohttp-3.12.13-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a77b48997c66722c65e157c06c74332cdf9c7ad00494b85ec43f324e5c5a9b9a", size = 1826635, upload-time = "2025-06-14T15:13:40.733Z" }, + { url = "https://files.pythonhosted.org/packages/75/a5/472e25f347da88459188cdaadd1f108f6292f8a25e62d226e63f860486d1/aiohttp-3.12.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6946bae55fd36cfb8e4092c921075cde029c71c7cb571d72f1079d1e4e013bc", size = 1729095, upload-time = "2025-06-14T15:13:42.312Z" }, + { url = "https://files.pythonhosted.org/packages/b9/fe/322a78b9ac1725bfc59dfc301a5342e73d817592828e4445bd8f4ff83489/aiohttp-3.12.13-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f95db8c8b219bcf294a53742c7bda49b80ceb9d577c8e7aa075612b7f39ffb7", size = 1666170, upload-time = "2025-06-14T15:13:44.884Z" }, + { url = "https://files.pythonhosted.org/packages/7a/77/ec80912270e231d5e3839dbd6c065472b9920a159ec8a1895cf868c2708e/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:03d5eb3cfb4949ab4c74822fb3326cd9655c2b9fe22e4257e2100d44215b2e2b", size = 1714444, upload-time = "2025-06-14T15:13:46.401Z" }, + { url = "https://files.pythonhosted.org/packages/21/b2/fb5aedbcb2b58d4180e58500e7c23ff8593258c27c089abfbcc7db65bd40/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6383dd0ffa15515283c26cbf41ac8e6705aab54b4cbb77bdb8935a713a89bee9", size = 1709604, upload-time = "2025-06-14T15:13:48.377Z" }, + { url = "https://files.pythonhosted.org/packages/e3/15/a94c05f7c4dc8904f80b6001ad6e07e035c58a8ebfcc15e6b5d58500c858/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6548a411bc8219b45ba2577716493aa63b12803d1e5dc70508c539d0db8dbf5a", size = 1689786, upload-time = "2025-06-14T15:13:50.401Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fd/0d2e618388f7a7a4441eed578b626bda9ec6b5361cd2954cfc5ab39aa170/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:81b0fcbfe59a4ca41dc8f635c2a4a71e63f75168cc91026c61be665945739e2d", size = 1783389, upload-time = "2025-06-14T15:13:51.945Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6b/6986d0c75996ef7e64ff7619b9b7449b1d1cbbe05c6755e65d92f1784fe9/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6a83797a0174e7995e5edce9dcecc517c642eb43bc3cba296d4512edf346eee2", size = 1803853, upload-time = "2025-06-14T15:13:53.533Z" }, + { url = "https://files.pythonhosted.org/packages/21/65/cd37b38f6655d95dd07d496b6d2f3924f579c43fd64b0e32b547b9c24df5/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5734d8469a5633a4e9ffdf9983ff7cdb512524645c7a3d4bc8a3de45b935ac3", size = 1716909, upload-time = "2025-06-14T15:13:55.148Z" }, + { url = "https://files.pythonhosted.org/packages/fd/20/2de7012427dc116714c38ca564467f6143aec3d5eca3768848d62aa43e62/aiohttp-3.12.13-cp311-cp311-win32.whl", hash = "sha256:fef8d50dfa482925bb6b4c208b40d8e9fa54cecba923dc65b825a72eed9a5dbd", size = 427036, upload-time = "2025-06-14T15:13:57.076Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b6/98518bcc615ef998a64bef371178b9afc98ee25895b4f476c428fade2220/aiohttp-3.12.13-cp311-cp311-win_amd64.whl", hash = "sha256:9a27da9c3b5ed9d04c36ad2df65b38a96a37e9cfba6f1381b842d05d98e6afe9", size = 451427, upload-time = "2025-06-14T15:13:58.505Z" }, + { url = "https://files.pythonhosted.org/packages/b4/6a/ce40e329788013cd190b1d62bbabb2b6a9673ecb6d836298635b939562ef/aiohttp-3.12.13-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0aa580cf80558557285b49452151b9c69f2fa3ad94c5c9e76e684719a8791b73", size = 700491, upload-time = "2025-06-14T15:14:00.048Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/7150d5cf9163e05081f1c5c64a0cdf3c32d2f56e2ac95db2a28fe90eca69/aiohttp-3.12.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b103a7e414b57e6939cc4dece8e282cfb22043efd0c7298044f6594cf83ab347", size = 475104, upload-time = "2025-06-14T15:14:01.691Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/d42ba4aed039ce6e449b3e2db694328756c152a79804e64e3da5bc19dffc/aiohttp-3.12.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f64e748e9e741d2eccff9597d09fb3cd962210e5b5716047cbb646dc8fe06f", size = 467948, upload-time = "2025-06-14T15:14:03.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/3b/06f0a632775946981d7c4e5a865cddb6e8dfdbaed2f56f9ade7bb4a1039b/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c955989bf4c696d2ededc6b0ccb85a73623ae6e112439398935362bacfaaf6", size = 1714742, upload-time = "2025-06-14T15:14:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/92/a6/2552eebad9ec5e3581a89256276009e6a974dc0793632796af144df8b740/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d640191016763fab76072c87d8854a19e8e65d7a6fcfcbf017926bdbbb30a7e5", size = 1697393, upload-time = "2025-06-14T15:14:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/d8/9f/bd08fdde114b3fec7a021381b537b21920cdd2aa29ad48c5dffd8ee314f1/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4dc507481266b410dede95dd9f26c8d6f5a14315372cc48a6e43eac652237d9b", size = 1752486, upload-time = "2025-06-14T15:14:08.808Z" }, + { url = "https://files.pythonhosted.org/packages/f7/e1/affdea8723aec5bd0959171b5490dccd9a91fcc505c8c26c9f1dca73474d/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8a94daa873465d518db073bd95d75f14302e0208a08e8c942b2f3f1c07288a75", size = 1798643, upload-time = "2025-06-14T15:14:10.767Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9d/666d856cc3af3a62ae86393baa3074cc1d591a47d89dc3bf16f6eb2c8d32/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f52420cde4ce0bb9425a375d95577fe082cb5721ecb61da3049b55189e4e6", size = 1718082, upload-time = "2025-06-14T15:14:12.38Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ce/3c185293843d17be063dada45efd2712bb6bf6370b37104b4eda908ffdbd/aiohttp-3.12.13-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f7df1f620ec40f1a7fbcb99ea17d7326ea6996715e78f71a1c9a021e31b96b8", size = 1633884, upload-time = "2025-06-14T15:14:14.415Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5b/f3413f4b238113be35dfd6794e65029250d4b93caa0974ca572217745bdb/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3062d4ad53b36e17796dce1c0d6da0ad27a015c321e663657ba1cc7659cfc710", size = 1694943, upload-time = "2025-06-14T15:14:16.48Z" }, + { url = "https://files.pythonhosted.org/packages/82/c8/0e56e8bf12081faca85d14a6929ad5c1263c146149cd66caa7bc12255b6d/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8605e22d2a86b8e51ffb5253d9045ea73683d92d47c0b1438e11a359bdb94462", size = 1716398, upload-time = "2025-06-14T15:14:18.589Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f3/33192b4761f7f9b2f7f4281365d925d663629cfaea093a64b658b94fc8e1/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:54fbbe6beafc2820de71ece2198458a711e224e116efefa01b7969f3e2b3ddae", size = 1657051, upload-time = "2025-06-14T15:14:20.223Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0b/26ddd91ca8f84c48452431cb4c5dd9523b13bc0c9766bda468e072ac9e29/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:050bd277dfc3768b606fd4eae79dd58ceda67d8b0b3c565656a89ae34525d15e", size = 1736611, upload-time = "2025-06-14T15:14:21.988Z" }, + { url = "https://files.pythonhosted.org/packages/c3/8d/e04569aae853302648e2c138a680a6a2f02e374c5b6711732b29f1e129cc/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2637a60910b58f50f22379b6797466c3aa6ae28a6ab6404e09175ce4955b4e6a", size = 1764586, upload-time = "2025-06-14T15:14:23.979Z" }, + { url = "https://files.pythonhosted.org/packages/ac/98/c193c1d1198571d988454e4ed75adc21c55af247a9fda08236602921c8c8/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e986067357550d1aaa21cfe9897fa19e680110551518a5a7cf44e6c5638cb8b5", size = 1724197, upload-time = "2025-06-14T15:14:25.692Z" }, + { url = "https://files.pythonhosted.org/packages/e7/9e/07bb8aa11eec762c6b1ff61575eeeb2657df11ab3d3abfa528d95f3e9337/aiohttp-3.12.13-cp312-cp312-win32.whl", hash = "sha256:ac941a80aeea2aaae2875c9500861a3ba356f9ff17b9cb2dbfb5cbf91baaf5bf", size = 421771, upload-time = "2025-06-14T15:14:27.364Z" }, + { url = "https://files.pythonhosted.org/packages/52/66/3ce877e56ec0813069cdc9607cd979575859c597b6fb9b4182c6d5f31886/aiohttp-3.12.13-cp312-cp312-win_amd64.whl", hash = "sha256:671f41e6146a749b6c81cb7fd07f5a8356d46febdaaaf07b0e774ff04830461e", size = 447869, upload-time = "2025-06-14T15:14:29.05Z" }, + { url = "https://files.pythonhosted.org/packages/11/0f/db19abdf2d86aa1deec3c1e0e5ea46a587b97c07a16516b6438428b3a3f8/aiohttp-3.12.13-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d4a18e61f271127465bdb0e8ff36e8f02ac4a32a80d8927aa52371e93cd87938", size = 694910, upload-time = "2025-06-14T15:14:30.604Z" }, + { url = "https://files.pythonhosted.org/packages/d5/81/0ab551e1b5d7f1339e2d6eb482456ccbe9025605b28eed2b1c0203aaaade/aiohttp-3.12.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:532542cb48691179455fab429cdb0d558b5e5290b033b87478f2aa6af5d20ace", size = 472566, upload-time = "2025-06-14T15:14:32.275Z" }, + { url = "https://files.pythonhosted.org/packages/34/3f/6b7d336663337672d29b1f82d1f252ec1a040fe2d548f709d3f90fa2218a/aiohttp-3.12.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d7eea18b52f23c050ae9db5d01f3d264ab08f09e7356d6f68e3f3ac2de9dfabb", size = 464856, upload-time = "2025-06-14T15:14:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/26/7f/32ca0f170496aa2ab9b812630fac0c2372c531b797e1deb3deb4cea904bd/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad7c8e5c25f2a26842a7c239de3f7b6bfb92304593ef997c04ac49fb703ff4d7", size = 1703683, upload-time = "2025-06-14T15:14:36.034Z" }, + { url = "https://files.pythonhosted.org/packages/ec/53/d5513624b33a811c0abea8461e30a732294112318276ce3dbf047dbd9d8b/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6af355b483e3fe9d7336d84539fef460120c2f6e50e06c658fe2907c69262d6b", size = 1684946, upload-time = "2025-06-14T15:14:38Z" }, + { url = "https://files.pythonhosted.org/packages/37/72/4c237dd127827b0247dc138d3ebd49c2ded6114c6991bbe969058575f25f/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a95cf9f097498f35c88e3609f55bb47b28a5ef67f6888f4390b3d73e2bac6177", size = 1737017, upload-time = "2025-06-14T15:14:39.951Z" }, + { url = "https://files.pythonhosted.org/packages/0d/67/8a7eb3afa01e9d0acc26e1ef847c1a9111f8b42b82955fcd9faeb84edeb4/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8ed8c38a1c584fe99a475a8f60eefc0b682ea413a84c6ce769bb19a7ff1c5ef", size = 1786390, upload-time = "2025-06-14T15:14:42.151Z" }, + { url = "https://files.pythonhosted.org/packages/48/19/0377df97dd0176ad23cd8cad4fd4232cfeadcec6c1b7f036315305c98e3f/aiohttp-3.12.13-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a0b9170d5d800126b5bc89d3053a2363406d6e327afb6afaeda2d19ee8bb103", size = 1708719, upload-time = "2025-06-14T15:14:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/61/97/ade1982a5c642b45f3622255173e40c3eed289c169f89d00eeac29a89906/aiohttp-3.12.13-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:372feeace612ef8eb41f05ae014a92121a512bd5067db8f25101dd88a8db11da", size = 1622424, upload-time = "2025-06-14T15:14:45.945Z" }, + { url = "https://files.pythonhosted.org/packages/99/ab/00ad3eea004e1d07ccc406e44cfe2b8da5acb72f8c66aeeb11a096798868/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a946d3702f7965d81f7af7ea8fb03bb33fe53d311df48a46eeca17e9e0beed2d", size = 1675447, upload-time = "2025-06-14T15:14:47.911Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fe/74e5ce8b2ccaba445fe0087abc201bfd7259431d92ae608f684fcac5d143/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a0c4725fae86555bbb1d4082129e21de7264f4ab14baf735278c974785cd2041", size = 1707110, upload-time = "2025-06-14T15:14:50.334Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c4/39af17807f694f7a267bd8ab1fbacf16ad66740862192a6c8abac2bff813/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b28ea2f708234f0a5c44eb6c7d9eb63a148ce3252ba0140d050b091b6e842d1", size = 1649706, upload-time = "2025-06-14T15:14:52.378Z" }, + { url = "https://files.pythonhosted.org/packages/38/e8/f5a0a5f44f19f171d8477059aa5f28a158d7d57fe1a46c553e231f698435/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d4f5becd2a5791829f79608c6f3dc745388162376f310eb9c142c985f9441cc1", size = 1725839, upload-time = "2025-06-14T15:14:54.617Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ac/81acc594c7f529ef4419d3866913f628cd4fa9cab17f7bf410a5c3c04c53/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:60f2ce6b944e97649051d5f5cc0f439360690b73909230e107fd45a359d3e911", size = 1759311, upload-time = "2025-06-14T15:14:56.597Z" }, + { url = "https://files.pythonhosted.org/packages/38/0d/aabe636bd25c6ab7b18825e5a97d40024da75152bec39aa6ac8b7a677630/aiohttp-3.12.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69fc1909857401b67bf599c793f2183fbc4804717388b0b888f27f9929aa41f3", size = 1708202, upload-time = "2025-06-14T15:14:58.598Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/561ef2d8a223261683fb95a6283ad0d36cb66c87503f3a7dde7afe208bb2/aiohttp-3.12.13-cp313-cp313-win32.whl", hash = "sha256:7d7e68787a2046b0e44ba5587aa723ce05d711e3a3665b6b7545328ac8e3c0dd", size = 420794, upload-time = "2025-06-14T15:15:00.939Z" }, + { url = "https://files.pythonhosted.org/packages/9d/47/b11d0089875a23bff0abd3edb5516bcd454db3fefab8604f5e4b07bd6210/aiohttp-3.12.13-cp313-cp313-win_amd64.whl", hash = "sha256:5a178390ca90419bfd41419a809688c368e63c86bd725e1186dd97f6b89c2706", size = 446735, upload-time = "2025-06-14T15:15:02.858Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/0f6b2b4797ac364b6ecc9176bb2dd24d4a9aeaa77ecb093c7f87e44dfbd6/aiohttp-3.12.13-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:36f6c973e003dc9b0bb4e8492a643641ea8ef0e97ff7aaa5c0f53d68839357b4", size = 704988, upload-time = "2025-06-14T15:15:04.705Z" }, + { url = "https://files.pythonhosted.org/packages/52/38/d51ea984c777b203959030895c1c8b1f9aac754f8e919e4942edce05958e/aiohttp-3.12.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6cbfc73179bd67c229eb171e2e3745d2afd5c711ccd1e40a68b90427f282eab1", size = 479967, upload-time = "2025-06-14T15:15:06.575Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0a/62f1c2914840eb2184939e773b65e1e5d6b651b78134798263467f0d2467/aiohttp-3.12.13-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1e8b27b2d414f7e3205aa23bb4a692e935ef877e3a71f40d1884f6e04fd7fa74", size = 467373, upload-time = "2025-06-14T15:15:08.788Z" }, + { url = "https://files.pythonhosted.org/packages/7b/4e/327a4b56bb940afb03ee45d5fd1ef7dae5ed6617889d61ed8abf0548310b/aiohttp-3.12.13-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eabded0c2b2ef56243289112c48556c395d70150ce4220d9008e6b4b3dd15690", size = 1642326, upload-time = "2025-06-14T15:15:10.74Z" }, + { url = "https://files.pythonhosted.org/packages/55/5d/f0277aad4d85a56cd6102335d5111c7c6d1f98cb760aa485e4fe11a24f52/aiohttp-3.12.13-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:003038e83f1a3ff97409999995ec02fe3008a1d675478949643281141f54751d", size = 1616820, upload-time = "2025-06-14T15:15:12.77Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ff/909193459a6d32ee806d9f7ae2342c940ee97d2c1416140c5aec3bd6bfc0/aiohttp-3.12.13-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b6f46613031dbc92bdcaad9c4c22c7209236ec501f9c0c5f5f0b6a689bf50f3", size = 1690448, upload-time = "2025-06-14T15:15:14.754Z" }, + { url = "https://files.pythonhosted.org/packages/45/e7/14d09183849e9bd69d8d5bf7df0ab7603996b83b00540e0890eeefa20e1e/aiohttp-3.12.13-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c332c6bb04650d59fb94ed96491f43812549a3ba6e7a16a218e612f99f04145e", size = 1729763, upload-time = "2025-06-14T15:15:16.783Z" }, + { url = "https://files.pythonhosted.org/packages/55/01/07b980d6226574cc2d157fa4978a3d77270a4e860193a579630a81b30e30/aiohttp-3.12.13-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3fea41a2c931fb582cb15dc86a3037329e7b941df52b487a9f8b5aa960153cbd", size = 1636002, upload-time = "2025-06-14T15:15:18.871Z" }, + { url = "https://files.pythonhosted.org/packages/73/cf/20a1f75ca3d8e48065412e80b79bb1c349e26a4fa51d660be186a9c0c1e3/aiohttp-3.12.13-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:846104f45d18fb390efd9b422b27d8f3cf8853f1218c537f36e71a385758c896", size = 1571003, upload-time = "2025-06-14T15:15:20.95Z" }, + { url = "https://files.pythonhosted.org/packages/e1/99/09520d83e5964d6267074be9c66698e2003dfe8c66465813f57b029dec8c/aiohttp-3.12.13-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d6c85ac7dd350f8da2520bac8205ce99df4435b399fa7f4dc4a70407073e390", size = 1618964, upload-time = "2025-06-14T15:15:23.155Z" }, + { url = "https://files.pythonhosted.org/packages/3a/01/c68f2c7632441fbbfc4a835e003e61eb1d63531857b0a2b73c9698846fa8/aiohttp-3.12.13-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5a1ecce0ed281bec7da8550da052a6b89552db14d0a0a45554156f085a912f48", size = 1629103, upload-time = "2025-06-14T15:15:25.209Z" }, + { url = "https://files.pythonhosted.org/packages/fb/fe/f9540bf12fa443d8870ecab70260c02140ed8b4c37884a2e1050bdd689a2/aiohttp-3.12.13-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:5304d74867028cca8f64f1cc1215eb365388033c5a691ea7aa6b0dc47412f495", size = 1605745, upload-time = "2025-06-14T15:15:27.604Z" }, + { url = "https://files.pythonhosted.org/packages/91/d7/526f1d16ca01e0c995887097b31e39c2e350dc20c1071e9b2dcf63a86fcd/aiohttp-3.12.13-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:64d1f24ee95a2d1e094a4cd7a9b7d34d08db1bbcb8aa9fb717046b0a884ac294", size = 1693348, upload-time = "2025-06-14T15:15:30.151Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0a/c103fdaab6fbde7c5f10450b5671dca32cea99800b1303ee8194a799bbb9/aiohttp-3.12.13-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:119c79922a7001ca6a9e253228eb39b793ea994fd2eccb79481c64b5f9d2a055", size = 1709023, upload-time = "2025-06-14T15:15:32.881Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bc/b8d14e754b5e0bf9ecf6df4b930f2cbd6eaaafcdc1b2f9271968747fb6e3/aiohttp-3.12.13-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:bb18f00396d22e2f10cd8825d671d9f9a3ba968d708a559c02a627536b36d91c", size = 1638691, upload-time = "2025-06-14T15:15:35.033Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7b/44b77bf4c48d95d81af5c57e79337d0d51350a85a84e9997a99a6205c441/aiohttp-3.12.13-cp39-cp39-win32.whl", hash = "sha256:0022de47ef63fd06b065d430ac79c6b0bd24cdae7feaf0e8c6bac23b805a23a8", size = 428365, upload-time = "2025-06-14T15:15:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/e5/cb/aaa022eb993e7d51928dc22d743ed17addb40142250e829701c5e6679615/aiohttp-3.12.13-cp39-cp39-win_amd64.whl", hash = "sha256:29e08111ccf81b2734ae03f1ad1cb03b9615e7d8f616764f22f71209c094f122", size = 451652, upload-time = "2025-06-14T15:15:39.079Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424, upload-time = "2024-12-13T17:10:40.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597, upload-time = "2024-12-13T17:10:38.469Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "ansicon" +version = "1.89.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/e2/1c866404ddbd280efedff4a9f15abfe943cb83cde6e895022370f3a61f85/ansicon-1.89.0.tar.gz", hash = "sha256:e4d039def5768a47e4afec8e89e83ec3ae5a26bf00ad851f914d1240b444d2b1", size = 67312, upload-time = "2019-04-29T20:23:57.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/f9/f1c10e223c7b56a38109a3f2eb4e7fe9a757ea3ed3a166754fb30f65e466/ansicon-1.89.0-py2.py3-none-any.whl", hash = "sha256:f1def52d17f65c2c9682cf8370c03f541f410c1752d6a14029f97318e4b9dfec", size = 63675, upload-time = "2019-04-29T20:23:53.83Z" }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, +] + +[[package]] +name = "argcomplete" +version = "3.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/0f/861e168fc813c56a78b35f3c30d91c6757d1fd185af1110f1aec784b35d0/argcomplete-3.6.2.tar.gz", hash = "sha256:d0519b1bc867f5f4f4713c41ad0aba73a4a5f007449716b16f385f2166dc6adf", size = 73403, upload-time = "2025-04-03T04:57:03.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/da/e42d7a9d8dd33fa775f467e4028a47936da2f01e4b0e561f9ba0d74cb0ca/argcomplete-3.6.2-py3-none-any.whl", hash = "sha256:65b3133a29ad53fb42c48cf5114752c7ab66c1c38544fdf6460f450c09b42591", size = 43708, upload-time = "2025-04-03T04:57:01.591Z" }, +] + +[[package]] +name = "asgiref" +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186, upload-time = "2024-03-22T14:39:36.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978, upload-time = "2024-11-30T04:30:14.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + +[[package]] +name = "autobahn" +version = "24.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "hyperlink" }, + { name = "setuptools" }, + { name = "txaio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/f2/8dffb3b709383ba5b47628b0cc4e43e8d12d59eecbddb62cfccac2e7cf6a/autobahn-24.4.2.tar.gz", hash = "sha256:a2d71ef1b0cf780b6d11f8b205fd2c7749765e65795f2ea7d823796642ee92c9", size = 482700, upload-time = "2024-08-02T09:26:48.241Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/ee/a6475f39ef6c6f41c33da6b193e0ffd2c6048f52e1698be6253c59301b72/autobahn-24.4.2-py2.py3-none-any.whl", hash = "sha256:c56a2abe7ac78abbfb778c02892d673a4de58fd004d088cd7ab297db25918e81", size = 666965, upload-time = "2024-08-02T09:26:44.274Z" }, +] + +[[package]] +name = "automat" +version = "25.4.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/0f/d40bbe294bbf004d436a8bcbcfaadca8b5140d39ad0ad3d73d1a8ba15f14/automat-25.4.16.tar.gz", hash = "sha256:0017591a5477066e90d26b0e696ddc143baafd87b588cfac8100bc6be9634de0", size = 129977, upload-time = "2025-04-16T20:12:16.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/ff/1175b0b7371e46244032d43a56862d0af455823b5280a50c63d99cc50f18/automat-25.4.16-py3-none-any.whl", hash = "sha256:04e9bce696a8d5671ee698005af6e5a9fa15354140a87f4870744604dcdd3ba1", size = 42842, upload-time = "2025-04-16T20:12:14.447Z" }, +] + +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "black" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449, upload-time = "2025-01-29T04:15:40.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/3b/4ba3f93ac8d90410423fdd31d7541ada9bcee1df32fb90d26de41ed40e1d/black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32", size = 1629419, upload-time = "2025-01-29T05:37:06.642Z" }, + { url = "https://files.pythonhosted.org/packages/b4/02/0bde0485146a8a5e694daed47561785e8b77a0466ccc1f3e485d5ef2925e/black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da", size = 1461080, upload-time = "2025-01-29T05:37:09.321Z" }, + { url = "https://files.pythonhosted.org/packages/52/0e/abdf75183c830eaca7589144ff96d49bce73d7ec6ad12ef62185cc0f79a2/black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7", size = 1766886, upload-time = "2025-01-29T04:18:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a6/97d8bb65b1d8a41f8a6736222ba0a334db7b7b77b8023ab4568288f23973/black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9", size = 1419404, upload-time = "2025-01-29T04:19:04.296Z" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/87f596aca05c3ce5b94b8663dbfe242a12843caaa82dd3f85f1ffdc3f177/black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", size = 1614372, upload-time = "2025-01-29T05:37:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d0/2c34c36190b741c59c901e56ab7f6e54dad8df05a6272a9747ecef7c6036/black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", size = 1442865, upload-time = "2025-01-29T05:37:14.309Z" }, + { url = "https://files.pythonhosted.org/packages/21/d4/7518c72262468430ead45cf22bd86c883a6448b9eb43672765d69a8f1248/black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", size = 1749699, upload-time = "2025-01-29T04:18:17.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/db/4f5beb989b547f79096e035c4981ceb36ac2b552d0ac5f2620e941501c99/black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", size = 1428028, upload-time = "2025-01-29T04:18:51.711Z" }, + { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988, upload-time = "2025-01-29T05:37:16.707Z" }, + { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985, upload-time = "2025-01-29T05:37:18.273Z" }, + { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816, upload-time = "2025-01-29T04:18:33.823Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860, upload-time = "2025-01-29T04:19:12.944Z" }, + { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673, upload-time = "2025-01-29T05:37:20.574Z" }, + { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190, upload-time = "2025-01-29T05:37:22.106Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926, upload-time = "2025-01-29T04:18:58.564Z" }, + { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613, upload-time = "2025-01-29T04:19:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b6/ae7507470a4830dbbfe875c701e84a4a5fb9183d1497834871a715716a92/black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0", size = 1628593, upload-time = "2025-01-29T05:37:23.672Z" }, + { url = "https://files.pythonhosted.org/packages/24/c1/ae36fa59a59f9363017ed397750a0cd79a470490860bc7713967d89cdd31/black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f", size = 1460000, upload-time = "2025-01-29T05:37:25.829Z" }, + { url = "https://files.pythonhosted.org/packages/ac/b6/98f832e7a6c49aa3a464760c67c7856363aa644f2f3c74cf7d624168607e/black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e", size = 1765963, upload-time = "2025-01-29T04:18:38.116Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e9/2cb0a017eb7024f70e0d2e9bdb8c5a5b078c5740c7f8816065d06f04c557/black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355", size = 1419419, upload-time = "2025-01-29T04:18:30.191Z" }, + { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload-time = "2025-01-29T04:15:38.082Z" }, +] + +[[package]] +name = "blessed" +version = "1.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinxed", marker = "sys_platform == 'win32'" }, + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/5e/3cada2f7514ee2a76bb8168c71f9b65d056840ebb711962e1ec08eeaa7b0/blessed-1.21.0.tar.gz", hash = "sha256:ece8bbc4758ab9176452f4e3a719d70088eb5739798cd5582c9e05f2a28337ec", size = 6660011, upload-time = "2025-04-26T21:56:58.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/8e/0a37e44878fd76fac9eff5355a1bf760701f53cb5c38cdcd59a8fd9ab2a2/blessed-1.21.0-py2.py3-none-any.whl", hash = "sha256:f831e847396f5a2eac6c106f4dfadedf46c4f804733574b15fe86d2ed45a9588", size = 84727, upload-time = "2025-04-26T16:58:29.919Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "botocore" +version = "1.38.41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/46/cb33f5a0b00086a97c4eebbc4e0211fe85d66d45e53a9545b33805f25b31/botocore-1.38.41.tar.gz", hash = "sha256:98e3fed636ebb519320c4b2d078db6fa6099b052b4bb9b5c66632a5a7fe72507", size = 14031081, upload-time = "2025-06-20T19:26:31.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/b7/37d9f1a633e72250408cb7d53d8915561ac6108b5c3a1973eb8f53ce2990/botocore-1.38.41-py3-none-any.whl", hash = "sha256:06069a06f1352accb1f6c9505d6e323753627112be80a9d2e057c6d9c9779ffd", size = 13690225, upload-time = "2025-06-20T19:26:26.014Z" }, +] + +[[package]] +name = "build" +version = "1.2.2.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10.2'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/46/aeab111f8e06793e4f0e421fcad593d547fb8313b50990f31681ee2fb1ad/build-1.2.2.post1.tar.gz", hash = "sha256:b36993e92ca9375a219c99e606a122ff365a760a2d4bba0caa09bd5278b608b7", size = 46701, upload-time = "2024-10-06T17:22:25.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/c2/80633736cd183ee4a62107413def345f7e6e3c01563dbca1417363cf957e/build-1.2.2.post1-py3-none-any.whl", hash = "sha256:1d61c0887fa860c01971625baae8bdd338e517b836a2f70dd1f7aa3a6b2fc5b5", size = 22950, upload-time = "2024-10-06T17:22:23.299Z" }, +] + +[[package]] +name = "cachecontrol" +version = "0.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/3a/0cbeb04ea57d2493f3ec5a069a117ab467f85e4a10017c6d854ddcbff104/cachecontrol-0.14.3.tar.gz", hash = "sha256:73e7efec4b06b20d9267b441c1f733664f989fb8688391b670ca812d70795d11", size = 28985, upload-time = "2025-04-30T16:45:06.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/4c/800b0607b00b3fd20f1087f80ab53d6b4d005515b0f773e4831e37cfa83f/cachecontrol-0.14.3-py3-none-any.whl", hash = "sha256:b35e44a3113f17d2a31c1e6b27b9de6d4405f84ae51baa8c1d3cc5b633010cae", size = 21802, upload-time = "2025-04-30T16:45:03.863Z" }, +] + +[package.optional-dependencies] +filecache = [ + { name = "filelock" }, +] + +[[package]] +name = "cattrs" +version = "25.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/2b/561d78f488dcc303da4639e02021311728fb7fda8006dd2835550cddd9ed/cattrs-25.1.1.tar.gz", hash = "sha256:c914b734e0f2d59e5b720d145ee010f1fd9a13ee93900922a2f3f9d593b8382c", size = 435016, upload-time = "2025-06-04T20:27:15.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/b0/215274ef0d835bbc1056392a367646648b6084e39d489099959aefcca2af/cattrs-25.1.1-py3-none-any.whl", hash = "sha256:1b40b2d3402af7be79a7e7e097a9b4cd16d4c06e6d526644b0b26a063a1cc064", size = 69386, upload-time = "2025-06-04T20:27:13.969Z" }, +] + +[[package]] +name = "certifi" +version = "2025.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753, upload-time = "2025-06-15T02:45:51.329Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload-time = "2025-06-15T02:45:49.977Z" }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191, upload-time = "2024-09-04T20:43:30.027Z" }, + { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592, upload-time = "2024-09-04T20:43:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024, upload-time = "2024-09-04T20:43:34.186Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188, upload-time = "2024-09-04T20:43:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571, upload-time = "2024-09-04T20:43:38.586Z" }, + { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687, upload-time = "2024-09-04T20:43:40.084Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211, upload-time = "2024-09-04T20:43:41.526Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325, upload-time = "2024-09-04T20:43:43.117Z" }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784, upload-time = "2024-09-04T20:43:45.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564, upload-time = "2024-09-04T20:43:46.779Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804, upload-time = "2024-09-04T20:43:48.186Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299, upload-time = "2024-09-04T20:43:49.812Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload-time = "2024-09-04T20:43:51.124Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload-time = "2024-09-04T20:43:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload-time = "2024-09-04T20:44:00.18Z" }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload-time = "2024-09-04T20:44:01.585Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload-time = "2024-09-04T20:44:03.467Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727, upload-time = "2024-09-04T20:44:09.481Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400, upload-time = "2024-09-04T20:44:10.873Z" }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ea/8bb50596b8ffbc49ddd7a1ad305035daa770202a6b782fc164647c2673ad/cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", size = 182220, upload-time = "2024-09-04T20:45:01.577Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/e77c8cd24f58285a82c23af484cf5b124a376b32644e445960d1a4654c3a/cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", size = 178605, upload-time = "2024-09-04T20:45:03.837Z" }, + { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910, upload-time = "2024-09-04T20:45:05.315Z" }, + { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200, upload-time = "2024-09-04T20:45:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565, upload-time = "2024-09-04T20:45:08.975Z" }, + { url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635, upload-time = "2024-09-04T20:45:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218, upload-time = "2024-09-04T20:45:12.366Z" }, + { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486, upload-time = "2024-09-04T20:45:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911, upload-time = "2024-09-04T20:45:15.696Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632, upload-time = "2024-09-04T20:45:17.284Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b5/fd9f8b5a84010ca169ee49f4e4ad6f8c05f4e3545b72ee041dbbcb159882/cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", size = 171820, upload-time = "2024-09-04T20:45:18.762Z" }, + { url = "https://files.pythonhosted.org/packages/8c/52/b08750ce0bce45c143e1b5d7357ee8c55341b52bdef4b0f081af1eb248c2/cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", size = 181290, upload-time = "2024-09-04T20:45:20.226Z" }, +] + +[[package]] +name = "chalice" +version = "1.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "inquirer" }, + { name = "jmespath" }, + { name = "pip" }, + { name = "pyyaml" }, + { name = "setuptools" }, + { name = "six" }, + { name = "wheel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/aa/ea9b4394c160dbbbbbd6b923b0e1ac5f6f5aad0361b0a74353f8b1fcdf62/chalice-1.32.0.tar.gz", hash = "sha256:c1d469316747ef8850b4b286c60bcf8c53da3bab1a2042d7551284aa8be06af2", size = 256997, upload-time = "2025-05-29T16:19:03.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/89/f3756545a0df8792f79921a0a08786db8039ae753c0da7ca0d0475df9701/chalice-1.32.0-py3-none-any.whl", hash = "sha256:671fdf45b8fe9315a29acb63a0accfdff60dfc582ea4faf54f0d463323930542", size = 265483, upload-time = "2025-05-29T16:19:00.88Z" }, +] + +[[package]] +name = "channels" +version = "4.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django", version = "4.2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "django", version = "5.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/d6/049f93c3c96a88265a52f85da91d2635279261bbd4a924b45caa43b8822e/channels-4.2.2.tar.gz", hash = "sha256:8d7208e48ab8fdb972aaeae8311ce920637d97656ffc7ae5eca4f93f84bcd9a0", size = 26647, upload-time = "2025-03-30T14:59:20.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/bf/4799809715225d19928147d59fda0d3a4129da055b59a9b3e35aa6223f52/channels-4.2.2-py3-none-any.whl", hash = "sha256:ff36a6e1576cacf40bcdc615fa7aece7a709fc4fdd2dc87f2971f4061ffdaa81", size = 31048, upload-time = "2025-03-30T14:59:18.969Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/28/9901804da60055b406e1a1c5ba7aac1276fb77f1dde635aabfc7fd84b8ab/charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", size = 201818, upload-time = "2025-05-02T08:31:46.725Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9b/892a8c8af9110935e5adcbb06d9c6fe741b6bb02608c6513983048ba1a18/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd", size = 144649, upload-time = "2025-05-02T08:31:48.889Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a5/4179abd063ff6414223575e008593861d62abfc22455b5d1a44995b7c101/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6", size = 155045, upload-time = "2025-05-02T08:31:50.757Z" }, + { url = "https://files.pythonhosted.org/packages/3b/95/bc08c7dfeddd26b4be8c8287b9bb055716f31077c8b0ea1cd09553794665/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d", size = 147356, upload-time = "2025-05-02T08:31:52.634Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/7a5b635aa65284bf3eab7653e8b4151ab420ecbae918d3e359d1947b4d61/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86", size = 149471, upload-time = "2025-05-02T08:31:56.207Z" }, + { url = "https://files.pythonhosted.org/packages/ae/38/51fc6ac74251fd331a8cfdb7ec57beba8c23fd5493f1050f71c87ef77ed0/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c", size = 151317, upload-time = "2025-05-02T08:31:57.613Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/edee1e32215ee6e9e46c3e482645b46575a44a2d72c7dfd49e49f60ce6bf/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0", size = 146368, upload-time = "2025-05-02T08:31:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/26/2c/ea3e66f2b5f21fd00b2825c94cafb8c326ea6240cd80a91eb09e4a285830/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef", size = 154491, upload-time = "2025-05-02T08:32:01.219Z" }, + { url = "https://files.pythonhosted.org/packages/52/47/7be7fa972422ad062e909fd62460d45c3ef4c141805b7078dbab15904ff7/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6", size = 157695, upload-time = "2025-05-02T08:32:03.045Z" }, + { url = "https://files.pythonhosted.org/packages/2f/42/9f02c194da282b2b340f28e5fb60762de1151387a36842a92b533685c61e/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366", size = 154849, upload-time = "2025-05-02T08:32:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/67/44/89cacd6628f31fb0b63201a618049be4be2a7435a31b55b5eb1c3674547a/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db", size = 150091, upload-time = "2025-05-02T08:32:06.719Z" }, + { url = "https://files.pythonhosted.org/packages/1f/79/4b8da9f712bc079c0f16b6d67b099b0b8d808c2292c937f267d816ec5ecc/charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a", size = 98445, upload-time = "2025-05-02T08:32:08.66Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d7/96970afb4fb66497a40761cdf7bd4f6fca0fc7bafde3a84f836c1f57a926/charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509", size = 105782, upload-time = "2025-05-02T08:32:10.46Z" }, + { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794, upload-time = "2025-05-02T08:32:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846, upload-time = "2025-05-02T08:32:13.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350, upload-time = "2025-05-02T08:32:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657, upload-time = "2025-05-02T08:32:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260, upload-time = "2025-05-02T08:32:18.807Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164, upload-time = "2025-05-02T08:32:20.333Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571, upload-time = "2025-05-02T08:32:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952, upload-time = "2025-05-02T08:32:23.434Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959, upload-time = "2025-05-02T08:32:24.993Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030, upload-time = "2025-05-02T08:32:26.435Z" }, + { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015, upload-time = "2025-05-02T08:32:28.376Z" }, + { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106, upload-time = "2025-05-02T08:32:30.281Z" }, + { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402, upload-time = "2025-05-02T08:32:32.191Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" }, + { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" }, + { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" }, + { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" }, + { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" }, + { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/28/f8/dfb01ff6cc9af38552c69c9027501ff5a5117c4cc18dcd27cb5259fa1888/charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4", size = 201671, upload-time = "2025-05-02T08:34:12.696Z" }, + { url = "https://files.pythonhosted.org/packages/32/fb/74e26ee556a9dbfe3bd264289b67be1e6d616329403036f6507bb9f3f29c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7", size = 144744, upload-time = "2025-05-02T08:34:14.665Z" }, + { url = "https://files.pythonhosted.org/packages/ad/06/8499ee5aa7addc6f6d72e068691826ff093329fe59891e83b092ae4c851c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836", size = 154993, upload-time = "2025-05-02T08:34:17.134Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a2/5e4c187680728219254ef107a6949c60ee0e9a916a5dadb148c7ae82459c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597", size = 147382, upload-time = "2025-05-02T08:34:19.081Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/56aca740dda674f0cc1ba1418c4d84534be51f639b5f98f538b332dc9a95/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7", size = 149536, upload-time = "2025-05-02T08:34:21.073Z" }, + { url = "https://files.pythonhosted.org/packages/53/13/db2e7779f892386b589173dd689c1b1e304621c5792046edd8a978cbf9e0/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f", size = 151349, upload-time = "2025-05-02T08:34:23.193Z" }, + { url = "https://files.pythonhosted.org/packages/69/35/e52ab9a276186f729bce7a0638585d2982f50402046e4b0faa5d2c3ef2da/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba", size = 146365, upload-time = "2025-05-02T08:34:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d8/af7333f732fc2e7635867d56cb7c349c28c7094910c72267586947561b4b/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12", size = 154499, upload-time = "2025-05-02T08:34:27.359Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/a5b2e48acef264d71e036ff30bcc49e51bde80219bb628ba3e00cf59baac/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518", size = 157735, upload-time = "2025-05-02T08:34:29.798Z" }, + { url = "https://files.pythonhosted.org/packages/85/d8/23e2c112532a29f3eef374375a8684a4f3b8e784f62b01da931186f43494/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5", size = 154786, upload-time = "2025-05-02T08:34:31.858Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/93e0169f08ecc20fe82d12254a200dfaceddc1c12a4077bf454ecc597e33/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3", size = 150203, upload-time = "2025-05-02T08:34:33.88Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9d/9bf2b005138e7e060d7ebdec7503d0ef3240141587651f4b445bdf7286c2/charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471", size = 98436, upload-time = "2025-05-02T08:34:35.907Z" }, + { url = "https://files.pythonhosted.org/packages/6d/24/5849d46cf4311bbf21b424c443b09b459f5b436b1558c04e45dbb7cc478b/charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e", size = 105772, upload-time = "2025-05-02T08:34:37.935Z" }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, +] + +[[package]] +name = "cleo" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "crashtest" }, + { name = "rapidfuzz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/30/f7960ed7041b158301c46774f87620352d50a9028d111b4211187af13783/cleo-2.1.0.tar.gz", hash = "sha256:0b2c880b5d13660a7ea651001fb4acb527696c01f15c9ee650f377aa543fd523", size = 79957, upload-time = "2023-10-30T18:54:12.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/f5/6bbead8b880620e5a99e0e4bb9e22e67cca16ff48d54105302a3e7821096/cleo-2.1.0-py3-none-any.whl", hash = "sha256:4a31bd4dd45695a64ee3c4758f583f134267c2bc518d8ae9a29cf237d009b07e", size = 78711, upload-time = "2023-10-30T18:54:08.557Z" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version >= '3.10' and python_full_version < '3.13'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[package]] +name = "codeflash" +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "coverage" }, + { name = "crosshair-tool" }, + { name = "dill" }, + { name = "gitpython" }, + { name = "humanize" }, + { name = "inquirer" }, + { name = "isort" }, + { name = "jedi" }, + { name = "junitparser" }, + { name = "libcst" }, + { name = "line-profiler" }, + { name = "lxml" }, + { name = "parameterized" }, + { name = "platformdirs" }, + { name = "posthog" }, + { name = "pydantic" }, + { name = "pytest" }, + { name = "pytest-timeout" }, + { name = "rich" }, + { name = "sentry-sdk" }, + { name = "timeout-decorator" }, + { name = "tomlkit" }, + { name = "unidiff" }, + { name = "unittest-xml-reporting" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/3e/b62bd959c4dfc759a226f41d00ab442caa21f5f0f40bf8bcb902c17ffa6a/codeflash-0.14.4.tar.gz", hash = "sha256:389d34ac96da35246a1743295339b043aa2fa70be61d4f02621b17c305ce5061", size = 172136, upload-time = "2025-06-19T01:01:19.386Z" } + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "colorlog" +version = "6.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/7a/359f4d5df2353f26172b3cc39ea32daa39af8de522205f512f458923e677/colorlog-6.9.0.tar.gz", hash = "sha256:bfba54a1b93b94f54e1f4fe48395725a3d92fd2a4af702f6bd70946bdc0c6ac2", size = 16624, upload-time = "2024-10-29T18:34:51.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl", hash = "sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff", size = 11424, upload-time = "2024-10-29T18:34:49.815Z" }, +] + +[[package]] +name = "constantly" +version = "23.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/6f/cb2a94494ff74aa9528a36c5b1422756330a75a8367bf20bd63171fc324d/constantly-23.10.4.tar.gz", hash = "sha256:aa92b70a33e2ac0bb33cd745eb61776594dc48764b06c35e0efd050b7f1c7cbd", size = 13300, upload-time = "2023-10-28T23:18:24.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/40/c199d095151addf69efdb4b9ca3a4f20f70e20508d6222bffb9b76f58573/constantly-23.10.4-py3-none-any.whl", hash = "sha256:3fd9b4d1c3dc1ec9757f3c52aef7e53ad9323dbe39f51dfd4c43853b68dfa3f9", size = 13547, upload-time = "2023-10-28T23:18:23.038Z" }, +] + +[[package]] +name = "coverage" +version = "7.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/e0/98670a80884f64578f0c22cd70c5e81a6e07b08167721c7487b4d70a7ca0/coverage-7.9.1.tar.gz", hash = "sha256:6cf43c78c4282708a28e466316935ec7489a9c487518a77fa68f716c67909cec", size = 813650, upload-time = "2025-06-13T13:02:28.627Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/78/1c1c5ec58f16817c09cbacb39783c3655d54a221b6552f47ff5ac9297603/coverage-7.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc94d7c5e8423920787c33d811c0be67b7be83c705f001f7180c7b186dcf10ca", size = 212028, upload-time = "2025-06-13T13:00:29.293Z" }, + { url = "https://files.pythonhosted.org/packages/98/db/e91b9076f3a888e3b4ad7972ea3842297a52cc52e73fd1e529856e473510/coverage-7.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:16aa0830d0c08a2c40c264cef801db8bc4fc0e1892782e45bcacbd5889270509", size = 212420, upload-time = "2025-06-13T13:00:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d0/2b3733412954576b0aea0a16c3b6b8fbe95eb975d8bfa10b07359ead4252/coverage-7.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf95981b126f23db63e9dbe4cf65bd71f9a6305696fa5e2262693bc4e2183f5b", size = 241529, upload-time = "2025-06-13T13:00:35.786Z" }, + { url = "https://files.pythonhosted.org/packages/b3/00/5e2e5ae2e750a872226a68e984d4d3f3563cb01d1afb449a17aa819bc2c4/coverage-7.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f05031cf21699785cd47cb7485f67df619e7bcdae38e0fde40d23d3d0210d3c3", size = 239403, upload-time = "2025-06-13T13:00:37.399Z" }, + { url = "https://files.pythonhosted.org/packages/37/3b/a2c27736035156b0a7c20683afe7df498480c0dfdf503b8c878a21b6d7fb/coverage-7.9.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb4fbcab8764dc072cb651a4bcda4d11fb5658a1d8d68842a862a6610bd8cfa3", size = 240548, upload-time = "2025-06-13T13:00:39.647Z" }, + { url = "https://files.pythonhosted.org/packages/98/f5/13d5fc074c3c0e0dc80422d9535814abf190f1254d7c3451590dc4f8b18c/coverage-7.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0f16649a7330ec307942ed27d06ee7e7a38417144620bb3d6e9a18ded8a2d3e5", size = 240459, upload-time = "2025-06-13T13:00:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/36/24/24b9676ea06102df824c4a56ffd13dc9da7904478db519efa877d16527d5/coverage-7.9.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cea0a27a89e6432705fffc178064503508e3c0184b4f061700e771a09de58187", size = 239128, upload-time = "2025-06-13T13:00:42.343Z" }, + { url = "https://files.pythonhosted.org/packages/be/05/242b7a7d491b369ac5fee7908a6e5ba42b3030450f3ad62c645b40c23e0e/coverage-7.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e980b53a959fa53b6f05343afbd1e6f44a23ed6c23c4b4c56c6662bbb40c82ce", size = 239402, upload-time = "2025-06-13T13:00:43.634Z" }, + { url = "https://files.pythonhosted.org/packages/73/e0/4de7f87192fa65c9c8fbaeb75507e124f82396b71de1797da5602898be32/coverage-7.9.1-cp310-cp310-win32.whl", hash = "sha256:70760b4c5560be6ca70d11f8988ee6542b003f982b32f83d5ac0b72476607b70", size = 214518, upload-time = "2025-06-13T13:00:45.622Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ab/5e4e2fe458907d2a65fab62c773671cfc5ac704f1e7a9ddd91996f66e3c2/coverage-7.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a66e8f628b71f78c0e0342003d53b53101ba4e00ea8dabb799d9dba0abbbcebe", size = 215436, upload-time = "2025-06-13T13:00:47.245Z" }, + { url = "https://files.pythonhosted.org/packages/60/34/fa69372a07d0903a78ac103422ad34db72281c9fc625eba94ac1185da66f/coverage-7.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:95c765060e65c692da2d2f51a9499c5e9f5cf5453aeaf1420e3fc847cc060582", size = 212146, upload-time = "2025-06-13T13:00:48.496Z" }, + { url = "https://files.pythonhosted.org/packages/27/f0/da1894915d2767f093f081c42afeba18e760f12fdd7a2f4acbe00564d767/coverage-7.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ba383dc6afd5ec5b7a0d0c23d38895db0e15bcba7fb0fa8901f245267ac30d86", size = 212536, upload-time = "2025-06-13T13:00:51.535Z" }, + { url = "https://files.pythonhosted.org/packages/10/d5/3fc33b06e41e390f88eef111226a24e4504d216ab8e5d1a7089aa5a3c87a/coverage-7.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37ae0383f13cbdcf1e5e7014489b0d71cc0106458878ccde52e8a12ced4298ed", size = 245092, upload-time = "2025-06-13T13:00:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/0a/39/7aa901c14977aba637b78e95800edf77f29f5a380d29768c5b66f258305b/coverage-7.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69aa417a030bf11ec46149636314c24c8d60fadb12fc0ee8f10fda0d918c879d", size = 242806, upload-time = "2025-06-13T13:00:54.571Z" }, + { url = "https://files.pythonhosted.org/packages/43/fc/30e5cfeaf560b1fc1989227adedc11019ce4bb7cce59d65db34fe0c2d963/coverage-7.9.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a4be2a28656afe279b34d4f91c3e26eccf2f85500d4a4ff0b1f8b54bf807338", size = 244610, upload-time = "2025-06-13T13:00:56.932Z" }, + { url = "https://files.pythonhosted.org/packages/bf/15/cca62b13f39650bc87b2b92bb03bce7f0e79dd0bf2c7529e9fc7393e4d60/coverage-7.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:382e7ddd5289f140259b610e5f5c58f713d025cb2f66d0eb17e68d0a94278875", size = 244257, upload-time = "2025-06-13T13:00:58.545Z" }, + { url = "https://files.pythonhosted.org/packages/cd/1a/c0f2abe92c29e1464dbd0ff9d56cb6c88ae2b9e21becdb38bea31fcb2f6c/coverage-7.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e5532482344186c543c37bfad0ee6069e8ae4fc38d073b8bc836fc8f03c9e250", size = 242309, upload-time = "2025-06-13T13:00:59.836Z" }, + { url = "https://files.pythonhosted.org/packages/57/8d/c6fd70848bd9bf88fa90df2af5636589a8126d2170f3aade21ed53f2b67a/coverage-7.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a39d18b3f50cc121d0ce3838d32d58bd1d15dab89c910358ebefc3665712256c", size = 242898, upload-time = "2025-06-13T13:01:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9e/6ca46c7bff4675f09a66fe2797cd1ad6a24f14c9c7c3b3ebe0470a6e30b8/coverage-7.9.1-cp311-cp311-win32.whl", hash = "sha256:dd24bd8d77c98557880def750782df77ab2b6885a18483dc8588792247174b32", size = 214561, upload-time = "2025-06-13T13:01:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/a1/30/166978c6302010742dabcdc425fa0f938fa5a800908e39aff37a7a876a13/coverage-7.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:6b55ad10a35a21b8015eabddc9ba31eb590f54adc9cd39bcf09ff5349fd52125", size = 215493, upload-time = "2025-06-13T13:01:05.702Z" }, + { url = "https://files.pythonhosted.org/packages/60/07/a6d2342cd80a5be9f0eeab115bc5ebb3917b4a64c2953534273cf9bc7ae6/coverage-7.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ad935f0016be24c0e97fc8c40c465f9c4b85cbbe6eac48934c0dc4d2568321e", size = 213869, upload-time = "2025-06-13T13:01:09.345Z" }, + { url = "https://files.pythonhosted.org/packages/68/d9/7f66eb0a8f2fce222de7bdc2046ec41cb31fe33fb55a330037833fb88afc/coverage-7.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8de12b4b87c20de895f10567639c0797b621b22897b0af3ce4b4e204a743626", size = 212336, upload-time = "2025-06-13T13:01:10.909Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/e07cb920ef3addf20f052ee3d54906e57407b6aeee3227a9c91eea38a665/coverage-7.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5add197315a054e92cee1b5f686a2bcba60c4c3e66ee3de77ace6c867bdee7cb", size = 212571, upload-time = "2025-06-13T13:01:12.518Z" }, + { url = "https://files.pythonhosted.org/packages/78/f8/96f155de7e9e248ca9c8ff1a40a521d944ba48bec65352da9be2463745bf/coverage-7.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600a1d4106fe66f41e5d0136dfbc68fe7200a5cbe85610ddf094f8f22e1b0300", size = 246377, upload-time = "2025-06-13T13:01:14.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cf/1d783bd05b7bca5c10ded5f946068909372e94615a4416afadfe3f63492d/coverage-7.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a876e4c3e5a2a1715a6608906aa5a2e0475b9c0f68343c2ada98110512ab1d8", size = 243394, upload-time = "2025-06-13T13:01:16.23Z" }, + { url = "https://files.pythonhosted.org/packages/02/dd/e7b20afd35b0a1abea09fb3998e1abc9f9bd953bee548f235aebd2b11401/coverage-7.9.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81f34346dd63010453922c8e628a52ea2d2ccd73cb2487f7700ac531b247c8a5", size = 245586, upload-time = "2025-06-13T13:01:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/4e/38/b30b0006fea9d617d1cb8e43b1bc9a96af11eff42b87eb8c716cf4d37469/coverage-7.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:888f8eee13f2377ce86d44f338968eedec3291876b0b8a7289247ba52cb984cd", size = 245396, upload-time = "2025-06-13T13:01:19.164Z" }, + { url = "https://files.pythonhosted.org/packages/31/e4/4d8ec1dc826e16791f3daf1b50943e8e7e1eb70e8efa7abb03936ff48418/coverage-7.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9969ef1e69b8c8e1e70d591f91bbc37fc9a3621e447525d1602801a24ceda898", size = 243577, upload-time = "2025-06-13T13:01:22.433Z" }, + { url = "https://files.pythonhosted.org/packages/25/f4/b0e96c5c38e6e40ef465c4bc7f138863e2909c00e54a331da335faf0d81a/coverage-7.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60c458224331ee3f1a5b472773e4a085cc27a86a0b48205409d364272d67140d", size = 244809, upload-time = "2025-06-13T13:01:24.143Z" }, + { url = "https://files.pythonhosted.org/packages/8a/65/27e0a1fa5e2e5079bdca4521be2f5dabf516f94e29a0defed35ac2382eb2/coverage-7.9.1-cp312-cp312-win32.whl", hash = "sha256:5f646a99a8c2b3ff4c6a6e081f78fad0dde275cd59f8f49dc4eab2e394332e74", size = 214724, upload-time = "2025-06-13T13:01:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a8/d5b128633fd1a5e0401a4160d02fa15986209a9e47717174f99dc2f7166d/coverage-7.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:30f445f85c353090b83e552dcbbdad3ec84c7967e108c3ae54556ca69955563e", size = 215535, upload-time = "2025-06-13T13:01:27.861Z" }, + { url = "https://files.pythonhosted.org/packages/a3/37/84bba9d2afabc3611f3e4325ee2c6a47cd449b580d4a606b240ce5a6f9bf/coverage-7.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:af41da5dca398d3474129c58cb2b106a5d93bbb196be0d307ac82311ca234342", size = 213904, upload-time = "2025-06-13T13:01:29.202Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a7/a027970c991ca90f24e968999f7d509332daf6b8c3533d68633930aaebac/coverage-7.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:31324f18d5969feef7344a932c32428a2d1a3e50b15a6404e97cba1cc9b2c631", size = 212358, upload-time = "2025-06-13T13:01:30.909Z" }, + { url = "https://files.pythonhosted.org/packages/f2/48/6aaed3651ae83b231556750280682528fea8ac7f1232834573472d83e459/coverage-7.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c804506d624e8a20fb3108764c52e0eef664e29d21692afa375e0dd98dc384f", size = 212620, upload-time = "2025-06-13T13:01:32.256Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/f4b613f3b44d8b9f144847c89151992b2b6b79cbc506dee89ad0c35f209d/coverage-7.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef64c27bc40189f36fcc50c3fb8f16ccda73b6a0b80d9bd6e6ce4cffcd810bbd", size = 245788, upload-time = "2025-06-13T13:01:33.948Z" }, + { url = "https://files.pythonhosted.org/packages/04/d2/de4fdc03af5e4e035ef420ed26a703c6ad3d7a07aff2e959eb84e3b19ca8/coverage-7.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4fe2348cc6ec372e25adec0219ee2334a68d2f5222e0cba9c0d613394e12d86", size = 243001, upload-time = "2025-06-13T13:01:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e8/eed18aa5583b0423ab7f04e34659e51101135c41cd1dcb33ac1d7013a6d6/coverage-7.9.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34ed2186fe52fcc24d4561041979a0dec69adae7bce2ae8d1c49eace13e55c43", size = 244985, upload-time = "2025-06-13T13:01:36.712Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/ae9e5cce8885728c934eaa58ebfa8281d488ef2afa81c3dbc8ee9e6d80db/coverage-7.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25308bd3d00d5eedd5ae7d4357161f4df743e3c0240fa773ee1b0f75e6c7c0f1", size = 245152, upload-time = "2025-06-13T13:01:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c8/272c01ae792bb3af9b30fac14d71d63371db227980682836ec388e2c57c0/coverage-7.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73e9439310f65d55a5a1e0564b48e34f5369bee943d72c88378f2d576f5a5751", size = 243123, upload-time = "2025-06-13T13:01:40.727Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d0/2819a1e3086143c094ab446e3bdf07138527a7b88cb235c488e78150ba7a/coverage-7.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ab6be0859141b53aa89412a82454b482c81cf750de4f29223d52268a86de67", size = 244506, upload-time = "2025-06-13T13:01:42.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4e/9f6117b89152df7b6112f65c7a4ed1f2f5ec8e60c4be8f351d91e7acc848/coverage-7.9.1-cp313-cp313-win32.whl", hash = "sha256:64bdd969456e2d02a8b08aa047a92d269c7ac1f47e0c977675d550c9a0863643", size = 214766, upload-time = "2025-06-13T13:01:44.482Z" }, + { url = "https://files.pythonhosted.org/packages/27/0f/4b59f7c93b52c2c4ce7387c5a4e135e49891bb3b7408dcc98fe44033bbe0/coverage-7.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:be9e3f68ca9edb897c2184ad0eee815c635565dbe7a0e7e814dc1f7cbab92c0a", size = 215568, upload-time = "2025-06-13T13:01:45.772Z" }, + { url = "https://files.pythonhosted.org/packages/09/1e/9679826336f8c67b9c39a359352882b24a8a7aee48d4c9cad08d38d7510f/coverage-7.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:1c503289ffef1d5105d91bbb4d62cbe4b14bec4d13ca225f9c73cde9bb46207d", size = 213939, upload-time = "2025-06-13T13:01:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/bb/5b/5c6b4e7a407359a2e3b27bf9c8a7b658127975def62077d441b93a30dbe8/coverage-7.9.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0b3496922cb5f4215bf5caaef4cf12364a26b0be82e9ed6d050f3352cf2d7ef0", size = 213079, upload-time = "2025-06-13T13:01:48.554Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/1e2e07279fd2fd97ae26c01cc2186e2258850e9ec125ae87184225662e89/coverage-7.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9565c3ab1c93310569ec0d86b017f128f027cab0b622b7af288696d7ed43a16d", size = 213299, upload-time = "2025-06-13T13:01:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/4c5125a4b69d66b8c85986d3321520f628756cf524af810baab0790c7647/coverage-7.9.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2241ad5dbf79ae1d9c08fe52b36d03ca122fb9ac6bca0f34439e99f8327ac89f", size = 256535, upload-time = "2025-06-13T13:01:51.314Z" }, + { url = "https://files.pythonhosted.org/packages/81/8b/e36a04889dda9960be4263e95e777e7b46f1bb4fc32202612c130a20c4da/coverage-7.9.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bb5838701ca68b10ebc0937dbd0eb81974bac54447c55cd58dea5bca8451029", size = 252756, upload-time = "2025-06-13T13:01:54.403Z" }, + { url = "https://files.pythonhosted.org/packages/98/82/be04eff8083a09a4622ecd0e1f31a2c563dbea3ed848069e7b0445043a70/coverage-7.9.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30a25f814591a8c0c5372c11ac8967f669b97444c47fd794926e175c4047ece", size = 254912, upload-time = "2025-06-13T13:01:56.769Z" }, + { url = "https://files.pythonhosted.org/packages/0f/25/c26610a2c7f018508a5ab958e5b3202d900422cf7cdca7670b6b8ca4e8df/coverage-7.9.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2d04b16a6062516df97969f1ae7efd0de9c31eb6ebdceaa0d213b21c0ca1a683", size = 256144, upload-time = "2025-06-13T13:01:58.19Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/fb9425c4684066c79e863f1e6e7ecebb49e3a64d9f7f7860ef1688c56f4a/coverage-7.9.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7931b9e249edefb07cd6ae10c702788546341d5fe44db5b6108a25da4dca513f", size = 254257, upload-time = "2025-06-13T13:01:59.645Z" }, + { url = "https://files.pythonhosted.org/packages/93/df/27b882f54157fc1131e0e215b0da3b8d608d9b8ef79a045280118a8f98fe/coverage-7.9.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52e92b01041151bf607ee858e5a56c62d4b70f4dac85b8c8cb7fb8a351ab2c10", size = 255094, upload-time = "2025-06-13T13:02:01.37Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/cad1c3dbed8b3ee9e16fa832afe365b4e3eeab1fb6edb65ebbf745eabc92/coverage-7.9.1-cp313-cp313t-win32.whl", hash = "sha256:684e2110ed84fd1ca5f40e89aa44adf1729dc85444004111aa01866507adf363", size = 215437, upload-time = "2025-06-13T13:02:02.905Z" }, + { url = "https://files.pythonhosted.org/packages/99/4d/fad293bf081c0e43331ca745ff63673badc20afea2104b431cdd8c278b4c/coverage-7.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:437c576979e4db840539674e68c84b3cda82bc824dd138d56bead1435f1cb5d7", size = 216605, upload-time = "2025-06-13T13:02:05.638Z" }, + { url = "https://files.pythonhosted.org/packages/1f/56/4ee027d5965fc7fc126d7ec1187529cc30cc7d740846e1ecb5e92d31b224/coverage-7.9.1-cp313-cp313t-win_arm64.whl", hash = "sha256:18a0912944d70aaf5f399e350445738a1a20b50fbea788f640751c2ed9208b6c", size = 214392, upload-time = "2025-06-13T13:02:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d6/c41dd9b02bf16ec001aaf1cbef665537606899a3db1094e78f5ae17540ca/coverage-7.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6f424507f57878e424d9a95dc4ead3fbdd72fd201e404e861e465f28ea469951", size = 212029, upload-time = "2025-06-13T13:02:09.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c0/40420d81d731f84c3916dcdf0506b3e6c6570817bff2576b83f780914ae6/coverage-7.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:535fde4001b2783ac80865d90e7cc7798b6b126f4cd8a8c54acfe76804e54e58", size = 212407, upload-time = "2025-06-13T13:02:11.151Z" }, + { url = "https://files.pythonhosted.org/packages/9b/87/f0db7d62d0e09f14d6d2f6ae8c7274a2f09edf74895a34b412a0601e375a/coverage-7.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02532fd3290bb8fa6bec876520842428e2a6ed6c27014eca81b031c2d30e3f71", size = 241160, upload-time = "2025-06-13T13:02:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b7/3337c064f058a5d7696c4867159651a5b5fb01a5202bcf37362f0c51400e/coverage-7.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56f5eb308b17bca3bbff810f55ee26d51926d9f89ba92707ee41d3c061257e55", size = 239027, upload-time = "2025-06-13T13:02:14.294Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/5898a283f66d1bd413c32c2e0e05408196fd4f37e206e2b06c6e0c626e0e/coverage-7.9.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfa447506c1a52271f1b0de3f42ea0fa14676052549095e378d5bff1c505ff7b", size = 240145, upload-time = "2025-06-13T13:02:15.745Z" }, + { url = "https://files.pythonhosted.org/packages/e0/33/d96e3350078a3c423c549cb5b2ba970de24c5257954d3e4066e2b2152d30/coverage-7.9.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9ca8e220006966b4a7b68e8984a6aee645a0384b0769e829ba60281fe61ec4f7", size = 239871, upload-time = "2025-06-13T13:02:17.344Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6e/6fb946072455f71a820cac144d49d11747a0f1a21038060a68d2d0200499/coverage-7.9.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:49f1d0788ba5b7ba65933f3a18864117c6506619f5ca80326b478f72acf3f385", size = 238122, upload-time = "2025-06-13T13:02:18.849Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5c/bc43f25c8586840ce25a796a8111acf6a2b5f0909ba89a10d41ccff3920d/coverage-7.9.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:68cd53aec6f45b8e4724c0950ce86eacb775c6be01ce6e3669fe4f3a21e768ed", size = 239058, upload-time = "2025-06-13T13:02:21.423Z" }, + { url = "https://files.pythonhosted.org/packages/11/d8/ce2007418dd7fd00ff8c8b898bb150bb4bac2d6a86df05d7b88a07ff595f/coverage-7.9.1-cp39-cp39-win32.whl", hash = "sha256:95335095b6c7b1cc14c3f3f17d5452ce677e8490d101698562b2ffcacc304c8d", size = 214532, upload-time = "2025-06-13T13:02:22.857Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/334e76fa246e92e6d69cab217f7c8a70ae0cc8f01438bd0544103f29528e/coverage-7.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:e1b5191d1648acc439b24721caab2fd0c86679d8549ed2c84d5a7ec1bedcc244", size = 215439, upload-time = "2025-06-13T13:02:24.268Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e5/c723545c3fd3204ebde3b4cc4b927dce709d3b6dc577754bb57f63ca4a4a/coverage-7.9.1-pp39.pp310.pp311-none-any.whl", hash = "sha256:db0f04118d1db74db6c9e1cb1898532c7dcc220f1d2718f058601f7c3f499514", size = 204009, upload-time = "2025-06-13T13:02:25.787Z" }, + { url = "https://files.pythonhosted.org/packages/08/b8/7ddd1e8ba9701dea08ce22029917140e6f66a859427406579fd8d0ca7274/coverage-7.9.1-py3-none-any.whl", hash = "sha256:66b974b145aa189516b6bf2d8423e888b742517d37872f6ee4c5be0073bd9a3c", size = 204000, upload-time = "2025-06-13T13:02:27.173Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "crashtest" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/5d/d79f51058e75948d6c9e7a3d679080a47be61c84d3cc8f71ee31255eb22b/crashtest-0.4.1.tar.gz", hash = "sha256:80d7b1f316ebfbd429f648076d6275c877ba30ba48979de4191714a75266f0ce", size = 4708, upload-time = "2022-11-02T21:15:13.722Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/5c/3ba7d12e7a79566f97b8f954400926d7b6eb33bcdccc1315a857f200f1f1/crashtest-0.4.1-py3-none-any.whl", hash = "sha256:8d23eac5fa660409f57472e3851dab7ac18aba459a8d19cbbba86d3d5aecd2a5", size = 7558, upload-time = "2022-11-02T21:15:12.437Z" }, +] + +[[package]] +name = "crosshair-tool" +version = "0.0.93" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "packaging" }, + { name = "pygls" }, + { name = "typeshed-client" }, + { name = "typing-extensions" }, + { name = "typing-inspect" }, + { name = "z3-solver" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/b9/43c645afe0f82038a3b6129fca3913fab486ae5a462ab4697c64def55d07/crosshair_tool-0.0.93.tar.gz", hash = "sha256:f9fbdffb9f1b7d1bc9adfe383093237cc2a0a4721bfcd92e7634dcf3ad4701b8", size = 468407, upload-time = "2025-06-13T19:20:22.855Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/3c/2a992b360a8a61c3192b51bc5adb2b733171478af54fbe9ee1c33365a2e1/crosshair_tool-0.0.93-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:07d20d6a126c9c8b122daee7a7f02b7be5d1f9a685985e550c0beb2f36d1eb79", size = 530713, upload-time = "2025-06-13T19:19:31.308Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c8/8390ff3153c5c65f779ae7bf9fe575365674998f14f35afc838f389e2ed4/crosshair_tool-0.0.93-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2755cb95a4e28190232b391d26c907610a354e4716a524fa5f1fd6ba8f0be680", size = 522798, upload-time = "2025-06-13T19:19:33.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/99/762bbe19aa095dde3baf99741e16b182e80218277a4e77017070258aebf9/crosshair_tool-0.0.93-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6054dc87c2dc024dada927513bfe9d4887267214b8c78917445832244673aacd", size = 523574, upload-time = "2025-06-13T19:19:34.193Z" }, + { url = "https://files.pythonhosted.org/packages/99/aa/ccc859bb484ce92f381c0581fb9e05f586437adbd0317ccebec1c7254253/crosshair_tool-0.0.93-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:649649dbf1b6aede8ea3151b0d5fa1028260f3ce6695c6de8852333acb1da46b", size = 547265, upload-time = "2025-06-13T19:19:35.298Z" }, + { url = "https://files.pythonhosted.org/packages/73/6e/35124029e39c888e4fcaf4d6ffae7b291667ab1679ec72610864122a0e30/crosshair_tool-0.0.93-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e3d1976d25fef5ce19008217bc3aff0c873c17689ca68d76179467ffac241ee1", size = 546052, upload-time = "2025-06-13T19:19:36.808Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/eb09d80c71d394f0915031e62b2497cccda83eca7750c5cb9212e829e048/crosshair_tool-0.0.93-cp310-cp310-win32.whl", hash = "sha256:6698be289f91c03d42e08145a04549936ffab724773d58be2b2d8050e649956a", size = 525731, upload-time = "2025-06-13T19:19:37.974Z" }, + { url = "https://files.pythonhosted.org/packages/a4/30/59bc5f33298841b92cad3a464ee52d2f3b6aebcbdd482966136d8ace8dc3/crosshair_tool-0.0.93-cp310-cp310-win_amd64.whl", hash = "sha256:bdb9a8590905eb263e88528550795458322169e0ab9004495fa39b835faed9ae", size = 526754, upload-time = "2025-06-13T19:19:39.041Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/38219a2bf5fdcfb6655d50e4f1c03a85d71209ecbba5c30c87ed044b10f8/crosshair_tool-0.0.93-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ef4c28fa690c67a1461a9be4ee309aec230821bb1f2b434b3835c3ed2d941f5e", size = 530799, upload-time = "2025-06-13T19:19:40.46Z" }, + { url = "https://files.pythonhosted.org/packages/af/3f/bc74e0c44e19ed9328672114b0bdb298785044222ca18bb71c1319512388/crosshair_tool-0.0.93-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b7862d41f1dd0603ecc71193e4206461333e802f1c682957e11945401ffea5d1", size = 522849, upload-time = "2025-06-13T19:19:41.661Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/01c7444891b8660730d8ced1be82866bcdc9da5a4b623235d1b9bbba1a6e/crosshair_tool-0.0.93-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:526e82b554456d138df302ed739f33de1214529de93e24dc6b39a5d8bcd9a646", size = 523615, upload-time = "2025-06-13T19:19:43.033Z" }, + { url = "https://files.pythonhosted.org/packages/25/c6/26fb42f4bc0fed35c9ab054e39c64d2c5e8307ed12549bff63386241543b/crosshair_tool-0.0.93-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18086425427f0ea970ee76a93be92541f8f1e9568648dae6993ebbd3efd77920", size = 547506, upload-time = "2025-06-13T19:19:44.078Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6d/f4785200c0205321f56c098da302e9f15e9e78dbf956be907ef2511f6269/crosshair_tool-0.0.93-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e2d71aafb49b7f3fd58f8d94bafd1ad8eeca375242b16b544dc2faa9ad96a827", size = 546430, upload-time = "2025-06-13T19:19:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4c/79/bac7bb1465a8551c21161b68e50be9291e3481a4af548f7adb9e26358a32/crosshair_tool-0.0.93-cp311-cp311-win32.whl", hash = "sha256:c2aed5ea2eeaf9061bdfcb4c916e01feee9ca837cca184cab67e779612796a57", size = 525769, upload-time = "2025-06-13T19:19:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/0b/61/9daf99ccbada871688ece7109d8b8b670765807c2d495561811737308640/crosshair_tool-0.0.93-cp311-cp311-win_amd64.whl", hash = "sha256:c7542273e0b4e28c14d4f04e3044d998afcbca626729c7dced848a4661977edd", size = 526792, upload-time = "2025-06-13T19:19:47.637Z" }, + { url = "https://files.pythonhosted.org/packages/e5/96/4c34435b9c564b6ea6da5fe241aaffc1e4069432b3fdcc2a6a2052fbded7/crosshair_tool-0.0.93-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dcb24cdb031b47fb9a14141230088f0a73d48d8eaec4ca8ee8a8708b13cb0a8f", size = 534691, upload-time = "2025-06-13T19:19:49.733Z" }, + { url = "https://files.pythonhosted.org/packages/0e/3e/b0354a95189b3c4e4fa1e439ca653d5d78ca2fd3132ff5724975767fcfe8/crosshair_tool-0.0.93-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8cc632233bccfb11cf590f4c25a79c2abb490c55b9a811d17919c59315d2fdaf", size = 525273, upload-time = "2025-06-13T19:19:51.186Z" }, + { url = "https://files.pythonhosted.org/packages/b0/0f/7eb68201405237691964c35670a7c3b0e6e30ee2168794194832a74d3e5b/crosshair_tool-0.0.93-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffe166b41eee56aceb7dd311fc628e86a45c6b814677753c31e634f629405351", size = 525900, upload-time = "2025-06-13T19:19:52.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/9a/740a9f571bb90d52b7959269c57480d703189c05ca835ae0c2133306b474/crosshair_tool-0.0.93-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d0462a658c710d71626025781014626002194400c691975cbba335c5d2d816b", size = 556492, upload-time = "2025-06-13T19:19:53.233Z" }, + { url = "https://files.pythonhosted.org/packages/5e/96/64c99f77383633e1ee6a827a2850c7df14c1f228a5c7870923565f50ddea/crosshair_tool-0.0.93-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8fe1a63d8f8f3bce2dc8c05e432439d9417048f8f75648685912ca3e9dba26d8", size = 555382, upload-time = "2025-06-13T19:19:54.33Z" }, + { url = "https://files.pythonhosted.org/packages/6d/86/5ea449f43eb0682c2495eaab176776c0379b2be1116c08a8c03c61cbb233/crosshair_tool-0.0.93-cp312-cp312-win32.whl", hash = "sha256:2b196ebd6fcec055404447062a01024ae6af47c6bd4b2b8034c86d8151a77d62", size = 527434, upload-time = "2025-06-13T19:19:55.404Z" }, + { url = "https://files.pythonhosted.org/packages/b3/60/290d3d9a66a7250c737b521b9af7cf0f1fefcb9e93f83f9e725d2df5420e/crosshair_tool-0.0.93-cp312-cp312-win_amd64.whl", hash = "sha256:6a32aa2435343fc84e183ab5ca0a2c354a9443db80fc61d688b75331dd6b9c64", size = 528603, upload-time = "2025-06-13T19:19:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/4b/68/1e249e1e6f3c72679d5817d858cae741eab476ffe2797b4e57f641dee46d/crosshair_tool-0.0.93-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d52a3503fef53915e7e25cfb02fa3f14cf29207f2377344f6eaf2f778a228e94", size = 543340, upload-time = "2025-06-13T19:19:58.271Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8f/52d7093d4ed113a6d386467f025ab262d9bc94d7290b6867e5685f838c62/crosshair_tool-0.0.93-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9f30c48905f806b7c6d4bd0e99805d24f4708ee2660fbd62f0a3494df87b505f", size = 529049, upload-time = "2025-06-13T19:19:59.378Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f2/d17ec57f1a0401e4d01e63fa9fa8db2ec6d173db273c2cee6dbd4b602bb0/crosshair_tool-0.0.93-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df4ad89717c173b7c2c2e78f66b5a55d7fe162d14061f907e69d8605faa4d3c1", size = 529730, upload-time = "2025-06-13T19:20:00.613Z" }, + { url = "https://files.pythonhosted.org/packages/63/f1/144c5769492061c0522926e15e52ad943c07737071ecf76ac333b219f8a2/crosshair_tool-0.0.93-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce5f08e1cf786c8d5ebd9bd3c9f140110bec6e2b87dbca81e60a86af8651762", size = 562703, upload-time = "2025-06-13T19:20:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b8/5a552ad08e3934084c1e7ecbeb0423036b25208f3c5f46f9ca3d82ca0808/crosshair_tool-0.0.93-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:551f2887a5b7da93eeba3046df02eb9d00de8d8d343bd82a79c19ce918f0b364", size = 562066, upload-time = "2025-06-13T19:20:03.068Z" }, + { url = "https://files.pythonhosted.org/packages/03/e2/4b1d6300166c960e3972d95b7a392f0f0156d7deb23b07707920edbc265b/crosshair_tool-0.0.93-cp313-cp313-win32.whl", hash = "sha256:d382a761d643533b1379728841652ce5f4ce62d0e5d1027570268ed5207b55ec", size = 527460, upload-time = "2025-06-13T19:20:04.119Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/7e5cf34c081272dec7efb461110af334d31577b8e2df879d98e8b08ba426/crosshair_tool-0.0.93-cp313-cp313-win_amd64.whl", hash = "sha256:47583d671ce7251e146af8675343ac59da2ba572f97430f552c962971b649d80", size = 528643, upload-time = "2025-06-13T19:20:05.248Z" }, + { url = "https://files.pythonhosted.org/packages/0d/32/b12838d595970631e48219dd57e564f346e5ed32107d21a428354d14d6cc/crosshair_tool-0.0.93-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:dc5bcdf09b12ba314891816864e7b2d5fc130b4b5d645541961da4cf70ef335b", size = 530619, upload-time = "2025-06-13T19:20:14.2Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/2ffc9dd27917c91454537d9d150bdf174efc14c8fbff780a78362b20d459/crosshair_tool-0.0.93-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7e1248f0eba8cf696a7c206355fb2220054db809d06d8951dac01bf9a0b5818b", size = 522742, upload-time = "2025-06-13T19:20:15.565Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ac/e493ef85b47868bd65720c9c10c8ed66cd0549e22e46285a0296b188e1e9/crosshair_tool-0.0.93-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:465405f9e4828c1259bd6dc2b0b4f312e4fdef3fdfd1537dfe5fb19c3e2399fb", size = 523539, upload-time = "2025-06-13T19:20:16.942Z" }, + { url = "https://files.pythonhosted.org/packages/7a/2c/a74c938928b1b4711a417adab79f74004818dc13ca347ac178333d491dbe/crosshair_tool-0.0.93-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97b3e397b3b487e8f1ab5db0291bc49b29a30586ef3e911355dc344d6e4769ac", size = 546512, upload-time = "2025-06-13T19:20:18.295Z" }, + { url = "https://files.pythonhosted.org/packages/45/10/e7ceb350299c5a61f5bb0b009734ea1b3af0b6cbaca3852678cec6f867c0/crosshair_tool-0.0.93-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e6ce216159a632992a12d8cf475582f86265f22267e1be0c6839e362109c5570", size = 545363, upload-time = "2025-06-13T19:20:19.464Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0c/c3f591acd4fb9313258e071dccc23704c567e8088524f5aaf000959d020e/crosshair_tool-0.0.93-cp39-cp39-win32.whl", hash = "sha256:8a0c7753c43252c0612f2c6914281ad4f7db9038ebb978082c000b38e8b1c221", size = 525724, upload-time = "2025-06-13T19:20:20.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/ce/f7ef4fac3399ebc4fb50a0897e6ab47bd2ba3863a2b8f5b53ae3de718a7a/crosshair_tool-0.0.93-cp39-cp39-win_amd64.whl", hash = "sha256:7e297a2a0c257b2b23a5a0a7a2f0b9e5b8f5a3f012b048837ca452c49fc8d9c0", size = 526774, upload-time = "2025-06-13T19:20:21.702Z" }, +] + +[[package]] +name = "cryptography" +version = "45.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/c8/a2a376a8711c1e11708b9c9972e0c3223f5fc682552c82d8db844393d6ce/cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57", size = 744890, upload-time = "2025-06-10T00:03:51.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/1c/92637793de053832523b410dbe016d3f5c11b41d0cf6eef8787aabb51d41/cryptography-45.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:425a9a6ac2823ee6e46a76a21a4e8342d8fa5c01e08b823c1f19a8b74f096069", size = 7055712, upload-time = "2025-06-10T00:02:38.826Z" }, + { url = "https://files.pythonhosted.org/packages/ba/14/93b69f2af9ba832ad6618a03f8a034a5851dc9a3314336a3d71c252467e1/cryptography-45.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d", size = 4205335, upload-time = "2025-06-10T00:02:41.64Z" }, + { url = "https://files.pythonhosted.org/packages/67/30/fae1000228634bf0b647fca80403db5ca9e3933b91dd060570689f0bd0f7/cryptography-45.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036", size = 4431487, upload-time = "2025-06-10T00:02:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5a/7dffcf8cdf0cb3c2430de7404b327e3db64735747d641fc492539978caeb/cryptography-45.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e", size = 4208922, upload-time = "2025-06-10T00:02:45.334Z" }, + { url = "https://files.pythonhosted.org/packages/c6/f3/528729726eb6c3060fa3637253430547fbaaea95ab0535ea41baa4a6fbd8/cryptography-45.0.4-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2", size = 3900433, upload-time = "2025-06-10T00:02:47.359Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4a/67ba2e40f619e04d83c32f7e1d484c1538c0800a17c56a22ff07d092ccc1/cryptography-45.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b", size = 4464163, upload-time = "2025-06-10T00:02:49.412Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9a/b4d5aa83661483ac372464809c4b49b5022dbfe36b12fe9e323ca8512420/cryptography-45.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1", size = 4208687, upload-time = "2025-06-10T00:02:50.976Z" }, + { url = "https://files.pythonhosted.org/packages/db/b7/a84bdcd19d9c02ec5807f2ec2d1456fd8451592c5ee353816c09250e3561/cryptography-45.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999", size = 4463623, upload-time = "2025-06-10T00:02:52.542Z" }, + { url = "https://files.pythonhosted.org/packages/d8/84/69707d502d4d905021cac3fb59a316344e9f078b1da7fb43ecde5e10840a/cryptography-45.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750", size = 4332447, upload-time = "2025-06-10T00:02:54.63Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ee/d4f2ab688e057e90ded24384e34838086a9b09963389a5ba6854b5876598/cryptography-45.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2", size = 4572830, upload-time = "2025-06-10T00:02:56.689Z" }, + { url = "https://files.pythonhosted.org/packages/70/d4/994773a261d7ff98034f72c0e8251fe2755eac45e2265db4c866c1c6829c/cryptography-45.0.4-cp311-abi3-win32.whl", hash = "sha256:e00a6c10a5c53979d6242f123c0a97cff9f3abed7f064fc412c36dc521b5f257", size = 2932769, upload-time = "2025-06-10T00:02:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/5a/42/c80bd0b67e9b769b364963b5252b17778a397cefdd36fa9aa4a5f34c599a/cryptography-45.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:817ee05c6c9f7a69a16200f0c90ab26d23a87701e2a284bd15156783e46dbcc8", size = 3410441, upload-time = "2025-06-10T00:03:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0b/2488c89f3a30bc821c9d96eeacfcab6ff3accc08a9601ba03339c0fd05e5/cryptography-45.0.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:964bcc28d867e0f5491a564b7debb3ffdd8717928d315d12e0d7defa9e43b723", size = 7031836, upload-time = "2025-06-10T00:03:01.726Z" }, + { url = "https://files.pythonhosted.org/packages/fe/51/8c584ed426093aac257462ae62d26ad61ef1cbf5b58d8b67e6e13c39960e/cryptography-45.0.4-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637", size = 4195746, upload-time = "2025-06-10T00:03:03.94Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7d/4b0ca4d7af95a704eef2f8f80a8199ed236aaf185d55385ae1d1610c03c2/cryptography-45.0.4-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d", size = 4424456, upload-time = "2025-06-10T00:03:05.589Z" }, + { url = "https://files.pythonhosted.org/packages/1d/45/5fabacbc6e76ff056f84d9f60eeac18819badf0cefc1b6612ee03d4ab678/cryptography-45.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee", size = 4198495, upload-time = "2025-06-10T00:03:09.172Z" }, + { url = "https://files.pythonhosted.org/packages/55/b7/ffc9945b290eb0a5d4dab9b7636706e3b5b92f14ee5d9d4449409d010d54/cryptography-45.0.4-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff", size = 3885540, upload-time = "2025-06-10T00:03:10.835Z" }, + { url = "https://files.pythonhosted.org/packages/7f/e3/57b010282346980475e77d414080acdcb3dab9a0be63071efc2041a2c6bd/cryptography-45.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6", size = 4452052, upload-time = "2025-06-10T00:03:12.448Z" }, + { url = "https://files.pythonhosted.org/packages/37/e6/ddc4ac2558bf2ef517a358df26f45bc774a99bf4653e7ee34b5e749c03e3/cryptography-45.0.4-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad", size = 4198024, upload-time = "2025-06-10T00:03:13.976Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c0/85fa358ddb063ec588aed4a6ea1df57dc3e3bc1712d87c8fa162d02a65fc/cryptography-45.0.4-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6", size = 4451442, upload-time = "2025-06-10T00:03:16.248Z" }, + { url = "https://files.pythonhosted.org/packages/33/67/362d6ec1492596e73da24e669a7fbbaeb1c428d6bf49a29f7a12acffd5dc/cryptography-45.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872", size = 4325038, upload-time = "2025-06-10T00:03:18.4Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/82a14bf047a96a1b13ebb47fb9811c4f73096cfa2e2b17c86879687f9027/cryptography-45.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4", size = 4560964, upload-time = "2025-06-10T00:03:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/cd/37/1a3cba4c5a468ebf9b95523a5ef5651244693dc712001e276682c278fc00/cryptography-45.0.4-cp37-abi3-win32.whl", hash = "sha256:c22fe01e53dc65edd1945a2e6f0015e887f84ced233acecb64b4daadb32f5c97", size = 2924557, upload-time = "2025-06-10T00:03:22.563Z" }, + { url = "https://files.pythonhosted.org/packages/2a/4b/3256759723b7e66380397d958ca07c59cfc3fb5c794fb5516758afd05d41/cryptography-45.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:627ba1bc94f6adf0b0a2e35d87020285ead22d9f648c7e75bb64f367375f3b22", size = 3395508, upload-time = "2025-06-10T00:03:24.586Z" }, + { url = "https://files.pythonhosted.org/packages/16/33/b38e9d372afde56906a23839302c19abdac1c505bfb4776c1e4b07c3e145/cryptography-45.0.4-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a77c6fb8d76e9c9f99f2f3437c1a4ac287b34eaf40997cfab1e9bd2be175ac39", size = 3580103, upload-time = "2025-06-10T00:03:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b9/357f18064ec09d4807800d05a48f92f3b369056a12f995ff79549fbb31f1/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7aad98a25ed8ac917fdd8a9c1e706e5a0956e06c498be1f713b61734333a4507", size = 4143732, upload-time = "2025-06-10T00:03:27.896Z" }, + { url = "https://files.pythonhosted.org/packages/c4/9c/7f7263b03d5db329093617648b9bd55c953de0b245e64e866e560f9aac07/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3530382a43a0e524bc931f187fc69ef4c42828cf7d7f592f7f249f602b5a4ab0", size = 4385424, upload-time = "2025-06-10T00:03:29.992Z" }, + { url = "https://files.pythonhosted.org/packages/a6/5a/6aa9d8d5073d5acc0e04e95b2860ef2684b2bd2899d8795fc443013e263b/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:6b613164cb8425e2f8db5849ffb84892e523bf6d26deb8f9bb76ae86181fa12b", size = 4142438, upload-time = "2025-06-10T00:03:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/42/1c/71c638420f2cdd96d9c2b287fec515faf48679b33a2b583d0f1eda3a3375/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:96d4819e25bf3b685199b304a0029ce4a3caf98947ce8a066c9137cc78ad2c58", size = 4384622, upload-time = "2025-06-10T00:03:33.491Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ab/e3a055c34e97deadbf0d846e189237d3385dca99e1a7e27384c3b2292041/cryptography-45.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b97737a3ffbea79eebb062eb0d67d72307195035332501722a9ca86bab9e3ab2", size = 3328911, upload-time = "2025-06-10T00:03:35.035Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ba/cf442ae99ef363855ed84b39e0fb3c106ac66b7a7703f3c9c9cfe05412cb/cryptography-45.0.4-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4828190fb6c4bcb6ebc6331f01fe66ae838bb3bd58e753b59d4b22eb444b996c", size = 3590512, upload-time = "2025-06-10T00:03:36.982Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a7d5bb87d149eb99a5abdc69a41e4e47b8001d767e5f403f78bfaafc7aa7/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:03dbff8411206713185b8cebe31bc5c0eb544799a50c09035733716b386e61a4", size = 4146899, upload-time = "2025-06-10T00:03:38.659Z" }, + { url = "https://files.pythonhosted.org/packages/17/11/9361c2c71c42cc5c465cf294c8030e72fb0c87752bacbd7a3675245e3db3/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51dfbd4d26172d31150d84c19bbe06c68ea4b7f11bbc7b3a5e146b367c311349", size = 4388900, upload-time = "2025-06-10T00:03:40.233Z" }, + { url = "https://files.pythonhosted.org/packages/c0/76/f95b83359012ee0e670da3e41c164a0c256aeedd81886f878911581d852f/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:0339a692de47084969500ee455e42c58e449461e0ec845a34a6a9b9bf7df7fb8", size = 4146422, upload-time = "2025-06-10T00:03:41.827Z" }, + { url = "https://files.pythonhosted.org/packages/09/ad/5429fcc4def93e577a5407988f89cf15305e64920203d4ac14601a9dc876/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:0cf13c77d710131d33e63626bd55ae7c0efb701ebdc2b3a7952b9b23a0412862", size = 4388475, upload-time = "2025-06-10T00:03:43.493Z" }, + { url = "https://files.pythonhosted.org/packages/99/49/0ab9774f64555a1b50102757811508f5ace451cf5dc0a2d074a4b9deca6a/cryptography-45.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bbc505d1dc469ac12a0a064214879eac6294038d6b24ae9f71faae1448a9608d", size = 3337594, upload-time = "2025-06-10T00:03:45.523Z" }, +] + +[[package]] +name = "daphne" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "autobahn" }, + { name = "twisted", extra = ["tls"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/fa/88208e036d1a000cc99eed11a5ddae2397a39d89c44ac22a3c35a58eb951/daphne-4.2.0.tar.gz", hash = "sha256:c055de9e685cab7aa369e25e16731baa9b310b9db1a76886dbdde0b4456fb056", size = 45302, upload-time = "2025-05-16T14:46:48.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/ad/b00755d09a1ec080ad5fe0d5a6e609f9e4441755204a16c17cc7a07f80f6/daphne-4.2.0-py3-none-any.whl", hash = "sha256:ccc7a476c498272237e27758a02aff11c76ab777c4e20b9b6c141729db599d5d", size = 28493, upload-time = "2025-05-16T14:46:46.859Z" }, +] + +[[package]] +name = "dependency-groups" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/55/f054de99871e7beb81935dea8a10b90cd5ce42122b1c3081d5282fdb3621/dependency_groups-1.3.1.tar.gz", hash = "sha256:78078301090517fd938c19f64a53ce98c32834dfe0dee6b88004a569a6adfefd", size = 10093, upload-time = "2025-05-02T00:34:29.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/c7/d1ec24fb280caa5a79b6b950db565dab30210a66259d17d5bb2b3a9f878d/dependency_groups-1.3.1-py3-none-any.whl", hash = "sha256:51aeaa0dfad72430fcfb7bcdbefbd75f3792e5919563077f30bc0d73f4493030", size = 8664, upload-time = "2025-05-02T00:34:27.085Z" }, +] + +[[package]] +name = "dill" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976, upload-time = "2025-04-16T00:41:48.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" }, +] + +[[package]] +name = "distlib" +version = "0.3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923, upload-time = "2024-10-09T18:35:47.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "django" +version = "4.2.23" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version < '3.10'" }, + { name = "sqlparse", marker = "python_full_version < '3.10'" }, + { name = "tzdata", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/20/02242739714eb4e53933d6c0fe2c57f41feb449955b0aa39fc2da82b8f3c/django-4.2.23.tar.gz", hash = "sha256:42fdeaba6e6449d88d4f66de47871015097dc6f1b87910db00a91946295cfae4", size = 10448384, upload-time = "2025-06-10T10:06:34.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/44/314e8e4612bd122dd0424c88b44730af68eafbee88cc887a86586b7a1f2a/django-4.2.23-py3-none-any.whl", hash = "sha256:dafbfaf52c2f289bd65f4ab935791cb4fb9a198f2a5ba9faf35d7338a77e9803", size = 7993904, upload-time = "2025-06-10T10:06:28.092Z" }, +] + +[[package]] +name = "django" +version = "5.2.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version >= '3.10' and python_full_version < '3.13'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version >= '3.10'" }, + { name = "sqlparse", marker = "python_full_version >= '3.10'" }, + { name = "tzdata", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/af/77b403926025dc6f7fd7b31256394d643469418965eb528eab45d0505358/django-5.2.3.tar.gz", hash = "sha256:335213277666ab2c5cac44a792a6d2f3d58eb79a80c14b6b160cd4afc3b75684", size = 10850303, upload-time = "2025-06-10T10:14:05.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/11/7aff961db37e1ea501a2bb663d27a8ce97f3683b9e5b83d3bfead8b86fa4/django-5.2.3-py3-none-any.whl", hash = "sha256:c517a6334e0fd940066aa9467b29401b93c37cec2e61365d663b80922542069d", size = 8301935, upload-time = "2025-06-10T10:13:58.993Z" }, +] + +[[package]] +name = "dulwich" +version = "0.22.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/8b/0f2de00c0c0d5881dc39be147ec2918725fb3628deeeb1f27d1c6cf6d9f4/dulwich-0.22.8.tar.gz", hash = "sha256:701547310415de300269331abe29cb5717aa1ea377af826bf513d0adfb1c209b", size = 466542, upload-time = "2025-03-02T23:08:10.375Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/4d/0bfc8a96456d033428875003b5104da2c32407363b5b829da5e27553b403/dulwich-0.22.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:546176d18b8cc0a492b0f23f07411e38686024cffa7e9d097ae20512a2e57127", size = 925150, upload-time = "2025-03-02T23:06:45.982Z" }, + { url = "https://files.pythonhosted.org/packages/99/71/0dd97cf5a7a09aee93f8266421898d705eba737ca904720450584f471bd3/dulwich-0.22.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d2434dd72b2ae09b653c9cfe6764a03c25cfbd99fbbb7c426f0478f6fb1100f", size = 994973, upload-time = "2025-03-02T23:06:48.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/40/831bed622eeacfa21f47d1fd75fc0c33a70a2cf1c091ae955be63e94144c/dulwich-0.22.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe8318bc0921d42e3e69f03716f983a301b5ee4c8dc23c7f2c5bbb28581257a9", size = 1002875, upload-time = "2025-03-02T23:06:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9e/5255b3927f355c95f6779debf11d551b7bb427a80a11564a1e1b78f0acf6/dulwich-0.22.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7a0f96a2a87f3b4f7feae79d2ac6b94107d6b7d827ac08f2f331b88c8f597a1", size = 1046048, upload-time = "2025-03-02T23:06:53.173Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f9/d3041cea8cbaaffbd4bf95343c5c16d64608200fc5fa26418bee00ebff23/dulwich-0.22.8-cp310-cp310-win32.whl", hash = "sha256:432a37b25733202897b8d67cdd641688444d980167c356ef4e4dd15a17a39a24", size = 592790, upload-time = "2025-03-02T23:06:55.319Z" }, + { url = "https://files.pythonhosted.org/packages/94/95/e90a292fb00ffae4f3fbb53b199574eedfaf57b72b67a8ddb835536fc66b/dulwich-0.22.8-cp310-cp310-win_amd64.whl", hash = "sha256:f3a15e58dac8b8a76073ddca34e014f66f3672a5540a99d49ef6a9c09ab21285", size = 609197, upload-time = "2025-03-02T23:06:57.439Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6e/de1a1c35960d0e399f71725cfcd4dfdb3c391b22c0e5059d991f7ade3488/dulwich-0.22.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0852edc51cff4f4f62976bdaa1d82f6ef248356c681c764c0feb699bc17d5782", size = 925222, upload-time = "2025-03-02T23:06:59.595Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/b65953b4e9c39268c67038bb8d88516885b720beb25b0f6a0ae95ea3f6b2/dulwich-0.22.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:826aae8b64ac1a12321d6b272fc13934d8f62804fda2bc6ae46f93f4380798eb", size = 994572, upload-time = "2025-03-02T23:07:00.971Z" }, + { url = "https://files.pythonhosted.org/packages/13/eb/07e3974964bfe05888457f7764cfe53b6b95082313c2be06fbbb72116372/dulwich-0.22.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7ae726f923057d36cdbb9f4fb7da0d0903751435934648b13f1b851f0e38ea1", size = 1002530, upload-time = "2025-03-02T23:07:02.927Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b3/69aebfda4dd4b05ae11af803e4df2d8d350356a30b3b6b6fc662fa1ff729/dulwich-0.22.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6987d753227f55cf75ba29a8dab69d1d83308ce483d7a8c6d223086f7a42e125", size = 1046084, upload-time = "2025-03-02T23:07:04.901Z" }, + { url = "https://files.pythonhosted.org/packages/d4/88/ea0f473d726e117f9fcd7c7a95d97f9ba0e0ee9d9005d745a38809d33352/dulwich-0.22.8-cp311-cp311-win32.whl", hash = "sha256:7757b4a2aad64c6f1920082fc1fccf4da25c3923a0ae7b242c08d06861dae6e1", size = 593130, upload-time = "2025-03-02T23:07:07.336Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/ed23a435d6922ba7d9601404f473e49acdcb5768a35d89a5bc5fa51d882b/dulwich-0.22.8-cp311-cp311-win_amd64.whl", hash = "sha256:12b243b7e912011c7225dc67480c313ac8d2990744789b876016fb593f6f3e19", size = 609118, upload-time = "2025-03-02T23:07:11.171Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f2/53c5a22a4a9c0033e10f35c293bc533d64fe3e0c4ff4421128a97d6feda9/dulwich-0.22.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d81697f74f50f008bb221ab5045595f8a3b87c0de2c86aa55be42ba97421f3cd", size = 915677, upload-time = "2025-03-02T23:07:13.292Z" }, + { url = "https://files.pythonhosted.org/packages/02/57/7163ed06a2d9bf1f34d89dcc7c5881119beeed287022c997b0a706edcfbe/dulwich-0.22.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bff1da8e2e6a607c3cb45f5c2e652739589fe891245e1d5b770330cdecbde41", size = 991955, upload-time = "2025-03-02T23:07:14.633Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/50ddf1f3ad592c2526cb34287f45b07ee6320b850efddda2917cc81ac651/dulwich-0.22.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9969099e15b939d3936f8bee8459eaef7ef5a86cd6173393a17fe28ca3d38aff", size = 1000045, upload-time = "2025-03-02T23:07:16.807Z" }, + { url = "https://files.pythonhosted.org/packages/70/6b/1153b2793bfc34253589badb5fc22ed476cf741dab7854919e6e51cb0441/dulwich-0.22.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:017152c51b9a613f0698db28c67cf3e0a89392d28050dbf4f4ac3f657ea4c0dc", size = 1044291, upload-time = "2025-03-02T23:07:18.912Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e3/6b013b98254d7f508f21456832e757b17a9116752979e8b923f89f8c8989/dulwich-0.22.8-cp312-cp312-win32.whl", hash = "sha256:ee70e8bb8798b503f81b53f7a103cb869c8e89141db9005909f79ab1506e26e9", size = 591258, upload-time = "2025-03-02T23:07:21.038Z" }, + { url = "https://files.pythonhosted.org/packages/81/20/b149f68557d42607b5dcc6f57c1650f2136049be617f3e68092c25861275/dulwich-0.22.8-cp312-cp312-win_amd64.whl", hash = "sha256:dc89c6f14dcdcbfee200b0557c59ae243835e42720be143526d834d0e53ed3af", size = 608693, upload-time = "2025-03-02T23:07:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b7/78116bfe8860edca277d00ac243749c8b94714dc3b4608f0c23fa7f4b78e/dulwich-0.22.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbade3342376be1cd2409539fe1b901d2d57a531106bbae204da921ef4456a74", size = 915617, upload-time = "2025-03-02T23:07:25.18Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/28c317a83d6ae9ca93a8decfaa50f09b25a73134f5087a98f51fa5a2d784/dulwich-0.22.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71420ffb6deebc59b2ce875e63d814509f9c1dc89c76db962d547aebf15670c7", size = 991271, upload-time = "2025-03-02T23:07:26.554Z" }, + { url = "https://files.pythonhosted.org/packages/84/a0/64a0376f79c7fb87ec6e6d9a0e2157f3196d1f5f75618c402645ac5ccf19/dulwich-0.22.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a626adbfac44646a125618266a24133763bdc992bf8bd0702910d67e6b994443", size = 999791, upload-time = "2025-03-02T23:07:28.068Z" }, + { url = "https://files.pythonhosted.org/packages/63/c3/260f060ededcdf5f13a7e63a36329c95225bf8e8c3f50aeca6820850b56a/dulwich-0.22.8-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f1476c9c4e4ede95714d06c4831883a26680e37b040b8b6230f506e5ba39f51", size = 1043970, upload-time = "2025-03-02T23:07:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/11/47/2bc02dd1c25eb13cb3cd20cd5a55dd9d7b9fa6af95ed574dd913dd67a0fb/dulwich-0.22.8-cp313-cp313-win32.whl", hash = "sha256:b2b31913932bb5bd41658dd398b33b1a2d4d34825123ad54e40912cfdfe60003", size = 590548, upload-time = "2025-03-02T23:07:31.518Z" }, + { url = "https://files.pythonhosted.org/packages/f3/17/66368fa9d4cffd52663d20354a74aa42d3a6d998f1a462e30aff38c99d25/dulwich-0.22.8-cp313-cp313-win_amd64.whl", hash = "sha256:7a44e5a61a7989aca1e301d39cfb62ad2f8853368682f524d6e878b4115d823d", size = 608200, upload-time = "2025-03-02T23:07:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c5/c67e7742c5fa7d70a01eb8689b3c2014e5151169fc5d19186ec81899001b/dulwich-0.22.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f9cd0c67fb44a38358b9fcabee948bf11044ef6ce7a129e50962f54c176d084e", size = 926618, upload-time = "2025-03-02T23:07:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/3a/92/7bd8fc43b02d6f3f997a5a201af6effed0d026359877092f84d50ac5f327/dulwich-0.22.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b79b94726c3f4a9e5a830c649376fd0963236e73142a4290bac6bc9fc9cb120", size = 995038, upload-time = "2025-03-02T23:07:35.979Z" }, + { url = "https://files.pythonhosted.org/packages/96/f3/8f96461752375bc0b81cab941d58824a1359b84d43a49311b5213a9699d0/dulwich-0.22.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16bbe483d663944972e22d64e1f191201123c3b5580fbdaac6a4f66bfaa4fc11", size = 1003876, upload-time = "2025-03-02T23:07:37.497Z" }, + { url = "https://files.pythonhosted.org/packages/d5/34/5d3b5b1ace0c2ab964f0a724f57523e07cf02eafa45df39328cd4bcf2e99/dulwich-0.22.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e02d403af23d93dc1f96eb2408e25efd50046e38590a88c86fa4002adc9849b0", size = 1048552, upload-time = "2025-03-02T23:07:39.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d9/16fcd2c973aa2c1ec3e880c43c95f5afced1abb3f655f5a3fd1911abf02b/dulwich-0.22.8-cp39-cp39-win32.whl", hash = "sha256:8bdd9543a77fb01be704377f5e634b71f955fec64caa4a493dc3bfb98e3a986e", size = 594500, upload-time = "2025-03-02T23:07:41.683Z" }, + { url = "https://files.pythonhosted.org/packages/ef/9b/e7f3d9a5b7ceed1c1051237abd48b5fa1c1a3ab716a4f9c56a1a2f5e839a/dulwich-0.22.8-cp39-cp39-win_amd64.whl", hash = "sha256:3b6757c6b3ba98212b854a766a4157b9cb79a06f4e1b06b46dec4bd834945b8e", size = 610275, upload-time = "2025-03-02T23:07:43.105Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a3/7f88ba8ed56eaed6206a7d9b35244964a32eb08635be33f2af60819e6431/dulwich-0.22.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7bb18fa09daa1586c1040b3e2777d38d4212a5cdbe47d384ba66a1ac336fcc4c", size = 947436, upload-time = "2025-03-02T23:07:44.398Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d0/664a38f03cf4264a4ab9112067eb4998d14ffbf3af4cff9fb2d1447f11bc/dulwich-0.22.8-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b2fda8e87907ed304d4a5962aea0338366144df0df60f950b8f7f125871707f", size = 998380, upload-time = "2025-03-02T23:07:45.935Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e4/3595a23375b797a8602a2ca8f6b8207b4ebdf2e3a1ccba306f7b90d74c3f/dulwich-0.22.8-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1748cd573a0aee4d530bc223a23ccb8bb5b319645931a37bd1cfb68933b720c1", size = 1006758, upload-time = "2025-03-02T23:07:47.503Z" }, + { url = "https://files.pythonhosted.org/packages/20/d1/32d89d37da8e2ae947558db0401940594efdda9fa5bb1c55c2b46c43f244/dulwich-0.22.8-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a631b2309feb9a9631eabd896612ba36532e3ffedccace57f183bb868d7afc06", size = 1050947, upload-time = "2025-03-02T23:07:49.208Z" }, + { url = "https://files.pythonhosted.org/packages/f5/dc/b9448b82de3e244400dc35813f31db9f4952605c7d4e3041fd94878613c9/dulwich-0.22.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:00e7d9a3d324f9e0a1b27880eec0e8e276ff76519621b66c1a429ca9eb3f5a8d", size = 612479, upload-time = "2025-03-02T23:07:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/e2/20/d855d603ea49ce437d2a015fad9dbb22409e23520340aef3d3dca8b299bb/dulwich-0.22.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f8aa3de93201f9e3e40198725389aa9554a4ee3318a865f96a8e9bc9080f0b25", size = 947073, upload-time = "2025-03-02T23:07:52.082Z" }, + { url = "https://files.pythonhosted.org/packages/30/06/390a3a9ce2f4d5b20af0e64f0e9bcefb4a87ad30ef53ee122887f5444076/dulwich-0.22.8-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e8da9dd8135884975f5be0563ede02179240250e11f11942801ae31ac293f37", size = 997873, upload-time = "2025-03-02T23:07:54.399Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cd/3c5731784bac200e41b5e66b1440f9f30f92781d3eeefb9f90147c3d392e/dulwich-0.22.8-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fc5ce2435fb3abdf76f1acabe48f2e4b3f7428232cadaef9daaf50ea7fa30ee", size = 1006609, upload-time = "2025-03-02T23:07:56.091Z" }, + { url = "https://files.pythonhosted.org/packages/19/cf/01180599b0028e2175da4c0878fbe050d1f197825529be19718f65c5a475/dulwich-0.22.8-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:982b21cc3100d959232cadb3da0a478bd549814dd937104ea50f43694ec27153", size = 1051004, upload-time = "2025-03-02T23:07:58.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/7b/df95faaf8746cce65704f1631a6626e5bb4604a499a0f63fc9103669deba/dulwich-0.22.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6bde2b13a05cc0ec2ecd4597a99896663544c40af1466121f4d046119b874ce3", size = 612529, upload-time = "2025-03-02T23:07:59.731Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/f9736e4a94f2d13220169c3293167e5d154508a6038613fcda8cc2515c55/dulwich-0.22.8-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6d446cb7d272a151934ad4b48ba691f32486d5267cf2de04ee3b5e05fc865326", size = 947961, upload-time = "2025-03-02T23:08:01.842Z" }, + { url = "https://files.pythonhosted.org/packages/3b/20/7d7a38b8409514365bd0bc046ced20f011daf363dba55434643a9cfbb484/dulwich-0.22.8-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f6338e6cf95cd76a0191b3637dc3caed1f988ae84d8e75f876d5cd75a8dd81a", size = 998944, upload-time = "2025-03-02T23:08:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/4f/a95c197882dd93c5e3997f64d5e53cd70ceec4dcc8ff9eb8fc1eb0cab34f/dulwich-0.22.8-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e004fc532ea262f2d5f375068101ca4792becb9d4aa663b050f5ac31fda0bb5c", size = 1007748, upload-time = "2025-03-02T23:08:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/79/45/d29a9fca7960d8ef9eb7e2cc8a8049add3a2e831e48a56f07a5ae886ace6/dulwich-0.22.8-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6bfdbc6fa477dee00d04e22d43a51571cd820cfaaaa886f0f155b8e29b3e3d45", size = 1053398, upload-time = "2025-03-02T23:08:06.29Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3a/2fdc2e85d9eea6324617a566138f60ffc2b3fdf89cd058aae0c4edb72a22/dulwich-0.22.8-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ae900c8e573f79d714c1d22b02cdadd50b64286dd7203028f0200f82089e4950", size = 613736, upload-time = "2025-03-02T23:08:07.662Z" }, + { url = "https://files.pythonhosted.org/packages/37/56/395c6d82d4d9eb7a7ab62939c99db5b746995b0f3ad3b31f43c15e3e07a0/dulwich-0.22.8-py3-none-any.whl", hash = "sha256:ffc7a02e62b72884de58baaa3b898b7f6427893e79b1289ffa075092efe59181", size = 273071, upload-time = "2025-03-02T23:08:09.013Z" }, +] + +[[package]] +name = "editor" +version = "1.6.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "runs" }, + { name = "xmod" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/92/734a4ab345914259cb6146fd36512608ea42be16195375c379046f33283d/editor-1.6.6.tar.gz", hash = "sha256:bb6989e872638cd119db9a4fce284cd8e13c553886a1c044c6b8d8a160c871f8", size = 3197, upload-time = "2024-01-25T10:44:59.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/c2/4bc8cd09b14e28ce3f406a8b05761bed0d785d1ca8c2a5c6684d884c66a2/editor-1.6.6-py3-none-any.whl", hash = "sha256:e818e6913f26c2a81eadef503a2741d7cca7f235d20e217274a009ecd5a74abf", size = 4017, upload-time = "2024-01-25T10:44:58.66Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524, upload-time = "2024-04-08T09:04:19.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612, upload-time = "2024-04-08T09:04:17.414Z" }, +] + +[[package]] +name = "executing" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693, upload-time = "2025-01-22T15:41:29.403Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702, upload-time = "2025-01-22T15:41:25.929Z" }, +] + +[[package]] +name = "faker" +version = "37.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/f9/66af4019ee952fc84b8fe5b523fceb7f9e631ed8484417b6f1e3092f8290/faker-37.4.0.tar.gz", hash = "sha256:7f69d579588c23d5ce671f3fa872654ede0e67047820255f43a4aa1925b89780", size = 1901976, upload-time = "2025-06-11T17:59:30.818Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/5e/c8c3c5ea0896ab747db2e2889bf5a6f618ed291606de6513df56ad8670a8/faker-37.4.0-py3-none-any.whl", hash = "sha256:cb81c09ebe06c32a10971d1bbdb264bb0e22b59af59548f011ac4809556ce533", size = 1942992, upload-time = "2025-06-11T17:59:28.698Z" }, +] + +[[package]] +name = "fastapi" +version = "0.115.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/64/ec0788201b5554e2a87c49af26b77a4d132f807a0fa9675257ac92c6aa0e/fastapi-0.115.13.tar.gz", hash = "sha256:55d1d25c2e1e0a0a50aceb1c8705cd932def273c102bff0b1c1da88b3c6eb307", size = 295680, upload-time = "2025-06-17T11:49:45.575Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/4a/e17764385382062b0edbb35a26b7cf76d71e27e456546277a42ba6545c6e/fastapi-0.115.13-py3-none-any.whl", hash = "sha256:0a0cab59afa7bab22f5eb347f8c9864b681558c278395e94035a741fc10cd865", size = 95315, upload-time = "2025-06-17T11:49:44.106Z" }, +] + +[[package]] +name = "fastjsonschema" +version = "2.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/50/4b769ce1ac4071a1ef6d86b1a3fb56cdc3a37615e8c5519e1af96cdac366/fastjsonschema-2.21.1.tar.gz", hash = "sha256:794d4f0a58f848961ba16af7b9c85a3e88cd360df008c59aac6fc5ae9323b5d4", size = 373939, upload-time = "2024-12-02T10:55:15.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/2b/0817a2b257fe88725c25589d89aec060581aabf668707a8d03b2e9e0cb2a/fastjsonschema-2.21.1-py3-none-any.whl", hash = "sha256:c9e5b7e908310918cf494a434eeb31384dd84a98b57a30bcb1f535015b554667", size = 23924, upload-time = "2024-12-02T10:55:07.599Z" }, +] + +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, +] + +[[package]] +name = "findpython" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/73/ab2c4fb7972145c1595c07837cffc1456c1510a908f5c8bda9745930ee60/findpython-0.6.3.tar.gz", hash = "sha256:5863ea55556d8aadc693481a14ac4f3624952719efc1c5591abb0b4a9e965c94", size = 17827, upload-time = "2025-03-10T02:21:20.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/cc/10e4ec45585eba7784a6e86f21990e97b828b8d8927d28ae639b06d50c59/findpython-0.6.3-py3-none-any.whl", hash = "sha256:a85bb589b559cdf1b87227cc233736eb7cad894b9e68021ee498850611939ebc", size = 20564, upload-time = "2025-03-10T02:21:19.624Z" }, +] + +[[package]] +name = "flask" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/de/e47735752347f4128bcf354e0da07ef311a78244eba9e3dc1d4a5ab21a98/flask-3.1.1.tar.gz", hash = "sha256:284c7b8f2f58cb737f0cf1c30fd7eaf0ccfcde196099d24ecede3fc2005aa59e", size = 753440, upload-time = "2025-05-13T15:01:17.447Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/68/9d4508e893976286d2ead7f8f571314af6c2037af34853a30fd769c02e9d/flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c", size = 103305, upload-time = "2025-05-13T15:01:15.591Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/36/0da0a49409f6b47cc2d060dc8c9040b897b5902a8a4e37d9bc1deb11f680/frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a", size = 81304, upload-time = "2025-06-09T22:59:46.226Z" }, + { url = "https://files.pythonhosted.org/packages/77/f0/77c11d13d39513b298e267b22eb6cb559c103d56f155aa9a49097221f0b6/frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61", size = 47735, upload-time = "2025-06-09T22:59:48.133Z" }, + { url = "https://files.pythonhosted.org/packages/37/12/9d07fa18971a44150593de56b2f2947c46604819976784bcf6ea0d5db43b/frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d", size = 46775, upload-time = "2025-06-09T22:59:49.564Z" }, + { url = "https://files.pythonhosted.org/packages/70/34/f73539227e06288fcd1f8a76853e755b2b48bca6747e99e283111c18bcd4/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e", size = 224644, upload-time = "2025-06-09T22:59:51.35Z" }, + { url = "https://files.pythonhosted.org/packages/fb/68/c1d9c2f4a6e438e14613bad0f2973567586610cc22dcb1e1241da71de9d3/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9", size = 222125, upload-time = "2025-06-09T22:59:52.884Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d0/98e8f9a515228d708344d7c6986752be3e3192d1795f748c24bcf154ad99/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c", size = 233455, upload-time = "2025-06-09T22:59:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/79/df/8a11bcec5600557f40338407d3e5bea80376ed1c01a6c0910fcfdc4b8993/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981", size = 227339, upload-time = "2025-06-09T22:59:56.187Z" }, + { url = "https://files.pythonhosted.org/packages/50/82/41cb97d9c9a5ff94438c63cc343eb7980dac4187eb625a51bdfdb7707314/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615", size = 212969, upload-time = "2025-06-09T22:59:57.604Z" }, + { url = "https://files.pythonhosted.org/packages/13/47/f9179ee5ee4f55629e4f28c660b3fdf2775c8bfde8f9c53f2de2d93f52a9/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50", size = 222862, upload-time = "2025-06-09T22:59:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/df81e41ec6b953902c8b7e3a83bee48b195cb0e5ec2eabae5d8330c78038/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa", size = 222492, upload-time = "2025-06-09T23:00:01.026Z" }, + { url = "https://files.pythonhosted.org/packages/84/17/30d6ea87fa95a9408245a948604b82c1a4b8b3e153cea596421a2aef2754/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577", size = 238250, upload-time = "2025-06-09T23:00:03.401Z" }, + { url = "https://files.pythonhosted.org/packages/8f/00/ecbeb51669e3c3df76cf2ddd66ae3e48345ec213a55e3887d216eb4fbab3/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59", size = 218720, upload-time = "2025-06-09T23:00:05.282Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c0/c224ce0e0eb31cc57f67742071bb470ba8246623c1823a7530be0e76164c/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e", size = 232585, upload-time = "2025-06-09T23:00:07.962Z" }, + { url = "https://files.pythonhosted.org/packages/55/3c/34cb694abf532f31f365106deebdeac9e45c19304d83cf7d51ebbb4ca4d1/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd", size = 234248, upload-time = "2025-06-09T23:00:09.428Z" }, + { url = "https://files.pythonhosted.org/packages/98/c0/2052d8b6cecda2e70bd81299e3512fa332abb6dcd2969b9c80dfcdddbf75/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718", size = 221621, upload-time = "2025-06-09T23:00:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bf/7dcebae315436903b1d98ffb791a09d674c88480c158aa171958a3ac07f0/frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e", size = 39578, upload-time = "2025-06-09T23:00:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/8f/5f/f69818f017fa9a3d24d1ae39763e29b7f60a59e46d5f91b9c6b21622f4cd/frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464", size = 43830, upload-time = "2025-06-09T23:00:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/34/7e/803dde33760128acd393a27eb002f2020ddb8d99d30a44bfbaab31c5f08a/frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a", size = 82251, upload-time = "2025-06-09T23:00:16.279Z" }, + { url = "https://files.pythonhosted.org/packages/75/a9/9c2c5760b6ba45eae11334db454c189d43d34a4c0b489feb2175e5e64277/frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750", size = 48183, upload-time = "2025-06-09T23:00:17.698Z" }, + { url = "https://files.pythonhosted.org/packages/47/be/4038e2d869f8a2da165f35a6befb9158c259819be22eeaf9c9a8f6a87771/frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd", size = 47107, upload-time = "2025-06-09T23:00:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/79/26/85314b8a83187c76a37183ceed886381a5f992975786f883472fcb6dc5f2/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2", size = 237333, upload-time = "2025-06-09T23:00:20.275Z" }, + { url = "https://files.pythonhosted.org/packages/1f/fd/e5b64f7d2c92a41639ffb2ad44a6a82f347787abc0c7df5f49057cf11770/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f", size = 231724, upload-time = "2025-06-09T23:00:21.705Z" }, + { url = "https://files.pythonhosted.org/packages/20/fb/03395c0a43a5976af4bf7534759d214405fbbb4c114683f434dfdd3128ef/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30", size = 245842, upload-time = "2025-06-09T23:00:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/d0/15/c01c8e1dffdac5d9803507d824f27aed2ba76b6ed0026fab4d9866e82f1f/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98", size = 239767, upload-time = "2025-06-09T23:00:25.103Z" }, + { url = "https://files.pythonhosted.org/packages/14/99/3f4c6fe882c1f5514b6848aa0a69b20cb5e5d8e8f51a339d48c0e9305ed0/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86", size = 224130, upload-time = "2025-06-09T23:00:27.061Z" }, + { url = "https://files.pythonhosted.org/packages/4d/83/220a374bd7b2aeba9d0725130665afe11de347d95c3620b9b82cc2fcab97/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae", size = 235301, upload-time = "2025-06-09T23:00:29.02Z" }, + { url = "https://files.pythonhosted.org/packages/03/3c/3e3390d75334a063181625343e8daab61b77e1b8214802cc4e8a1bb678fc/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8", size = 234606, upload-time = "2025-06-09T23:00:30.514Z" }, + { url = "https://files.pythonhosted.org/packages/23/1e/58232c19608b7a549d72d9903005e2d82488f12554a32de2d5fb59b9b1ba/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31", size = 248372, upload-time = "2025-06-09T23:00:31.966Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/e4a567e01702a88a74ce8a324691e62a629bf47d4f8607f24bf1c7216e7f/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7", size = 229860, upload-time = "2025-06-09T23:00:33.375Z" }, + { url = "https://files.pythonhosted.org/packages/73/a6/63b3374f7d22268b41a9db73d68a8233afa30ed164c46107b33c4d18ecdd/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5", size = 245893, upload-time = "2025-06-09T23:00:35.002Z" }, + { url = "https://files.pythonhosted.org/packages/6d/eb/d18b3f6e64799a79673c4ba0b45e4cfbe49c240edfd03a68be20002eaeaa/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898", size = 246323, upload-time = "2025-06-09T23:00:36.468Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f5/720f3812e3d06cd89a1d5db9ff6450088b8f5c449dae8ffb2971a44da506/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56", size = 233149, upload-time = "2025-06-09T23:00:37.963Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/03efbf545e217d5db8446acfd4c447c15b7c8cf4dbd4a58403111df9322d/frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7", size = 39565, upload-time = "2025-06-09T23:00:39.753Z" }, + { url = "https://files.pythonhosted.org/packages/58/17/fe61124c5c333ae87f09bb67186d65038834a47d974fc10a5fadb4cc5ae1/frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d", size = 44019, upload-time = "2025-06-09T23:00:40.988Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" }, + { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084, upload-time = "2025-06-09T23:00:46.125Z" }, + { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524, upload-time = "2025-06-09T23:00:47.73Z" }, + { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493, upload-time = "2025-06-09T23:00:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116, upload-time = "2025-06-09T23:00:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557, upload-time = "2025-06-09T23:00:52.855Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820, upload-time = "2025-06-09T23:00:54.43Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542, upload-time = "2025-06-09T23:00:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350, upload-time = "2025-06-09T23:00:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093, upload-time = "2025-06-09T23:01:00.015Z" }, + { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482, upload-time = "2025-06-09T23:01:01.474Z" }, + { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590, upload-time = "2025-06-09T23:01:02.961Z" }, + { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785, upload-time = "2025-06-09T23:01:05.095Z" }, + { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload-time = "2025-06-09T23:01:06.54Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload-time = "2025-06-09T23:01:07.752Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, + { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, + { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, + { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" }, + { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, + { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, + { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, + { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, + { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, + { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, + { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, + { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/ee59496f51cd244039330015d60f13ce5a54a0f2bd8d79e4a4a375ab7469/frozenlist-1.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cea3dbd15aea1341ea2de490574a4a37ca080b2ae24e4b4f4b51b9057b4c3630", size = 82434, upload-time = "2025-06-09T23:02:05.195Z" }, + { url = "https://files.pythonhosted.org/packages/75/e1/d518391ce36a6279b3fa5bc14327dde80bcb646bb50d059c6ca0756b8d05/frozenlist-1.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7d536ee086b23fecc36c2073c371572374ff50ef4db515e4e503925361c24f71", size = 48232, upload-time = "2025-06-09T23:02:07.728Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8d/a0d04f28b6e821a9685c22e67b5fb798a5a7b68752f104bfbc2dccf080c4/frozenlist-1.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dfcebf56f703cb2e346315431699f00db126d158455e513bd14089d992101e44", size = 47186, upload-time = "2025-06-09T23:02:09.243Z" }, + { url = "https://files.pythonhosted.org/packages/93/3a/a5334c0535c8b7c78eeabda1579179e44fe3d644e07118e59a2276dedaf1/frozenlist-1.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:974c5336e61d6e7eb1ea5b929cb645e882aadab0095c5a6974a111e6479f8878", size = 226617, upload-time = "2025-06-09T23:02:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/8258d971f519dc3f278c55069a775096cda6610a267b53f6248152b72b2f/frozenlist-1.7.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c70db4a0ab5ab20878432c40563573229a7ed9241506181bba12f6b7d0dc41cb", size = 224179, upload-time = "2025-06-09T23:02:12.603Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/8225905bf889b97c6d935dd3aeb45668461e59d415cb019619383a8a7c3b/frozenlist-1.7.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1137b78384eebaf70560a36b7b229f752fb64d463d38d1304939984d5cb887b6", size = 235783, upload-time = "2025-06-09T23:02:14.678Z" }, + { url = "https://files.pythonhosted.org/packages/54/6e/ef52375aa93d4bc510d061df06205fa6dcfd94cd631dd22956b09128f0d4/frozenlist-1.7.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e793a9f01b3e8b5c0bc646fb59140ce0efcc580d22a3468d70766091beb81b35", size = 229210, upload-time = "2025-06-09T23:02:16.313Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/62c87d1a6547bfbcd645df10432c129100c5bd0fd92a384de6e3378b07c1/frozenlist-1.7.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74739ba8e4e38221d2c5c03d90a7e542cb8ad681915f4ca8f68d04f810ee0a87", size = 215994, upload-time = "2025-06-09T23:02:17.9Z" }, + { url = "https://files.pythonhosted.org/packages/45/d2/263fea1f658b8ad648c7d94d18a87bca7e8c67bd6a1bbf5445b1bd5b158c/frozenlist-1.7.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e63344c4e929b1a01e29bc184bbb5fd82954869033765bfe8d65d09e336a677", size = 225122, upload-time = "2025-06-09T23:02:19.479Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/7145e35d12fb368d92124f679bea87309495e2e9ddf14c6533990cb69218/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2ea2a7369eb76de2217a842f22087913cdf75f63cf1307b9024ab82dfb525938", size = 224019, upload-time = "2025-06-09T23:02:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/44/1e/7dae8c54301beb87bcafc6144b9a103bfd2c8f38078c7902984c9a0c4e5b/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:836b42f472a0e006e02499cef9352ce8097f33df43baaba3e0a28a964c26c7d2", size = 239925, upload-time = "2025-06-09T23:02:22.466Z" }, + { url = "https://files.pythonhosted.org/packages/4b/1e/99c93e54aa382e949a98976a73b9b20c3aae6d9d893f31bbe4991f64e3a8/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e22b9a99741294b2571667c07d9f8cceec07cb92aae5ccda39ea1b6052ed4319", size = 220881, upload-time = "2025-06-09T23:02:24.521Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9c/ca5105fa7fb5abdfa8837581be790447ae051da75d32f25c8f81082ffc45/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:9a19e85cc503d958abe5218953df722748d87172f71b73cf3c9257a91b999890", size = 234046, upload-time = "2025-06-09T23:02:26.206Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4d/e99014756093b4ddbb67fb8f0df11fe7a415760d69ace98e2ac6d5d43402/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f22dac33bb3ee8fe3e013aa7b91dc12f60d61d05b7fe32191ffa84c3aafe77bd", size = 235756, upload-time = "2025-06-09T23:02:27.79Z" }, + { url = "https://files.pythonhosted.org/packages/8b/72/a19a40bcdaa28a51add2aaa3a1a294ec357f36f27bd836a012e070c5e8a5/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9ccec739a99e4ccf664ea0775149f2749b8a6418eb5b8384b4dc0a7d15d304cb", size = 222894, upload-time = "2025-06-09T23:02:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/08/49/0042469993e023a758af81db68c76907cd29e847d772334d4d201cbe9a42/frozenlist-1.7.0-cp39-cp39-win32.whl", hash = "sha256:b3950f11058310008a87757f3eee16a8e1ca97979833239439586857bc25482e", size = 39848, upload-time = "2025-06-09T23:02:31.413Z" }, + { url = "https://files.pythonhosted.org/packages/5a/45/827d86ee475c877f5f766fbc23fb6acb6fada9e52f1c9720e2ba3eae32da/frozenlist-1.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:43a82fce6769c70f2f5a06248b614a7d268080a9d20f7457ef10ecee5af82b63", size = 44102, upload-time = "2025-06-09T23:02:32.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.44" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/89/37df0b71473153574a5cdef8f242de422a0f5d26d7a9e231e6f169b4ad14/gitpython-3.1.44.tar.gz", hash = "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269", size = 214196, upload-time = "2025-01-02T07:32:43.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/9a/4114a9057db2f1462d5c8f8390ab7383925fe1ac012eaa42402ad65c2963/GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110", size = 207599, upload-time = "2025-01-02T07:32:40.731Z" }, +] + +[[package]] +name = "graphql-core" +version = "3.3.0a9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/50/54c02cb0781df65fd998f8f499dfebb40cf1146ddd63c9e356748ad2a234/graphql_core-3.3.0a9.tar.gz", hash = "sha256:f442b34311c815281ba7a984c4389a1e74cda89bd3af291b98aae0b06fb338ef", size = 588475, upload-time = "2025-06-18T21:37:19.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/48/98ccc5fde6e4f7060cbce766d58e3364425af1374ea7379244b2c34cf635/graphql_core-3.3.0a9-py3-none-any.whl", hash = "sha256:73eeb24b8d0256cea62b70f0a7caa8bfc290e2e87769e0ccfdf4e9e4f06911cc", size = 227535, upload-time = "2025-06-18T21:37:17.579Z" }, +] + +[[package]] +name = "graphql-server" +version = "3.0.0b8" +source = { editable = "." } +dependencies = [ + { name = "graphql-core" }, +] + +[package.optional-dependencies] +aiohttp = [ + { name = "aiohttp" }, +] +asgi = [ + { name = "python-multipart" }, + { name = "starlette" }, +] +chalice = [ + { name = "chalice" }, +] +channels = [ + { name = "asgiref" }, + { name = "channels" }, +] +debug = [ + { name = "libcst" }, + { name = "rich" }, +] +debug-server = [ + { name = "libcst" }, + { name = "pygments" }, + { name = "python-multipart" }, + { name = "rich" }, + { name = "starlette" }, + { name = "typer" }, + { name = "uvicorn" }, + { name = "websockets" }, +] +django = [ + { name = "asgiref" }, + { name = "django", version = "4.2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "django", version = "5.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +fastapi = [ + { name = "fastapi" }, + { name = "python-multipart" }, +] +flask = [ + { name = "flask" }, +] +litestar = [ + { name = "litestar", marker = "python_full_version >= '3.10'" }, +] +opentelemetry = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, +] +pyinstrument = [ + { name = "pyinstrument" }, +] +quart = [ + { name = "quart" }, +] +sanic = [ + { name = "sanic" }, +] + +[package.dev-dependencies] +dev = [ + { name = "asgiref" }, + { name = "codeflash" }, + { name = "inline-snapshot" }, + { name = "mypy" }, + { name = "nox" }, + { name = "poetry-plugin-export" }, + { name = "pygments" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-codspeed" }, + { name = "pytest-cov" }, + { name = "pytest-emoji" }, + { name = "pytest-mock" }, + { name = "pytest-snapshot" }, + { name = "pytest-xdist", extra = ["psutil"] }, + { name = "python-multipart" }, + { name = "ruff" }, + { name = "sanic-testing" }, + { name = "types-deprecated" }, + { name = "types-six" }, + { name = "urllib3" }, +] +integrations = [ + { name = "aiohttp" }, + { name = "chalice" }, + { name = "channels" }, + { name = "daphne" }, + { name = "django", version = "4.2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "django", version = "5.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "fastapi" }, + { name = "flask" }, + { name = "litestar", marker = "python_full_version >= '3.10'" }, + { name = "pydantic" }, + { name = "pytest-aiohttp" }, + { name = "pytest-django" }, + { name = "quart" }, + { name = "sanic" }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", marker = "extra == 'aiohttp'", specifier = ">=3.7.4.post0,<4" }, + { name = "asgiref", marker = "extra == 'channels'", specifier = "~=3.2" }, + { name = "asgiref", marker = "extra == 'django'", specifier = "~=3.2" }, + { name = "chalice", marker = "extra == 'chalice'", specifier = "~=1.22" }, + { name = "channels", marker = "extra == 'channels'", specifier = ">=3.0.5" }, + { name = "django", marker = "extra == 'django'", specifier = ">=3.2" }, + { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.65.2" }, + { name = "flask", marker = "extra == 'flask'", specifier = ">=1.1" }, + { name = "graphql-core", specifier = ">=3.2.0,<3.4.0" }, + { name = "libcst", marker = "extra == 'debug'" }, + { name = "libcst", marker = "extra == 'debug-server'" }, + { name = "litestar", marker = "python_full_version >= '3.10' and python_full_version < '4' and extra == 'litestar'", specifier = ">=2" }, + { name = "opentelemetry-api", marker = "extra == 'opentelemetry'", specifier = "<2" }, + { name = "opentelemetry-sdk", marker = "extra == 'opentelemetry'", specifier = "<2" }, + { name = "pygments", marker = "extra == 'debug-server'", specifier = "~=2.3" }, + { name = "pyinstrument", marker = "extra == 'pyinstrument'", specifier = ">=4.0.0" }, + { name = "python-multipart", marker = "extra == 'asgi'", specifier = ">=0.0.7" }, + { name = "python-multipart", marker = "extra == 'debug-server'", specifier = ">=0.0.7" }, + { name = "python-multipart", marker = "extra == 'fastapi'", specifier = ">=0.0.7" }, + { name = "quart", marker = "extra == 'quart'", specifier = ">=0.19.3" }, + { name = "rich", marker = "extra == 'debug'", specifier = ">=12.0.0" }, + { name = "rich", marker = "extra == 'debug-server'", specifier = ">=12.0.0" }, + { name = "sanic", marker = "extra == 'sanic'", specifier = ">=20.12.2" }, + { name = "starlette", marker = "extra == 'asgi'", specifier = ">=0.18.0" }, + { name = "starlette", marker = "extra == 'debug-server'", specifier = ">=0.18.0" }, + { name = "typer", marker = "extra == 'debug-server'", specifier = ">=0.7.0" }, + { name = "uvicorn", marker = "extra == 'debug-server'", specifier = ">=0.11.6" }, + { name = "websockets", marker = "extra == 'debug-server'", specifier = ">=15.0.1,<16" }, +] +provides-extras = ["aiohttp", "asgi", "debug", "debug-server", "django", "channels", "flask", "quart", "opentelemetry", "sanic", "fastapi", "chalice", "litestar", "pyinstrument"] + +[package.metadata.requires-dev] +dev = [ + { name = "asgiref", specifier = ">=3.2,<4.0" }, + { name = "codeflash", specifier = ">=0.9.2" }, + { name = "inline-snapshot", specifier = ">=0.10.1,<0.11" }, + { name = "mypy", specifier = ">=1.15.0,<2.0" }, + { name = "nox", specifier = ">=2025.5.1" }, + { name = "poetry-plugin-export", marker = "python_full_version < '4'", specifier = ">=1.6.0,<2.0" }, + { name = "pygments", specifier = ">=2.3,<3.0" }, + { name = "pyright", specifier = "==1.1.401" }, + { name = "pytest", specifier = ">=7.2,<8.0" }, + { name = "pytest-asyncio", specifier = ">=0.20.3" }, + { name = "pytest-codspeed", marker = "python_full_version >= '3.9'", specifier = ">=3.0.0" }, + { name = "pytest-cov", specifier = ">=4.0.0,<5.0" }, + { name = "pytest-emoji", specifier = ">=0.2.0,<0.3" }, + { name = "pytest-mock", specifier = ">=3.10,<4.0" }, + { name = "pytest-snapshot", specifier = ">=0.9.0,<1.0" }, + { name = "pytest-xdist", extras = ["psutil"], specifier = ">=3.1.0,<4.0" }, + { name = "python-multipart", specifier = ">=0.0.7" }, + { name = "ruff", specifier = ">=0.11.4,<0.12" }, + { name = "sanic-testing", specifier = ">=22.9,<24.0" }, + { name = "types-deprecated", specifier = ">=1.2.15.20241117,<2.0" }, + { name = "types-six", specifier = ">=1.17.0.20250403,<2.0" }, + { name = "urllib3", specifier = "<2" }, +] +integrations = [ + { name = "aiohttp", specifier = ">=3.7.4.post0,<4.0" }, + { name = "chalice", specifier = ">=1.22,<2.0" }, + { name = "channels", specifier = ">=3.0.5,<5.0.0" }, + { name = "daphne", specifier = ">=4.0.0,<5.0" }, + { name = "django", specifier = ">=3.2" }, + { name = "fastapi", specifier = ">=0.65.0" }, + { name = "flask", specifier = ">=1.1" }, + { name = "litestar", marker = "python_full_version >= '3.10' and python_full_version < '4'", specifier = ">=2" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pytest-aiohttp", specifier = ">=1.0.3,<2.0" }, + { name = "pytest-django", specifier = ">=4.5,<5.0" }, + { name = "quart", specifier = ">=0.19.3" }, + { name = "sanic", specifier = ">=20.12.2" }, + { name = "starlette", specifier = ">=0.13.6" }, + { name = "uvicorn", specifier = ">=0.11.6" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/38/d7f80fd13e6582fb8e0df8c9a653dcc02b03ca34f4d72f34869298c5baf8/h2-4.2.0.tar.gz", hash = "sha256:c8a52129695e88b1a0578d8d2cc6842bbd79128ac685463b887ee278126ad01f", size = 2150682, upload-time = "2025-02-02T07:43:51.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/9e/984486f2d0a0bd2b024bf4bc1c62688fcafa9e61991f041fb0e2def4a982/h2-4.2.0-py3-none-any.whl", hash = "sha256:479a53ad425bb29af087f3458a61d30780bc818e4ebcf01f0b536ba916462ed0", size = 60957, upload-time = "2025-02-01T11:02:26.481Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + +[[package]] +name = "html5tagger" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/02/2ae5f46d517a2c1d4a17f2b1e4834c2c7cc0fb3a69c92389172fa16ab389/html5tagger-1.3.0.tar.gz", hash = "sha256:84fa3dfb49e5c83b79bbd856ab7b1de8e2311c3bb46a8be925f119e3880a8da9", size = 14196, upload-time = "2023-03-28T05:59:34.642Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/12/2f5d43ee912ea14a6baba4b3db6d309b02d932e3b7074c3339b4aded98ff/html5tagger-1.3.0-py3-none-any.whl", hash = "sha256:ce14313515edffec8ed8a36c5890d023922641171b4e6e5774ad1a74998f5351", size = 10956, upload-time = "2023-03-28T05:59:32.524Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload-time = "2024-10-16T19:45:08.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/6f/972f8eb0ea7d98a1c6be436e2142d51ad2a64ee18e02b0e7ff1f62171ab1/httptools-0.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3c73ce323711a6ffb0d247dcd5a550b8babf0f757e86a52558fe5b86d6fefcc0", size = 198780, upload-time = "2024-10-16T19:44:06.882Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/17c672b4bc5c7ba7f201eada4e96c71d0a59fbc185e60e42580093a86f21/httptools-0.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:345c288418f0944a6fe67be8e6afa9262b18c7626c3ef3c28adc5eabc06a68da", size = 103297, upload-time = "2024-10-16T19:44:08.129Z" }, + { url = "https://files.pythonhosted.org/packages/92/5e/b4a826fe91971a0b68e8c2bd4e7db3e7519882f5a8ccdb1194be2b3ab98f/httptools-0.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deee0e3343f98ee8047e9f4c5bc7cedbf69f5734454a94c38ee829fb2d5fa3c1", size = 443130, upload-time = "2024-10-16T19:44:09.45Z" }, + { url = "https://files.pythonhosted.org/packages/b0/51/ce61e531e40289a681a463e1258fa1e05e0be54540e40d91d065a264cd8f/httptools-0.6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca80b7485c76f768a3bc83ea58373f8db7b015551117375e4918e2aa77ea9b50", size = 442148, upload-time = "2024-10-16T19:44:11.539Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/270b7d767849b0c96f275c695d27ca76c30671f8eb8cc1bab6ced5c5e1d0/httptools-0.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:90d96a385fa941283ebd231464045187a31ad932ebfa541be8edf5b3c2328959", size = 415949, upload-time = "2024-10-16T19:44:13.388Z" }, + { url = "https://files.pythonhosted.org/packages/81/86/ced96e3179c48c6f656354e106934e65c8963d48b69be78f355797f0e1b3/httptools-0.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:59e724f8b332319e2875efd360e61ac07f33b492889284a3e05e6d13746876f4", size = 417591, upload-time = "2024-10-16T19:44:15.258Z" }, + { url = "https://files.pythonhosted.org/packages/75/73/187a3f620ed3175364ddb56847d7a608a6fc42d551e133197098c0143eca/httptools-0.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:c26f313951f6e26147833fc923f78f95604bbec812a43e5ee37f26dc9e5a686c", size = 88344, upload-time = "2024-10-16T19:44:16.54Z" }, + { url = "https://files.pythonhosted.org/packages/7b/26/bb526d4d14c2774fe07113ca1db7255737ffbb119315839af2065abfdac3/httptools-0.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f47f8ed67cc0ff862b84a1189831d1d33c963fb3ce1ee0c65d3b0cbe7b711069", size = 199029, upload-time = "2024-10-16T19:44:18.427Z" }, + { url = "https://files.pythonhosted.org/packages/a6/17/3e0d3e9b901c732987a45f4f94d4e2c62b89a041d93db89eafb262afd8d5/httptools-0.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0614154d5454c21b6410fdf5262b4a3ddb0f53f1e1721cfd59d55f32138c578a", size = 103492, upload-time = "2024-10-16T19:44:19.515Z" }, + { url = "https://files.pythonhosted.org/packages/b7/24/0fe235d7b69c42423c7698d086d4db96475f9b50b6ad26a718ef27a0bce6/httptools-0.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8787367fbdfccae38e35abf7641dafc5310310a5987b689f4c32cc8cc3ee975", size = 462891, upload-time = "2024-10-16T19:44:21.067Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2f/205d1f2a190b72da6ffb5f41a3736c26d6fa7871101212b15e9b5cd8f61d/httptools-0.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b0f7fe4fd38e6a507bdb751db0379df1e99120c65fbdc8ee6c1d044897a636", size = 459788, upload-time = "2024-10-16T19:44:22.958Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4c/d09ce0eff09057a206a74575ae8f1e1e2f0364d20e2442224f9e6612c8b9/httptools-0.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40a5ec98d3f49904b9fe36827dcf1aadfef3b89e2bd05b0e35e94f97c2b14721", size = 433214, upload-time = "2024-10-16T19:44:24.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/84c9e23edbccc4a4c6f96a1b8d99dfd2350289e94f00e9ccc7aadde26fb5/httptools-0.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dacdd3d10ea1b4ca9df97a0a303cbacafc04b5cd375fa98732678151643d4988", size = 434120, upload-time = "2024-10-16T19:44:26.295Z" }, + { url = "https://files.pythonhosted.org/packages/d0/46/4d8e7ba9581416de1c425b8264e2cadd201eb709ec1584c381f3e98f51c1/httptools-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:288cd628406cc53f9a541cfaf06041b4c71d751856bab45e3702191f931ccd17", size = 88565, upload-time = "2024-10-16T19:44:29.188Z" }, + { url = "https://files.pythonhosted.org/packages/bb/0e/d0b71465c66b9185f90a091ab36389a7352985fe857e352801c39d6127c8/httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2", size = 200683, upload-time = "2024-10-16T19:44:30.175Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b8/412a9bb28d0a8988de3296e01efa0bd62068b33856cdda47fe1b5e890954/httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44", size = 104337, upload-time = "2024-10-16T19:44:31.786Z" }, + { url = "https://files.pythonhosted.org/packages/9b/01/6fb20be3196ffdc8eeec4e653bc2a275eca7f36634c86302242c4fbb2760/httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1", size = 508796, upload-time = "2024-10-16T19:44:32.825Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/b644c44acc1368938317d76ac991c9bba1166311880bcc0ac297cb9d6bd7/httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2", size = 510837, upload-time = "2024-10-16T19:44:33.974Z" }, + { url = "https://files.pythonhosted.org/packages/52/d8/254d16a31d543073a0e57f1c329ca7378d8924e7e292eda72d0064987486/httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81", size = 485289, upload-time = "2024-10-16T19:44:35.111Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3c/4aee161b4b7a971660b8be71a92c24d6c64372c1ab3ae7f366b3680df20f/httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f", size = 489779, upload-time = "2024-10-16T19:44:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/12/b7/5cae71a8868e555f3f67a50ee7f673ce36eac970f029c0c5e9d584352961/httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970", size = 88634, upload-time = "2024-10-16T19:44:37.357Z" }, + { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214, upload-time = "2024-10-16T19:44:38.738Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431, upload-time = "2024-10-16T19:44:39.818Z" }, + { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121, upload-time = "2024-10-16T19:44:41.189Z" }, + { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805, upload-time = "2024-10-16T19:44:42.384Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858, upload-time = "2024-10-16T19:44:43.959Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042, upload-time = "2024-10-16T19:44:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682, upload-time = "2024-10-16T19:44:46.46Z" }, + { url = "https://files.pythonhosted.org/packages/51/b1/4fc6f52afdf93b7c4304e21f6add9e981e4f857c2fa622a55dfe21b6059e/httptools-0.6.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:85797e37e8eeaa5439d33e556662cc370e474445d5fab24dcadc65a8ffb04003", size = 201123, upload-time = "2024-10-16T19:44:59.13Z" }, + { url = "https://files.pythonhosted.org/packages/c2/01/e6ecb40ac8fdfb76607c7d3b74a41b464458d5c8710534d8f163b0c15f29/httptools-0.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:db353d22843cf1028f43c3651581e4bb49374d85692a85f95f7b9a130e1b2cab", size = 104507, upload-time = "2024-10-16T19:45:00.254Z" }, + { url = "https://files.pythonhosted.org/packages/dc/24/c70c34119d209bf08199d938dc9c69164f585ed3029237b4bdb90f673cb9/httptools-0.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1ffd262a73d7c28424252381a5b854c19d9de5f56f075445d33919a637e3547", size = 449615, upload-time = "2024-10-16T19:45:01.351Z" }, + { url = "https://files.pythonhosted.org/packages/2b/62/e7f317fed3703bd81053840cacba4e40bcf424b870e4197f94bd1cf9fe7a/httptools-0.6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:703c346571fa50d2e9856a37d7cd9435a25e7fd15e236c397bf224afaa355fe9", size = 448819, upload-time = "2024-10-16T19:45:02.652Z" }, + { url = "https://files.pythonhosted.org/packages/2a/13/68337d3be6b023260139434c49d7aa466aaa98f9aee7ed29270ac7dde6a2/httptools-0.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aafe0f1918ed07b67c1e838f950b1c1fabc683030477e60b335649b8020e1076", size = 422093, upload-time = "2024-10-16T19:45:03.765Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b3/3a1bc45be03dda7a60c7858e55b6cd0489a81613c1908fb81cf21d34ae50/httptools-0.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0e563e54979e97b6d13f1bbc05a96109923e76b901f786a5eae36e99c01237bd", size = 423898, upload-time = "2024-10-16T19:45:05.683Z" }, + { url = "https://files.pythonhosted.org/packages/05/72/2ddc2ae5f7ace986f7e68a326215b2e7c32e32fd40e6428fa8f1d8065c7e/httptools-0.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:b799de31416ecc589ad79dd85a0b2657a8fe39327944998dea368c1d4c9e55e6", size = 89552, upload-time = "2024-10-16T19:45:07.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "humanize" +version = "4.12.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/d1/bbc4d251187a43f69844f7fd8941426549bbe4723e8ff0a7441796b0789f/humanize-4.12.3.tar.gz", hash = "sha256:8430be3a615106fdfceb0b2c1b41c4c98c6b0fc5cc59663a5539b111dd325fb0", size = 80514, upload-time = "2025-04-30T11:51:07.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/1e/62a2ec3104394a2975a2629eec89276ede9dbe717092f6966fcf963e1bf0/humanize-4.12.3-py3-none-any.whl", hash = "sha256:2cbf6370af06568fa6d2da77c86edb7886f3160ecd19ee1ffef07979efc597f6", size = 128487, upload-time = "2025-04-30T11:51:06.468Z" }, +] + +[[package]] +name = "hypercorn" +version = "0.17.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "h11" }, + { name = "h2" }, + { name = "priority" }, + { name = "taskgroup", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/3a/df6c27642e0dcb7aff688ca4be982f0fb5d89f2afd3096dc75347c16140f/hypercorn-0.17.3.tar.gz", hash = "sha256:1b37802ee3ac52d2d85270700d565787ab16cf19e1462ccfa9f089ca17574165", size = 44409, upload-time = "2024-05-28T20:55:53.06Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/3b/dfa13a8d96aa24e40ea74a975a9906cfdc2ab2f4e3b498862a57052f04eb/hypercorn-0.17.3-py3-none-any.whl", hash = "sha256:059215dec34537f9d40a69258d323f56344805efb462959e727152b0aa504547", size = 61742, upload-time = "2024-05-28T20:55:48.829Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "hyperlink" +version = "21.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/51/1947bd81d75af87e3bb9e34593a4cf118115a8feb451ce7a69044ef1412e/hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", size = 140743, upload-time = "2021-01-08T05:51:20.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4", size = 74638, upload-time = "2021-01-08T05:51:22.906Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + +[[package]] +name = "importlib-resources" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, +] + +[[package]] +name = "incremental" +version = "24.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/87/156b374ff6578062965afe30cc57627d35234369b3336cf244b240c8d8e6/incremental-24.7.2.tar.gz", hash = "sha256:fb4f1d47ee60efe87d4f6f0ebb5f70b9760db2b2574c59c8e8912be4ebd464c9", size = 28157, upload-time = "2024-07-29T20:03:55.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/38/221e5b2ae676a3938c2c1919131410c342b6efc2baffeda395dd66eeca8f/incremental-24.7.2-py3-none-any.whl", hash = "sha256:8cb2c3431530bec48ad70513931a760f446ad6c25e8333ca5d95e24b0ed7b8fe", size = 20516, upload-time = "2024-07-29T20:03:53.677Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "inline-snapshot" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "black" }, + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "executing" }, + { name = "rich" }, + { name = "toml" }, + { name = "types-toml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/5e/46006dade79f0cc2af5bc2b87215359e505fa19615060856498ecf13ba5e/inline_snapshot-0.10.2.tar.gz", hash = "sha256:fb3c1410a08c9700ca838a269f70117760b024d99d6193661a8b47f8302b09cd", size = 21172, upload-time = "2024-05-28T06:55:46.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/c0/f7e31cf9cbdadf8e723681fca9d803c27a51f054390e1ffe47232b31c04a/inline_snapshot-0.10.2-py3-none-any.whl", hash = "sha256:f61d42f0d4bddd2a3efae041f5b168e94ac2df566cbf2c67a26d03d5f090835a", size = 23011, upload-time = "2024-05-28T06:55:45.062Z" }, +] + +[[package]] +name = "inquirer" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blessed" }, + { name = "editor" }, + { name = "readchar" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/06/ef91eb8f3feafb736aa33dcb278fc9555d17861aa571b684715d095db24d/inquirer-3.4.0.tar.gz", hash = "sha256:8edc99c076386ee2d2204e5e3653c2488244e82cb197b2d498b3c1b5ffb25d0b", size = 14472, upload-time = "2024-08-12T12:03:43.83Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/b2/be907c8c0f8303bc4b10089f5470014c3bf3521e9b8d3decf3037fd94725/inquirer-3.4.0-py3-none-any.whl", hash = "sha256:bb0ec93c833e4ce7b51b98b1644b0a4d2bb39755c39787f6a504e4fee7a11b60", size = 18077, upload-time = "2024-08-12T12:03:41.589Z" }, +] + +[[package]] +name = "installer" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/18/ceeb4e3ab3aa54495775775b38ae42b10a92f42ce42dfa44da684289b8c8/installer-0.7.0.tar.gz", hash = "sha256:a26d3e3116289bb08216e0d0f7d925fcef0b0194eedfa0c944bcaaa106c4b631", size = 474349, upload-time = "2023-03-17T20:39:38.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ca/1172b6638d52f2d6caa2dd262ec4c811ba59eee96d54a7701930726bce18/installer-0.7.0-py3-none-any.whl", hash = "sha256:05d1933f0a5ba7d8d6296bb6d5018e7c94fa473ceb10cf198a92ccea19c27b53", size = 453838, upload-time = "2023-03-17T20:39:36.219Z" }, +] + +[[package]] +name = "isort" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/21/1e2a441f74a653a144224d7d21afe8f4169e6c7c20bb13aec3a2dc3815e0/isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450", size = 821955, upload-time = "2025-02-26T21:13:16.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/11/114d0a5f4dabbdcedc1125dee0888514c3c3b16d3e9facad87ed96fad97c/isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615", size = 94186, upload-time = "2025-02-26T21:13:14.911Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/23/9894b3df5d0a6eb44611c36aec777823fc2e07740dabbd0b810e19594013/jaraco_functools-4.1.0.tar.gz", hash = "sha256:70f7e0e2ae076498e212562325e805204fc092d7b4c17e0e86c959e249701a9d", size = 19159, upload-time = "2024-09-27T19:47:09.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/4f/24b319316142c44283d7540e76c7b5a6dbd5db623abd86bb7b3491c21018/jaraco.functools-4.1.0-py3-none-any.whl", hash = "sha256:ad159f13428bc4acbf5541ad6dec511f91573b90fba04df61dafa2a1231cf649", size = 10187, upload-time = "2024-09-27T19:47:07.14Z" }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jinxed" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ansicon", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/d0/59b2b80e7a52d255f9e0ad040d2e826342d05580c4b1d7d7747cfb8db731/jinxed-1.3.0.tar.gz", hash = "sha256:1593124b18a41b7a3da3b078471442e51dbad3d77b4d4f2b0c26ab6f7d660dbf", size = 80981, upload-time = "2024-07-31T22:39:18.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/e3/0e0014d6ab159d48189e92044ace13b1e1fe9aa3024ba9f4e8cf172aa7c2/jinxed-1.3.0-py2.py3-none-any.whl", hash = "sha256:b993189f39dc2d7504d802152671535b06d380b26d78070559551cbf92df4fc5", size = 33085, upload-time = "2024-07-31T22:39:17.426Z" }, +] + +[[package]] +name = "jmespath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843, upload-time = "2022-06-17T18:00:12.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload-time = "2022-06-17T18:00:10.251Z" }, +] + +[[package]] +name = "junitparser" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/88/6a268028a297751ed73be8e291f12aa727caf22adbc218e8dfbafcc974af/junitparser-3.2.0.tar.gz", hash = "sha256:b05e89c27e7b74b3c563a078d6e055d95cf397444f8f689b0ca616ebda0b3c65", size = 20073, upload-time = "2024-09-01T04:07:42.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/f9/321d566c9f2af81fdb4bb3d5900214116b47be9e26b82219da8b818d9da9/junitparser-3.2.0-py2.py3-none-any.whl", hash = "sha256:e14fdc0a999edfc15889b637390e8ef6ca09a49532416d3bd562857d42d4b96d", size = 13394, upload-time = "2024-09-01T04:07:40.541Z" }, +] + +[[package]] +name = "keyring" +version = "25.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750, upload-time = "2024-12-25T15:26:45.782Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085, upload-time = "2024-12-25T15:26:44.377Z" }, +] + +[[package]] +name = "libcst" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml", marker = "python_full_version < '3.13'" }, + { name = "pyyaml-ft", marker = "python_full_version >= '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/aa/b52d195b167958fe1bd106a260f64cc80ec384f6ac2a9cda874d8803df06/libcst-1.8.2.tar.gz", hash = "sha256:66e82cedba95a6176194a817be4232c720312f8be6d2c8f3847f3317d95a0c7f", size = 881534, upload-time = "2025-06-13T20:56:37.915Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/2e/1d7f67d2ef6f875e9e8798c024f7cb3af3fe861e417bff485c69b655ac96/libcst-1.8.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:67d9720d91f507c87b3e5f070627ad640a00bc6cfdf5635f8c6ee9f2964cf71c", size = 2195106, upload-time = "2025-06-13T20:54:49.166Z" }, + { url = "https://files.pythonhosted.org/packages/82/d0/3d94fee2685f263fd8d85a83e2537fcc78b644eae450738bf2c72604f0df/libcst-1.8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:94b7c032b72566077614a02baab1929739fd0af0cc1d46deaba4408b870faef2", size = 2080577, upload-time = "2025-06-13T20:54:51.518Z" }, + { url = "https://files.pythonhosted.org/packages/14/87/c9b49bebb9a930fdcb59bf841f1c45719d2a4a39c3eb7efacfd30a2bfb0a/libcst-1.8.2-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:11ea148902e3e1688afa392087c728ac3a843e54a87d334d1464d2097d3debb7", size = 2404076, upload-time = "2025-06-13T20:54:53.303Z" }, + { url = "https://files.pythonhosted.org/packages/49/fa/9ca145aa9033f9a8362a5663ceb28dfb67082574de8118424b6b8e445e7a/libcst-1.8.2-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:22c9473a2cc53faabcc95a0ac6ca4e52d127017bf34ba9bc0f8e472e44f7b38e", size = 2219813, upload-time = "2025-06-13T20:54:55.351Z" }, + { url = "https://files.pythonhosted.org/packages/0c/25/496a025c09e96116437a57fd34abefe84c041d930f832c6e42d84d9e028c/libcst-1.8.2-cp310-cp310-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b5269b96367e65793a7714608f6d906418eb056d59eaac9bba980486aabddbed", size = 2189782, upload-time = "2025-06-13T20:54:57.013Z" }, + { url = "https://files.pythonhosted.org/packages/b3/75/826b5772192826d70480efe93bab3e4f0b4a24d31031f45547257ad5f9a8/libcst-1.8.2-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:d20e932ddd9a389da57b060c26e84a24118c96ff6fc5dcc7b784da24e823b694", size = 2312403, upload-time = "2025-06-13T20:54:58.996Z" }, + { url = "https://files.pythonhosted.org/packages/93/f4/316fa14ea6c61ea8755672d60e012558f0216300b3819e72bebc7864a507/libcst-1.8.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a553d452004e44b841788f6faa7231a02157527ddecc89dbbe5b689b74822226", size = 2280566, upload-time = "2025-06-13T20:55:00.707Z" }, + { url = "https://files.pythonhosted.org/packages/fc/52/74b69350db379b1646739288b88ffab2981b2ad48407faf03df3768d7d2f/libcst-1.8.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7fe762c4c390039b79b818cbc725d8663586b25351dc18a2704b0e357d69b924", size = 2388508, upload-time = "2025-06-13T20:55:02.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c6/fa92699b537ed65e93c2869144e23bdf156ec81ae7b84b4f34cbc20d6048/libcst-1.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:5c513e64eff0f7bf2a908e2d987a98653eb33e1062ce2afd3a84af58159a24f9", size = 2093260, upload-time = "2025-06-13T20:55:04.771Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ac/4ec4ae9da311f72cd97e930c325bb605e9ad0baaafcafadb0588e1dc5c4e/libcst-1.8.2-cp310-cp310-win_arm64.whl", hash = "sha256:41613fe08e647213546c7c59a5a1fc5484666e7d4cab6e80260c612acbb20e8c", size = 1985236, upload-time = "2025-06-13T20:55:06.317Z" }, + { url = "https://files.pythonhosted.org/packages/c5/73/f0a4d807bff6931e3d8c3180472cf43d63a121aa60be895425fba2ed4f3a/libcst-1.8.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:688a03bac4dfb9afc5078ec01d53c21556381282bdf1a804dd0dbafb5056de2a", size = 2195040, upload-time = "2025-06-13T20:55:08.117Z" }, + { url = "https://files.pythonhosted.org/packages/e5/fa/ede0cfc410e498e1279eb489603f31077d2ca112d84e1327b04b508c0cbe/libcst-1.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c34060ff2991707c710250463ae9f415ebb21653f2f5b013c61c9c376ff9b715", size = 2080304, upload-time = "2025-06-13T20:55:09.729Z" }, + { url = "https://files.pythonhosted.org/packages/39/8d/59f7c488dbedf96454c07038dea72ee2a38de13d52b4f796a875a1dc45a6/libcst-1.8.2-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f54f5c4176d60e7cd6b0880e18fb3fa8501ae046069151721cab457c7c538a3d", size = 2403816, upload-time = "2025-06-13T20:55:11.527Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c2/af8d6cc0c6dcd1a5d0ed5cf846be242354513139a9358e005c63252c6ab7/libcst-1.8.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:d11992561de0ad29ec2800230fbdcbef9efaa02805d5c633a73ab3cf2ba51bf1", size = 2219415, upload-time = "2025-06-13T20:55:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b8/1638698d6c33bdb4397ee6f60e534e7504ef2cd1447b24104df65623dedb/libcst-1.8.2-cp311-cp311-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fa3b807c2d2b34397c135d19ad6abb20c47a2ddb7bf65d90455f2040f7797e1e", size = 2189568, upload-time = "2025-06-13T20:55:15.119Z" }, + { url = "https://files.pythonhosted.org/packages/05/16/51c1015dada47b8464c5fa0cbf70fecc5fce0facd07d05a5cb6e7eb68b88/libcst-1.8.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b0110140738be1287e3724080a101e7cec6ae708008b7650c9d8a1c1788ec03a", size = 2312018, upload-time = "2025-06-13T20:55:16.831Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/8d24158f345ea2921d0d7ff49a6bf86fd4a08b0f05735f14a84ea9e28fa9/libcst-1.8.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a50618f4819a97ef897e055ac7aaf1cad5df84c206f33be35b0759d671574197", size = 2279875, upload-time = "2025-06-13T20:55:18.418Z" }, + { url = "https://files.pythonhosted.org/packages/73/fd/0441cc1bcf188300aaa41ca5d473919a00939cc7f4934b3b08b23c8740c1/libcst-1.8.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e9bb599c175dc34a4511f0e26d5b5374fbcc91ea338871701a519e95d52f3c28", size = 2388060, upload-time = "2025-06-13T20:55:20.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fc/28f6380eefd58543f80589b77cab81eb038e7cc86f7c34a815a287dba82f/libcst-1.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:96e2363e1f6e44bd7256bbbf3a53140743f821b5133046e6185491e0d9183447", size = 2093117, upload-time = "2025-06-13T20:55:21.977Z" }, + { url = "https://files.pythonhosted.org/packages/ef/db/cdbd1531bca276c44bc485e40c3156e770e01020f8c1a737282bf884d69f/libcst-1.8.2-cp311-cp311-win_arm64.whl", hash = "sha256:f5391d71bd7e9e6c73dcb3ee8d8c63b09efc14ce6e4dad31568d4838afc9aae0", size = 1985285, upload-time = "2025-06-13T20:55:24.438Z" }, + { url = "https://files.pythonhosted.org/packages/31/2d/8726bf8ea8252e8fd1e48980753eef5449622c5f6cf731102bc43dcdc2c6/libcst-1.8.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2e8c1dfa854e700fcf6cd79b2796aa37d55697a74646daf5ea47c7c764bac31c", size = 2185942, upload-time = "2025-06-13T20:55:26.105Z" }, + { url = "https://files.pythonhosted.org/packages/99/b3/565d24db8daed66eae7653c1fc1bc97793d49d5d3bcef530450ee8da882c/libcst-1.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b5c57a3c1976c365678eb0730bcb140d40510990cb77df9a91bb5c41d587ba6", size = 2072622, upload-time = "2025-06-13T20:55:27.548Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d6/5a433e8a58eeb5c5d46635cfe958d0605f598d87977d4560484e3662d438/libcst-1.8.2-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:0f23409add2aaebbb6d8e881babab43c2d979f051b8bd8aed5fe779ea180a4e8", size = 2402738, upload-time = "2025-06-13T20:55:29.539Z" }, + { url = "https://files.pythonhosted.org/packages/85/e4/0dd752c1880b570118fa91ac127589e6cf577ddcb2eef1aaf8b81ecc3f79/libcst-1.8.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:b88e9104c456590ad0ef0e82851d4fc03e9aa9d621fa8fdd4cd0907152a825ae", size = 2219932, upload-time = "2025-06-13T20:55:31.17Z" }, + { url = "https://files.pythonhosted.org/packages/42/bc/fceae243c6a329477ac6d4edb887bcaa2ae7a3686158d8d9b9abb3089c37/libcst-1.8.2-cp312-cp312-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5ba3ea570c8fb6fc44f71aa329edc7c668e2909311913123d0d7ab8c65fc357", size = 2191891, upload-time = "2025-06-13T20:55:33.066Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7d/eb341bdc11f1147e7edeccffd0f2f785eff014e72134f5e46067472012b0/libcst-1.8.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:460fcf3562f078781e1504983cb11909eb27a1d46eaa99e65c4b0fafdc298298", size = 2311927, upload-time = "2025-06-13T20:55:34.614Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/78bfc7aa5a542574d2ab0768210d084901dec5fc373103ca119905408cf2/libcst-1.8.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1381ddbd1066d543e05d580c15beacf671e1469a0b2adb6dba58fec311f4eed", size = 2281098, upload-time = "2025-06-13T20:55:36.089Z" }, + { url = "https://files.pythonhosted.org/packages/83/37/a41788a72dc06ed3566606f7cf50349c9918cee846eeae45d1bac03d54c2/libcst-1.8.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a70e40ce7600e1b32e293bb9157e9de3b69170e2318ccb219102f1abb826c94a", size = 2387649, upload-time = "2025-06-13T20:55:37.797Z" }, + { url = "https://files.pythonhosted.org/packages/bb/df/7a49576c9fd55cdfd8bcfb725273aa4ee7dc41e87609f3451a4901d68057/libcst-1.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:3ece08ba778b6eeea74d9c705e9af2d1b4e915e9bc6de67ad173b962e575fcc0", size = 2094574, upload-time = "2025-06-13T20:55:39.833Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/27381e194d2af08bfd0fed090c905b2732907b69da48d97d86c056d70790/libcst-1.8.2-cp312-cp312-win_arm64.whl", hash = "sha256:5efd1bf6ee5840d1b0b82ec8e0b9c64f182fa5a7c8aad680fbd918c4fa3826e0", size = 1984568, upload-time = "2025-06-13T20:55:41.511Z" }, + { url = "https://files.pythonhosted.org/packages/11/9c/e3d4c7f1eb5c23907f905f84a4da271b60cd15b746ac794d42ea18bb105e/libcst-1.8.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08e9dca4ab6f8551794ce7ec146f86def6a82da41750cbed2c07551345fa10d3", size = 2185848, upload-time = "2025-06-13T20:55:43.653Z" }, + { url = "https://files.pythonhosted.org/packages/59/e0/635cbb205d42fd296c01ab5cd1ba485b0aee92bffe061de587890c81f1bf/libcst-1.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8310521f2ccb79b5c4345750d475b88afa37bad930ab5554735f85ad5e3add30", size = 2072510, upload-time = "2025-06-13T20:55:45.287Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/8911cfe9413fd690a024a1ff2c8975f060dd721160178679d3f6a21f939e/libcst-1.8.2-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:da2d8b008aff72acd5a4a588491abdda1b446f17508e700f26df9be80d8442ae", size = 2403226, upload-time = "2025-06-13T20:55:46.927Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/819d2b1b1fd870ad34ce4f34ec68704ca69bf48ef2d7665483115f267ec4/libcst-1.8.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:be821d874ce8b26cbadd7277fa251a9b37f6d2326f8b5682b6fc8966b50a3a59", size = 2220669, upload-time = "2025-06-13T20:55:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/d4/2f/2c4742bf834f88a9803095915c4f41cafefb7b04bde66ea86f74668b4b7b/libcst-1.8.2-cp313-cp313-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f74b0bc7378ad5afcf25ac9d0367b4dbba50f6f6468faa41f5dfddcf8bf9c0f8", size = 2191919, upload-time = "2025-06-13T20:55:50.092Z" }, + { url = "https://files.pythonhosted.org/packages/64/f4/107e13815f1ee5aad642d4eb4671c0273ee737f3832e3dbca9603b39f8d9/libcst-1.8.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:b68ea4a6018abfea1f68d50f74de7d399172684c264eb09809023e2c8696fc23", size = 2311965, upload-time = "2025-06-13T20:55:51.974Z" }, + { url = "https://files.pythonhosted.org/packages/03/63/2948b6e4be367ad375d273a8ad00df573029cffe5ac8f6c09398c250de5b/libcst-1.8.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e264307ec49b2c72480422abafe80457f90b4e6e693b7ddf8a23d24b5c24001", size = 2281704, upload-time = "2025-06-13T20:55:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d3/590cde9c8c386d5f4f05fdef3394c437ea51060478a5141ff4a1f289e747/libcst-1.8.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5d5519962ce7c72d81888fb0c09e58e308ba4c376e76bcd853b48151063d6a8", size = 2387511, upload-time = "2025-06-13T20:55:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/96/3d/ba5e36c663028043fc607dc33e5c390c7f73136fb15a890fb3710ee9d158/libcst-1.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:b62aa11d6b74ed5545e58ac613d3f63095e5fd0254b3e0d1168fda991b9a6b41", size = 2094526, upload-time = "2025-06-13T20:55:57.486Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/530ca3b972dddad562f266c81190bea29376f8ba70054ea7b45b114504cd/libcst-1.8.2-cp313-cp313-win_arm64.whl", hash = "sha256:9c2bd4ac288a9cdb7ffc3229a9ce8027a66a3fd3f2ab9e13da60f5fbfe91f3b2", size = 1984627, upload-time = "2025-06-13T20:55:59.017Z" }, + { url = "https://files.pythonhosted.org/packages/19/9f/491f7b8d9d93444cd9bf711156ee1f122c38d25b903599e363d669acc8ab/libcst-1.8.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:08a8c7d9922ca6eed24e2c13a3c552b3c186af8fc78e5d4820b58487d780ec19", size = 2175415, upload-time = "2025-06-13T20:56:01.157Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fe/4d13437f453f92687246aa7c5138e102ee5186fe96609ee4c598bb9f9ecb/libcst-1.8.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:bba7c2b5063e8ada5a5477f9fa0c01710645426b5a8628ec50d558542a0a292e", size = 2063719, upload-time = "2025-06-13T20:56:02.787Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/758ae142c6607f275269021362b731e0f22ff5c9aa7cc67b0ed3a6bc930f/libcst-1.8.2-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:d97c9fe13aacfbefded6861f5200dcb8e837da7391a9bdeb44ccb133705990af", size = 2380624, upload-time = "2025-06-13T20:56:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c5/31d214a0bcb3523243a9b5643b597ff653d6ec9e1f3326cfcc16bcbf185d/libcst-1.8.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d2194ae959630aae4176a4b75bd320b3274c20bef2a5ca6b8d6fc96d3c608edf", size = 2208801, upload-time = "2025-06-13T20:56:06.983Z" }, + { url = "https://files.pythonhosted.org/packages/70/16/a53f852322b266c63b492836a5c4968f192ee70fb52795a79feb4924e9ed/libcst-1.8.2-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0be639f5b2e1999a4b4a82a0f4633969f97336f052d0c131627983589af52f56", size = 2179557, upload-time = "2025-06-13T20:56:09.09Z" }, + { url = "https://files.pythonhosted.org/packages/fa/49/12a5664c73107187ba3af14869d3878fca1fd4c37f6fbb9adb943cb7a791/libcst-1.8.2-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6753e50904e05c27915933da41518ecd7a8ca4dd3602112ba44920c6e353a455", size = 2302499, upload-time = "2025-06-13T20:56:10.751Z" }, + { url = "https://files.pythonhosted.org/packages/e9/46/2d62552a9346a040c045d6619b645d59bb707a586318121f099abd0cd5c4/libcst-1.8.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:706d07106af91c343150be86caeae1ea3851b74aa0730fcbbf8cd089e817f818", size = 2271070, upload-time = "2025-06-13T20:56:12.445Z" }, + { url = "https://files.pythonhosted.org/packages/af/67/b625fd6ae22575255aade0a24f45e1d430b7e7279729c9c51d4faac982d2/libcst-1.8.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd4310ea8ddc49cc8872e083737cf806299b17f93159a1f354d59aa08993e876", size = 2380767, upload-time = "2025-06-13T20:56:13.995Z" }, + { url = "https://files.pythonhosted.org/packages/e6/84/fb88f2ffdb045ff7323a6c05dd3d243a9eb3cb3517a6269dee43fbfb9990/libcst-1.8.2-cp313-cp313t-win_amd64.whl", hash = "sha256:51bbafdd847529e8a16d1965814ed17831af61452ee31943c414cb23451de926", size = 2083403, upload-time = "2025-06-13T20:56:15.959Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8f/da755d6d517eb8ec9664afae967b00a9b8dd567bbbb350e261359c1b47fc/libcst-1.8.2-cp313-cp313t-win_arm64.whl", hash = "sha256:4f14f5045766646ed9e8826b959c6d07194788babed1e0ba08c94ea4f39517e3", size = 1974355, upload-time = "2025-06-13T20:56:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/2e/55/7c223ffc44fa623cc4c6c45e932d8e0724e31c8daede8a66d6a53ccd49a1/libcst-1.8.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:f69582e24667715e3860d80d663f1caeb2398110077e23cc0a1e0066a851f5ab", size = 2195291, upload-time = "2025-06-13T20:56:20.114Z" }, + { url = "https://files.pythonhosted.org/packages/77/3a/dced5455963238f1ebedd28cf48bfd5e5d84c847132846a2567f5beaf7fc/libcst-1.8.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1ba85f9e6a7f37ef998168aa3fd28d263d7f83016bd306a4508a2394e5e793b4", size = 2080544, upload-time = "2025-06-13T20:56:22.096Z" }, + { url = "https://files.pythonhosted.org/packages/da/ec/2bce80fb362961191e3ac67a38619780f9bd5203732ad95962458a3b71c0/libcst-1.8.2-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:43ccaa6c54daa1749cec53710c70d47150965574d4c6d4c4f2e3f87b9bf9f591", size = 2404396, upload-time = "2025-06-13T20:56:24.215Z" }, + { url = "https://files.pythonhosted.org/packages/6a/33/dd10a5ad783f3c1edc55fe97f5cbfe3924f6a7ce3556464538640a348e04/libcst-1.8.2-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:8a81d816c2088d2055112af5ecd82fdfbe8ff277600e94255e2639b07de10234", size = 2219446, upload-time = "2025-06-13T20:56:25.84Z" }, + { url = "https://files.pythonhosted.org/packages/dd/66/e7a208e5208bbd37b5be989e22b7abd117c40866b7880e7c447f4fb8ee46/libcst-1.8.2-cp39-cp39-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:449f9ff8a5025dcd5c8d4ad28f6c291de5de89e4c044b0bda96b45bef8999b75", size = 2189946, upload-time = "2025-06-13T20:56:27.472Z" }, + { url = "https://files.pythonhosted.org/packages/08/6f/5ef938f947e7cdd83bdffb6929697e7f27b0ae4a6f84a7f30e044690ba1c/libcst-1.8.2-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:36d5ab95f39f855521585b0e819dc2d4d1b2a4080bad04c2f3de1e387a5d2233", size = 2312416, upload-time = "2025-06-13T20:56:29.49Z" }, + { url = "https://files.pythonhosted.org/packages/04/5b/2f965ae65ef12bc0800a35c5668df3eda26437f6a8bcc0f5520b02f3c3a5/libcst-1.8.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:207575dec2dae722acf6ab39b4b361151c65f8f895fd37edf9d384f5541562e1", size = 2280429, upload-time = "2025-06-13T20:56:30.995Z" }, + { url = "https://files.pythonhosted.org/packages/35/1d/f67e6cb1146c0b546f095baf0d6ff6fa561bd61c1e1a5357e9557a16d501/libcst-1.8.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52a1067cf31d9e9e4be514b253bea6276f1531dd7de6ab0917df8ce5b468a820", size = 2388615, upload-time = "2025-06-13T20:56:32.655Z" }, + { url = "https://files.pythonhosted.org/packages/b7/83/b4d659782e88f46c073ea5cbd9a4e99bf7ea17883632371795f91121b220/libcst-1.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:59e8f611c977206eba294c296c2d29a1c1b1b88206cb97cd0d4847c1a3d923e7", size = 2093194, upload-time = "2025-06-13T20:56:34.348Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/3614b732cb25a3bba93ffde84b9e006007c687a9c84d22e64add56dee5fd/libcst-1.8.2-cp39-cp39-win_arm64.whl", hash = "sha256:ae22376633cfa3db21c4eed2870d1c36b5419289975a41a45f34a085b2d9e6ea", size = 1985259, upload-time = "2025-06-13T20:56:36.337Z" }, +] + +[[package]] +name = "line-profiler" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/55/3f/f0659eb67f76022b5f7722cdc71a6059536e11f20c9dcc5a96a2f923923d/line_profiler-4.2.0.tar.gz", hash = "sha256:09e10f25f876514380b3faee6de93fb0c228abba85820ba1a591ddb3eb451a96", size = 199037, upload-time = "2024-12-03T17:12:20.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/24/a7f141527f126965d141733140c648710b39daf00417afe9c459ebbb89e0/line_profiler-4.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:70e2503f52ee6464ac908b578d73ad6dae21d689c95f2252fee97d7aa8426693", size = 221762, upload-time = "2024-12-03T17:10:58.782Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9c/3a215f70f4d1946eb3afb9a07def86242f108d138ae250eb23b70f56ceb1/line_profiler-4.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b6047c8748d7a2453522eaea3edc8d9febc658b57f2ea189c03fe3d5e34595b5", size = 141549, upload-time = "2024-12-03T17:11:01.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/187ba46030274c29d898d4b47eeac53a833450037634e87e6aa78be9cb8f/line_profiler-4.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0048360a2afbd92c0b423f8207af1f6581d85c064c0340b0d02c63c8e0c8292c", size = 134961, upload-time = "2024-12-03T17:11:03.049Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f8/efe6b3be4f0b15ca977da4bf54e40a27d4210fda11e82fe8ad802f259cc8/line_profiler-4.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e71fa1c85f21e3de575c7c617fd4eb607b052cc7b4354035fecc18f3f2a4317", size = 700997, upload-time = "2024-12-03T17:11:04.879Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e3/3a3206285f8df202d00da7aa67664a3892a0ed607a15f59a64516c112266/line_profiler-4.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5ec99d48cffdf36efbcd7297e81cc12bf2c0a7e0627a567f3ab0347e607b242", size = 718256, upload-time = "2024-12-03T17:11:07.29Z" }, + { url = "https://files.pythonhosted.org/packages/83/19/ada8573aff98a7893f4c960e51e37abccc8a758855d6f0af55a3c002af5f/line_profiler-4.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:bfc9582f19a64283434fc6a3fd41a3a51d59e3cce2dc7adc5fe859fcae67e746", size = 1801932, upload-time = "2024-12-03T17:11:08.745Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9c/91c22b6ef3275c0eefb0d72da7a50114c20ef595086982679c6ae2dfbf20/line_profiler-4.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2b5dcfb3205e18c98c94388065f1604dc9d709df4dd62300ff8c5bbbd9bd163f", size = 1706908, upload-time = "2024-12-03T17:11:11.436Z" }, + { url = "https://files.pythonhosted.org/packages/bc/af/a71d69019639313a7d9c5e86fdc819cdce8b0745356d20daf05050070463/line_profiler-4.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:4999eb1db5d52cb34a5293941986eea4357fb9fe3305a160694e5f13c9ec4008", size = 128018, upload-time = "2024-12-03T17:11:12.862Z" }, + { url = "https://files.pythonhosted.org/packages/2f/8b/cd2a2ad1b80a92f3a5c707945c839fec7170b6e3790b2d86f275e6dee5fe/line_profiler-4.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:402406f200401a496fb93e1788387bf2d87c921d7f8f7e5f88324ac9efb672ac", size = 221775, upload-time = "2024-12-03T17:11:14.1Z" }, + { url = "https://files.pythonhosted.org/packages/8a/43/916491dc01aa4bfa08c0e1868af6c7f14bef3c7b4ed652fd4df7e1c2e8e7/line_profiler-4.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d9a0b5696f1ad42bb31e90706e5d57845833483d1d07f092b66b4799847a2f76", size = 141769, upload-time = "2024-12-03T17:11:16.41Z" }, + { url = "https://files.pythonhosted.org/packages/40/51/cbeab2995b18c74db1bfdf0ac07910661be1fc2afa7425c899d940001097/line_profiler-4.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2f950fa19f797a9ab55c8d7b33a7cdd95c396cf124c3adbc1cf93a1978d2767", size = 134789, upload-time = "2024-12-03T17:11:17.642Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c8/e94b4ef5854515e0f3baad48e9ebc335d8bd4f9f05336167c6c65446b79a/line_profiler-4.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d09fd8f580716da5a0b9a7f544a306b468f38eee28ba2465c56e0aa5d7d1822", size = 728859, upload-time = "2024-12-03T17:11:19.614Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ae/b92c4cfa52a84d794907e7ce6e206fa3ea4e4a6d7b950c525b8d118988fc/line_profiler-4.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:628f585960c6538873a9760d112db20b76b6035d3eaad7711a8bd80fa909d7ea", size = 750156, upload-time = "2024-12-03T17:11:21.066Z" }, + { url = "https://files.pythonhosted.org/packages/60/9f/c18cf5b17d79e5b420b35c73cb9fad299f779cf78a4812c97266962dfd55/line_profiler-4.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:63ed929c7d41e230cc1c4838c25bbee165d7f2fa974ca28d730ea69e501fc44d", size = 1828250, upload-time = "2024-12-03T17:11:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/d2/dc/14daab09eb1e30772d42b23140e5716034fbeb04224e6903c208212b9e97/line_profiler-4.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6bda74fc206ba375396068526e9e7b5466a24c7e54cbd6ee1c98c1e0d1f0fd99", size = 1739326, upload-time = "2024-12-03T17:11:24.12Z" }, + { url = "https://files.pythonhosted.org/packages/79/4b/8acfbc5413ed87ebaaa1fc2844e59da3136661885d8be2797e0d20d0ac25/line_profiler-4.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:eaf6eb827c202c07b8b8d82363bb039a6747fbf84ca04279495a91b7da3b773f", size = 128882, upload-time = "2024-12-03T17:11:25.623Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/f8330f4533434a90daa240ea9a3296e704a5d644339352316e20102add6f/line_profiler-4.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:82d29887f1226938a86db30ca3a125b1bde89913768a2a486fa14d0d3f8c0d91", size = 221536, upload-time = "2024-12-03T17:11:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/0f6fba16a9f67e083a277242a24344c0a482263a47462b4ce50c6cc7a5dc/line_profiler-4.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bf60706467203db0a872b93775a5e5902a02b11d79f8f75a8f8ef381b75789e1", size = 141581, upload-time = "2024-12-03T17:11:29.202Z" }, + { url = "https://files.pythonhosted.org/packages/5c/2b/a3a76c5879a3540b44eacdd0276e566a9c7fc381978fc527b6fc8e67a513/line_profiler-4.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:934fd964eed9bed87e3c01e8871ee6bdc54d10edf7bf14d20e72f7be03567ae3", size = 134641, upload-time = "2024-12-03T17:11:30.494Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/6381342ea05e42205322170cebcc0f0b7c7b6c63e259a2bcade65c6be0b4/line_profiler-4.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d623e5b37fa48c7ad0c29b4353244346a5dcb1bf75e117e19400b8ffd3393d1b", size = 693309, upload-time = "2024-12-03T17:11:32.609Z" }, + { url = "https://files.pythonhosted.org/packages/28/5a/2aa1c21bf5568f019343a6e8505cba35c70edd9acb0ed863b0b8f928dd15/line_profiler-4.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efcdbed9ba9003792d8bfd56c11bb3d4e29ad7e0d2f583e1c774de73bbf02933", size = 720065, upload-time = "2024-12-03T17:11:34.78Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d3/e596439f55d347e5c9c6cde8fef6dcdab02f29e3fc8db7b14e0303b38274/line_profiler-4.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:df0149c191a95f2dbc93155b2f9faaee563362d61e78b8986cdb67babe017cdc", size = 1787230, upload-time = "2024-12-03T17:11:36.438Z" }, + { url = "https://files.pythonhosted.org/packages/75/45/bc7d816ab60f0d8397090a32c3f798a53253ceb18d83f900434425d3b70f/line_profiler-4.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5e3a1ca491a8606ed674882b59354087f6e9ab6b94aa6d5fa5d565c6f2acc7a8", size = 1701460, upload-time = "2024-12-03T17:11:38.593Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/b7c02db2668bfd8de7b84f3d13dc36e4aca7dc8dba978b34f9e56dd0f103/line_profiler-4.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:a85ff57d4ef9d899ca12d6b0883c3cab1786388b29d2fb5f30f909e70bb9a691", size = 128330, upload-time = "2024-12-03T17:11:40.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/33/44bdf36948154a76aee5652dd405ce50a45fa4177c987c1694eea13eac31/line_profiler-4.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:49db0804e9e330076f0b048d63fd3206331ca0104dd549f61b2466df0f10ecda", size = 218791, upload-time = "2024-12-03T17:11:41.16Z" }, + { url = "https://files.pythonhosted.org/packages/51/78/7a41c05af37e0b7230593f3ae8d06d45a122fb84e1e70dcbba319c080887/line_profiler-4.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2e983ed4fb2cd68bb8896f6bad7f29ddf9112b978f700448510477bc9fde18db", size = 140191, upload-time = "2024-12-03T17:11:43.044Z" }, + { url = "https://files.pythonhosted.org/packages/d9/03/ac68ebaffa41d4fda12d8ecb47b686d8c1a0fad6db03bdfb3490ad6035c7/line_profiler-4.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d6b27c5880b29369e6bebfe434a16c60cbcd290aa4c384ac612e5777737893f8", size = 133297, upload-time = "2024-12-03T17:11:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/da/19/2ae0d8f9e39ad3413a219f69acb23a371c99863d48cce0273926d9dc4204/line_profiler-4.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2584dc0af3107efa60bd2ccaa7233dca98e3dff4b11138c0ac30355bc87f1a", size = 691235, upload-time = "2024-12-03T17:11:46.932Z" }, + { url = "https://files.pythonhosted.org/packages/e4/36/ecc106dd448a112455a8585db0994886b0439bbf808215249a89302dd626/line_profiler-4.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6767d8b922a7368b6917a47c164c3d96d48b82109ad961ef518e78800947cef4", size = 718497, upload-time = "2024-12-03T17:11:48.961Z" }, + { url = "https://files.pythonhosted.org/packages/8a/61/6293341fbcc6c5b4469f49bd94f37fea5d2efc8cce441809012346a5b7d0/line_profiler-4.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3137672a769717be4da3a6e006c3bd7b66ad4a341ba89ee749ef96c158a15b22", size = 1701191, upload-time = "2024-12-03T17:11:50.41Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/ab8a94c30c082caca87bc0db78efe91372e45d35a700ef07ffe78ed10cda/line_profiler-4.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:727e970d358616a1a33d51d696efec932a5ef7730785df62658bd7e74aa58951", size = 128232, upload-time = "2024-12-03T17:11:51.741Z" }, + { url = "https://files.pythonhosted.org/packages/12/01/cf45c629c00ef4161e5d274b9f65f47f5635af5f267fc21d4351a52558c3/line_profiler-4.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:80dd7e7990e346ed8ef32702f8fe3c60abdb0de95980d422c02f1ef30a6a828d", size = 222647, upload-time = "2024-12-03T17:12:08.87Z" }, + { url = "https://files.pythonhosted.org/packages/6f/be/0660f801d3f1b3f407c0725fba8507f811c429910993b9ab8cae9949e72e/line_profiler-4.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:31e1057448cfdb2678756163135b43bbbf698b2a1f7c88eb807f3fb2cdc2e3e7", size = 141985, upload-time = "2024-12-03T17:12:10.044Z" }, + { url = "https://files.pythonhosted.org/packages/bb/46/c705efb02fc069524a154d0de34314e11e5b8fa8276a54677586a755d441/line_profiler-4.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3ea02ccd7dc97b5777c032297991b5637130fbd07fa2c6a1f89f248aa12ef71b", size = 135482, upload-time = "2024-12-03T17:12:11.301Z" }, + { url = "https://files.pythonhosted.org/packages/29/50/5f4d266f1a8c3cdfa0c1a9f9ab2cad6162fd40f503d3caac2e8eb7be3477/line_profiler-4.2.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4bbbc4e8545f0c187cfed7c323b8cc1121d28001b222b26f6bc3bc554ba82d4f", size = 700169, upload-time = "2024-12-03T17:12:12.672Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0a/be2eb9a67270d746f684dbdebf78a96836cef1ea2743f401d441e81a321e/line_profiler-4.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d76d37c1084210363261d08eaabd30310eefb707ba8ab736a61e43930afaf47", size = 719660, upload-time = "2024-12-03T17:12:14.006Z" }, + { url = "https://files.pythonhosted.org/packages/db/8b/7bd8dc5092c59158014f9bf079ce8785bf7c72a440e1e43a6e970e4516cb/line_profiler-4.2.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:22f84c3dbb807a26c115626bee19cb5f93683fa08c8d3836ec30af06fa9eb5c3", size = 1801928, upload-time = "2024-12-03T17:12:15.381Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d6/f817e4ccbc2dc896b256b9ffdf92b46f6e1563eba2889b07f0fb088283ae/line_profiler-4.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e6131bcd5888371b61e05631555592feba12e73c96596b8d26ffe03cea0fc088", size = 1705618, upload-time = "2024-12-03T17:12:17.682Z" }, + { url = "https://files.pythonhosted.org/packages/21/93/bf3e70e583a456520168b008e8024725c9c807380aae74a12b1f6f38c500/line_profiler-4.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:fb58aa12cf64f0176d84bc4033bb0701fe8075d5da57149839ef895d961bbdad", size = 128255, upload-time = "2024-12-03T17:12:18.977Z" }, +] + +[[package]] +name = "litestar" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "python_full_version >= '3.10'" }, + { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "httpx", marker = "python_full_version >= '3.10'" }, + { name = "litestar-htmx", marker = "python_full_version >= '3.10'" }, + { name = "msgspec", marker = "python_full_version >= '3.10'" }, + { name = "multidict", marker = "python_full_version >= '3.10'" }, + { name = "multipart", marker = "python_full_version >= '3.10'" }, + { name = "polyfactory", marker = "python_full_version >= '3.10'" }, + { name = "pyyaml", marker = "python_full_version >= '3.10'" }, + { name = "rich", marker = "python_full_version >= '3.10'" }, + { name = "rich-click", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/4e/3376d5737a4c2e26fb2991a046265c38335b134d3e04e93c6d754e962e4e/litestar-2.16.0.tar.gz", hash = "sha256:f65c0d543bfec12b7433dff624322936f30bbdfb54ad3c5b7ef22ab2d092be2d", size = 399637, upload-time = "2025-05-04T11:00:46.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/dc/4d1018577683918cd24a58228c90833f71f792aafcfffb44905c9062f737/litestar-2.16.0-py3-none-any.whl", hash = "sha256:8a48557198556f01d3d70da3859a471aa56595a4a344362d9529ed65804e3ee4", size = 573158, upload-time = "2025-05-04T11:00:44.558Z" }, +] + +[[package]] +name = "litestar-htmx" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/b9/7e296aa1adada25cce8e5f89a996b0e38d852d93b1b656a2058226c542a2/litestar_htmx-0.5.0.tar.gz", hash = "sha256:e02d1a3a92172c874835fa3e6749d65ae9fc626d0df46719490a16293e2146fb", size = 119755, upload-time = "2025-06-11T21:19:45.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/24/8d99982f0aa9c1cd82073c6232b54a0dbe6797c7d63c0583a6c68ee3ddf2/litestar_htmx-0.5.0-py3-none-any.whl", hash = "sha256:92833aa47e0d0e868d2a7dbfab75261f124f4b83d4f9ad12b57b9a68f86c50e6", size = 9970, upload-time = "2025-06-11T21:19:44.465Z" }, +] + +[[package]] +name = "lsprotocol" +version = "2023.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cattrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/f6/6e80484ec078d0b50699ceb1833597b792a6c695f90c645fbaf54b947e6f/lsprotocol-2023.0.1.tar.gz", hash = "sha256:cc5c15130d2403c18b734304339e51242d3018a05c4f7d0f198ad6e0cd21861d", size = 69434, upload-time = "2024-01-09T17:21:12.625Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/37/2351e48cb3309673492d3a8c59d407b75fb6630e560eb27ecd4da03adc9a/lsprotocol-2023.0.1-py3-none-any.whl", hash = "sha256:c75223c9e4af2f24272b14c6375787438279369236cd568f596d4951052a60f2", size = 70826, upload-time = "2024-01-09T17:21:14.491Z" }, +] + +[[package]] +name = "lxml" +version = "5.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/3d/14e82fc7c8fb1b7761f7e748fd47e2ec8276d137b6acfe5a4bb73853e08f/lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd", size = 3679479, upload-time = "2025-04-23T01:50:29.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/1f/a3b6b74a451ceb84b471caa75c934d2430a4d84395d38ef201d539f38cd1/lxml-5.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e7bc6df34d42322c5289e37e9971d6ed114e3776b45fa879f734bded9d1fea9c", size = 8076838, upload-time = "2025-04-23T01:44:29.325Z" }, + { url = "https://files.pythonhosted.org/packages/36/af/a567a55b3e47135b4d1f05a1118c24529104c003f95851374b3748139dc1/lxml-5.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6854f8bd8a1536f8a1d9a3655e6354faa6406621cf857dc27b681b69860645c7", size = 4381827, upload-time = "2025-04-23T01:44:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/50/ba/4ee47d24c675932b3eb5b6de77d0f623c2db6dc466e7a1f199792c5e3e3a/lxml-5.4.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:696ea9e87442467819ac22394ca36cb3d01848dad1be6fac3fb612d3bd5a12cf", size = 5204098, upload-time = "2025-04-23T01:44:35.809Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0f/b4db6dfebfefe3abafe360f42a3d471881687fd449a0b86b70f1f2683438/lxml-5.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ef80aeac414f33c24b3815ecd560cee272786c3adfa5f31316d8b349bfade28", size = 4930261, upload-time = "2025-04-23T01:44:38.271Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1f/0bb1bae1ce056910f8db81c6aba80fec0e46c98d77c0f59298c70cd362a3/lxml-5.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b9c2754cef6963f3408ab381ea55f47dabc6f78f4b8ebb0f0b25cf1ac1f7609", size = 5529621, upload-time = "2025-04-23T01:44:40.921Z" }, + { url = "https://files.pythonhosted.org/packages/21/f5/e7b66a533fc4a1e7fa63dd22a1ab2ec4d10319b909211181e1ab3e539295/lxml-5.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a62cc23d754bb449d63ff35334acc9f5c02e6dae830d78dab4dd12b78a524f4", size = 4983231, upload-time = "2025-04-23T01:44:43.871Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/a38244b669c2d95a6a101a84d3c85ba921fea827e9e5483e93168bf1ccb2/lxml-5.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f82125bc7203c5ae8633a7d5d20bcfdff0ba33e436e4ab0abc026a53a8960b7", size = 5084279, upload-time = "2025-04-23T01:44:46.632Z" }, + { url = "https://files.pythonhosted.org/packages/db/64/48cac242347a09a07740d6cee7b7fd4663d5c1abd65f2e3c60420e231b27/lxml-5.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:b67319b4aef1a6c56576ff544b67a2a6fbd7eaee485b241cabf53115e8908b8f", size = 4927405, upload-time = "2025-04-23T01:44:49.843Z" }, + { url = "https://files.pythonhosted.org/packages/98/89/97442835fbb01d80b72374f9594fe44f01817d203fa056e9906128a5d896/lxml-5.4.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:a8ef956fce64c8551221f395ba21d0724fed6b9b6242ca4f2f7beb4ce2f41997", size = 5550169, upload-time = "2025-04-23T01:44:52.791Z" }, + { url = "https://files.pythonhosted.org/packages/f1/97/164ca398ee654eb21f29c6b582685c6c6b9d62d5213abc9b8380278e9c0a/lxml-5.4.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:0a01ce7d8479dce84fc03324e3b0c9c90b1ece9a9bb6a1b6c9025e7e4520e78c", size = 5062691, upload-time = "2025-04-23T01:44:56.108Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bc/712b96823d7feb53482d2e4f59c090fb18ec7b0d0b476f353b3085893cda/lxml-5.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:91505d3ddebf268bb1588eb0f63821f738d20e1e7f05d3c647a5ca900288760b", size = 5133503, upload-time = "2025-04-23T01:44:59.222Z" }, + { url = "https://files.pythonhosted.org/packages/d4/55/a62a39e8f9da2a8b6002603475e3c57c870cd9c95fd4b94d4d9ac9036055/lxml-5.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a3bcdde35d82ff385f4ede021df801b5c4a5bcdfb61ea87caabcebfc4945dc1b", size = 4999346, upload-time = "2025-04-23T01:45:02.088Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/a393728ae001b92bb1a9e095e570bf71ec7f7fbae7688a4792222e56e5b9/lxml-5.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aea7c06667b987787c7d1f5e1dfcd70419b711cdb47d6b4bb4ad4b76777a0563", size = 5627139, upload-time = "2025-04-23T01:45:04.582Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5f/9dcaaad037c3e642a7ea64b479aa082968de46dd67a8293c541742b6c9db/lxml-5.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a7fb111eef4d05909b82152721a59c1b14d0f365e2be4c742a473c5d7372f4f5", size = 5465609, upload-time = "2025-04-23T01:45:07.649Z" }, + { url = "https://files.pythonhosted.org/packages/a7/0a/ebcae89edf27e61c45023005171d0ba95cb414ee41c045ae4caf1b8487fd/lxml-5.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43d549b876ce64aa18b2328faff70f5877f8c6dede415f80a2f799d31644d776", size = 5192285, upload-time = "2025-04-23T01:45:10.456Z" }, + { url = "https://files.pythonhosted.org/packages/42/ad/cc8140ca99add7d85c92db8b2354638ed6d5cc0e917b21d36039cb15a238/lxml-5.4.0-cp310-cp310-win32.whl", hash = "sha256:75133890e40d229d6c5837b0312abbe5bac1c342452cf0e12523477cd3aa21e7", size = 3477507, upload-time = "2025-04-23T01:45:12.474Z" }, + { url = "https://files.pythonhosted.org/packages/e9/39/597ce090da1097d2aabd2f9ef42187a6c9c8546d67c419ce61b88b336c85/lxml-5.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:de5b4e1088523e2b6f730d0509a9a813355b7f5659d70eb4f319c76beea2e250", size = 3805104, upload-time = "2025-04-23T01:45:15.104Z" }, + { url = "https://files.pythonhosted.org/packages/81/2d/67693cc8a605a12e5975380d7ff83020dcc759351b5a066e1cced04f797b/lxml-5.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:98a3912194c079ef37e716ed228ae0dcb960992100461b704aea4e93af6b0bb9", size = 8083240, upload-time = "2025-04-23T01:45:18.566Z" }, + { url = "https://files.pythonhosted.org/packages/73/53/b5a05ab300a808b72e848efd152fe9c022c0181b0a70b8bca1199f1bed26/lxml-5.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ea0252b51d296a75f6118ed0d8696888e7403408ad42345d7dfd0d1e93309a7", size = 4387685, upload-time = "2025-04-23T01:45:21.387Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/1a3879c5f512bdcd32995c301886fe082b2edd83c87d41b6d42d89b4ea4d/lxml-5.4.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b92b69441d1bd39f4940f9eadfa417a25862242ca2c396b406f9272ef09cdcaa", size = 4991164, upload-time = "2025-04-23T01:45:23.849Z" }, + { url = "https://files.pythonhosted.org/packages/f9/94/bbc66e42559f9d04857071e3b3d0c9abd88579367fd2588a4042f641f57e/lxml-5.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20e16c08254b9b6466526bc1828d9370ee6c0d60a4b64836bc3ac2917d1e16df", size = 4746206, upload-time = "2025-04-23T01:45:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/66/95/34b0679bee435da2d7cae895731700e519a8dfcab499c21662ebe671603e/lxml-5.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7605c1c32c3d6e8c990dd28a0970a3cbbf1429d5b92279e37fda05fb0c92190e", size = 5342144, upload-time = "2025-04-23T01:45:28.939Z" }, + { url = "https://files.pythonhosted.org/packages/e0/5d/abfcc6ab2fa0be72b2ba938abdae1f7cad4c632f8d552683ea295d55adfb/lxml-5.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ecf4c4b83f1ab3d5a7ace10bafcb6f11df6156857a3c418244cef41ca9fa3e44", size = 4825124, upload-time = "2025-04-23T01:45:31.361Z" }, + { url = "https://files.pythonhosted.org/packages/5a/78/6bd33186c8863b36e084f294fc0a5e5eefe77af95f0663ef33809cc1c8aa/lxml-5.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cef4feae82709eed352cd7e97ae062ef6ae9c7b5dbe3663f104cd2c0e8d94ba", size = 4876520, upload-time = "2025-04-23T01:45:34.191Z" }, + { url = "https://files.pythonhosted.org/packages/3b/74/4d7ad4839bd0fc64e3d12da74fc9a193febb0fae0ba6ebd5149d4c23176a/lxml-5.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:df53330a3bff250f10472ce96a9af28628ff1f4efc51ccba351a8820bca2a8ba", size = 4765016, upload-time = "2025-04-23T01:45:36.7Z" }, + { url = "https://files.pythonhosted.org/packages/24/0d/0a98ed1f2471911dadfc541003ac6dd6879fc87b15e1143743ca20f3e973/lxml-5.4.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:aefe1a7cb852fa61150fcb21a8c8fcea7b58c4cb11fbe59c97a0a4b31cae3c8c", size = 5362884, upload-time = "2025-04-23T01:45:39.291Z" }, + { url = "https://files.pythonhosted.org/packages/48/de/d4f7e4c39740a6610f0f6959052b547478107967362e8424e1163ec37ae8/lxml-5.4.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ef5a7178fcc73b7d8c07229e89f8eb45b2908a9238eb90dcfc46571ccf0383b8", size = 4902690, upload-time = "2025-04-23T01:45:42.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/8c/61763abd242af84f355ca4ef1ee096d3c1b7514819564cce70fd18c22e9a/lxml-5.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d2ed1b3cb9ff1c10e6e8b00941bb2e5bb568b307bfc6b17dffbbe8be5eecba86", size = 4944418, upload-time = "2025-04-23T01:45:46.051Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/6d7e3b63e7e282619193961a570c0a4c8a57fe820f07ca3fe2f6bd86608a/lxml-5.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:72ac9762a9f8ce74c9eed4a4e74306f2f18613a6b71fa065495a67ac227b3056", size = 4827092, upload-time = "2025-04-23T01:45:48.943Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/e60a306df54680b103348545706a98a7514a42c8b4fbfdcaa608567bb065/lxml-5.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f5cb182f6396706dc6cc1896dd02b1c889d644c081b0cdec38747573db88a7d7", size = 5418231, upload-time = "2025-04-23T01:45:51.481Z" }, + { url = "https://files.pythonhosted.org/packages/27/f2/9754aacd6016c930875854f08ac4b192a47fe19565f776a64004aa167521/lxml-5.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3a3178b4873df8ef9457a4875703488eb1622632a9cee6d76464b60e90adbfcd", size = 5261798, upload-time = "2025-04-23T01:45:54.146Z" }, + { url = "https://files.pythonhosted.org/packages/38/a2/0c49ec6941428b1bd4f280650d7b11a0f91ace9db7de32eb7aa23bcb39ff/lxml-5.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e094ec83694b59d263802ed03a8384594fcce477ce484b0cbcd0008a211ca751", size = 4988195, upload-time = "2025-04-23T01:45:56.685Z" }, + { url = "https://files.pythonhosted.org/packages/7a/75/87a3963a08eafc46a86c1131c6e28a4de103ba30b5ae903114177352a3d7/lxml-5.4.0-cp311-cp311-win32.whl", hash = "sha256:4329422de653cdb2b72afa39b0aa04252fca9071550044904b2e7036d9d97fe4", size = 3474243, upload-time = "2025-04-23T01:45:58.863Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/1f0964c4f6c2be861c50db380c554fb8befbea98c6404744ce243a3c87ef/lxml-5.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd3be6481ef54b8cfd0e1e953323b7aa9d9789b94842d0e5b142ef4bb7999539", size = 3815197, upload-time = "2025-04-23T01:46:01.096Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/d101ace719ca6a4ec043eb516fcfcb1b396a9fccc4fcd9ef593df34ba0d5/lxml-5.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b5aff6f3e818e6bdbbb38e5967520f174b18f539c2b9de867b1e7fde6f8d95a4", size = 8127392, upload-time = "2025-04-23T01:46:04.09Z" }, + { url = "https://files.pythonhosted.org/packages/11/84/beddae0cec4dd9ddf46abf156f0af451c13019a0fa25d7445b655ba5ccb7/lxml-5.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942a5d73f739ad7c452bf739a62a0f83e2578afd6b8e5406308731f4ce78b16d", size = 4415103, upload-time = "2025-04-23T01:46:07.227Z" }, + { url = "https://files.pythonhosted.org/packages/d0/25/d0d93a4e763f0462cccd2b8a665bf1e4343dd788c76dcfefa289d46a38a9/lxml-5.4.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460508a4b07364d6abf53acaa0a90b6d370fafde5693ef37602566613a9b0779", size = 5024224, upload-time = "2025-04-23T01:46:10.237Z" }, + { url = "https://files.pythonhosted.org/packages/31/ce/1df18fb8f7946e7f3388af378b1f34fcf253b94b9feedb2cec5969da8012/lxml-5.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529024ab3a505fed78fe3cc5ddc079464e709f6c892733e3f5842007cec8ac6e", size = 4769913, upload-time = "2025-04-23T01:46:12.757Z" }, + { url = "https://files.pythonhosted.org/packages/4e/62/f4a6c60ae7c40d43657f552f3045df05118636be1165b906d3423790447f/lxml-5.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ca56ebc2c474e8f3d5761debfd9283b8b18c76c4fc0967b74aeafba1f5647f9", size = 5290441, upload-time = "2025-04-23T01:46:16.037Z" }, + { url = "https://files.pythonhosted.org/packages/9e/aa/04f00009e1e3a77838c7fc948f161b5d2d5de1136b2b81c712a263829ea4/lxml-5.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a81e1196f0a5b4167a8dafe3a66aa67c4addac1b22dc47947abd5d5c7a3f24b5", size = 4820165, upload-time = "2025-04-23T01:46:19.137Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/e0b2f61fa2404bf0f1fdf1898377e5bd1b74cc9b2cf2c6ba8509b8f27990/lxml-5.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5", size = 4932580, upload-time = "2025-04-23T01:46:21.963Z" }, + { url = "https://files.pythonhosted.org/packages/24/a2/8263f351b4ffe0ed3e32ea7b7830f845c795349034f912f490180d88a877/lxml-5.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c5681160758d3f6ac5b4fea370495c48aac0989d6a0f01bb9a72ad8ef5ab75c4", size = 4759493, upload-time = "2025-04-23T01:46:24.316Z" }, + { url = "https://files.pythonhosted.org/packages/05/00/41db052f279995c0e35c79d0f0fc9f8122d5b5e9630139c592a0b58c71b4/lxml-5.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:2dc191e60425ad70e75a68c9fd90ab284df64d9cd410ba8d2b641c0c45bc006e", size = 5324679, upload-time = "2025-04-23T01:46:27.097Z" }, + { url = "https://files.pythonhosted.org/packages/1d/be/ee99e6314cdef4587617d3b3b745f9356d9b7dd12a9663c5f3b5734b64ba/lxml-5.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:67f779374c6b9753ae0a0195a892a1c234ce8416e4448fe1e9f34746482070a7", size = 4890691, upload-time = "2025-04-23T01:46:30.009Z" }, + { url = "https://files.pythonhosted.org/packages/ad/36/239820114bf1d71f38f12208b9c58dec033cbcf80101cde006b9bde5cffd/lxml-5.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:79d5bfa9c1b455336f52343130b2067164040604e41f6dc4d8313867ed540079", size = 4955075, upload-time = "2025-04-23T01:46:32.33Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e1/1b795cc0b174efc9e13dbd078a9ff79a58728a033142bc6d70a1ee8fc34d/lxml-5.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d3c30ba1c9b48c68489dc1829a6eede9873f52edca1dda900066542528d6b20", size = 4838680, upload-time = "2025-04-23T01:46:34.852Z" }, + { url = "https://files.pythonhosted.org/packages/72/48/3c198455ca108cec5ae3662ae8acd7fd99476812fd712bb17f1b39a0b589/lxml-5.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1af80c6316ae68aded77e91cd9d80648f7dd40406cef73df841aa3c36f6907c8", size = 5391253, upload-time = "2025-04-23T01:46:37.608Z" }, + { url = "https://files.pythonhosted.org/packages/d6/10/5bf51858971c51ec96cfc13e800a9951f3fd501686f4c18d7d84fe2d6352/lxml-5.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4d885698f5019abe0de3d352caf9466d5de2baded00a06ef3f1216c1a58ae78f", size = 5261651, upload-time = "2025-04-23T01:46:40.183Z" }, + { url = "https://files.pythonhosted.org/packages/2b/11/06710dd809205377da380546f91d2ac94bad9ff735a72b64ec029f706c85/lxml-5.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea53d51859b6c64e7c51d522c03cc2c48b9b5d6172126854cc7f01aa11f52bc", size = 5024315, upload-time = "2025-04-23T01:46:43.333Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b0/15b6217834b5e3a59ebf7f53125e08e318030e8cc0d7310355e6edac98ef/lxml-5.4.0-cp312-cp312-win32.whl", hash = "sha256:d90b729fd2732df28130c064aac9bb8aff14ba20baa4aee7bd0795ff1187545f", size = 3486149, upload-time = "2025-04-23T01:46:45.684Z" }, + { url = "https://files.pythonhosted.org/packages/91/1e/05ddcb57ad2f3069101611bd5f5084157d90861a2ef460bf42f45cced944/lxml-5.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1dc4ca99e89c335a7ed47d38964abcb36c5910790f9bd106f2a8fa2ee0b909d2", size = 3817095, upload-time = "2025-04-23T01:46:48.521Z" }, + { url = "https://files.pythonhosted.org/packages/87/cb/2ba1e9dd953415f58548506fa5549a7f373ae55e80c61c9041b7fd09a38a/lxml-5.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:773e27b62920199c6197130632c18fb7ead3257fce1ffb7d286912e56ddb79e0", size = 8110086, upload-time = "2025-04-23T01:46:52.218Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3e/6602a4dca3ae344e8609914d6ab22e52ce42e3e1638c10967568c5c1450d/lxml-5.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9c671845de9699904b1e9df95acfe8dfc183f2310f163cdaa91a3535af95de", size = 4404613, upload-time = "2025-04-23T01:46:55.281Z" }, + { url = "https://files.pythonhosted.org/packages/4c/72/bf00988477d3bb452bef9436e45aeea82bb40cdfb4684b83c967c53909c7/lxml-5.4.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9454b8d8200ec99a224df8854786262b1bd6461f4280064c807303c642c05e76", size = 5012008, upload-time = "2025-04-23T01:46:57.817Z" }, + { url = "https://files.pythonhosted.org/packages/92/1f/93e42d93e9e7a44b2d3354c462cd784dbaaf350f7976b5d7c3f85d68d1b1/lxml-5.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cccd007d5c95279e529c146d095f1d39ac05139de26c098166c4beb9374b0f4d", size = 4760915, upload-time = "2025-04-23T01:47:00.745Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/363009390d0b461cf9976a499e83b68f792e4c32ecef092f3f9ef9c4ba54/lxml-5.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fce1294a0497edb034cb416ad3e77ecc89b313cff7adbee5334e4dc0d11f422", size = 5283890, upload-time = "2025-04-23T01:47:04.702Z" }, + { url = "https://files.pythonhosted.org/packages/19/dc/6056c332f9378ab476c88e301e6549a0454dbee8f0ae16847414f0eccb74/lxml-5.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24974f774f3a78ac12b95e3a20ef0931795ff04dbb16db81a90c37f589819551", size = 4812644, upload-time = "2025-04-23T01:47:07.833Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8a/f8c66bbb23ecb9048a46a5ef9b495fd23f7543df642dabeebcb2eeb66592/lxml-5.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:497cab4d8254c2a90bf988f162ace2ddbfdd806fce3bda3f581b9d24c852e03c", size = 4921817, upload-time = "2025-04-23T01:47:10.317Z" }, + { url = "https://files.pythonhosted.org/packages/04/57/2e537083c3f381f83d05d9b176f0d838a9e8961f7ed8ddce3f0217179ce3/lxml-5.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e794f698ae4c5084414efea0f5cc9f4ac562ec02d66e1484ff822ef97c2cadff", size = 4753916, upload-time = "2025-04-23T01:47:12.823Z" }, + { url = "https://files.pythonhosted.org/packages/d8/80/ea8c4072109a350848f1157ce83ccd9439601274035cd045ac31f47f3417/lxml-5.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2c62891b1ea3094bb12097822b3d44b93fc6c325f2043c4d2736a8ff09e65f60", size = 5289274, upload-time = "2025-04-23T01:47:15.916Z" }, + { url = "https://files.pythonhosted.org/packages/b3/47/c4be287c48cdc304483457878a3f22999098b9a95f455e3c4bda7ec7fc72/lxml-5.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:142accb3e4d1edae4b392bd165a9abdee8a3c432a2cca193df995bc3886249c8", size = 4874757, upload-time = "2025-04-23T01:47:19.793Z" }, + { url = "https://files.pythonhosted.org/packages/2f/04/6ef935dc74e729932e39478e44d8cfe6a83550552eaa072b7c05f6f22488/lxml-5.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a42b3a19346e5601d1b8296ff6ef3d76038058f311902edd574461e9c036982", size = 4947028, upload-time = "2025-04-23T01:47:22.401Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f9/c33fc8daa373ef8a7daddb53175289024512b6619bc9de36d77dca3df44b/lxml-5.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4291d3c409a17febf817259cb37bc62cb7eb398bcc95c1356947e2871911ae61", size = 4834487, upload-time = "2025-04-23T01:47:25.513Z" }, + { url = "https://files.pythonhosted.org/packages/8d/30/fc92bb595bcb878311e01b418b57d13900f84c2b94f6eca9e5073ea756e6/lxml-5.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4f5322cf38fe0e21c2d73901abf68e6329dc02a4994e483adbcf92b568a09a54", size = 5381688, upload-time = "2025-04-23T01:47:28.454Z" }, + { url = "https://files.pythonhosted.org/packages/43/d1/3ba7bd978ce28bba8e3da2c2e9d5ae3f8f521ad3f0ca6ea4788d086ba00d/lxml-5.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0be91891bdb06ebe65122aa6bf3fc94489960cf7e03033c6f83a90863b23c58b", size = 5242043, upload-time = "2025-04-23T01:47:31.208Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cd/95fa2201041a610c4d08ddaf31d43b98ecc4b1d74b1e7245b1abdab443cb/lxml-5.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:15a665ad90054a3d4f397bc40f73948d48e36e4c09f9bcffc7d90c87410e478a", size = 5021569, upload-time = "2025-04-23T01:47:33.805Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a6/31da006fead660b9512d08d23d31e93ad3477dd47cc42e3285f143443176/lxml-5.4.0-cp313-cp313-win32.whl", hash = "sha256:d5663bc1b471c79f5c833cffbc9b87d7bf13f87e055a5c86c363ccd2348d7e82", size = 3485270, upload-time = "2025-04-23T01:47:36.133Z" }, + { url = "https://files.pythonhosted.org/packages/fc/14/c115516c62a7d2499781d2d3d7215218c0731b2c940753bf9f9b7b73924d/lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f", size = 3814606, upload-time = "2025-04-23T01:47:39.028Z" }, + { url = "https://files.pythonhosted.org/packages/1e/04/acd238222ea25683e43ac7113facc380b3aaf77c53e7d88c4f544cef02ca/lxml-5.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bda3ea44c39eb74e2488297bb39d47186ed01342f0022c8ff407c250ac3f498e", size = 8082189, upload-time = "2025-04-23T01:48:51.829Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4e/cc7fe9ccb9999cc648492ce970b63c657606aefc7d0fba46b17aa2ba93fb/lxml-5.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9ceaf423b50ecfc23ca00b7f50b64baba85fb3fb91c53e2c9d00bc86150c7e40", size = 4384950, upload-time = "2025-04-23T01:48:54.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/bf/acd219c489346d0243a30769b9d446b71e5608581db49a18c8d91a669e19/lxml-5.4.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:664cdc733bc87449fe781dbb1f309090966c11cc0c0cd7b84af956a02a8a4729", size = 5209823, upload-time = "2025-04-23T01:48:57.192Z" }, + { url = "https://files.pythonhosted.org/packages/57/51/ec31cd33175c09aa7b93d101f56eed43d89e15504455d884d021df7166a7/lxml-5.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67ed8a40665b84d161bae3181aa2763beea3747f748bca5874b4af4d75998f87", size = 4931808, upload-time = "2025-04-23T01:48:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/e5/68/865d229f191514da1777125598d028dc88a5ea300d68c30e1f120bfd01bd/lxml-5.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b4a3bd174cc9cdaa1afbc4620c049038b441d6ba07629d89a83b408e54c35cd", size = 5086067, upload-time = "2025-04-23T01:49:02.887Z" }, + { url = "https://files.pythonhosted.org/packages/82/01/4c958c5848b4e263cd9e83dff6b49f975a5a0854feb1070dfe0bdcdf70a0/lxml-5.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:b0989737a3ba6cf2a16efb857fb0dfa20bc5c542737fddb6d893fde48be45433", size = 4929026, upload-time = "2025-04-23T01:49:05.624Z" }, + { url = "https://files.pythonhosted.org/packages/55/31/5327d8af74d7f35e645b40ae6658761e1fee59ebecaa6a8d295e495c2ca9/lxml-5.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:dc0af80267edc68adf85f2a5d9be1cdf062f973db6790c1d065e45025fa26140", size = 5134245, upload-time = "2025-04-23T01:49:08.918Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c9/204eba2400beb0016dacc2c5335ecb1e37f397796683ffdb7f471e86bddb/lxml-5.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:639978bccb04c42677db43c79bdaa23785dc7f9b83bfd87570da8207872f1ce5", size = 5001020, upload-time = "2025-04-23T01:49:11.643Z" }, + { url = "https://files.pythonhosted.org/packages/07/53/979165f50a853dab1cf3b9e53105032d55f85c5993f94afc4d9a61a22877/lxml-5.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5a99d86351f9c15e4a901fc56404b485b1462039db59288b203f8c629260a142", size = 5192346, upload-time = "2025-04-23T01:49:14.868Z" }, + { url = "https://files.pythonhosted.org/packages/17/2b/f37b5ae28949143f863ba3066b30eede6107fc9a503bd0d01677d4e2a1e0/lxml-5.4.0-cp39-cp39-win32.whl", hash = "sha256:3e6d5557989cdc3ebb5302bbdc42b439733a841891762ded9514e74f60319ad6", size = 3478275, upload-time = "2025-04-23T01:49:17.249Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d5/b795a183680126147665a8eeda8e802c180f2f7661aa9a550bba5bcdae63/lxml-5.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:a8c9b7f16b63e65bbba889acb436a1034a82d34fa09752d754f88d708eca80e1", size = 3806275, upload-time = "2025-04-23T01:49:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b0/e4d1cbb8c078bc4ae44de9c6a79fec4e2b4151b1b4d50af71d799e76b177/lxml-5.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1b717b00a71b901b4667226bba282dd462c42ccf618ade12f9ba3674e1fabc55", size = 3892319, upload-time = "2025-04-23T01:49:22.069Z" }, + { url = "https://files.pythonhosted.org/packages/5b/aa/e2bdefba40d815059bcb60b371a36fbfcce970a935370e1b367ba1cc8f74/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27a9ded0f0b52098ff89dd4c418325b987feed2ea5cc86e8860b0f844285d740", size = 4211614, upload-time = "2025-04-23T01:49:24.599Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/91ff89d1e092e7cfdd8453a939436ac116db0a665e7f4be0cd8e65c7dc5a/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b7ce10634113651d6f383aa712a194179dcd496bd8c41e191cec2099fa09de5", size = 4306273, upload-time = "2025-04-23T01:49:27.355Z" }, + { url = "https://files.pythonhosted.org/packages/be/7c/8c3f15df2ca534589717bfd19d1e3482167801caedfa4d90a575facf68a6/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53370c26500d22b45182f98847243efb518d268374a9570409d2e2276232fd37", size = 4208552, upload-time = "2025-04-23T01:49:29.949Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/9567afb1665f64d73fc54eb904e418d1138d7f011ed00647121b4dd60b38/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c6364038c519dffdbe07e3cf42e6a7f8b90c275d4d1617a69bb59734c1a2d571", size = 4331091, upload-time = "2025-04-23T01:49:32.842Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ab/fdbbd91d8d82bf1a723ba88ec3e3d76c022b53c391b0c13cad441cdb8f9e/lxml-5.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b12cb6527599808ada9eb2cd6e0e7d3d8f13fe7bbb01c6311255a15ded4c7ab4", size = 3487862, upload-time = "2025-04-23T01:49:36.296Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fb/d19b67e4bb63adc20574ba3476cf763b3514df1a37551084b890254e4b15/lxml-5.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9459e6892f59ecea2e2584ee1058f5d8f629446eab52ba2305ae13a32a059530", size = 3891034, upload-time = "2025-04-23T01:50:12.71Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5d/6e1033ee0cdb2f9bc93164f9df14e42cb5bbf1bbed3bf67f687de2763104/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47fb24cc0f052f0576ea382872b3fc7e1f7e3028e53299ea751839418ade92a6", size = 4207420, upload-time = "2025-04-23T01:50:15.281Z" }, + { url = "https://files.pythonhosted.org/packages/f3/4b/23ac79efc32d913259d66672c5f93daac7750a3d97cdc1c1a9a5d1c1b46c/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50441c9de951a153c698b9b99992e806b71c1f36d14b154592580ff4a9d0d877", size = 4305106, upload-time = "2025-04-23T01:50:17.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7a/fe558bee63a62f7a75a52111c0a94556c1c1bdcf558cd7d52861de558759/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ab339536aa798b1e17750733663d272038bf28069761d5be57cb4a9b0137b4f8", size = 4205587, upload-time = "2025-04-23T01:50:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5b/3207e6bd8d67c952acfec6bac9d1fa0ee353202e7c40b335ebe00879ab7d/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9776af1aad5a4b4a1317242ee2bea51da54b2a7b7b48674be736d463c999f37d", size = 4329077, upload-time = "2025-04-23T01:50:23.996Z" }, + { url = "https://files.pythonhosted.org/packages/a1/25/d381abcfd00102d3304aa191caab62f6e3bcbac93ee248771db6be153dfd/lxml-5.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:63e7968ff83da2eb6fdda967483a7a023aa497d85ad8f05c3ad9b1f2e8c84987", size = 3486416, upload-time = "2025-04-23T01:50:26.388Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357, upload-time = "2024-10-18T15:20:51.44Z" }, + { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393, upload-time = "2024-10-18T15:20:52.426Z" }, + { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732, upload-time = "2024-10-18T15:20:53.578Z" }, + { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866, upload-time = "2024-10-18T15:20:55.06Z" }, + { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964, upload-time = "2024-10-18T15:20:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977, upload-time = "2024-10-18T15:20:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366, upload-time = "2024-10-18T15:20:58.235Z" }, + { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091, upload-time = "2024-10-18T15:20:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065, upload-time = "2024-10-18T15:21:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514, upload-time = "2024-10-18T15:21:01.122Z" }, + { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload-time = "2024-10-18T15:21:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload-time = "2024-10-18T15:21:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload-time = "2024-10-18T15:21:03.953Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload-time = "2024-10-18T15:21:06.495Z" }, + { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload-time = "2024-10-18T15:21:07.295Z" }, + { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload-time = "2024-10-18T15:21:08.073Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload-time = "2024-10-18T15:21:09.318Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload-time = "2024-10-18T15:21:10.185Z" }, + { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" }, + { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ea/9b1530c3fdeeca613faeb0fb5cbcf2389d816072fab72a71b45749ef6062/MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", size = 14344, upload-time = "2024-10-18T15:21:43.721Z" }, + { url = "https://files.pythonhosted.org/packages/4b/c2/fbdbfe48848e7112ab05e627e718e854d20192b674952d9042ebd8c9e5de/MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", size = 12389, upload-time = "2024-10-18T15:21:44.666Z" }, + { url = "https://files.pythonhosted.org/packages/f0/25/7a7c6e4dbd4f867d95d94ca15449e91e52856f6ed1905d58ef1de5e211d0/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", size = 21607, upload-time = "2024-10-18T15:21:45.452Z" }, + { url = "https://files.pythonhosted.org/packages/53/8f/f339c98a178f3c1e545622206b40986a4c3307fe39f70ccd3d9df9a9e425/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", size = 20728, upload-time = "2024-10-18T15:21:46.295Z" }, + { url = "https://files.pythonhosted.org/packages/1a/03/8496a1a78308456dbd50b23a385c69b41f2e9661c67ea1329849a598a8f9/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", size = 20826, upload-time = "2024-10-18T15:21:47.134Z" }, + { url = "https://files.pythonhosted.org/packages/e6/cf/0a490a4bd363048c3022f2f475c8c05582179bb179defcee4766fb3dcc18/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", size = 21843, upload-time = "2024-10-18T15:21:48.334Z" }, + { url = "https://files.pythonhosted.org/packages/19/a3/34187a78613920dfd3cdf68ef6ce5e99c4f3417f035694074beb8848cd77/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", size = 21219, upload-time = "2024-10-18T15:21:49.587Z" }, + { url = "https://files.pythonhosted.org/packages/17/d8/5811082f85bb88410ad7e452263af048d685669bbbfb7b595e8689152498/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", size = 20946, upload-time = "2024-10-18T15:21:50.441Z" }, + { url = "https://files.pythonhosted.org/packages/7c/31/bd635fb5989440d9365c5e3c47556cfea121c7803f5034ac843e8f37c2f2/MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", size = 15063, upload-time = "2024-10-18T15:21:51.385Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506, upload-time = "2024-10-18T15:21:52.974Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "more-itertools" +version = "10.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/a0/834b0cebabbfc7e311f30b46c8188790a37f89fc8d756660346fe5abfd09/more_itertools-10.7.0.tar.gz", hash = "sha256:9fddd5403be01a94b204faadcff459ec3568cf110265d3c54323e1e866ad29d3", size = 127671, upload-time = "2025-04-22T14:17:41.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/9f/7ba6f94fc1e9ac3d2b853fdff3035fb2fa5afbed898c4a72b8a020610594/more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e", size = 65278, upload-time = "2025-04-22T14:17:40.49Z" }, +] + +[[package]] +name = "msgpack" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/b1/ea4f68038a18c77c9467400d166d74c4ffa536f34761f7983a104357e614/msgpack-1.1.1.tar.gz", hash = "sha256:77b79ce34a2bdab2594f490c8e80dd62a02d650b91a75159a63ec413b8d104cd", size = 173555, upload-time = "2025-06-13T06:52:51.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/52/f30da112c1dc92cf64f57d08a273ac771e7b29dea10b4b30369b2d7e8546/msgpack-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:353b6fc0c36fde68b661a12949d7d49f8f51ff5fa019c1e47c87c4ff34b080ed", size = 81799, upload-time = "2025-06-13T06:51:37.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/7bfc0def2f04ab4145f7f108e3563f9b4abae4ab0ed78a61f350518cc4d2/msgpack-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:79c408fcf76a958491b4e3b103d1c417044544b68e96d06432a189b43d1215c8", size = 78278, upload-time = "2025-06-13T06:51:38.534Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c5/df5d6c1c39856bc55f800bf82778fd4c11370667f9b9e9d51b2f5da88f20/msgpack-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78426096939c2c7482bf31ef15ca219a9e24460289c00dd0b94411040bb73ad2", size = 402805, upload-time = "2025-06-13T06:51:39.538Z" }, + { url = "https://files.pythonhosted.org/packages/20/8e/0bb8c977efecfe6ea7116e2ed73a78a8d32a947f94d272586cf02a9757db/msgpack-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b17ba27727a36cb73aabacaa44b13090feb88a01d012c0f4be70c00f75048b4", size = 408642, upload-time = "2025-06-13T06:51:41.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/731d52c1aeec52006be6d1f8027c49fdc2cfc3ab7cbe7c28335b2910d7b6/msgpack-1.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a17ac1ea6ec3c7687d70201cfda3b1e8061466f28f686c24f627cae4ea8efd0", size = 395143, upload-time = "2025-06-13T06:51:42.575Z" }, + { url = "https://files.pythonhosted.org/packages/2b/92/b42911c52cda2ba67a6418ffa7d08969edf2e760b09015593c8a8a27a97d/msgpack-1.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88d1e966c9235c1d4e2afac21ca83933ba59537e2e2727a999bf3f515ca2af26", size = 395986, upload-time = "2025-06-13T06:51:43.807Z" }, + { url = "https://files.pythonhosted.org/packages/61/dc/8ae165337e70118d4dab651b8b562dd5066dd1e6dd57b038f32ebc3e2f07/msgpack-1.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f6d58656842e1b2ddbe07f43f56b10a60f2ba5826164910968f5933e5178af75", size = 402682, upload-time = "2025-06-13T06:51:45.534Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/555851cb98dcbd6ce041df1eacb25ac30646575e9cd125681aa2f4b1b6f1/msgpack-1.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96decdfc4adcbc087f5ea7ebdcfd3dee9a13358cae6e81d54be962efc38f6338", size = 406368, upload-time = "2025-06-13T06:51:46.97Z" }, + { url = "https://files.pythonhosted.org/packages/d4/64/39a26add4ce16f24e99eabb9005e44c663db00e3fce17d4ae1ae9d61df99/msgpack-1.1.1-cp310-cp310-win32.whl", hash = "sha256:6640fd979ca9a212e4bcdf6eb74051ade2c690b862b679bfcb60ae46e6dc4bfd", size = 65004, upload-time = "2025-06-13T06:51:48.582Z" }, + { url = "https://files.pythonhosted.org/packages/7d/18/73dfa3e9d5d7450d39debde5b0d848139f7de23bd637a4506e36c9800fd6/msgpack-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:8b65b53204fe1bd037c40c4148d00ef918eb2108d24c9aaa20bc31f9810ce0a8", size = 71548, upload-time = "2025-06-13T06:51:49.558Z" }, + { url = "https://files.pythonhosted.org/packages/7f/83/97f24bf9848af23fe2ba04380388216defc49a8af6da0c28cc636d722502/msgpack-1.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:71ef05c1726884e44f8b1d1773604ab5d4d17729d8491403a705e649116c9558", size = 82728, upload-time = "2025-06-13T06:51:50.68Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/2eaa388267a78401f6e182662b08a588ef4f3de6f0eab1ec09736a7aaa2b/msgpack-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:36043272c6aede309d29d56851f8841ba907a1a3d04435e43e8a19928e243c1d", size = 79279, upload-time = "2025-06-13T06:51:51.72Z" }, + { url = "https://files.pythonhosted.org/packages/f8/46/31eb60f4452c96161e4dfd26dbca562b4ec68c72e4ad07d9566d7ea35e8a/msgpack-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a32747b1b39c3ac27d0670122b57e6e57f28eefb725e0b625618d1b59bf9d1e0", size = 423859, upload-time = "2025-06-13T06:51:52.749Z" }, + { url = "https://files.pythonhosted.org/packages/45/16/a20fa8c32825cc7ae8457fab45670c7a8996d7746ce80ce41cc51e3b2bd7/msgpack-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a8b10fdb84a43e50d38057b06901ec9da52baac6983d3f709d8507f3889d43f", size = 429975, upload-time = "2025-06-13T06:51:53.97Z" }, + { url = "https://files.pythonhosted.org/packages/86/ea/6c958e07692367feeb1a1594d35e22b62f7f476f3c568b002a5ea09d443d/msgpack-1.1.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba0c325c3f485dc54ec298d8b024e134acf07c10d494ffa24373bea729acf704", size = 413528, upload-time = "2025-06-13T06:51:55.507Z" }, + { url = "https://files.pythonhosted.org/packages/75/05/ac84063c5dae79722bda9f68b878dc31fc3059adb8633c79f1e82c2cd946/msgpack-1.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:88daaf7d146e48ec71212ce21109b66e06a98e5e44dca47d853cbfe171d6c8d2", size = 413338, upload-time = "2025-06-13T06:51:57.023Z" }, + { url = "https://files.pythonhosted.org/packages/69/e8/fe86b082c781d3e1c09ca0f4dacd457ede60a13119b6ce939efe2ea77b76/msgpack-1.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8b55ea20dc59b181d3f47103f113e6f28a5e1c89fd5b67b9140edb442ab67f2", size = 422658, upload-time = "2025-06-13T06:51:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2b/bafc9924df52d8f3bb7c00d24e57be477f4d0f967c0a31ef5e2225e035c7/msgpack-1.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a28e8072ae9779f20427af07f53bbb8b4aa81151054e882aee333b158da8752", size = 427124, upload-time = "2025-06-13T06:51:59.969Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3b/1f717e17e53e0ed0b68fa59e9188f3f610c79d7151f0e52ff3cd8eb6b2dc/msgpack-1.1.1-cp311-cp311-win32.whl", hash = "sha256:7da8831f9a0fdb526621ba09a281fadc58ea12701bc709e7b8cbc362feabc295", size = 65016, upload-time = "2025-06-13T06:52:01.294Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/9d1780768d3b249accecc5a38c725eb1e203d44a191f7b7ff1941f7df60c/msgpack-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5fd1b58e1431008a57247d6e7cc4faa41c3607e8e7d4aaf81f7c29ea013cb458", size = 72267, upload-time = "2025-06-13T06:52:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/389b9c593eda2b8551b2e7126ad3a06af6f9b44274eb3a4f054d48ff7e47/msgpack-1.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae497b11f4c21558d95de9f64fff7053544f4d1a17731c866143ed6bb4591238", size = 82359, upload-time = "2025-06-13T06:52:03.909Z" }, + { url = "https://files.pythonhosted.org/packages/ab/65/7d1de38c8a22cf8b1551469159d4b6cf49be2126adc2482de50976084d78/msgpack-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:33be9ab121df9b6b461ff91baac6f2731f83d9b27ed948c5b9d1978ae28bf157", size = 79172, upload-time = "2025-06-13T06:52:05.246Z" }, + { url = "https://files.pythonhosted.org/packages/0f/bd/cacf208b64d9577a62c74b677e1ada005caa9b69a05a599889d6fc2ab20a/msgpack-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f64ae8fe7ffba251fecb8408540c34ee9df1c26674c50c4544d72dbf792e5ce", size = 425013, upload-time = "2025-06-13T06:52:06.341Z" }, + { url = "https://files.pythonhosted.org/packages/4d/ec/fd869e2567cc9c01278a736cfd1697941ba0d4b81a43e0aa2e8d71dab208/msgpack-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a494554874691720ba5891c9b0b39474ba43ffb1aaf32a5dac874effb1619e1a", size = 426905, upload-time = "2025-06-13T06:52:07.501Z" }, + { url = "https://files.pythonhosted.org/packages/55/2a/35860f33229075bce803a5593d046d8b489d7ba2fc85701e714fc1aaf898/msgpack-1.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb643284ab0ed26f6957d969fe0dd8bb17beb567beb8998140b5e38a90974f6c", size = 407336, upload-time = "2025-06-13T06:52:09.047Z" }, + { url = "https://files.pythonhosted.org/packages/8c/16/69ed8f3ada150bf92745fb4921bd621fd2cdf5a42e25eb50bcc57a5328f0/msgpack-1.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d275a9e3c81b1093c060c3837e580c37f47c51eca031f7b5fb76f7b8470f5f9b", size = 409485, upload-time = "2025-06-13T06:52:10.382Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b6/0c398039e4c6d0b2e37c61d7e0e9d13439f91f780686deb8ee64ecf1ae71/msgpack-1.1.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fd6b577e4541676e0cc9ddc1709d25014d3ad9a66caa19962c4f5de30fc09ef", size = 412182, upload-time = "2025-06-13T06:52:11.644Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d0/0cf4a6ecb9bc960d624c93effaeaae75cbf00b3bc4a54f35c8507273cda1/msgpack-1.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb29aaa613c0a1c40d1af111abf025f1732cab333f96f285d6a93b934738a68a", size = 419883, upload-time = "2025-06-13T06:52:12.806Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/9697c211720fa71a2dfb632cad6196a8af3abea56eece220fde4674dc44b/msgpack-1.1.1-cp312-cp312-win32.whl", hash = "sha256:870b9a626280c86cff9c576ec0d9cbcc54a1e5ebda9cd26dab12baf41fee218c", size = 65406, upload-time = "2025-06-13T06:52:14.271Z" }, + { url = "https://files.pythonhosted.org/packages/c0/23/0abb886e80eab08f5e8c485d6f13924028602829f63b8f5fa25a06636628/msgpack-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:5692095123007180dca3e788bb4c399cc26626da51629a31d40207cb262e67f4", size = 72558, upload-time = "2025-06-13T06:52:15.252Z" }, + { url = "https://files.pythonhosted.org/packages/a1/38/561f01cf3577430b59b340b51329803d3a5bf6a45864a55f4ef308ac11e3/msgpack-1.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3765afa6bd4832fc11c3749be4ba4b69a0e8d7b728f78e68120a157a4c5d41f0", size = 81677, upload-time = "2025-06-13T06:52:16.64Z" }, + { url = "https://files.pythonhosted.org/packages/09/48/54a89579ea36b6ae0ee001cba8c61f776451fad3c9306cd80f5b5c55be87/msgpack-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8ddb2bcfd1a8b9e431c8d6f4f7db0773084e107730ecf3472f1dfe9ad583f3d9", size = 78603, upload-time = "2025-06-13T06:52:17.843Z" }, + { url = "https://files.pythonhosted.org/packages/a0/60/daba2699b308e95ae792cdc2ef092a38eb5ee422f9d2fbd4101526d8a210/msgpack-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:196a736f0526a03653d829d7d4c5500a97eea3648aebfd4b6743875f28aa2af8", size = 420504, upload-time = "2025-06-13T06:52:18.982Z" }, + { url = "https://files.pythonhosted.org/packages/20/22/2ebae7ae43cd8f2debc35c631172ddf14e2a87ffcc04cf43ff9df9fff0d3/msgpack-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d592d06e3cc2f537ceeeb23d38799c6ad83255289bb84c2e5792e5a8dea268a", size = 423749, upload-time = "2025-06-13T06:52:20.211Z" }, + { url = "https://files.pythonhosted.org/packages/40/1b/54c08dd5452427e1179a40b4b607e37e2664bca1c790c60c442c8e972e47/msgpack-1.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4df2311b0ce24f06ba253fda361f938dfecd7b961576f9be3f3fbd60e87130ac", size = 404458, upload-time = "2025-06-13T06:52:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/2e/60/6bb17e9ffb080616a51f09928fdd5cac1353c9becc6c4a8abd4e57269a16/msgpack-1.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4141c5a32b5e37905b5940aacbc59739f036930367d7acce7a64e4dec1f5e0b", size = 405976, upload-time = "2025-06-13T06:52:22.995Z" }, + { url = "https://files.pythonhosted.org/packages/ee/97/88983e266572e8707c1f4b99c8fd04f9eb97b43f2db40e3172d87d8642db/msgpack-1.1.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b1ce7f41670c5a69e1389420436f41385b1aa2504c3b0c30620764b15dded2e7", size = 408607, upload-time = "2025-06-13T06:52:24.152Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/36c78af2efaffcc15a5a61ae0df53a1d025f2680122e2a9eb8442fed3ae4/msgpack-1.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4147151acabb9caed4e474c3344181e91ff7a388b888f1e19ea04f7e73dc7ad5", size = 424172, upload-time = "2025-06-13T06:52:25.704Z" }, + { url = "https://files.pythonhosted.org/packages/8c/87/a75eb622b555708fe0427fab96056d39d4c9892b0c784b3a721088c7ee37/msgpack-1.1.1-cp313-cp313-win32.whl", hash = "sha256:500e85823a27d6d9bba1d057c871b4210c1dd6fb01fbb764e37e4e8847376323", size = 65347, upload-time = "2025-06-13T06:52:26.846Z" }, + { url = "https://files.pythonhosted.org/packages/ca/91/7dc28d5e2a11a5ad804cf2b7f7a5fcb1eb5a4966d66a5d2b41aee6376543/msgpack-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d489fba546295983abd142812bda76b57e33d0b9f5d5b71c09a583285506f69", size = 72341, upload-time = "2025-06-13T06:52:27.835Z" }, + { url = "https://files.pythonhosted.org/packages/1f/bd/0792be119d7fe7dc2148689ef65c90507d82d20a204aab3b98c74a1f8684/msgpack-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5be6b6bc52fad84d010cb45433720327ce886009d862f46b26d4d154001994b", size = 81882, upload-time = "2025-06-13T06:52:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/ce06c8e26a816ae8730a8e030d263c5289adcaff9f0476f9b270bdd7c5c2/msgpack-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3a89cd8c087ea67e64844287ea52888239cbd2940884eafd2dcd25754fb72232", size = 78414, upload-time = "2025-06-13T06:52:40.341Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/190576c497677fb4a0d05d896b24aea6cdccd910f206aaa7b511901befed/msgpack-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d75f3807a9900a7d575d8d6674a3a47e9f227e8716256f35bc6f03fc597ffbf", size = 400927, upload-time = "2025-06-13T06:52:41.399Z" }, + { url = "https://files.pythonhosted.org/packages/ed/af/6a0aa5a06762e70726ec3c10fb966600d84a7220b52635cb0ab2dc64d32f/msgpack-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d182dac0221eb8faef2e6f44701812b467c02674a322c739355c39e94730cdbf", size = 405903, upload-time = "2025-06-13T06:52:42.699Z" }, + { url = "https://files.pythonhosted.org/packages/1e/80/3f3da358cecbbe8eb12360814bd1277d59d2608485934742a074d99894a9/msgpack-1.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b13fe0fb4aac1aa5320cd693b297fe6fdef0e7bea5518cbc2dd5299f873ae90", size = 393192, upload-time = "2025-06-13T06:52:43.986Z" }, + { url = "https://files.pythonhosted.org/packages/98/c6/3a0ec7fdebbb4f3f8f254696cd91d491c29c501dbebd86286c17e8f68cd7/msgpack-1.1.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:435807eeb1bc791ceb3247d13c79868deb22184e1fc4224808750f0d7d1affc1", size = 393851, upload-time = "2025-06-13T06:52:45.177Z" }, + { url = "https://files.pythonhosted.org/packages/39/37/df50d5f8e68514b60fbe70f6e8337ea2b32ae2be030871bcd9d1cf7d4b62/msgpack-1.1.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4835d17af722609a45e16037bb1d4d78b7bdf19d6c0128116d178956618c4e88", size = 400292, upload-time = "2025-06-13T06:52:46.381Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ec/1e067292e02d2ceb4c8cb5ba222c4f7bb28730eef5676740609dc2627e0f/msgpack-1.1.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8ef6e342c137888ebbfb233e02b8fbd689bb5b5fcc59b34711ac47ebd504478", size = 401873, upload-time = "2025-06-13T06:52:47.957Z" }, + { url = "https://files.pythonhosted.org/packages/d3/31/e8c9c6b5b58d64c9efa99c8d181fcc25f38ead357b0360379fbc8a4234ad/msgpack-1.1.1-cp39-cp39-win32.whl", hash = "sha256:61abccf9de335d9efd149e2fff97ed5974f2481b3353772e8e2dd3402ba2bd57", size = 65028, upload-time = "2025-06-13T06:52:49.166Z" }, + { url = "https://files.pythonhosted.org/packages/20/d6/cd62cded572e5e25892747a5d27850170bcd03c855e9c69c538e024de6f9/msgpack-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:40eae974c873b2992fd36424a5d9407f93e97656d999f43fca9d29f820899084", size = 71700, upload-time = "2025-06-13T06:52:50.244Z" }, +] + +[[package]] +name = "msgspec" +version = "0.19.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/9b/95d8ce458462b8b71b8a70fa94563b2498b89933689f3a7b8911edfae3d7/msgspec-0.19.0.tar.gz", hash = "sha256:604037e7cd475345848116e89c553aa9a233259733ab51986ac924ab1b976f8e", size = 216934, upload-time = "2024-12-27T17:40:28.597Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/40/817282b42f58399762267b30deb8ac011d8db373f8da0c212c85fbe62b8f/msgspec-0.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d8dd848ee7ca7c8153462557655570156c2be94e79acec3561cf379581343259", size = 190019, upload-time = "2024-12-27T17:39:13.803Z" }, + { url = "https://files.pythonhosted.org/packages/92/99/bd7ed738c00f223a8119928661167a89124140792af18af513e6519b0d54/msgspec-0.19.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0553bbc77662e5708fe66aa75e7bd3e4b0f209709c48b299afd791d711a93c36", size = 183680, upload-time = "2024-12-27T17:39:17.847Z" }, + { url = "https://files.pythonhosted.org/packages/e5/27/322badde18eb234e36d4a14122b89edd4e2973cdbc3da61ca7edf40a1ccd/msgspec-0.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe2c4bf29bf4e89790b3117470dea2c20b59932772483082c468b990d45fb947", size = 209334, upload-time = "2024-12-27T17:39:19.065Z" }, + { url = "https://files.pythonhosted.org/packages/c6/65/080509c5774a1592b2779d902a70b5fe008532759927e011f068145a16cb/msgspec-0.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e87ecfa9795ee5214861eab8326b0e75475c2e68a384002aa135ea2a27d909", size = 211551, upload-time = "2024-12-27T17:39:21.767Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/1c23c6b4ca6f4285c30a39def1054e2bee281389e4b681b5e3711bd5a8c9/msgspec-0.19.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3c4ec642689da44618f68c90855a10edbc6ac3ff7c1d94395446c65a776e712a", size = 215099, upload-time = "2024-12-27T17:39:24.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/fe/95f9654518879f3359d1e76bc41189113aa9102452170ab7c9a9a4ee52f6/msgspec-0.19.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2719647625320b60e2d8af06b35f5b12d4f4d281db30a15a1df22adb2295f633", size = 218211, upload-time = "2024-12-27T17:39:27.396Z" }, + { url = "https://files.pythonhosted.org/packages/79/f6/71ca7e87a1fb34dfe5efea8156c9ef59dd55613aeda2ca562f122cd22012/msgspec-0.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:695b832d0091edd86eeb535cd39e45f3919f48d997685f7ac31acb15e0a2ed90", size = 186174, upload-time = "2024-12-27T17:39:29.647Z" }, + { url = "https://files.pythonhosted.org/packages/24/d4/2ec2567ac30dab072cce3e91fb17803c52f0a37aab6b0c24375d2b20a581/msgspec-0.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa77046904db764b0462036bc63ef71f02b75b8f72e9c9dd4c447d6da1ed8f8e", size = 187939, upload-time = "2024-12-27T17:39:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/18226e4328897f4f19875cb62bb9259fe47e901eade9d9376ab5f251a929/msgspec-0.19.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:047cfa8675eb3bad68722cfe95c60e7afabf84d1bd8938979dd2b92e9e4a9551", size = 182202, upload-time = "2024-12-27T17:39:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/81/25/3a4b24d468203d8af90d1d351b77ea3cffb96b29492855cf83078f16bfe4/msgspec-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e78f46ff39a427e10b4a61614a2777ad69559cc8d603a7c05681f5a595ea98f7", size = 209029, upload-time = "2024-12-27T17:39:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/85/2e/db7e189b57901955239f7689b5dcd6ae9458637a9c66747326726c650523/msgspec-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c7adf191e4bd3be0e9231c3b6dc20cf1199ada2af523885efc2ed218eafd011", size = 210682, upload-time = "2024-12-27T17:39:36.384Z" }, + { url = "https://files.pythonhosted.org/packages/03/97/7c8895c9074a97052d7e4a1cc1230b7b6e2ca2486714eb12c3f08bb9d284/msgspec-0.19.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f04cad4385e20be7c7176bb8ae3dca54a08e9756cfc97bcdb4f18560c3042063", size = 214003, upload-time = "2024-12-27T17:39:39.097Z" }, + { url = "https://files.pythonhosted.org/packages/61/61/e892997bcaa289559b4d5869f066a8021b79f4bf8e955f831b095f47a4cd/msgspec-0.19.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45c8fb410670b3b7eb884d44a75589377c341ec1392b778311acdbfa55187716", size = 216833, upload-time = "2024-12-27T17:39:41.203Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3d/71b2dffd3a1c743ffe13296ff701ee503feaebc3f04d0e75613b6563c374/msgspec-0.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:70eaef4934b87193a27d802534dc466778ad8d536e296ae2f9334e182ac27b6c", size = 186184, upload-time = "2024-12-27T17:39:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5f/a70c24f075e3e7af2fae5414c7048b0e11389685b7f717bb55ba282a34a7/msgspec-0.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f98bd8962ad549c27d63845b50af3f53ec468b6318400c9f1adfe8b092d7b62f", size = 190485, upload-time = "2024-12-27T17:39:44.974Z" }, + { url = "https://files.pythonhosted.org/packages/89/b0/1b9763938cfae12acf14b682fcf05c92855974d921a5a985ecc197d1c672/msgspec-0.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:43bbb237feab761b815ed9df43b266114203f53596f9b6e6f00ebd79d178cdf2", size = 183910, upload-time = "2024-12-27T17:39:46.401Z" }, + { url = "https://files.pythonhosted.org/packages/87/81/0c8c93f0b92c97e326b279795f9c5b956c5a97af28ca0fbb9fd86c83737a/msgspec-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cfc033c02c3e0aec52b71710d7f84cb3ca5eb407ab2ad23d75631153fdb1f12", size = 210633, upload-time = "2024-12-27T17:39:49.099Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/c5422ce8af73928d194a6606f8ae36e93a52fd5e8df5abd366903a5ca8da/msgspec-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d911c442571605e17658ca2b416fd8579c5050ac9adc5e00c2cb3126c97f73bc", size = 213594, upload-time = "2024-12-27T17:39:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/19/2b/4137bc2ed45660444842d042be2cf5b18aa06efd2cda107cff18253b9653/msgspec-0.19.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:757b501fa57e24896cf40a831442b19a864f56d253679f34f260dcb002524a6c", size = 214053, upload-time = "2024-12-27T17:39:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e6/8ad51bdc806aac1dc501e8fe43f759f9ed7284043d722b53323ea421c360/msgspec-0.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5f0f65f29b45e2816d8bded36e6b837a4bf5fb60ec4bc3c625fa2c6da4124537", size = 219081, upload-time = "2024-12-27T17:39:55.142Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ef/27dd35a7049c9a4f4211c6cd6a8c9db0a50647546f003a5867827ec45391/msgspec-0.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:067f0de1c33cfa0b6a8206562efdf6be5985b988b53dd244a8e06f993f27c8c0", size = 187467, upload-time = "2024-12-27T17:39:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/3c/cb/2842c312bbe618d8fefc8b9cedce37f773cdc8fa453306546dba2c21fd98/msgspec-0.19.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f12d30dd6266557aaaf0aa0f9580a9a8fbeadfa83699c487713e355ec5f0bd86", size = 190498, upload-time = "2024-12-27T17:40:00.427Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/c40b01b93465e1a5f3b6c7d91b10fb574818163740cc3acbe722d1e0e7e4/msgspec-0.19.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82b2c42c1b9ebc89e822e7e13bbe9d17ede0c23c187469fdd9505afd5a481314", size = 183950, upload-time = "2024-12-27T17:40:04.219Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f0/5b764e066ce9aba4b70d1db8b087ea66098c7c27d59b9dd8a3532774d48f/msgspec-0.19.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19746b50be214a54239aab822964f2ac81e38b0055cca94808359d779338c10e", size = 210647, upload-time = "2024-12-27T17:40:05.606Z" }, + { url = "https://files.pythonhosted.org/packages/9d/87/bc14f49bc95c4cb0dd0a8c56028a67c014ee7e6818ccdce74a4862af259b/msgspec-0.19.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60ef4bdb0ec8e4ad62e5a1f95230c08efb1f64f32e6e8dd2ced685bcc73858b5", size = 213563, upload-time = "2024-12-27T17:40:10.516Z" }, + { url = "https://files.pythonhosted.org/packages/53/2f/2b1c2b056894fbaa975f68f81e3014bb447516a8b010f1bed3fb0e016ed7/msgspec-0.19.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac7f7c377c122b649f7545810c6cd1b47586e3aa3059126ce3516ac7ccc6a6a9", size = 213996, upload-time = "2024-12-27T17:40:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5a/4cd408d90d1417e8d2ce6a22b98a6853c1b4d7cb7669153e4424d60087f6/msgspec-0.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5bc1472223a643f5ffb5bf46ccdede7f9795078194f14edd69e3aab7020d327", size = 219087, upload-time = "2024-12-27T17:40:14.881Z" }, + { url = "https://files.pythonhosted.org/packages/23/d8/f15b40611c2d5753d1abb0ca0da0c75348daf1252220e5dda2867bd81062/msgspec-0.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:317050bc0f7739cb30d257ff09152ca309bf5a369854bbf1e57dffc310c1f20f", size = 187432, upload-time = "2024-12-27T17:40:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d0/323f867eaec1f2236ba30adf613777b1c97a7e8698e2e881656b21871fa4/msgspec-0.19.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15c1e86fff77184c20a2932cd9742bf33fe23125fa3fcf332df9ad2f7d483044", size = 189926, upload-time = "2024-12-27T17:40:18.939Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/c3e1b39bdae90a7258d77959f5f5e36ad44b40e2be91cff83eea33c54d43/msgspec-0.19.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3b5541b2b3294e5ffabe31a09d604e23a88533ace36ac288fa32a420aa38d229", size = 183873, upload-time = "2024-12-27T17:40:20.214Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a2/48f2c15c7644668e51f4dce99d5f709bd55314e47acb02e90682f5880f35/msgspec-0.19.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f5c043ace7962ef188746e83b99faaa9e3e699ab857ca3f367b309c8e2c6b12", size = 209272, upload-time = "2024-12-27T17:40:21.534Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/aa339cf08b990c3f07e67b229a3a8aa31bf129ed974b35e5daa0df7d9d56/msgspec-0.19.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca06aa08e39bf57e39a258e1996474f84d0dd8130d486c00bec26d797b8c5446", size = 211396, upload-time = "2024-12-27T17:40:22.897Z" }, + { url = "https://files.pythonhosted.org/packages/c7/00/c7fb9d524327c558b2803973cc3f988c5100a1708879970a9e377bdf6f4f/msgspec-0.19.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e695dad6897896e9384cf5e2687d9ae9feaef50e802f93602d35458e20d1fb19", size = 215002, upload-time = "2024-12-27T17:40:24.341Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bf/d9f9fff026c1248cde84a5ce62b3742e8a63a3c4e811f99f00c8babf7615/msgspec-0.19.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3be5c02e1fee57b54130316a08fe40cca53af92999a302a6054cd451700ea7db", size = 218132, upload-time = "2024-12-27T17:40:25.744Z" }, + { url = "https://files.pythonhosted.org/packages/00/03/b92011210f79794958167a3a3ea64a71135d9a2034cfb7597b545a42606d/msgspec-0.19.0-cp39-cp39-win_amd64.whl", hash = "sha256:0684573a821be3c749912acf5848cce78af4298345cb2d7a8b8948a0a5a27cfe", size = 186301, upload-time = "2024-12-27T17:40:27.076Z" }, +] + +[[package]] +name = "multidict" +version = "6.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b5/59f27b4ce9951a4bce56b88ba5ff5159486797ab18863f2b4c1c5e8465bd/multidict-6.5.0.tar.gz", hash = "sha256:942bd8002492ba819426a8d7aefde3189c1b87099cdf18aaaefefcf7f3f7b6d2", size = 98512, upload-time = "2025-06-17T14:15:56.556Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/88/f8354ef1cb1121234c3461ff3d11eac5f4fe115f00552d3376306275c9ab/multidict-6.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e118a202904623b1d2606d1c8614e14c9444b59d64454b0c355044058066469", size = 73858, upload-time = "2025-06-17T14:13:21.451Z" }, + { url = "https://files.pythonhosted.org/packages/49/04/634b49c7abe71bd1c61affaeaa0c2a46b6be8d599a07b495259615dbdfe0/multidict-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a42995bdcaff4e22cb1280ae7752c3ed3fbb398090c6991a2797a4a0e5ed16a9", size = 43186, upload-time = "2025-06-17T14:13:23.615Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ff/091ff4830ec8f96378578bfffa7f324a9dd16f60274cec861ae65ba10be3/multidict-6.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2261b538145723ca776e55208640fffd7ee78184d223f37c2b40b9edfe0e818a", size = 43031, upload-time = "2025-06-17T14:13:24.725Z" }, + { url = "https://files.pythonhosted.org/packages/10/c1/1b4137845f8b8dbc2332af54e2d7761c6a29c2c33c8d47a0c8c70676bac1/multidict-6.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e5b19f8cd67235fab3e195ca389490415d9fef5a315b1fa6f332925dc924262", size = 233588, upload-time = "2025-06-17T14:13:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/c3/77/cbe9a1f58c6d4f822663788e414637f256a872bc352cedbaf7717b62db58/multidict-6.5.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:177b081e4dec67c3320b16b3aa0babc178bbf758553085669382c7ec711e1ec8", size = 222714, upload-time = "2025-06-17T14:13:27.482Z" }, + { url = "https://files.pythonhosted.org/packages/6c/37/39e1142c2916973818515adc13bbdb68d3d8126935e3855200e059a79bab/multidict-6.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d30a2cc106a7d116b52ee046207614db42380b62e6b1dd2a50eba47c5ca5eb1", size = 242741, upload-time = "2025-06-17T14:13:28.92Z" }, + { url = "https://files.pythonhosted.org/packages/a3/aa/60c3ef0c87ccad3445bf01926a1b8235ee24c3dde483faef1079cc91706d/multidict-6.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a72933bc308d7a64de37f0d51795dbeaceebdfb75454f89035cdfc6a74cfd129", size = 235008, upload-time = "2025-06-17T14:13:30.587Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5e/f7e0fd5f5b8a7b9a75b0f5642ca6b6dde90116266920d8cf63b513f3908b/multidict-6.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96d109e663d032280ef8ef62b50924b2e887d5ddf19e301844a6cb7e91a172a6", size = 226627, upload-time = "2025-06-17T14:13:31.831Z" }, + { url = "https://files.pythonhosted.org/packages/b7/74/1bc0a3c6a9105051f68a6991fe235d7358836e81058728c24d5bbdd017cb/multidict-6.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b555329c9894332401f03b9a87016f0b707b6fccd4706793ec43b4a639e75869", size = 228232, upload-time = "2025-06-17T14:13:33.402Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/37118291cdc31f4cc680d54047cdea9b520e9a724a643919f71f8c2a2aeb/multidict-6.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6994bad9d471ef2156f2b6850b51e20ee409c6b9deebc0e57be096be9faffdce", size = 246616, upload-time = "2025-06-17T14:13:34.964Z" }, + { url = "https://files.pythonhosted.org/packages/ff/89/e2c08d6bdb21a1a55be4285510d058ace5f5acabe6b57900432e863d4c70/multidict-6.5.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:b15f817276c96cde9060569023808eec966bd8da56a97e6aa8116f34ddab6534", size = 235007, upload-time = "2025-06-17T14:13:36.428Z" }, + { url = "https://files.pythonhosted.org/packages/89/1e/e39a98e8e1477ec7a871b3c17265658fbe6d617048059ae7fa5011b224f3/multidict-6.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b4bf507c991db535a935b2127cf057a58dbc688c9f309c72080795c63e796f58", size = 244824, upload-time = "2025-06-17T14:13:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ba/63e11edd45c31e708c5a1904aa7ac4de01e13135a04cfe96bc71eb359b85/multidict-6.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:60c3f8f13d443426c55f88cf3172547bbc600a86d57fd565458b9259239a6737", size = 257229, upload-time = "2025-06-17T14:13:39.554Z" }, + { url = "https://files.pythonhosted.org/packages/0f/00/bdcceb6af424936adfc8b92a79d3a95863585f380071393934f10a63f9e3/multidict-6.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a10227168a24420c158747fc201d4279aa9af1671f287371597e2b4f2ff21879", size = 247118, upload-time = "2025-06-17T14:13:40.795Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a0/4aa79e991909cca36ca821a9ba5e8e81e4cd5b887c81f89ded994e0f49df/multidict-6.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e3b1425fe54ccfde66b8cfb25d02be34d5dfd2261a71561ffd887ef4088b4b69", size = 243948, upload-time = "2025-06-17T14:13:42.477Z" }, + { url = "https://files.pythonhosted.org/packages/21/8b/e45e19ce43afb31ff6b0fd5d5816b4fcc1fcc2f37e8a82aefae06c40c7a6/multidict-6.5.0-cp310-cp310-win32.whl", hash = "sha256:b4e47ef51237841d1087e1e1548071a6ef22e27ed0400c272174fa585277c4b4", size = 40433, upload-time = "2025-06-17T14:13:43.972Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6e/96e0ba4601343d9344e69503fca072ace19c35f7d4ca3d68401e59acdc8f/multidict-6.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:63b3b24fadc7067282c88fae5b2f366d5b3a7c15c021c2838de8c65a50eeefb4", size = 44423, upload-time = "2025-06-17T14:13:44.991Z" }, + { url = "https://files.pythonhosted.org/packages/eb/4a/9befa919d7a390f13a5511a69282b7437782071160c566de6e0ebf712c9f/multidict-6.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:8b2d61afbafc679b7eaf08e9de4fa5d38bd5dc7a9c0a577c9f9588fb49f02dbb", size = 41481, upload-time = "2025-06-17T14:13:49.389Z" }, + { url = "https://files.pythonhosted.org/packages/75/ba/484f8e96ee58ec4fef42650eb9dbbedb24f9bc155780888398a4725d2270/multidict-6.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8b4bf6bb15a05796a07a248084e3e46e032860c899c7a9b981030e61368dba95", size = 73283, upload-time = "2025-06-17T14:13:50.406Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/01d62ea6199d76934c87746695b3ed16aeedfdd564e8d89184577037baac/multidict-6.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46bb05d50219655c42a4b8fcda9c7ee658a09adbb719c48e65a20284e36328ea", size = 42937, upload-time = "2025-06-17T14:13:51.45Z" }, + { url = "https://files.pythonhosted.org/packages/da/cf/bb462d920f26d9e2e0aff8a78aeb06af1225b826e9a5468870c57591910a/multidict-6.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:54f524d73f4d54e87e03c98f6af601af4777e4668a52b1bd2ae0a4d6fc7b392b", size = 42748, upload-time = "2025-06-17T14:13:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b1/d5c11ea0fdad68d3ed45f0e2527de6496d2fac8afe6b8ca6d407c20ad00f/multidict-6.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529b03600466480ecc502000d62e54f185a884ed4570dee90d9a273ee80e37b5", size = 236448, upload-time = "2025-06-17T14:13:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/69/c3ceb264994f5b338c812911a8d660084f37779daef298fc30bd817f75c7/multidict-6.5.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69ad681ad7c93a41ee7005cc83a144b5b34a3838bcf7261e2b5356057b0f78de", size = 228695, upload-time = "2025-06-17T14:13:54.775Z" }, + { url = "https://files.pythonhosted.org/packages/81/3d/c23dcc0d34a35ad29974184db2878021d28fe170ecb9192be6bfee73f1f2/multidict-6.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fe9fada8bc0839466b09fa3f6894f003137942984843ec0c3848846329a36ae", size = 247434, upload-time = "2025-06-17T14:13:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/06/b3/06cf7a049129ff52525a859277abb5648e61d7afae7fb7ed02e3806be34e/multidict-6.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f94c6ea6405fcf81baef1e459b209a78cda5442e61b5b7a57ede39d99b5204a0", size = 239431, upload-time = "2025-06-17T14:13:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/b2fe2fafa23af0c6123aebe23b4cd23fdad01dfe7009bb85624e4636d0dd/multidict-6.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84ca75ad8a39ed75f079a8931435a5b51ee4c45d9b32e1740f99969a5d1cc2ee", size = 231542, upload-time = "2025-06-17T14:13:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c9/a52ca0a342a02411a31b6af197a6428a5137d805293f10946eeab614ec06/multidict-6.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be4c08f3a2a6cc42b414496017928d95898964fed84b1b2dace0c9ee763061f9", size = 233069, upload-time = "2025-06-17T14:13:59.834Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/a3328a3929b8e131e2678d5e65f552b0a6874fab62123e31f5a5625650b0/multidict-6.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:046a7540cfbb4d5dc846a1fd9843f3ba980c6523f2e0c5b8622b4a5c94138ae6", size = 250596, upload-time = "2025-06-17T14:14:01.178Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b8/aa3905a38a8287013aeb0a54c73f79ccd8b32d2f1d53e5934643a36502c2/multidict-6.5.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:64306121171d988af77d74be0d8c73ee1a69cf6f96aea7fa6030c88f32a152dd", size = 237858, upload-time = "2025-06-17T14:14:03.232Z" }, + { url = "https://files.pythonhosted.org/packages/d3/eb/f11d5af028014f402e5dd01ece74533964fa4e7bfae4af4824506fa8c398/multidict-6.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b4ac1dd5eb0ecf6f7351d5a9137f30a83f7182209c5d37f61614dfdce5714853", size = 249175, upload-time = "2025-06-17T14:14:04.561Z" }, + { url = "https://files.pythonhosted.org/packages/ac/57/d451905a62e5ef489cb4f92e8190d34ac5329427512afd7f893121da4e96/multidict-6.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bab4a8337235365f4111a7011a1f028826ca683834ebd12de4b85e2844359c36", size = 259532, upload-time = "2025-06-17T14:14:05.798Z" }, + { url = "https://files.pythonhosted.org/packages/d3/90/ff82b5ac5cabe3c79c50cf62a62f3837905aa717e67b6b4b7872804f23c8/multidict-6.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a05b5604c5a75df14a63eeeca598d11b2c3745b9008539b70826ea044063a572", size = 250554, upload-time = "2025-06-17T14:14:07.382Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5a/0cabc50d4bc16e61d8b0a8a74499a1409fa7b4ef32970b7662a423781fc7/multidict-6.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67c4a640952371c9ca65b6a710598be246ef3be5ca83ed38c16a7660d3980877", size = 248159, upload-time = "2025-06-17T14:14:08.65Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1d/adeabae0771544f140d9f42ab2c46eaf54e793325999c36106078b7f6600/multidict-6.5.0-cp311-cp311-win32.whl", hash = "sha256:fdeae096ca36c12d8aca2640b8407a9d94e961372c68435bef14e31cce726138", size = 40357, upload-time = "2025-06-17T14:14:09.91Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fe/bbd85ae65c96de5c9910c332ee1f4b7be0bf0fb21563895167bcb6502a1f/multidict-6.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:e2977ef8b7ce27723ee8c610d1bd1765da4f3fbe5a64f9bf1fd3b4770e31fbc0", size = 44432, upload-time = "2025-06-17T14:14:11.013Z" }, + { url = "https://files.pythonhosted.org/packages/96/af/f9052d9c4e65195b210da9f7afdea06d3b7592b3221cc0ef1b407f762faa/multidict-6.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:82d0cf0ea49bae43d9e8c3851e21954eff716259ff42da401b668744d1760bcb", size = 41408, upload-time = "2025-06-17T14:14:12.112Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fa/18f4950e00924f7e84c8195f4fc303295e14df23f713d64e778b8fa8b903/multidict-6.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1bb986c8ea9d49947bc325c51eced1ada6d8d9b4c5b15fd3fcdc3c93edef5a74", size = 73474, upload-time = "2025-06-17T14:14:13.528Z" }, + { url = "https://files.pythonhosted.org/packages/6c/66/0392a2a8948bccff57e4793c9dde3e5c088f01e8b7f8867ee58a2f187fc5/multidict-6.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:03c0923da300120830fc467e23805d63bbb4e98b94032bd863bc7797ea5fa653", size = 43741, upload-time = "2025-06-17T14:14:15.188Z" }, + { url = "https://files.pythonhosted.org/packages/98/3e/f48487c91b2a070566cfbab876d7e1ebe7deb0a8002e4e896a97998ae066/multidict-6.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4c78d5ec00fdd35c91680ab5cf58368faad4bd1a8721f87127326270248de9bc", size = 42143, upload-time = "2025-06-17T14:14:16.612Z" }, + { url = "https://files.pythonhosted.org/packages/3f/49/439c6cc1cd00365cf561bdd3579cc3fa1a0d38effb3a59b8d9562839197f/multidict-6.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aadc3cb78be90a887f8f6b73945b840da44b4a483d1c9750459ae69687940c97", size = 239303, upload-time = "2025-06-17T14:14:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/c4/24/491786269e90081cb536e4d7429508725bc92ece176d1204a4449de7c41c/multidict-6.5.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b02e1ca495d71e07e652e4cef91adae3bf7ae4493507a263f56e617de65dafc", size = 236913, upload-time = "2025-06-17T14:14:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/e8/76/bbe2558b820ebeca8a317ab034541790e8160ca4b1e450415383ac69b339/multidict-6.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7fe92a62326eef351668eec4e2dfc494927764a0840a1895cff16707fceffcd3", size = 250752, upload-time = "2025-06-17T14:14:20.297Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e3/3977f2c1123f553ceff9f53cd4de04be2c1912333c6fabbcd51531655476/multidict-6.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7673ee4f63879ecd526488deb1989041abcb101b2d30a9165e1e90c489f3f7fb", size = 243937, upload-time = "2025-06-17T14:14:21.935Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b8/7a6e9c13c79709cdd2f22ee849f058e6da76892d141a67acc0e6c30d845c/multidict-6.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa097ae2a29f573de7e2d86620cbdda5676d27772d4ed2669cfa9961a0d73955", size = 237419, upload-time = "2025-06-17T14:14:23.215Z" }, + { url = "https://files.pythonhosted.org/packages/84/9d/8557f5e88da71bc7e7a8ace1ada4c28197f3bfdc2dd6e51d3b88f2e16e8e/multidict-6.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:300da0fa4f8457d9c4bd579695496116563409e676ac79b5e4dca18e49d1c308", size = 237222, upload-time = "2025-06-17T14:14:24.516Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3b/8f023ad60e7969cb6bc0683738d0e1618f5ff5723d6d2d7818dc6df6ad3d/multidict-6.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9a19bd108c35877b57393243d392d024cfbfdefe759fd137abb98f6fc910b64c", size = 247861, upload-time = "2025-06-17T14:14:25.839Z" }, + { url = "https://files.pythonhosted.org/packages/af/1c/9cf5a099ce7e3189906cf5daa72c44ee962dcb4c1983659f3a6f8a7446ab/multidict-6.5.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f32a1777465a35c35ddbbd7fc1293077938a69402fcc59e40b2846d04a120dd", size = 243917, upload-time = "2025-06-17T14:14:27.164Z" }, + { url = "https://files.pythonhosted.org/packages/6c/bb/88ee66ebeef56868044bac58feb1cc25658bff27b20e3cfc464edc181287/multidict-6.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9cc1e10c14ce8112d1e6d8971fe3cdbe13e314f68bea0e727429249d4a6ce164", size = 249214, upload-time = "2025-06-17T14:14:28.795Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/a90e88cc4a1309f33088ab1cdd5c0487718f49dfb82c5ffc845bb17c1973/multidict-6.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e95c5e07a06594bdc288117ca90e89156aee8cb2d7c330b920d9c3dd19c05414", size = 258682, upload-time = "2025-06-17T14:14:30.066Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/16dd69a6811920a31f4e06114ebe67b1cd922c8b05c9c82b050706d0b6fe/multidict-6.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:40ff26f58323795f5cd2855e2718a1720a1123fb90df4553426f0efd76135462", size = 254254, upload-time = "2025-06-17T14:14:31.323Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a8/90193a5f5ca1bdbf92633d69a25a2ef9bcac7b412b8d48c84d01a2732518/multidict-6.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76803a29fd71869a8b59c2118c9dcfb3b8f9c8723e2cce6baeb20705459505cf", size = 247741, upload-time = "2025-06-17T14:14:32.717Z" }, + { url = "https://files.pythonhosted.org/packages/cd/43/29c7a747153c05b41d1f67455426af39ed88d6de3f21c232b8f2724bde13/multidict-6.5.0-cp312-cp312-win32.whl", hash = "sha256:df7ecbc65a53a2ce1b3a0c82e6ad1a43dcfe7c6137733f9176a92516b9f5b851", size = 41049, upload-time = "2025-06-17T14:14:33.941Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e8/8f3fc32b7e901f3a2719764d64aeaf6ae77b4ba961f1c3a3cf3867766636/multidict-6.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ec1c3fbbb0b655a6540bce408f48b9a7474fd94ed657dcd2e890671fefa7743", size = 44700, upload-time = "2025-06-17T14:14:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/24/e4/e250806adc98d524d41e69c8d4a42bc3513464adb88cb96224df12928617/multidict-6.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:2d24a00d34808b22c1f15902899b9d82d0faeca9f56281641c791d8605eacd35", size = 41703, upload-time = "2025-06-17T14:14:36.168Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c9/092c4e9402b6d16de761cff88cb842a5c8cc50ccecaf9c4481ba53264b9e/multidict-6.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:53d92df1752df67a928fa7f884aa51edae6f1cf00eeb38cbcf318cf841c17456", size = 73486, upload-time = "2025-06-17T14:14:37.238Z" }, + { url = "https://files.pythonhosted.org/packages/08/f9/6f7ddb8213f5fdf4db48d1d640b78e8aef89b63a5de8a2313286db709250/multidict-6.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:680210de2c38eef17ce46b8df8bf2c1ece489261a14a6e43c997d49843a27c99", size = 43745, upload-time = "2025-06-17T14:14:38.32Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a7/b9be0163bfeee3bb08a77a1705e24eb7e651d594ea554107fac8a1ca6a4d/multidict-6.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e279259bcb936732bfa1a8eec82b5d2352b3df69d2fa90d25808cfc403cee90a", size = 42135, upload-time = "2025-06-17T14:14:39.897Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/93c8203f943a417bda3c573a34d5db0cf733afdfffb0ca78545c7716dbd8/multidict-6.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1c185fc1069781e3fc8b622c4331fb3b433979850392daa5efbb97f7f9959bb", size = 238585, upload-time = "2025-06-17T14:14:41.332Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fe/2582b56a1807604774f566eeef183b0d6b148f4b89d1612cd077567b2e1e/multidict-6.5.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6bb5f65ff91daf19ce97f48f63585e51595539a8a523258b34f7cef2ec7e0617", size = 236174, upload-time = "2025-06-17T14:14:42.602Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c4/d8b66d42d385bd4f974cbd1eaa8b265e6b8d297249009f312081d5ded5c7/multidict-6.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8646b4259450c59b9286db280dd57745897897284f6308edbdf437166d93855", size = 250145, upload-time = "2025-06-17T14:14:43.944Z" }, + { url = "https://files.pythonhosted.org/packages/bc/64/62feda5093ee852426aae3df86fab079f8bf1cdbe403e1078c94672ad3ec/multidict-6.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d245973d4ecc04eea0a8e5ebec7882cf515480036e1b48e65dffcfbdf86d00be", size = 243470, upload-time = "2025-06-17T14:14:45.343Z" }, + { url = "https://files.pythonhosted.org/packages/67/dc/9f6fa6e854625cf289c0e9f4464b40212a01f76b2f3edfe89b6779b4fb93/multidict-6.5.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a133e7ddc9bc7fb053733d0ff697ce78c7bf39b5aec4ac12857b6116324c8d75", size = 236968, upload-time = "2025-06-17T14:14:46.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/ae/4b81c6e3745faee81a156f3f87402315bdccf04236f75c03e37be19c94ff/multidict-6.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80d696fa38d738fcebfd53eec4d2e3aeb86a67679fd5e53c325756682f152826", size = 236575, upload-time = "2025-06-17T14:14:47.929Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fa/4089d7642ea344226e1bfab60dd588761d4791754f8072e911836a39bedf/multidict-6.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:20d30c9410ac3908abbaa52ee5967a754c62142043cf2ba091e39681bd51d21a", size = 247632, upload-time = "2025-06-17T14:14:49.525Z" }, + { url = "https://files.pythonhosted.org/packages/16/ee/a353dac797de0f28fb7f078cc181c5f2eefe8dd16aa11a7100cbdc234037/multidict-6.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6c65068cc026f217e815fa519d8e959a7188e94ec163ffa029c94ca3ef9d4a73", size = 243520, upload-time = "2025-06-17T14:14:50.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/ec/560deb3d2d95822d6eb1bcb1f1cb728f8f0197ec25be7c936d5d6a5d133c/multidict-6.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e355ac668a8c3e49c2ca8daa4c92f0ad5b705d26da3d5af6f7d971e46c096da7", size = 248551, upload-time = "2025-06-17T14:14:52.229Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/ddf277e67c78205f6695f2a7639be459bca9cc353b962fd8085a492a262f/multidict-6.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08db204213d0375a91a381cae0677ab95dd8c67a465eb370549daf6dbbf8ba10", size = 258362, upload-time = "2025-06-17T14:14:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/02/fc/d64ee1df9b87c5210f2d4c419cab07f28589c81b4e5711eda05a122d0614/multidict-6.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ffa58e3e215af8f6536dc837a990e456129857bb6fd546b3991be470abd9597a", size = 253862, upload-time = "2025-06-17T14:14:55.323Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7c/a2743c00d9e25f4826d3a77cc13d4746398872cf21c843eef96bb9945665/multidict-6.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e86eb90015c6f21658dbd257bb8e6aa18bdb365b92dd1fba27ec04e58cdc31b", size = 247391, upload-time = "2025-06-17T14:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/9b/03/7773518db74c442904dbd349074f1e7f2a854cee4d9529fc59e623d3949e/multidict-6.5.0-cp313-cp313-win32.whl", hash = "sha256:f34a90fbd9959d0f857323bd3c52b3e6011ed48f78d7d7b9e04980b8a41da3af", size = 41115, upload-time = "2025-06-17T14:14:59.33Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9a/6fc51b1dc11a7baa944bc101a92167d8b0f5929d376a8c65168fc0d35917/multidict-6.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fcb2aa79ac6aef8d5b709bbfc2fdb1d75210ba43038d70fbb595b35af470ce06", size = 44768, upload-time = "2025-06-17T14:15:00.427Z" }, + { url = "https://files.pythonhosted.org/packages/82/2d/0d010be24b663b3c16e3d3307bbba2de5ae8eec496f6027d5c0515b371a8/multidict-6.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:6dcee5e7e92060b4bb9bb6f01efcbb78c13d0e17d9bc6eec71660dd71dc7b0c2", size = 41770, upload-time = "2025-06-17T14:15:01.854Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d1/a71711a5f32f84b7b036e82182e3250b949a0ce70d51a2c6a4079e665449/multidict-6.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:cbbc88abea2388fde41dd574159dec2cda005cb61aa84950828610cb5010f21a", size = 80450, upload-time = "2025-06-17T14:15:02.968Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a2/953a9eede63a98fcec2c1a2c1a0d88de120056219931013b871884f51b43/multidict-6.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:70b599f70ae6536e5976364d3c3cf36f40334708bd6cebdd1e2438395d5e7676", size = 46971, upload-time = "2025-06-17T14:15:04.149Z" }, + { url = "https://files.pythonhosted.org/packages/44/61/60250212953459edda2c729e1d85130912f23c67bd4f585546fe4bdb1578/multidict-6.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:828bab777aa8d29d59700018178061854e3a47727e0611cb9bec579d3882de3b", size = 45548, upload-time = "2025-06-17T14:15:05.666Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/e78ee82e96c495bc2582b303f68bed176b481c8d81a441fec07404fce2ca/multidict-6.5.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9695fc1462f17b131c111cf0856a22ff154b0480f86f539d24b2778571ff94d", size = 238545, upload-time = "2025-06-17T14:15:06.88Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0f/6132ca06670c8d7b374c3a4fd1ba896fc37fbb66b0de903f61db7d1020ec/multidict-6.5.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b5ac6ebaf5d9814b15f399337ebc6d3a7f4ce9331edd404e76c49a01620b68d", size = 229931, upload-time = "2025-06-17T14:15:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/c0/63/d9957c506e6df6b3e7a194f0eea62955c12875e454b978f18262a65d017b/multidict-6.5.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84a51e3baa77ded07be4766a9e41d977987b97e49884d4c94f6d30ab6acaee14", size = 248181, upload-time = "2025-06-17T14:15:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/43/3f/7d5490579640db5999a948e2c41d4a0efd91a75989bda3e0a03a79c92be2/multidict-6.5.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8de67f79314d24179e9b1869ed15e88d6ba5452a73fc9891ac142e0ee018b5d6", size = 241846, upload-time = "2025-06-17T14:15:11.596Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/252b1ce949ece52bba4c0de7aa2e3a3d5964e800bce71fb778c2e6c66f7c/multidict-6.5.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17f78a52c214481d30550ec18208e287dfc4736f0c0148208334b105fd9e0887", size = 232893, upload-time = "2025-06-17T14:15:12.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/0070bfd48c16afc26e056f2acce49e853c0d604a69c7124bc0bbdb1bcc0a/multidict-6.5.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2966d0099cb2e2039f9b0e73e7fd5eb9c85805681aa2a7f867f9d95b35356921", size = 228567, upload-time = "2025-06-17T14:15:14.267Z" }, + { url = "https://files.pythonhosted.org/packages/2a/31/90551c75322113ebf5fd9c5422e8641d6952f6edaf6b6c07fdc49b1bebdd/multidict-6.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:86fb42ed5ed1971c642cc52acc82491af97567534a8e381a8d50c02169c4e684", size = 246188, upload-time = "2025-06-17T14:15:15.985Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e2/aa4b02a55e7767ff292871023817fe4db83668d514dab7ccbce25eaf7659/multidict-6.5.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:4e990cbcb6382f9eae4ec720bcac6a1351509e6fc4a5bb70e4984b27973934e6", size = 235178, upload-time = "2025-06-17T14:15:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5c/f67e726717c4b138b166be1700e2b56e06fbbcb84643d15f9a9d7335ff41/multidict-6.5.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d99a59d64bb1f7f2117bec837d9e534c5aeb5dcedf4c2b16b9753ed28fdc20a3", size = 243422, upload-time = "2025-06-17T14:15:18.939Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/15fa318285e26a50aa3fa979bbcffb90f9b4d5ec58882d0590eda067d0da/multidict-6.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:e8ef15cc97c9890212e1caf90f0d63f6560e1e101cf83aeaf63a57556689fb34", size = 254898, upload-time = "2025-06-17T14:15:20.31Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3d/d6c6d1c2e9b61ca80313912d30bb90d4179335405e421ef0a164eac2c0f9/multidict-6.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:b8a09aec921b34bd8b9f842f0bcfd76c6a8c033dc5773511e15f2d517e7e1068", size = 247129, upload-time = "2025-06-17T14:15:21.665Z" }, + { url = "https://files.pythonhosted.org/packages/29/15/1568258cf0090bfa78d44be66247cfdb16e27dfd935c8136a1e8632d3057/multidict-6.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ff07b504c23b67f2044533244c230808a1258b3493aaf3ea2a0785f70b7be461", size = 243841, upload-time = "2025-06-17T14:15:23.38Z" }, + { url = "https://files.pythonhosted.org/packages/65/57/64af5dbcfd61427056e840c8e520b502879d480f9632fbe210929fd87393/multidict-6.5.0-cp313-cp313t-win32.whl", hash = "sha256:9232a117341e7e979d210e41c04e18f1dc3a1d251268df6c818f5334301274e1", size = 46761, upload-time = "2025-06-17T14:15:24.733Z" }, + { url = "https://files.pythonhosted.org/packages/26/a8/cac7f7d61e188ff44f28e46cb98f9cc21762e671c96e031f06c84a60556e/multidict-6.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:44cb5c53fb2d4cbcee70a768d796052b75d89b827643788a75ea68189f0980a1", size = 52112, upload-time = "2025-06-17T14:15:25.906Z" }, + { url = "https://files.pythonhosted.org/packages/51/9f/076533feb1b5488d22936da98b9c217205cfbf9f56f7174e8c5c86d86fe6/multidict-6.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:51d33fafa82640c0217391d4ce895d32b7e84a832b8aee0dcc1b04d8981ec7f4", size = 44358, upload-time = "2025-06-17T14:15:27.117Z" }, + { url = "https://files.pythonhosted.org/packages/68/0b/b024da30f18241e03a400aebdc3ca1bcbdc0561f9d48019cbe66549aea3e/multidict-6.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c0078358470da8dc90c37456f4a9cde9f86200949a048d53682b9cd21e5bbf2b", size = 73804, upload-time = "2025-06-17T14:15:28.305Z" }, + { url = "https://files.pythonhosted.org/packages/a3/8f/5e69092bb8a75b95dd27ed4d21220641ede7e127d8a0228cd5e1d5f2150e/multidict-6.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5cc7968b7d1bf8b973c307d38aa3a2f2c783f149bcac855944804252f1df5105", size = 43161, upload-time = "2025-06-17T14:15:29.47Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d9/51968d296800285343055d482b65001bda4fa4950aad5575afe17906f16f/multidict-6.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ad73a60e11aa92f1f2c9330efdeaac4531b719fc568eb8d312fd4112f34cc18", size = 42996, upload-time = "2025-06-17T14:15:30.622Z" }, + { url = "https://files.pythonhosted.org/packages/38/1c/19ce336cf8af2b7c530ea890496603eb9bbf0da4e3a8e0fcc3669ad30c21/multidict-6.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3233f21abdcd180b2624eb6988a1e1287210e99bca986d8320afca5005d85844", size = 231051, upload-time = "2025-06-17T14:15:32.296Z" }, + { url = "https://files.pythonhosted.org/packages/73/9b/2cf6eff5b30ff8a67ca231a741053c8cc8269fd860cac2c0e16b376de89d/multidict-6.5.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bee5c0b79fca78fd2ab644ca4dc831ecf793eb6830b9f542ee5ed2c91bc35a0e", size = 219511, upload-time = "2025-06-17T14:15:33.602Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ac/43c89a11d710ce6e5c824ece7b570fd79839e3d25a6a7d3b2526a77b290c/multidict-6.5.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e053a4d690f4352ce46583080fefade9a903ce0fa9d820db1be80bdb9304fa2f", size = 240287, upload-time = "2025-06-17T14:15:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/16/94/1896d424324618f2e2adbf9acb049aeef8da3f31c109e37ffda63b58d1b5/multidict-6.5.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42bdee30424c1f4dcda96e07ac60e2a4ede8a89f8ae2f48b5e4ccc060f294c52", size = 232748, upload-time = "2025-06-17T14:15:36.576Z" }, + { url = "https://files.pythonhosted.org/packages/e1/43/2f852c12622bda304a2e0c4419250de3cd0345776ae2e699416cbdc15c9f/multidict-6.5.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58b2ded1a7982cf7b8322b0645713a0086b2b3cf5bb9f7c01edfc1a9f98d20dc", size = 224910, upload-time = "2025-06-17T14:15:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/9c32a0305a11aec71a85f354d739011221507bce977a3be8d9fa248763e7/multidict-6.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f805b8b951d1fadc5bc18c3c93e509608ac5a883045ee33bc22e28806847c20", size = 225773, upload-time = "2025-06-17T14:15:39.645Z" }, + { url = "https://files.pythonhosted.org/packages/bc/81/488054827b644e615f59211fc26fd64b28a1366143e4985326802f18773b/multidict-6.5.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2540395b63723da748f850568357a39cd8d8d4403ca9439f9fcdad6dd423c780", size = 244097, upload-time = "2025-06-17T14:15:41.164Z" }, + { url = "https://files.pythonhosted.org/packages/9f/71/b9d96548da768dd7284c1f21187129a48906f526d5ed4f71bb050476d91f/multidict-6.5.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:c96aedff25f4e47b6697ba048b2c278f7caa6df82c7c3f02e077bcc8d47b4b76", size = 232831, upload-time = "2025-06-17T14:15:42.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/45/0c57c9bf9be7808252269f0d3964c1495413bcee36a7a7e836fdb778a578/multidict-6.5.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e80de5ad995de210fd02a65c2350649b8321d09bd2e44717eaefb0f5814503e8", size = 242201, upload-time = "2025-06-17T14:15:44.286Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/2441e56b32f7d25c917557641b35a89e0142a7412bc57182c80330975b8d/multidict-6.5.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:6cb9bcedd9391b313e5ec2fb3aa07c03e050550e7b9e4646c076d5c24ba01532", size = 254479, upload-time = "2025-06-17T14:15:45.718Z" }, + { url = "https://files.pythonhosted.org/packages/0d/93/acbc2fed235c7a7b2b21fe8c6ac1b612f7fee79dbddd9c73d42b1a65599c/multidict-6.5.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a7d130ed7a112e25ab47309962ecafae07d073316f9d158bc7b3936b52b80121", size = 244179, upload-time = "2025-06-17T14:15:47.174Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b2/07ce91400ee2b296de2d6d55f1d948d88d148182b35a3edcc480ddb0f99a/multidict-6.5.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:95750a9a9741cd1855d1b6cb4c6031ae01c01ad38d280217b64bfae986d39d56", size = 241173, upload-time = "2025-06-17T14:15:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/a0/09/61c0b044065a1d2e1329b0e4f0f2afa992d3bb319129b63dd63c54c2cc15/multidict-6.5.0-cp39-cp39-win32.whl", hash = "sha256:7f78caf409914f108f4212b53a9033abfdc2cbab0647e9ac3a25bb0f21ab43d2", size = 40467, upload-time = "2025-06-17T14:15:50.285Z" }, + { url = "https://files.pythonhosted.org/packages/7f/43/48c2837046222ea6800824d576f110d7622c4048b3dd252ef62c51a0969b/multidict-6.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:220c74009507e847a3a6fc5375875f2a2e05bd9ce28cf607be0e8c94600f4472", size = 44449, upload-time = "2025-06-17T14:15:51.84Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4e/b61b006e75c6e071fac1bd0f32696ad1b052772493c4e9d0121ba604b215/multidict-6.5.0-cp39-cp39-win_arm64.whl", hash = "sha256:d98f4ac9c1ede7e9d04076e2e6d967e15df0079a6381b297270f6bcab661195e", size = 41477, upload-time = "2025-06-17T14:15:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/44/d8/45e8fc9892a7386d074941429e033adb4640e59ff0780d96a8cf46fe788e/multidict-6.5.0-py3-none-any.whl", hash = "sha256:5634b35f225977605385f56153bd95a7133faffc0ffe12ad26e10517537e8dfc", size = 12181, upload-time = "2025-06-17T14:15:55.156Z" }, +] + +[[package]] +name = "multipart" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/91/6c93b6a95e6a99ef929a99d019fbf5b5f7fd3368389a0b1ec7ce0a23565b/multipart-1.2.1.tar.gz", hash = "sha256:829b909b67bc1ad1c6d4488fcdc6391c2847842b08323addf5200db88dbe9480", size = 36507, upload-time = "2024-11-29T08:45:48.818Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/d1/3598d1e73385baaab427392856f915487db7aa10abadd436f8f2d3e3b0f9/multipart-1.2.1-py3-none-any.whl", hash = "sha256:c03dc203bc2e67f6b46a599467ae0d87cf71d7530504b2c1ff4a9ea21d8b8c8c", size = 13730, upload-time = "2024-11-29T08:45:44.557Z" }, +] + +[[package]] +name = "mypy" +version = "1.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/92c7fa98112e4d9eb075a239caa4ef4649ad7d441545ccffbd5e34607cbb/mypy-1.16.1.tar.gz", hash = "sha256:6bd00a0a2094841c5e47e7374bb42b83d64c527a502e3334e1173a0c24437bab", size = 3324747, upload-time = "2025-06-16T16:51:35.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/12/2bf23a80fcef5edb75de9a1e295d778e0f46ea89eb8b115818b663eff42b/mypy-1.16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b4f0fed1022a63c6fec38f28b7fc77fca47fd490445c69d0a66266c59dd0b88a", size = 10958644, upload-time = "2025-06-16T16:51:11.649Z" }, + { url = "https://files.pythonhosted.org/packages/08/50/bfe47b3b278eacf348291742fd5e6613bbc4b3434b72ce9361896417cfe5/mypy-1.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86042bbf9f5a05ea000d3203cf87aa9d0ccf9a01f73f71c58979eb9249f46d72", size = 10087033, upload-time = "2025-06-16T16:35:30.089Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/40307c12fe25675a0776aaa2cdd2879cf30d99eec91b898de00228dc3ab5/mypy-1.16.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea7469ee5902c95542bea7ee545f7006508c65c8c54b06dc2c92676ce526f3ea", size = 11875645, upload-time = "2025-06-16T16:35:48.49Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d8/85bdb59e4a98b7a31495bd8f1a4445d8ffc86cde4ab1f8c11d247c11aedc/mypy-1.16.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:352025753ef6a83cb9e7f2427319bb7875d1fdda8439d1e23de12ab164179574", size = 12616986, upload-time = "2025-06-16T16:48:39.526Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d0/bb25731158fa8f8ee9e068d3e94fcceb4971fedf1424248496292512afe9/mypy-1.16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff9fa5b16e4c1364eb89a4d16bcda9987f05d39604e1e6c35378a2987c1aac2d", size = 12878632, upload-time = "2025-06-16T16:36:08.195Z" }, + { url = "https://files.pythonhosted.org/packages/2d/11/822a9beb7a2b825c0cb06132ca0a5183f8327a5e23ef89717c9474ba0bc6/mypy-1.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:1256688e284632382f8f3b9e2123df7d279f603c561f099758e66dd6ed4e8bd6", size = 9484391, upload-time = "2025-06-16T16:37:56.151Z" }, + { url = "https://files.pythonhosted.org/packages/9a/61/ec1245aa1c325cb7a6c0f8570a2eee3bfc40fa90d19b1267f8e50b5c8645/mypy-1.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:472e4e4c100062488ec643f6162dd0d5208e33e2f34544e1fc931372e806c0cc", size = 10890557, upload-time = "2025-06-16T16:37:21.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/bb/6eccc0ba0aa0c7a87df24e73f0ad34170514abd8162eb0c75fd7128171fb/mypy-1.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea16e2a7d2714277e349e24d19a782a663a34ed60864006e8585db08f8ad1782", size = 10012921, upload-time = "2025-06-16T16:51:28.659Z" }, + { url = "https://files.pythonhosted.org/packages/5f/80/b337a12e2006715f99f529e732c5f6a8c143bb58c92bb142d5ab380963a5/mypy-1.16.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08e850ea22adc4d8a4014651575567b0318ede51e8e9fe7a68f25391af699507", size = 11802887, upload-time = "2025-06-16T16:50:53.627Z" }, + { url = "https://files.pythonhosted.org/packages/d9/59/f7af072d09793d581a745a25737c7c0a945760036b16aeb620f658a017af/mypy-1.16.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22d76a63a42619bfb90122889b903519149879ddbf2ba4251834727944c8baca", size = 12531658, upload-time = "2025-06-16T16:33:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/82/c4/607672f2d6c0254b94a646cfc45ad589dd71b04aa1f3d642b840f7cce06c/mypy-1.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2c7ce0662b6b9dc8f4ed86eb7a5d505ee3298c04b40ec13b30e572c0e5ae17c4", size = 12732486, upload-time = "2025-06-16T16:37:03.301Z" }, + { url = "https://files.pythonhosted.org/packages/b6/5e/136555ec1d80df877a707cebf9081bd3a9f397dedc1ab9750518d87489ec/mypy-1.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:211287e98e05352a2e1d4e8759c5490925a7c784ddc84207f4714822f8cf99b6", size = 9479482, upload-time = "2025-06-16T16:47:37.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d6/39482e5fcc724c15bf6280ff5806548c7185e0c090712a3736ed4d07e8b7/mypy-1.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:af4792433f09575d9eeca5c63d7d90ca4aeceda9d8355e136f80f8967639183d", size = 11066493, upload-time = "2025-06-16T16:47:01.683Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/26c347890efc6b757f4d5bb83f4a0cf5958b8cf49c938ac99b8b72b420a6/mypy-1.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66df38405fd8466ce3517eda1f6640611a0b8e70895e2a9462d1d4323c5eb4b9", size = 10081687, upload-time = "2025-06-16T16:48:19.367Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/b5cb264c97b86914487d6a24bd8688c0172e37ec0f43e93b9691cae9468b/mypy-1.16.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44e7acddb3c48bd2713994d098729494117803616e116032af192871aed80b79", size = 11839723, upload-time = "2025-06-16T16:49:20.912Z" }, + { url = "https://files.pythonhosted.org/packages/15/f8/491997a9b8a554204f834ed4816bda813aefda31cf873bb099deee3c9a99/mypy-1.16.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ab5eca37b50188163fa7c1b73c685ac66c4e9bdee4a85c9adac0e91d8895e15", size = 12722980, upload-time = "2025-06-16T16:37:40.929Z" }, + { url = "https://files.pythonhosted.org/packages/df/f0/2bd41e174b5fd93bc9de9a28e4fb673113633b8a7f3a607fa4a73595e468/mypy-1.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb6229b2c9086247e21a83c309754b9058b438704ad2f6807f0d8227f6ebdd", size = 12903328, upload-time = "2025-06-16T16:34:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/61/81/5572108a7bec2c46b8aff7e9b524f371fe6ab5efb534d38d6b37b5490da8/mypy-1.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:1f0435cf920e287ff68af3d10a118a73f212deb2ce087619eb4e648116d1fe9b", size = 9562321, upload-time = "2025-06-16T16:48:58.823Z" }, + { url = "https://files.pythonhosted.org/packages/28/e3/96964af4a75a949e67df4b95318fe2b7427ac8189bbc3ef28f92a1c5bc56/mypy-1.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ddc91eb318c8751c69ddb200a5937f1232ee8efb4e64e9f4bc475a33719de438", size = 11063480, upload-time = "2025-06-16T16:47:56.205Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/cd1a42b8e5be278fab7010fb289d9307a63e07153f0ae1510a3d7b703193/mypy-1.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:87ff2c13d58bdc4bbe7dc0dedfe622c0f04e2cb2a492269f3b418df2de05c536", size = 10090538, upload-time = "2025-06-16T16:46:43.92Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4f/c3c6b4b66374b5f68bab07c8cabd63a049ff69796b844bc759a0ca99bb2a/mypy-1.16.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a7cfb0fe29fe5a9841b7c8ee6dffb52382c45acdf68f032145b75620acfbd6f", size = 11836839, upload-time = "2025-06-16T16:36:28.039Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7e/81ca3b074021ad9775e5cb97ebe0089c0f13684b066a750b7dc208438403/mypy-1.16.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:051e1677689c9d9578b9c7f4d206d763f9bbd95723cd1416fad50db49d52f359", size = 12715634, upload-time = "2025-06-16T16:50:34.441Z" }, + { url = "https://files.pythonhosted.org/packages/e9/95/bdd40c8be346fa4c70edb4081d727a54d0a05382d84966869738cfa8a497/mypy-1.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d5d2309511cc56c021b4b4e462907c2b12f669b2dbeb68300110ec27723971be", size = 12895584, upload-time = "2025-06-16T16:34:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fd/d486a0827a1c597b3b48b1bdef47228a6e9ee8102ab8c28f944cb83b65dc/mypy-1.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:4f58ac32771341e38a853c5d0ec0dfe27e18e27da9cdb8bbc882d2249c71a3ee", size = 9573886, upload-time = "2025-06-16T16:36:43.589Z" }, + { url = "https://files.pythonhosted.org/packages/49/5e/ed1e6a7344005df11dfd58b0fdd59ce939a0ba9f7ed37754bf20670b74db/mypy-1.16.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7fc688329af6a287567f45cc1cefb9db662defeb14625213a5b7da6e692e2069", size = 10959511, upload-time = "2025-06-16T16:47:21.945Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/a7cbc2541e91fe04f43d9e4577264b260fecedb9bccb64ffb1a34b7e6c22/mypy-1.16.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e198ab3f55924c03ead626ff424cad1732d0d391478dfbf7bb97b34602395da", size = 10075555, upload-time = "2025-06-16T16:50:14.084Z" }, + { url = "https://files.pythonhosted.org/packages/93/f7/c62b1e31a32fbd1546cca5e0a2e5f181be5761265ad1f2e94f2a306fa906/mypy-1.16.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09aa4f91ada245f0a45dbc47e548fd94e0dd5a8433e0114917dc3b526912a30c", size = 11874169, upload-time = "2025-06-16T16:49:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/c8/15/db580a28034657fb6cb87af2f8996435a5b19d429ea4dcd6e1c73d418e60/mypy-1.16.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13c7cd5b1cb2909aa318a90fd1b7e31f17c50b242953e7dd58345b2a814f6383", size = 12610060, upload-time = "2025-06-16T16:34:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/ec/78/c17f48f6843048fa92d1489d3095e99324f2a8c420f831a04ccc454e2e51/mypy-1.16.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:58e07fb958bc5d752a280da0e890c538f1515b79a65757bbdc54252ba82e0b40", size = 12875199, upload-time = "2025-06-16T16:35:14.448Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d6/ed42167d0a42680381653fd251d877382351e1bd2c6dd8a818764be3beb1/mypy-1.16.1-cp39-cp39-win_amd64.whl", hash = "sha256:f895078594d918f93337a505f8add9bd654d1a24962b4c6ed9390e12531eb31b", size = 9487033, upload-time = "2025-06-16T16:49:57.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d3/53e684e78e07c1a2bf7105715e5edd09ce951fc3f47cf9ed095ec1b7a037/mypy-1.16.1-py3-none-any.whl", hash = "sha256:5fc2ac4027d0ef28d6ba69a0343737a23c4d1b83672bf38d1fe237bdc0643b37", size = 2265923, upload-time = "2025-06-16T16:48:02.366Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "nox" +version = "2025.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "attrs" }, + { name = "colorlog" }, + { name = "dependency-groups" }, + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/80/47712208c410defec169992e57c179f0f4d92f5dd17ba8daca50a8077e23/nox-2025.5.1.tar.gz", hash = "sha256:2a571dfa7a58acc726521ac3cd8184455ebcdcbf26401c7b737b5bc6701427b2", size = 4023334, upload-time = "2025-05-01T16:35:48.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/be/7b423b02b09eb856beffe76fe8c4121c99852db74dd12a422dcb72d1134e/nox-2025.5.1-py3-none-any.whl", hash = "sha256:56abd55cf37ff523c254fcec4d152ed51e5fe80e2ab8317221d8b828ac970a31", size = 71753, upload-time = "2025-05-01T16:35:46.037Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.34.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/5e/94a8cb759e4e409022229418294e098ca7feca00eb3c467bb20cbd329bda/opentelemetry_api-1.34.1.tar.gz", hash = "sha256:64f0bd06d42824843731d05beea88d4d4b6ae59f9fe347ff7dfa2cc14233bbb3", size = 64987, upload-time = "2025-06-10T08:55:19.818Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/3a/2ba85557e8dc024c0842ad22c570418dc02c36cbd1ab4b832a93edf071b8/opentelemetry_api-1.34.1-py3-none-any.whl", hash = "sha256:b7df4cb0830d5a6c29ad0c0691dbae874d8daefa934b8b1d642de48323d32a8c", size = 65767, upload-time = "2025-06-10T08:54:56.717Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.34.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/41/fe20f9036433da8e0fcef568984da4c1d1c771fa072ecd1a4d98779dccdd/opentelemetry_sdk-1.34.1.tar.gz", hash = "sha256:8091db0d763fcd6098d4781bbc80ff0971f94e260739aa6afe6fd379cdf3aa4d", size = 159441, upload-time = "2025-06-10T08:55:33.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/1b/def4fe6aa73f483cabf4c748f4c25070d5f7604dcc8b52e962983491b29e/opentelemetry_sdk-1.34.1-py3-none-any.whl", hash = "sha256:308effad4059562f1d92163c61c8141df649da24ce361827812c40abb2a1e96e", size = 118477, upload-time = "2025-06-10T08:55:16.02Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.55b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/f0/f33458486da911f47c4aa6db9bda308bb80f3236c111bf848bd870c16b16/opentelemetry_semantic_conventions-0.55b1.tar.gz", hash = "sha256:ef95b1f009159c28d7a7849f5cbc71c4c34c845bb514d66adfdf1b3fff3598b3", size = 119829, upload-time = "2025-06-10T08:55:33.881Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/89/267b0af1b1d0ba828f0e60642b6a5116ac1fd917cde7fc02821627029bd1/opentelemetry_semantic_conventions-0.55b1-py3-none-any.whl", hash = "sha256:5da81dfdf7d52e3d37f8fe88d5e771e191de924cfff5f550ab0b8f7b2409baed", size = 196223, upload-time = "2025-06-10T08:55:17.638Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "parameterized" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/49/00c0c0cc24ff4266025a53e41336b79adaa5a4ebfad214f433d623f9865e/parameterized-0.9.0.tar.gz", hash = "sha256:7fc905272cefa4f364c1a3429cbbe9c0f98b793988efb5bf90aac80f08db09b1", size = 24351, upload-time = "2023-03-27T02:01:11.592Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/2f/804f58f0b856ab3bf21617cccf5b39206e6c4c94c2cd227bde125ea6105f/parameterized-0.9.0-py2.py3-none-any.whl", hash = "sha256:4e0758e3d41bea3bbd05ec14fc2c24736723f243b28d702081aef438c9372b1b", size = 20475, upload-time = "2023-03-27T02:01:09.31Z" }, +] + +[[package]] +name = "parso" +version = "0.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609, upload-time = "2024-04-05T09:43:55.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650, upload-time = "2024-04-05T09:43:53.299Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "pbs-installer" +version = "2025.6.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/02/bd162be66772b5dbdfd719d4ced63e14730d8260417db1c43ac8017e2b3e/pbs_installer-2025.6.12.tar.gz", hash = "sha256:ae2d3990848652dca699a680b00ea8e19b970cb6172967cb00539bfeed5a7465", size = 57106, upload-time = "2025-06-12T22:01:59.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/81/2c31b2137b771e61dc3183848273c3c901459abd367de462df7b9845cfea/pbs_installer-2025.6.12-py3-none-any.whl", hash = "sha256:438e75de131a2114ac5e86156fc51da7dadd6734844de329ad162cca63709297", size = 58847, upload-time = "2025-06-12T22:01:58.423Z" }, +] + +[package.optional-dependencies] +download = [ + { name = "httpx" }, +] +install = [ + { name = "zstandard" }, +] + +[[package]] +name = "pip" +version = "25.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/53/b309b4a497b09655cb7e07088966881a57d082f48ac3cb54ea729fd2c6cf/pip-25.0.1.tar.gz", hash = "sha256:88f96547ea48b940a3a385494e181e29fb8637898f88d88737c5049780f196ea", size = 1950850, upload-time = "2025-02-09T17:14:04.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/bc/b7db44f5f39f9d0494071bddae6880eb645970366d0a200022a1a93d57f5/pip-25.0.1-py3-none-any.whl", hash = "sha256:c46efd13b6aa8279f33f2864459c8ce587ea6a1a59ee20de055868d8f7688f7f", size = 1841526, upload-time = "2025-02-09T17:14:01.463Z" }, +] + +[[package]] +name = "pkginfo" +version = "1.12.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/03/e26bf3d6453b7fda5bd2b84029a426553bb373d6277ef6b5ac8863421f87/pkginfo-1.12.1.2.tar.gz", hash = "sha256:5cd957824ac36f140260964eba3c6be6442a8359b8c48f4adf90210f33a04b7b", size = 451828, upload-time = "2025-02-19T15:27:37.188Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/3d/f4f2ba829efb54b6cd2d91349c7463316a9cc55a43fc980447416c88540f/pkginfo-1.12.1.2-py3-none-any.whl", hash = "sha256:c783ac885519cab2c34927ccfa6bf64b5a704d7c69afaea583dd9b7afe969343", size = 32717, upload-time = "2025-02-19T15:27:33.071Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "poetry" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "build", marker = "python_full_version < '3.10'" }, + { name = "cachecontrol", extra = ["filecache"], marker = "python_full_version < '3.10'" }, + { name = "cleo", marker = "python_full_version < '3.10'" }, + { name = "dulwich", marker = "python_full_version < '3.10'" }, + { name = "fastjsonschema", marker = "python_full_version < '3.10'" }, + { name = "findpython", marker = "python_full_version < '3.10'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "installer", marker = "python_full_version < '3.10'" }, + { name = "keyring", marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pbs-installer", extra = ["download", "install"], marker = "python_full_version < '3.10'" }, + { name = "pkginfo", marker = "python_full_version < '3.10'" }, + { name = "platformdirs", marker = "python_full_version < '3.10'" }, + { name = "poetry-core", version = "2.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyproject-hooks", marker = "python_full_version < '3.10'" }, + { name = "requests", marker = "python_full_version < '3.10'" }, + { name = "requests-toolbelt", marker = "python_full_version < '3.10'" }, + { name = "shellingham", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "tomlkit", marker = "python_full_version < '3.10'" }, + { name = "trove-classifiers", marker = "python_full_version < '3.10'" }, + { name = "virtualenv", marker = "python_full_version < '3.10'" }, + { name = "xattr", marker = "python_full_version < '3.10' and sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/96/187b538742df11fe32beca5c146d9522b1fd9f42897f0772ff8dfc04972f/poetry-2.1.2.tar.gz", hash = "sha256:6a0694645ee24ba93cb94254db66e47971344562ddd5578e82bf35e572bc546d", size = 3434250, upload-time = "2025-03-29T21:40:37.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/62/8d4340e9f4af810633ad3ba5b2c1cc6a43c1a138e909c1450e44ba90aba1/poetry-2.1.2-py3-none-any.whl", hash = "sha256:df7dfe7e5f9cd50ed3b8d1a013afcc379645f66d7e9aa43728689e34fb016216", size = 277844, upload-time = "2025-03-29T21:40:35.534Z" }, +] + +[[package]] +name = "poetry" +version = "2.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version >= '3.10' and python_full_version < '3.13'", +] +dependencies = [ + { name = "build", marker = "python_full_version >= '3.10'" }, + { name = "cachecontrol", extra = ["filecache"], marker = "python_full_version >= '3.10'" }, + { name = "cleo", marker = "python_full_version >= '3.10'" }, + { name = "dulwich", marker = "python_full_version >= '3.10'" }, + { name = "fastjsonschema", marker = "python_full_version >= '3.10'" }, + { name = "findpython", marker = "python_full_version >= '3.10'" }, + { name = "installer", marker = "python_full_version >= '3.10'" }, + { name = "keyring", marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pbs-installer", extra = ["download", "install"], marker = "python_full_version >= '3.10'" }, + { name = "pkginfo", marker = "python_full_version >= '3.10'" }, + { name = "platformdirs", marker = "python_full_version >= '3.10'" }, + { name = "poetry-core", version = "2.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyproject-hooks", marker = "python_full_version >= '3.10'" }, + { name = "requests", marker = "python_full_version >= '3.10'" }, + { name = "requests-toolbelt", marker = "python_full_version >= '3.10'" }, + { name = "shellingham", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "tomlkit", marker = "python_full_version >= '3.10'" }, + { name = "trove-classifiers", marker = "python_full_version >= '3.10'" }, + { name = "virtualenv", marker = "python_full_version >= '3.10'" }, + { name = "xattr", marker = "python_full_version >= '3.10' and sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/12/1c8d8b2c6017a33a9c9c708c6d2bb883af7f447520a466dc21d2c74ecfe1/poetry-2.1.3.tar.gz", hash = "sha256:f2c9bd6790b19475976d88ea4553bcc3533c0dc73f740edc4fffe9e2add50594", size = 3435640, upload-time = "2025-05-04T13:38:43.927Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/d7/d2ea346dd948fef5ab2e40ac2b337e461015ecff72919507eb347dad85a7/poetry-2.1.3-py3-none-any.whl", hash = "sha256:7054d3f97ccce7f31961ead16250407c4577bfe57e2037a190ae2913fc40a20c", size = 278572, upload-time = "2025-05-04T13:38:41.521Z" }, +] + +[[package]] +name = "poetry-core" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/84/2a/572c141e2a15b933b4a49eb888b0ae7335604f57c0f91a7298ae56d2df7c/poetry_core-2.1.2.tar.gz", hash = "sha256:f9dbbbd0ebf9755476a1d57f04b30e9aecf71ca9dc2fcd4b17aba92c0002aa04", size = 364452, upload-time = "2025-03-29T20:38:17.236Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/a3/f1fd35e3863b50713642d8745ba28e12e7cb8a6a28beab064ff1e8455db4/poetry_core-2.1.2-py3-none-any.whl", hash = "sha256:ecb1e8f7d4f071a21cd0feb8c19bd1aec80de6fb0e82aa9d809a591e544431b4", size = 332318, upload-time = "2025-03-29T20:38:15.256Z" }, +] + +[[package]] +name = "poetry-core" +version = "2.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version >= '3.10' and python_full_version < '3.13'", +] +sdist = { url = "https://files.pythonhosted.org/packages/44/ca/c2d21635a4525d427ae969d4cde155fb055c3b5d0bc4199b6de35bb6a826/poetry_core-2.1.3.tar.gz", hash = "sha256:0522a015477ed622c89aad56a477a57813cace0c8e7ff2a2906b7ef4a2e296a4", size = 365027, upload-time = "2025-05-04T12:43:11.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/f1/fb218aebd29bca5c506230201c346881ae9b43de7bbb21a68dc648e972b3/poetry_core-2.1.3-py3-none-any.whl", hash = "sha256:2c704f05016698a54ca1d327f46ce2426d72eaca6ff614132c8477c292266771", size = 332607, upload-time = "2025-05-04T12:43:09.814Z" }, +] + +[[package]] +name = "poetry-plugin-export" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "poetry", version = "2.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "poetry", version = "2.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "poetry-core", version = "2.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "poetry-core", version = "2.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/25/c6516829d49afce7f3852ef7dd708b3d19cf647a6b5b834c369a59af52fe/poetry_plugin_export-1.9.0.tar.gz", hash = "sha256:6fc8755cfac93c74752f85510b171983e2e47d782d4ab5be4ffc4f6945be7967", size = 30835, upload-time = "2025-01-12T15:44:25.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/41/05f9494adb6ad0640be758de67db255a91a46362996020dd8cd21d0fcf60/poetry_plugin_export-1.9.0-py3-none-any.whl", hash = "sha256:e2621dd8c260dd705a8227f076075246a7ff5c697e18ddb90ff68081f47ee642", size = 11309, upload-time = "2025-01-12T15:44:23.522Z" }, +] + +[[package]] +name = "polyfactory" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "faker", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/d0/8ce6a9912a6f1077710ebc46a6aa9a79a64a06b69d2d6b4ccefc9765ce8f/polyfactory-2.21.0.tar.gz", hash = "sha256:a6d8dba91b2515d744cc014b5be48835633f7ccb72519a68f8801759e5b1737a", size = 246314, upload-time = "2025-04-18T10:19:33.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/ba/c148fba517a0aaccfc4fca5e61bf2a051e084a417403e930dc615886d4e6/polyfactory-2.21.0-py3-none-any.whl", hash = "sha256:9483b764756c8622313d99f375889b1c0d92f09affb05742d7bcfa2b5198d8c5", size = 60875, upload-time = "2025-04-18T10:19:31.881Z" }, +] + +[[package]] +name = "posthog" +version = "5.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backoff" }, + { name = "distro" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/20/60ae67bb9d82f00427946218d49e2e7e80fb41c15dc5019482289ec9ce8d/posthog-5.4.0.tar.gz", hash = "sha256:701669261b8d07cdde0276e5bc096b87f9e200e3b9589c5ebff14df658c5893c", size = 88076, upload-time = "2025-06-20T23:19:23.485Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/98/e480cab9a08d1c09b1c59a93dade92c1bb7544826684ff2acbfd10fcfbd4/posthog-5.4.0-py3-none-any.whl", hash = "sha256:284dfa302f64353484420b52d4ad81ff5c2c2d1d607c4e2db602ac72761831bd", size = 105364, upload-time = "2025-06-20T23:19:22.001Z" }, +] + +[[package]] +name = "priority" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792, upload-time = "2021-06-27T10:15:05.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" }, +] + +[[package]] +name = "propcache" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/14/510deed325e262afeb8b360043c5d7c960da7d3ecd6d6f9496c9c56dc7f4/propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770", size = 73178, upload-time = "2025-06-09T22:53:40.126Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4e/ad52a7925ff01c1325653a730c7ec3175a23f948f08626a534133427dcff/propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3", size = 43133, upload-time = "2025-06-09T22:53:41.965Z" }, + { url = "https://files.pythonhosted.org/packages/63/7c/e9399ba5da7780871db4eac178e9c2e204c23dd3e7d32df202092a1ed400/propcache-0.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3def3da3ac3ce41562d85db655d18ebac740cb3fa4367f11a52b3da9d03a5cc3", size = 43039, upload-time = "2025-06-09T22:53:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/22/e1/58da211eb8fdc6fc854002387d38f415a6ca5f5c67c1315b204a5d3e9d7a/propcache-0.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bec58347a5a6cebf239daba9bda37dffec5b8d2ce004d9fe4edef3d2815137e", size = 201903, upload-time = "2025-06-09T22:53:44.872Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0a/550ea0f52aac455cb90111c8bab995208443e46d925e51e2f6ebdf869525/propcache-0.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55ffda449a507e9fbd4aca1a7d9aa6753b07d6166140e5a18d2ac9bc49eac220", size = 213362, upload-time = "2025-06-09T22:53:46.707Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/9893b7d878deda9bb69fcf54600b247fba7317761b7db11fede6e0f28bd0/propcache-0.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a67fb39229a8a8491dd42f864e5e263155e729c2e7ff723d6e25f596b1e8cb", size = 210525, upload-time = "2025-06-09T22:53:48.547Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bb/38fd08b278ca85cde36d848091ad2b45954bc5f15cce494bb300b9285831/propcache-0.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da1cf97b92b51253d5b68cf5a2b9e0dafca095e36b7f2da335e27dc6172a614", size = 198283, upload-time = "2025-06-09T22:53:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/78/8c/9fe55bd01d362bafb413dfe508c48753111a1e269737fa143ba85693592c/propcache-0.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f559e127134b07425134b4065be45b166183fdcb433cb6c24c8e4149056ad50", size = 191872, upload-time = "2025-06-09T22:53:51.438Z" }, + { url = "https://files.pythonhosted.org/packages/54/14/4701c33852937a22584e08abb531d654c8bcf7948a8f87ad0a4822394147/propcache-0.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aff2e4e06435d61f11a428360a932138d0ec288b0a31dd9bd78d200bd4a2b339", size = 199452, upload-time = "2025-06-09T22:53:53.229Z" }, + { url = "https://files.pythonhosted.org/packages/16/44/447f2253d859602095356007657ee535e0093215ea0b3d1d6a41d16e5201/propcache-0.3.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4927842833830942a5d0a56e6f4839bc484785b8e1ce8d287359794818633ba0", size = 191567, upload-time = "2025-06-09T22:53:54.541Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b3/e4756258749bb2d3b46defcff606a2f47410bab82be5824a67e84015b267/propcache-0.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6107ddd08b02654a30fb8ad7a132021759d750a82578b94cd55ee2772b6ebea2", size = 193015, upload-time = "2025-06-09T22:53:56.44Z" }, + { url = "https://files.pythonhosted.org/packages/1e/df/e6d3c7574233164b6330b9fd697beeac402afd367280e6dc377bb99b43d9/propcache-0.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70bd8b9cd6b519e12859c99f3fc9a93f375ebd22a50296c3a295028bea73b9e7", size = 204660, upload-time = "2025-06-09T22:53:57.839Z" }, + { url = "https://files.pythonhosted.org/packages/b2/53/e4d31dd5170b4a0e2e6b730f2385a96410633b4833dc25fe5dffd1f73294/propcache-0.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2183111651d710d3097338dd1893fcf09c9f54e27ff1a8795495a16a469cc90b", size = 206105, upload-time = "2025-06-09T22:53:59.638Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fe/74d54cf9fbe2a20ff786e5f7afcfde446588f0cf15fb2daacfbc267b866c/propcache-0.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb075ad271405dcad8e2a7ffc9a750a3bf70e533bd86e89f0603e607b93aa64c", size = 196980, upload-time = "2025-06-09T22:54:01.071Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/c469c9d59dada8a7679625e0440b544fe72e99311a4679c279562051f6fc/propcache-0.3.2-cp310-cp310-win32.whl", hash = "sha256:404d70768080d3d3bdb41d0771037da19d8340d50b08e104ca0e7f9ce55fce70", size = 37679, upload-time = "2025-06-09T22:54:03.003Z" }, + { url = "https://files.pythonhosted.org/packages/38/35/07a471371ac89d418f8d0b699c75ea6dca2041fbda360823de21f6a9ce0a/propcache-0.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:7435d766f978b4ede777002e6b3b6641dd229cd1da8d3d3106a45770365f9ad9", size = 41459, upload-time = "2025-06-09T22:54:04.134Z" }, + { url = "https://files.pythonhosted.org/packages/80/8d/e8b436717ab9c2cfc23b116d2c297305aa4cd8339172a456d61ebf5669b8/propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be", size = 74207, upload-time = "2025-06-09T22:54:05.399Z" }, + { url = "https://files.pythonhosted.org/packages/d6/29/1e34000e9766d112171764b9fa3226fa0153ab565d0c242c70e9945318a7/propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f", size = 43648, upload-time = "2025-06-09T22:54:08.023Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/1ad5af0df781e76988897da39b5f086c2bf0f028b7f9bd1f409bb05b6874/propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9", size = 43496, upload-time = "2025-06-09T22:54:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ce/e96392460f9fb68461fabab3e095cb00c8ddf901205be4eae5ce246e5b7e/propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf", size = 217288, upload-time = "2025-06-09T22:54:10.466Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2a/866726ea345299f7ceefc861a5e782b045545ae6940851930a6adaf1fca6/propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9", size = 227456, upload-time = "2025-06-09T22:54:11.828Z" }, + { url = "https://files.pythonhosted.org/packages/de/03/07d992ccb6d930398689187e1b3c718339a1c06b8b145a8d9650e4726166/propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66", size = 225429, upload-time = "2025-06-09T22:54:13.823Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e6/116ba39448753b1330f48ab8ba927dcd6cf0baea8a0ccbc512dfb49ba670/propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df", size = 213472, upload-time = "2025-06-09T22:54:15.232Z" }, + { url = "https://files.pythonhosted.org/packages/a6/85/f01f5d97e54e428885a5497ccf7f54404cbb4f906688a1690cd51bf597dc/propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2", size = 204480, upload-time = "2025-06-09T22:54:17.104Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/7bf5ab9033b8b8194cc3f7cf1aaa0e9c3256320726f64a3e1f113a812dce/propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7", size = 214530, upload-time = "2025-06-09T22:54:18.512Z" }, + { url = "https://files.pythonhosted.org/packages/31/0b/bd3e0c00509b609317df4a18e6b05a450ef2d9a963e1d8bc9c9415d86f30/propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95", size = 205230, upload-time = "2025-06-09T22:54:19.947Z" }, + { url = "https://files.pythonhosted.org/packages/7a/23/fae0ff9b54b0de4e819bbe559508da132d5683c32d84d0dc2ccce3563ed4/propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e", size = 206754, upload-time = "2025-06-09T22:54:21.716Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/ad6a3c22630aaa5f618b4dc3c3598974a72abb4c18e45a50b3cdd091eb2f/propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e", size = 218430, upload-time = "2025-06-09T22:54:23.17Z" }, + { url = "https://files.pythonhosted.org/packages/5b/2c/ba4f1c0e8a4b4c75910742f0d333759d441f65a1c7f34683b4a74c0ee015/propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf", size = 223884, upload-time = "2025-06-09T22:54:25.539Z" }, + { url = "https://files.pythonhosted.org/packages/88/e4/ebe30fc399e98572019eee82ad0caf512401661985cbd3da5e3140ffa1b0/propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e", size = 211480, upload-time = "2025-06-09T22:54:26.892Z" }, + { url = "https://files.pythonhosted.org/packages/96/0a/7d5260b914e01d1d0906f7f38af101f8d8ed0dc47426219eeaf05e8ea7c2/propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897", size = 37757, upload-time = "2025-06-09T22:54:28.241Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2d/89fe4489a884bc0da0c3278c552bd4ffe06a1ace559db5ef02ef24ab446b/propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39", size = 41500, upload-time = "2025-06-09T22:54:29.4Z" }, + { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" }, + { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" }, + { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958, upload-time = "2025-06-09T22:54:35.186Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894, upload-time = "2025-06-09T22:54:36.708Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672, upload-time = "2025-06-09T22:54:38.062Z" }, + { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395, upload-time = "2025-06-09T22:54:39.634Z" }, + { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510, upload-time = "2025-06-09T22:54:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949, upload-time = "2025-06-09T22:54:43.038Z" }, + { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258, upload-time = "2025-06-09T22:54:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036, upload-time = "2025-06-09T22:54:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684, upload-time = "2025-06-09T22:54:47.63Z" }, + { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562, upload-time = "2025-06-09T22:54:48.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142, upload-time = "2025-06-09T22:54:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload-time = "2025-06-09T22:54:52.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload-time = "2025-06-09T22:54:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, + { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, + { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, + { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, + { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, + { url = "https://files.pythonhosted.org/packages/6c/39/8ea9bcfaaff16fd0b0fc901ee522e24c9ec44b4ca0229cfffb8066a06959/propcache-0.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a7fad897f14d92086d6b03fdd2eb844777b0c4d7ec5e3bac0fbae2ab0602bbe5", size = 74678, upload-time = "2025-06-09T22:55:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/d3/85/cab84c86966e1d354cf90cdc4ba52f32f99a5bca92a1529d666d957d7686/propcache-0.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1f43837d4ca000243fd7fd6301947d7cb93360d03cd08369969450cc6b2ce3b4", size = 43829, upload-time = "2025-06-09T22:55:42.417Z" }, + { url = "https://files.pythonhosted.org/packages/23/f7/9cb719749152d8b26d63801b3220ce2d3931312b2744d2b3a088b0ee9947/propcache-0.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:261df2e9474a5949c46e962065d88eb9b96ce0f2bd30e9d3136bcde84befd8f2", size = 43729, upload-time = "2025-06-09T22:55:43.651Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a2/0b2b5a210ff311260002a315f6f9531b65a36064dfb804655432b2f7d3e3/propcache-0.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e514326b79e51f0a177daab1052bc164d9d9e54133797a3a58d24c9c87a3fe6d", size = 204483, upload-time = "2025-06-09T22:55:45.327Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e0/7aff5de0c535f783b0c8be5bdb750c305c1961d69fbb136939926e155d98/propcache-0.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4a996adb6904f85894570301939afeee65f072b4fd265ed7e569e8d9058e4ec", size = 217425, upload-time = "2025-06-09T22:55:46.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/1d/65fa889eb3b2a7d6e4ed3c2b568a9cb8817547a1450b572de7bf24872800/propcache-0.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:76cace5d6b2a54e55b137669b30f31aa15977eeed390c7cbfb1dafa8dfe9a701", size = 214723, upload-time = "2025-06-09T22:55:48.342Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e2/eecf6989870988dfd731de408a6fa366e853d361a06c2133b5878ce821ad/propcache-0.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31248e44b81d59d6addbb182c4720f90b44e1efdc19f58112a3c3a1615fb47ef", size = 200166, upload-time = "2025-06-09T22:55:49.775Z" }, + { url = "https://files.pythonhosted.org/packages/12/06/c32be4950967f18f77489268488c7cdc78cbfc65a8ba8101b15e526b83dc/propcache-0.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abb7fa19dbf88d3857363e0493b999b8011eea856b846305d8c0512dfdf8fbb1", size = 194004, upload-time = "2025-06-09T22:55:51.335Z" }, + { url = "https://files.pythonhosted.org/packages/46/6c/17b521a6b3b7cbe277a4064ff0aa9129dd8c89f425a5a9b6b4dd51cc3ff4/propcache-0.3.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d81ac3ae39d38588ad0549e321e6f773a4e7cc68e7751524a22885d5bbadf886", size = 203075, upload-time = "2025-06-09T22:55:52.681Z" }, + { url = "https://files.pythonhosted.org/packages/62/cb/3bdba2b736b3e45bc0e40f4370f745b3e711d439ffbffe3ae416393eece9/propcache-0.3.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:cc2782eb0f7a16462285b6f8394bbbd0e1ee5f928034e941ffc444012224171b", size = 195407, upload-time = "2025-06-09T22:55:54.048Z" }, + { url = "https://files.pythonhosted.org/packages/29/bd/760c5c6a60a4a2c55a421bc34a25ba3919d49dee411ddb9d1493bb51d46e/propcache-0.3.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:db429c19a6c7e8a1c320e6a13c99799450f411b02251fb1b75e6217cf4a14fcb", size = 196045, upload-time = "2025-06-09T22:55:55.485Z" }, + { url = "https://files.pythonhosted.org/packages/76/58/ced2757a46f55b8c84358d6ab8de4faf57cba831c51e823654da7144b13a/propcache-0.3.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:21d8759141a9e00a681d35a1f160892a36fb6caa715ba0b832f7747da48fb6ea", size = 208432, upload-time = "2025-06-09T22:55:56.884Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ec/d98ea8d5a4d8fe0e372033f5254eddf3254344c0c5dc6c49ab84349e4733/propcache-0.3.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2ca6d378f09adb13837614ad2754fa8afaee330254f404299611bce41a8438cb", size = 210100, upload-time = "2025-06-09T22:55:58.498Z" }, + { url = "https://files.pythonhosted.org/packages/56/84/b6d8a7ecf3f62d7dd09d9d10bbf89fad6837970ef868b35b5ffa0d24d9de/propcache-0.3.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:34a624af06c048946709f4278b4176470073deda88d91342665d95f7c6270fbe", size = 200712, upload-time = "2025-06-09T22:55:59.906Z" }, + { url = "https://files.pythonhosted.org/packages/bf/32/889f4903ddfe4a9dc61da71ee58b763758cf2d608fe1decede06e6467f8d/propcache-0.3.2-cp39-cp39-win32.whl", hash = "sha256:4ba3fef1c30f306b1c274ce0b8baaa2c3cdd91f645c48f06394068f37d3837a1", size = 38187, upload-time = "2025-06-09T22:56:01.212Z" }, + { url = "https://files.pythonhosted.org/packages/67/74/d666795fb9ba1dc139d30de64f3b6fd1ff9c9d3d96ccfdb992cd715ce5d2/propcache-0.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:7a2368eed65fc69a7a7a40b27f22e85e7627b74216f0846b04ba5c116e191ec9", size = 42025, upload-time = "2025-06-09T22:56:02.875Z" }, + { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, +] + +[[package]] +name = "psutil" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003, upload-time = "2025-02-13T21:54:07.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051, upload-time = "2025-02-13T21:54:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535, upload-time = "2025-02-13T21:54:16.07Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004, upload-time = "2025-02-13T21:54:18.662Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986, upload-time = "2025-02-13T21:54:21.811Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544, upload-time = "2025-02-13T21:54:24.68Z" }, + { url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053, upload-time = "2025-02-13T21:54:34.31Z" }, + { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" }, + { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" }, + { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" }, + { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" }, + { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/53/ea/bbe9095cdd771987d13c82d104a9c8559ae9aec1e29f139e286fd2e9256e/pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d", size = 2028677, upload-time = "2025-04-23T18:32:27.227Z" }, + { url = "https://files.pythonhosted.org/packages/49/1d/4ac5ed228078737d457a609013e8f7edc64adc37b91d619ea965758369e5/pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954", size = 1864735, upload-time = "2025-04-23T18:32:29.019Z" }, + { url = "https://files.pythonhosted.org/packages/23/9a/2e70d6388d7cda488ae38f57bc2f7b03ee442fbcf0d75d848304ac7e405b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb", size = 1898467, upload-time = "2025-04-23T18:32:31.119Z" }, + { url = "https://files.pythonhosted.org/packages/ff/2e/1568934feb43370c1ffb78a77f0baaa5a8b6897513e7a91051af707ffdc4/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7", size = 1983041, upload-time = "2025-04-23T18:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/01/1a/1a1118f38ab64eac2f6269eb8c120ab915be30e387bb561e3af904b12499/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4", size = 2136503, upload-time = "2025-04-23T18:32:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/5c/da/44754d1d7ae0f22d6d3ce6c6b1486fc07ac2c524ed8f6eca636e2e1ee49b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b", size = 2736079, upload-time = "2025-04-23T18:32:37.659Z" }, + { url = "https://files.pythonhosted.org/packages/4d/98/f43cd89172220ec5aa86654967b22d862146bc4d736b1350b4c41e7c9c03/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3", size = 2006508, upload-time = "2025-04-23T18:32:39.637Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cc/f77e8e242171d2158309f830f7d5d07e0531b756106f36bc18712dc439df/pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a", size = 2113693, upload-time = "2025-04-23T18:32:41.818Z" }, + { url = "https://files.pythonhosted.org/packages/54/7a/7be6a7bd43e0a47c147ba7fbf124fe8aaf1200bc587da925509641113b2d/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782", size = 2074224, upload-time = "2025-04-23T18:32:44.033Z" }, + { url = "https://files.pythonhosted.org/packages/2a/07/31cf8fadffbb03be1cb520850e00a8490c0927ec456e8293cafda0726184/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9", size = 2245403, upload-time = "2025-04-23T18:32:45.836Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8d/bbaf4c6721b668d44f01861f297eb01c9b35f612f6b8e14173cb204e6240/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e", size = 2242331, upload-time = "2025-04-23T18:32:47.618Z" }, + { url = "https://files.pythonhosted.org/packages/bb/93/3cc157026bca8f5006250e74515119fcaa6d6858aceee8f67ab6dc548c16/pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9", size = 1910571, upload-time = "2025-04-23T18:32:49.401Z" }, + { url = "https://files.pythonhosted.org/packages/5b/90/7edc3b2a0d9f0dda8806c04e511a67b0b7a41d2187e2003673a996fb4310/pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3", size = 1956504, upload-time = "2025-04-23T18:32:51.287Z" }, + { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, + { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" }, + { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, + { url = "https://files.pythonhosted.org/packages/08/98/dbf3fdfabaf81cda5622154fda78ea9965ac467e3239078e0dcd6df159e7/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101", size = 2024034, upload-time = "2025-04-23T18:33:32.843Z" }, + { url = "https://files.pythonhosted.org/packages/8d/99/7810aa9256e7f2ccd492590f86b79d370df1e9292f1f80b000b6a75bd2fb/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64", size = 1858578, upload-time = "2025-04-23T18:33:34.912Z" }, + { url = "https://files.pythonhosted.org/packages/d8/60/bc06fa9027c7006cc6dd21e48dbf39076dc39d9abbaf718a1604973a9670/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d", size = 1892858, upload-time = "2025-04-23T18:33:36.933Z" }, + { url = "https://files.pythonhosted.org/packages/f2/40/9d03997d9518816c68b4dfccb88969756b9146031b61cd37f781c74c9b6a/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535", size = 2068498, upload-time = "2025-04-23T18:33:38.997Z" }, + { url = "https://files.pythonhosted.org/packages/d8/62/d490198d05d2d86672dc269f52579cad7261ced64c2df213d5c16e0aecb1/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d", size = 2108428, upload-time = "2025-04-23T18:33:41.18Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ec/4cd215534fd10b8549015f12ea650a1a973da20ce46430b68fc3185573e8/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6", size = 2069854, upload-time = "2025-04-23T18:33:43.446Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1a/abbd63d47e1d9b0d632fee6bb15785d0889c8a6e0a6c3b5a8e28ac1ec5d2/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca", size = 2237859, upload-time = "2025-04-23T18:33:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/80/1c/fa883643429908b1c90598fd2642af8839efd1d835b65af1f75fba4d94fe/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039", size = 2239059, upload-time = "2025-04-23T18:33:47.735Z" }, + { url = "https://files.pythonhosted.org/packages/d4/29/3cade8a924a61f60ccfa10842f75eb12787e1440e2b8660ceffeb26685e7/pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27", size = 2066661, upload-time = "2025-04-23T18:33:49.995Z" }, +] + +[[package]] +name = "pygls" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cattrs" }, + { name = "lsprotocol" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/b9/41d173dad9eaa9db9c785a85671fc3d68961f08d67706dc2e79011e10b5c/pygls-1.3.1.tar.gz", hash = "sha256:140edceefa0da0e9b3c533547c892a42a7d2fd9217ae848c330c53d266a55018", size = 45527, upload-time = "2024-03-26T18:44:25.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/19/b74a10dd24548e96e8c80226cbacb28b021bc3a168a7d2709fb0d0185348/pygls-1.3.1-py3-none-any.whl", hash = "sha256:6e00f11efc56321bdeb6eac04f6d86131f654c7d49124344a9ebb968da3dd91e", size = 56031, upload-time = "2024-03-26T18:44:24.249Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, +] + +[[package]] +name = "pyinstrument" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/d0/665828770e8fcd5c50880dc83f03811f814d6260bc6a8068dca0a520e68a/pyinstrument-5.0.2.tar.gz", hash = "sha256:e466033ead16a48ffa8bedbd633b90d416fa772b3b22f61226882ace0371f5f3", size = 263930, upload-time = "2025-05-24T15:47:13.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/25/f64d0be5f574d2df9ddac3e7a381863f92d8ad30170b1a9de0cf805f4318/pyinstrument-5.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1aeaf6b39ad40b3f03bea5fa3a9bd453a92aeb721dde29c1597f842ed9c8566a", size = 129638, upload-time = "2025-05-24T15:45:20.113Z" }, + { url = "https://files.pythonhosted.org/packages/6e/b8/bc6657f91a8d2f7cf58b0993aa4e6cf20e027b53aca65c2464a50738d711/pyinstrument-5.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d734bd236d00e0e7f950019c689eaba1c9dd15e355867d8926c8b18b6077b221", size = 122220, upload-time = "2025-05-24T15:45:22.4Z" }, + { url = "https://files.pythonhosted.org/packages/63/5f/9a7edf13333015a9ccfd3fcf5c75ea793fbb30b153aebf6c6ace40a607b2/pyinstrument-5.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:520208a9b6c3985473aa9c3f30875ae5e78e77a81081df1d8aeb4fd8b4caf197", size = 146928, upload-time = "2025-05-24T15:45:23.802Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f9/f7d7b28c9038f1a570e96c8eea2a9ffeeb3ee9e75cfc74a370554776f1a6/pyinstrument-5.0.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75e115b759288b8d65a0bf31a34a542ae102c58ef407e0614a43e0c39d261875", size = 157136, upload-time = "2025-05-24T15:45:25.629Z" }, + { url = "https://files.pythonhosted.org/packages/db/ee/aa99f275b3c5f0f32ccd37f77cb64e57597a1f26280aec03a50d2158eab7/pyinstrument-5.0.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:091f93e6787c485a7ddf670608c00448e858a056677fc25ce349f8e44d6a9e54", size = 144680, upload-time = "2025-05-24T15:45:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/cd7300a5e099c4ad971a647ea8fb9bd081482a9e5751479034e206cd1f69/pyinstrument-5.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b07971afa2652cb4f2bdcffaef11aefa32b5384c0cfb32acf9955e96dd8df8", size = 145624, upload-time = "2025-05-24T15:45:28.517Z" }, + { url = "https://files.pythonhosted.org/packages/30/59/1957e2ca2277ecc69e247383527df331002e23940d5b0a79fc5f3b870d60/pyinstrument-5.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e1bcb28a21b80eea5986eb5cb3180689b1d489b7c6fddf34e1f4df1f95d467ad", size = 145901, upload-time = "2025-05-24T15:45:30.365Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/396ebdf387cde376ac4b70d52f3df07374f2501ac4c09992dadf641cd71f/pyinstrument-5.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:80d28162070ff40c6d2ac7dc15b933ba20ef49e891a2e650cd2b91d30cd262b2", size = 145355, upload-time = "2025-05-24T15:45:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/48/5b/fec77476a9b4a316861b29f14cd0962871ad5c54c21e41c540f7c18c950c/pyinstrument-5.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c75e52a9bf76f084ba074323835cba4927ab3e572adfc96439698b097e523780", size = 145008, upload-time = "2025-05-24T15:45:33.417Z" }, + { url = "https://files.pythonhosted.org/packages/fd/75/dcd391ca2790b32e41bbd49ad33626e85eb1ce00116b273d5e1d99b3e829/pyinstrument-5.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ccefdd7dd938548ada43c95b24c42ec57e258ac7994a5ec7e4cc934fa4f1743b", size = 145396, upload-time = "2025-05-24T15:45:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5c/64e026ccf2c7908d10882955993e73ac35a1a77426bde2617973deeda07c/pyinstrument-5.0.2-cp310-cp310-win32.whl", hash = "sha256:6b617fb024c244738aa2f6b8c2a25853eac765360ac91062578bbbcc8e22ebfe", size = 123419, upload-time = "2025-05-24T15:45:36.276Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7c/7d221db96d461c7d28897499bdad55a8ae5ded983f60743bdfbf17438c20/pyinstrument-5.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6788c8f93c1a6e0ad8d0ccde1631d17eca3839945d0fa4d506cf5d4bd7a26b77", size = 124299, upload-time = "2025-05-24T15:45:37.642Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f2/b3f2416740be762fdfb052b63e1d85591682fa1d2ea6ee1b10db774f6350/pyinstrument-5.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0eec7a263cc1ccfb101594e13256115366338fee2a156be4172fe5315f71ec45", size = 129386, upload-time = "2025-05-24T15:45:39.429Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fa/a55b0bf911041b51d2a7a0e8a3feef5ed5ddb48ff0943fc667079955c14c/pyinstrument-5.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddd5effefb470d7f1886dc16467501b866e3b5883cf74773f13179e718b28393", size = 122100, upload-time = "2025-05-24T15:45:41.253Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e1/c42b94c795bc89d5a486ad7ef349fe3b7a8c3a4e730c09b5fa54af616a6b/pyinstrument-5.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e7458a6aa4048c1703354fc8a4a3c8b59d27b1409aafb707cf339d3c0bc794c", size = 145385, upload-time = "2025-05-24T15:45:43.024Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/b511141cc336ffeac284cce7d121f05802ffea4ab2c19df8869adda49743/pyinstrument-5.0.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2373dd699711463011ec14e4918427a777f7ab73b31ae374d960725dbd5d5a28", size = 156093, upload-time = "2025-05-24T15:45:44.755Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/4a7bc4f1c60d4886efb7397fd5bdcc7e537d01ec7372824cd834fff967a1/pyinstrument-5.0.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38ef498fbe71c2bbd11247b71e722290da93a367d88a5a8e0f66f6cc764c2b60", size = 143136, upload-time = "2025-05-24T15:45:46.469Z" }, + { url = "https://files.pythonhosted.org/packages/d8/69/0ac06cf609153fc5eb30ccc0071ce300a181f422836ca7ce8cd431ac3ab4/pyinstrument-5.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a58a8a50f0cb3ee1c2e43ffec51bf48f48945e141feed7ccd9194917b97fe5b", size = 144077, upload-time = "2025-05-24T15:45:48.333Z" }, + { url = "https://files.pythonhosted.org/packages/e3/24/12bd82822393f708e5da8f6c0b82def3f0cbe1f4fbd72a082688c583d7fa/pyinstrument-5.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad2a97c79ecf0e610df292abb5c46d01a4f99778598881d6e918650fa39801b6", size = 144545, upload-time = "2025-05-24T15:45:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/c9/62/40e7511fa46247ca56734d34e2d2eb6b14390c72b155255ecd1b2288d02d/pyinstrument-5.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:57ec0277042ee198eb749b76a975fe60f006cd51ea0c7ce3054c937577d19315", size = 144010, upload-time = "2025-05-24T15:45:52.256Z" }, + { url = "https://files.pythonhosted.org/packages/82/77/6d40880dc46a6243951ad7cd50a77f26f6ad126b80d803616934efccf539/pyinstrument-5.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:73d34047266f27acb67218e331288c0241cf0080fe4b87dfad5596236c71abd7", size = 143746, upload-time = "2025-05-24T15:45:53.702Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a2/08b056d2420199dab877c665ed45bb685863dc5b83d31b2c4311430b2bbd/pyinstrument-5.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cfdc23284a8e2f27637b357c226a15d52b96608d9dde187b68dfe33a947f4908", size = 143928, upload-time = "2025-05-24T15:45:55.103Z" }, + { url = "https://files.pythonhosted.org/packages/39/a1/bab336f70cd5f798d7fa21ec92784b99d3b2df0b5c1736a64fdaa4521004/pyinstrument-5.0.2-cp311-cp311-win32.whl", hash = "sha256:3e6fa135aee6af2c608e912d8d07906bbac3c5e564d94f92721831a957297c26", size = 123395, upload-time = "2025-05-24T15:45:56.469Z" }, + { url = "https://files.pythonhosted.org/packages/f2/15/8a7ac268ffe913aa64bb42ad43315dd0fc3ac493d451a50d4431ecb736c2/pyinstrument-5.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:6317df42a98a8074ccd25af5482312ec59a1f27c05dab408eb3c7b2081242733", size = 124198, upload-time = "2025-05-24T15:45:57.814Z" }, + { url = "https://files.pythonhosted.org/packages/95/36/4afdffbc4fd77dd0155c8943101f175e701ba00cb374c5e84e64790a2a32/pyinstrument-5.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d0b680ef269b528d8dcd8151362fba9683b0ac22ffe74cc8161c33b53c65b899", size = 129527, upload-time = "2025-05-24T15:45:59.216Z" }, + { url = "https://files.pythonhosted.org/packages/96/fe/7ea5af73d65f8f22585005f6e2ce1016fb3145a8ecc1ded51f965c2e98cc/pyinstrument-5.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1c70b50ec90ae793b74733a6fc992723c6ee27c0fcb7d99848239316ded61189", size = 122068, upload-time = "2025-05-24T15:46:01.05Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d2/cf8f3b8fde3f3b6768f8407c681fb57e7b5a5bf5e7450a9fbec15164987b/pyinstrument-5.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3aae5f4f78515009f72393fdb271a15861534a586401383785f823cf8f60aa02", size = 146679, upload-time = "2025-05-24T15:46:02.841Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/6c00273778596560c7033cfee34aab07da6009f32c5a4dbcc35b64700e73/pyinstrument-5.0.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3aec8bc3d1c064ff849ca3568d6b0a7cfa0162d590a9d4d250c7118d09518b22", size = 157606, upload-time = "2025-05-24T15:46:04.551Z" }, + { url = "https://files.pythonhosted.org/packages/4c/cc/ec099f566e381f8e5db21d9523dd97b3255047813da57481ab3f45436089/pyinstrument-5.0.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28d87fac2bc0fed802b14a26982440f36c85dc53f303530ff7665a6e470315bb", size = 144317, upload-time = "2025-05-24T15:46:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/37/a7/e2e54bf6d996b3c807534dbc4fe270f373660b89871c63965d3f895c285d/pyinstrument-5.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b9caac53c7eda8187ed122d4f7fcc6e3392f04c583d6d70b373351cede2b829", size = 145622, upload-time = "2025-05-24T15:46:07.334Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c6/0b084ddf8d836076e04912ea83ccae0f83bf4897d0168b0fd7684efdc2a4/pyinstrument-5.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8124419e8731a7bdbb9f7f885a8956806a4e9ab9dd19294f8a99e74c0bbdd327", size = 145645, upload-time = "2025-05-24T15:46:09.236Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4d/3e542c5986cc30bc86c304492f4696e58dc03d1816d35c5b2cabfac1d01e/pyinstrument-5.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:9990d9bd05fbb4fa83f24f0a62989b8e0a3ac15ff0fa19b49348c8ef5f9db50a", size = 145619, upload-time = "2025-05-24T15:46:10.643Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/1e4664bf5ada1cff56852d10954b1ff5a39dad17b9b98a2f27054a0c0d95/pyinstrument-5.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:1dc35f3d200866a43d4bc7570799a405f001591c8f19a30eb7a983a717c1e1f7", size = 145049, upload-time = "2025-05-24T15:46:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/fb/59/08a5237c8d1343842ac9ed3c661dce40c450f1750128fd4789ad80539253/pyinstrument-5.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a335a40d0ba1fe3658ef1a5ff2fc7a6870905828014645cb19dab5c1de379447", size = 145451, upload-time = "2025-05-24T15:46:13.49Z" }, + { url = "https://files.pythonhosted.org/packages/53/d0/321b5301e36ac1577dbf73cb49769779c41ebf72ba70a3f6f62d34df902b/pyinstrument-5.0.2-cp312-cp312-win32.whl", hash = "sha256:29e565ce85e03d2541330a8174124c1ecdb073d945962a8eb738d3b1c806ac83", size = 123491, upload-time = "2025-05-24T15:46:15.319Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a6/40f05febe6ab0856b4bfa119113d550d868d94a36b501e6b9fd64379b4ba/pyinstrument-5.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:300b0cc453ffe7661d5f3ceb94cdd98996fd9118f5ff1182b5336489c7d4e45c", size = 124277, upload-time = "2025-05-24T15:46:16.693Z" }, + { url = "https://files.pythonhosted.org/packages/03/88/48654e4b8c6853f218e0506e0609060a54559500b3af5ed6ac752ac4d64f/pyinstrument-5.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8141a5f78b927a88de46fb2bbb17e710e41d16e161fca99991635ff7196dbd5d", size = 129528, upload-time = "2025-05-24T15:46:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/92/a7/885418b733350f6c2b1d8fcca322a1eee87216a266ac516d7aefd6757ec8/pyinstrument-5.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:12a0095ae408dbbdd429501fd4c6a3ab51d1aeff5f31be36cc3eedc8c4870ede", size = 122072, upload-time = "2025-05-24T15:46:19.513Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d5/dd0b323d2949d1a3ee0531ec6cdd66c3c69c13b9a8739aeec929a0b55fd2/pyinstrument-5.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eca651d840e8e75ae5330abfc5c90f6ea4af3f78f9f0269231328305a5f9c667", size = 146874, upload-time = "2025-05-24T15:46:21.38Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3b/429572b57c9ae2874e86c48db91ddcd5d619bd798f73d7d2e51b28abb08d/pyinstrument-5.0.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:89d6ffc5459b19f1c85d4433bb9bbc8925ec04a8d7caf2694218b1f557555f23", size = 155257, upload-time = "2025-05-24T15:46:22.791Z" }, + { url = "https://files.pythonhosted.org/packages/7a/98/03cd22f68607362fd8d1ba72e6367104a9dc32bd4a0dbafc823c4e366f35/pyinstrument-5.0.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c84845ccc5318072708dc5535b6bedd54494e92a68e282e6b97b53c1db65331", size = 144380, upload-time = "2025-05-24T15:46:24.26Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c4/40d7b4be6c9620c4d9bbe9788eb9bac892f386c9bd40f1937464b2b95c09/pyinstrument-5.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6511092384b5729bbbf4b35534120d2969c5fdfd4f39080badedd973676b8725", size = 145794, upload-time = "2025-05-24T15:46:25.751Z" }, + { url = "https://files.pythonhosted.org/packages/05/07/3b2084b78521d5bbbc328ca9527fb54fbf645a5e62f25169b49f7bbb0bc3/pyinstrument-5.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73f08cff7a8d9714be15440046289ab1a70cbc429e09967a3a106ac61538773e", size = 145803, upload-time = "2025-05-24T15:46:27.277Z" }, + { url = "https://files.pythonhosted.org/packages/22/eb/e3ffcc8734e3d9f50b6bb750209c3ad0c4626dcc3754529741499d9f1d5c/pyinstrument-5.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3905b510cdab1a8255a23fbdedcba4685245cbf814fd80f5b2005b472161d16e", size = 145763, upload-time = "2025-05-24T15:46:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/c6/34/6b94945a02afced9e486e9a6b20de0edcfec543e4942dea96d745e2148ac/pyinstrument-5.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cd693a616166679da529168037c294ff25746c7ae5e8b547811fb25bb26439f5", size = 145208, upload-time = "2025-05-24T15:46:30.125Z" }, + { url = "https://files.pythonhosted.org/packages/99/af/0339bbfe52de9a7df01e5a244a5fec4c228d23b1f422a55318fc6d0b9d91/pyinstrument-5.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:83a1659a3bc4123c81fcddfcc86608f37bd6a951da9692766c2251500a77ac06", size = 145591, upload-time = "2025-05-24T15:46:31.556Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f4/76a2c652e203c15cbc7aa3f8341e07d1ea865764b3ed9f9a97b3c4a5eda2/pyinstrument-5.0.2-cp313-cp313-win32.whl", hash = "sha256:386d047db6c043dcc86bac592873234a89eaa258460e1ad8f47a11fcc7b024d5", size = 123490, upload-time = "2025-05-24T15:46:32.951Z" }, + { url = "https://files.pythonhosted.org/packages/e4/63/14f5c6253e8c85c758485c7717f542346a0d4487818afc28721912a1574b/pyinstrument-5.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:971c974c061019fa6177a021882255e639399bc15bf71b0a17979830702ad8d3", size = 124287, upload-time = "2025-05-24T15:46:34.333Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e2/26f60a7af42d5b93279ee81a7a176f9369cd65e4a4d800521e1ddf2c341f/pyinstrument-5.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:39cc70da2bf101e650bfcfbfea8270e308de47d785965203b42547f8af222d9e", size = 129634, upload-time = "2025-05-24T15:46:54.791Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b9/108149137ed72432aa775be19eb079c60deac76552809950a3016923c683/pyinstrument-5.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:230f6d4b5f41136a4f316fee2c9a9e7934ef89f498417d88aff5cbc8cf805b80", size = 122217, upload-time = "2025-05-24T15:46:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/29/ce/1c21256fe4af106e62d098d87a93f8bde4ba4aec99f99bcfbb6dd9cc1349/pyinstrument-5.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:034245ff794d0f3d5d81ca87642df0b16e3a2fcbced374e28ad8a1fe34758672", size = 146550, upload-time = "2025-05-24T15:46:57.623Z" }, + { url = "https://files.pythonhosted.org/packages/08/c6/4fcefada84c98ec18984e1fde54438c5fc894d2afc868a9d5962c0ab69f0/pyinstrument-5.0.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:953abfbae7bcefd496831451c33f15957b485df7f51a7108dc8e857bb8c4b5cf", size = 156570, upload-time = "2025-05-24T15:46:59.159Z" }, + { url = "https://files.pythonhosted.org/packages/73/d6/f5bae351da87609289268bce331a9bd24819bcf17129de07072b921fdcfd/pyinstrument-5.0.2-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eceadce147d9e7332c26c3beecdd25bb054d531d6ed75ab804e15310aa93e0fd", size = 144349, upload-time = "2025-05-24T15:47:00.608Z" }, + { url = "https://files.pythonhosted.org/packages/fa/fd/57b56cfdb9b0fb99aa66b9ada8b8cdb3e77112c1aa108037e014ce96cc5d/pyinstrument-5.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52bfaff4bad998163f9884a70418270389835a775164b4886357818b5736068c", size = 145279, upload-time = "2025-05-24T15:47:01.981Z" }, + { url = "https://files.pythonhosted.org/packages/cb/42/b7823f4df8289ae14d4ed57cd0880112063f45556468ff1af1506a9fb60a/pyinstrument-5.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3daeb12ca1af4ded4acc017991e646c5ad6db4c8c81195917603756674e6a5d0", size = 145580, upload-time = "2025-05-24T15:47:03.767Z" }, + { url = "https://files.pythonhosted.org/packages/c7/33/c2a307a285463dab10ef4507e44154e775b8437711ece1e9919fcee51ad3/pyinstrument-5.0.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:7ee5c9dc172879b7baa8884d38ef0ab4484fae382aee12a016a10aea8a67118e", size = 145000, upload-time = "2025-05-24T15:47:05.184Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f0/08dc32486782888cb238481adda0cf4d7d020b730a17774382d57e6f4745/pyinstrument-5.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4c990d675b7d9cb13af4abbb356c5bc65a4719befaf9deb6779aa9b194761654", size = 144652, upload-time = "2025-05-24T15:47:06.674Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2e/780a190cc50abc73b8bd533476da81c5de51cde5358f5166d3eca8553830/pyinstrument-5.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:640db54338ff66cb28c119f3c0ea0e158e112eb8477a12b94e85a19504a37235", size = 145095, upload-time = "2025-05-24T15:47:08.142Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8c/a004986442e75b916bc9fe83abc6da2d10f209af7634eb550552086ff39c/pyinstrument-5.0.2-cp39-cp39-win32.whl", hash = "sha256:7e611c9bffa0c446d694f40e56b2ab266ca97f0d093b16c360c1318625f0173b", size = 123428, upload-time = "2025-05-24T15:47:09.936Z" }, + { url = "https://files.pythonhosted.org/packages/55/55/a2b57979000af71adbf2cf0fcba35454e141af53466f7607eaff1db675d2/pyinstrument-5.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:cdec7ff308930f349904fdc1cb45491a157900303975572ee2dfb55feba79405", size = 124310, upload-time = "2025-05-24T15:47:11.767Z" }, +] + +[[package]] +name = "pyopenssl" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/8c/cd89ad05804f8e3c17dea8f178c3f40eeab5694c30e0c9f5bcd49f576fc3/pyopenssl-25.1.0.tar.gz", hash = "sha256:8d031884482e0c67ee92bf9a4d8cceb08d92aba7136432ffb0703c5280fc205b", size = 179937, upload-time = "2025-05-17T16:28:31.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/28/2659c02301b9500751f8d42f9a6632e1508aa5120de5e43042b8b30f8d5d/pyopenssl-25.1.0-py3-none-any.whl", hash = "sha256:2b11f239acc47ac2e5aca04fd7fa829800aeee22a2eb30d744572a157bd8a1ab", size = 56771, upload-time = "2025-05-17T16:28:29.197Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.401" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/9a/7ab2b333b921b2d6bfcffe05a0e0a0bbeff884bd6fb5ed50cd68e2898e53/pyright-1.1.401.tar.gz", hash = "sha256:788a82b6611fa5e34a326a921d86d898768cddf59edde8e93e56087d277cc6f1", size = 3894193, upload-time = "2025-05-21T10:44:52.03Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/e6/1f908fce68b0401d41580e0f9acc4c3d1b248adcff00dfaad75cd21a1370/pyright-1.1.401-py3-none-any.whl", hash = "sha256:6fde30492ba5b0d7667c16ecaf6c699fab8d7a1263f6a18549e0b00bf7724c06", size = 5629193, upload-time = "2025-05-21T10:44:50.129Z" }, +] + +[[package]] +name = "pytest" +version = "7.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/1f/9d8e98e4133ffb16c90f3b405c43e38d3abb715bb5d7a63a5a684f7e46a3/pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280", size = 1357116, upload-time = "2023-12-31T12:00:18.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8", size = 325287, upload-time = "2023-12-31T12:00:13.963Z" }, +] + +[[package]] +name = "pytest-aiohttp" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/4b/d326890c153f2c4ce1bf45d07683c08c10a1766058a22934620bc6ac6592/pytest_aiohttp-1.1.0.tar.gz", hash = "sha256:147de8cb164f3fc9d7196967f109ab3c0b93ea3463ab50631e56438eab7b5adc", size = 12842, upload-time = "2025-01-23T12:44:04.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/0f/e6af71c02e0f1098eaf7d2dbf3ffdf0a69fc1e0ef174f96af05cef161f1b/pytest_aiohttp-1.1.0-py3-none-any.whl", hash = "sha256:f39a11693a0dce08dd6c542d241e199dd8047a6e6596b2bcfa60d373f143456d", size = 8932, upload-time = "2025-01-23T12:44:03.27Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.23.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/b4/0b378b7bf26a8ae161c3890c0b48a91a04106c5713ce81b4b080ea2f4f18/pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3", size = 46920, upload-time = "2024-07-17T17:39:34.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/82/62e2d63639ecb0fbe8a7ee59ef0bc69a4669ec50f6d3459f74ad4e4189a2/pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2", size = 17663, upload-time = "2024-07-17T17:39:32.478Z" }, +] + +[[package]] +name = "pytest-codspeed" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "pytest" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/98/16fe3895b1b8a6d537a89eecb120b97358df8f0002c6ecd11555d6304dc8/pytest_codspeed-3.2.0.tar.gz", hash = "sha256:f9d1b1a3b2c69cdc0490a1e8b1ced44bffbd0e8e21d81a7160cfdd923f6e8155", size = 18409, upload-time = "2025-01-31T14:28:26.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/31/62b93ee025ca46016d01325f58997d32303752286bf929588c8796a25b13/pytest_codspeed-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5165774424c7ab8db7e7acdb539763a0e5657996effefdf0664d7fd95158d34", size = 26802, upload-time = "2025-01-31T14:28:10.723Z" }, + { url = "https://files.pythonhosted.org/packages/89/60/2bc46bdf8c8ddb7e59cd9d480dc887d0ac6039f88c856d1ae3d29a4e648d/pytest_codspeed-3.2.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bd55f92d772592c04a55209950c50880413ae46876e66bd349ef157075ca26c", size = 25442, upload-time = "2025-01-31T14:28:11.774Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/1b65ba0ae1af7fd7ce14a66e7599833efe8bbd0fcecd3614db0017ca224a/pytest_codspeed-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf6f56067538f4892baa8d7ab5ef4e45bb59033be1ef18759a2c7fc55b32035", size = 26810, upload-time = "2025-01-31T14:28:12.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/e6/d1fafb09a1c4983372f562d9e158735229cb0b11603a61d4fad05463f977/pytest_codspeed-3.2.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a687b05c3d145642061b45ea78e47e12f13ce510104d1a2cda00eee0e36f58", size = 25442, upload-time = "2025-01-31T14:28:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8b/9e95472589d17bb68960f2a09cfa8f02c4d43c82de55b73302bbe0fa4350/pytest_codspeed-3.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46a1afaaa1ac4c2ca5b0700d31ac46d80a27612961d031067d73c6ccbd8d3c2b", size = 27182, upload-time = "2025-01-31T14:28:15.828Z" }, + { url = "https://files.pythonhosted.org/packages/2a/18/82aaed8095e84d829f30dda3ac49fce4e69685d769aae463614a8d864cdd/pytest_codspeed-3.2.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c48ce3af3dfa78413ed3d69d1924043aa1519048dbff46edccf8f35a25dab3c2", size = 25933, upload-time = "2025-01-31T14:28:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/e2/15/60b18d40da66e7aa2ce4c4c66d5a17de20a2ae4a89ac09a58baa7a5bc535/pytest_codspeed-3.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66692506d33453df48b36a84703448cb8b22953eea51f03fbb2eb758dc2bdc4f", size = 27180, upload-time = "2025-01-31T14:28:18.056Z" }, + { url = "https://files.pythonhosted.org/packages/51/bd/6b164d4ae07d8bea5d02ad664a9762bdb63f83c0805a3c8fe7dc6ec38407/pytest_codspeed-3.2.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:479774f80d0bdfafa16112700df4dbd31bf2a6757fac74795fd79c0a7b3c389b", size = 25923, upload-time = "2025-01-31T14:28:19.725Z" }, + { url = "https://files.pythonhosted.org/packages/90/bb/5d73c59d750264863c25fc202bcc37c5f8a390df640a4760eba54151753e/pytest_codspeed-3.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:109f9f4dd1088019c3b3f887d003b7d65f98a7736ca1d457884f5aa293e8e81c", size = 26795, upload-time = "2025-01-31T14:28:22.021Z" }, + { url = "https://files.pythonhosted.org/packages/65/17/d4bf207b63f1edc5b9c06ad77df565d186e0fd40f13459bb124304b54b1d/pytest_codspeed-3.2.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2f69a03b52c9bb041aec1b8ee54b7b6c37a6d0a948786effa4c71157765b6da", size = 25433, upload-time = "2025-01-31T14:28:22.955Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9b/952c70bd1fae9baa58077272e7f191f377c86d812263c21b361195e125e6/pytest_codspeed-3.2.0-py3-none-any.whl", hash = "sha256:54b5c2e986d6a28e7b0af11d610ea57bd5531cec8326abe486f1b55b09d91c39", size = 15007, upload-time = "2025-01-31T14:28:24.458Z" }, +] + +[[package]] +name = "pytest-cov" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/15/da3df99fd551507694a9b01f512a2f6cf1254f33601605843c3775f39460/pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6", size = 63245, upload-time = "2023-05-24T18:44:56.845Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/4b/8b78d126e275efa2379b1c2e09dc52cf70df16fc3b90613ef82531499d73/pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a", size = 21949, upload-time = "2023-05-24T18:44:54.079Z" }, +] + +[[package]] +name = "pytest-django" +version = "4.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/fb/55d580352db26eb3d59ad50c64321ddfe228d3d8ac107db05387a2fadf3a/pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991", size = 86202, upload-time = "2025-04-03T18:56:09.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281, upload-time = "2025-04-03T18:56:07.678Z" }, +] + +[[package]] +name = "pytest-emoji" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/4d/d489f939f0717a034cea7955d36bc2a7a5ba1b263871e63ad8cb16d47555/pytest-emoji-0.2.0.tar.gz", hash = "sha256:e1bd4790d87649c2d09c272c88bdfc4d37c1cc7c7a46583087d7c510944571e8", size = 6171, upload-time = "2019-02-19T09:33:17.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/51/80af966c0aded877da7577d21c4601ca98c6f603c6e6073ddea071af01ec/pytest_emoji-0.2.0-py3-none-any.whl", hash = "sha256:6e34ed21970fa4b80a56ad11417456bd873eb066c02315fe9df0fafe6d4d4436", size = 5664, upload-time = "2019-02-19T09:33:15.771Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/28/67172c96ba684058a4d24ffe144d64783d2a270d0af0d9e792737bddc75c/pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e", size = 33241, upload-time = "2025-05-26T13:58:45.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" }, +] + +[[package]] +name = "pytest-snapshot" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/7b/ab8f1fc1e687218aa66acec1c3674d9c443f6a2dc8cb6a50f464548ffa34/pytest-snapshot-0.9.0.tar.gz", hash = "sha256:c7013c3abc3e860f9feff899f8b4debe3708650d8d8242a61bf2625ff64db7f3", size = 19877, upload-time = "2022-04-23T17:35:31.751Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/29/518f32faf6edad9f56d6e0107217f7de6b79f297a47170414a2bd4be7f01/pytest_snapshot-0.9.0-py3-none-any.whl", hash = "sha256:4b9fe1c21c868fe53a545e4e3184d36bc1c88946e3f5c1d9dd676962a9b3d4ab", size = 10715, upload-time = "2022-04-23T17:35:30.288Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/dc/865845cfe987b21658e871d16e0a24e871e00884c545f246dd8f6f69edda/pytest_xdist-3.7.0.tar.gz", hash = "sha256:f9248c99a7c15b7d2f90715df93610353a485827bc06eefb6566d23f6400f126", size = 87550, upload-time = "2025-05-26T21:18:20.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/b2/0e802fde6f1c5b2f7ae7e9ad42b83fd4ecebac18a8a8c2f2f14e39dce6e1/pytest_xdist-3.7.0-py3-none-any.whl", hash = "sha256:7d3fbd255998265052435eb9daa4e99b62e6fb9cfb6efd1f858d4d8c0c7f0ca0", size = 46142, upload-time = "2025-05-26T21:18:18.759Z" }, +] + +[package.optional-dependencies] +psutil = [ + { name = "psutil" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, + { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777, upload-time = "2024-08-06T20:33:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318, upload-time = "2024-08-06T20:33:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891, upload-time = "2024-08-06T20:33:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614, upload-time = "2024-08-06T20:33:34.157Z" }, + { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360, upload-time = "2024-08-06T20:33:35.84Z" }, + { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006, upload-time = "2024-08-06T20:33:37.501Z" }, + { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577, upload-time = "2024-08-06T20:33:39.389Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593, upload-time = "2024-08-06T20:33:46.63Z" }, + { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312, upload-time = "2024-08-06T20:33:49.073Z" }, +] + +[[package]] +name = "pyyaml-ft" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/eb/5a0d575de784f9a1f94e2b1288c6886f13f34185e13117ed530f32b6f8a8/pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab", size = 141057, upload-time = "2025-06-10T15:32:15.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/ba/a067369fe61a2e57fb38732562927d5bae088c73cb9bb5438736a9555b29/pyyaml_ft-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c1306282bc958bfda31237f900eb52c9bedf9b93a11f82e1aab004c9a5657a6", size = 187027, upload-time = "2025-06-10T15:31:48.722Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c5/a3d2020ce5ccfc6aede0d45bcb870298652ac0cf199f67714d250e0cdf39/pyyaml_ft-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30c5f1751625786c19de751e3130fc345ebcba6a86f6bddd6e1285342f4bbb69", size = 176146, upload-time = "2025-06-10T15:31:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bb/23a9739291086ca0d3189eac7cd92b4d00e9fdc77d722ab610c35f9a82ba/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fa992481155ddda2e303fcc74c79c05eddcdbc907b888d3d9ce3ff3e2adcfb0", size = 746792, upload-time = "2025-06-10T15:31:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c2/e8825f4ff725b7e560d62a3609e31d735318068e1079539ebfde397ea03e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cec6c92b4207004b62dfad1f0be321c9f04725e0f271c16247d8b39c3bf3ea42", size = 786772, upload-time = "2025-06-10T15:31:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/35/be/58a4dcae8854f2fdca9b28d9495298fd5571a50d8430b1c3033ec95d2d0e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06237267dbcab70d4c0e9436d8f719f04a51123f0ca2694c00dd4b68c338e40b", size = 778723, upload-time = "2025-06-10T15:31:56.093Z" }, + { url = "https://files.pythonhosted.org/packages/86/ed/fed0da92b5d5d7340a082e3802d84c6dc9d5fa142954404c41a544c1cb92/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a7f332bc565817644cdb38ffe4739e44c3e18c55793f75dddb87630f03fc254", size = 758478, upload-time = "2025-06-10T15:31:58.314Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/ac02afe286275980ecb2dcdc0156617389b7e0c0a3fcdedf155c67be2b80/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d10175a746be65f6feb86224df5d6bc5c049ebf52b89a88cf1cd78af5a367a8", size = 799159, upload-time = "2025-06-10T15:31:59.675Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ac/c492a9da2e39abdff4c3094ec54acac9747743f36428281fb186a03fab76/pyyaml_ft-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:58e1015098cf8d8aec82f360789c16283b88ca670fe4275ef6c48c5e30b22a96", size = 158779, upload-time = "2025-06-10T15:32:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9b/41998df3298960d7c67653669f37710fa2d568a5fc933ea24a6df60acaf6/pyyaml_ft-8.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5f3e2ceb790d50602b2fd4ec37abbd760a8c778e46354df647e7c5a4ebb", size = 191331, upload-time = "2025-06-10T15:32:02.602Z" }, + { url = "https://files.pythonhosted.org/packages/0f/16/2710c252ee04cbd74d9562ebba709e5a284faeb8ada88fcda548c9191b47/pyyaml_ft-8.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d445bf6ea16bb93c37b42fdacfb2f94c8e92a79ba9e12768c96ecde867046d1", size = 182879, upload-time = "2025-06-10T15:32:04.466Z" }, + { url = "https://files.pythonhosted.org/packages/9a/40/ae8163519d937fa7bfa457b6f78439cc6831a7c2b170e4f612f7eda71815/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c56bb46b4fda34cbb92a9446a841da3982cdde6ea13de3fbd80db7eeeab8b49", size = 811277, upload-time = "2025-06-10T15:32:06.214Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/28d82dbff7f87b96f0eeac79b7d972a96b4980c1e445eb6a857ba91eda00/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dab0abb46eb1780da486f022dce034b952c8ae40753627b27a626d803926483b", size = 831650, upload-time = "2025-06-10T15:32:08.076Z" }, + { url = "https://files.pythonhosted.org/packages/e8/df/161c4566facac7d75a9e182295c223060373d4116dead9cc53a265de60b9/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd48d639cab5ca50ad957b6dd632c7dd3ac02a1abe0e8196a3c24a52f5db3f7a", size = 815755, upload-time = "2025-06-10T15:32:09.435Z" }, + { url = "https://files.pythonhosted.org/packages/05/10/f42c48fa5153204f42eaa945e8d1fd7c10d6296841dcb2447bf7da1be5c4/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:052561b89d5b2a8e1289f326d060e794c21fa068aa11255fe71d65baf18a632e", size = 810403, upload-time = "2025-06-10T15:32:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/e369064aa51009eb9245399fd8ad2c562bd0bcd392a00be44b2a824ded7c/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3bb4b927929b0cb162fb1605392a321e3333e48ce616cdcfa04a839271373255", size = 835581, upload-time = "2025-06-10T15:32:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/c0/28/26534bed77109632a956977f60d8519049f545abc39215d086e33a61f1f2/pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793", size = 171579, upload-time = "2025-06-10T15:32:14.34Z" }, +] + +[[package]] +name = "quart" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "blinker" }, + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "flask" }, + { name = "hypercorn" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/9d/12e1143a5bd2ccc05c293a6f5ae1df8fd94a8fc1440ecc6c344b2b30ce13/quart-0.20.0.tar.gz", hash = "sha256:08793c206ff832483586f5ae47018c7e40bdd75d886fee3fabbdaa70c2cf505d", size = 63874, upload-time = "2024-12-23T13:53:05.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/e9/cc28f21f52913adf333f653b9e0a3bf9cb223f5083a26422968ba73edd8d/quart-0.20.0-py3-none-any.whl", hash = "sha256:003c08f551746710acb757de49d9b768986fd431517d0eb127380b656b98b8f1", size = 77960, upload-time = "2024-12-23T13:53:02.842Z" }, +] + +[[package]] +name = "rapidfuzz" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/f6/6895abc3a3d056b9698da3199b04c0e56226d530ae44a470edabf8b664f0/rapidfuzz-3.13.0.tar.gz", hash = "sha256:d2eaf3839e52cbcc0accbe9817a67b4b0fcf70aaeb229cfddc1c28061f9ce5d8", size = 57904226, upload-time = "2025-04-03T20:38:51.226Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/27/ca10b3166024ae19a7e7c21f73c58dfd4b7fef7420e5497ee64ce6b73453/rapidfuzz-3.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:aafc42a1dc5e1beeba52cd83baa41372228d6d8266f6d803c16dbabbcc156255", size = 1998899, upload-time = "2025-04-03T20:35:08.764Z" }, + { url = "https://files.pythonhosted.org/packages/f0/38/c4c404b13af0315483a6909b3a29636e18e1359307fb74a333fdccb3730d/rapidfuzz-3.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:85c9a131a44a95f9cac2eb6e65531db014e09d89c4f18c7b1fa54979cb9ff1f3", size = 1449949, upload-time = "2025-04-03T20:35:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/12/ae/15c71d68a6df6b8e24595421fdf5bcb305888318e870b7be8d935a9187ee/rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d7cec4242d30dd521ef91c0df872e14449d1dffc2a6990ede33943b0dae56c3", size = 1424199, upload-time = "2025-04-03T20:35:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9a/765beb9e14d7b30d12e2d6019e8b93747a0bedbc1d0cce13184fa3825426/rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e297c09972698c95649e89121e3550cee761ca3640cd005e24aaa2619175464e", size = 5352400, upload-time = "2025-04-03T20:35:15.421Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b8/49479fe6f06b06cd54d6345ed16de3d1ac659b57730bdbe897df1e059471/rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef0f5f03f61b0e5a57b1df7beafd83df993fd5811a09871bad6038d08e526d0d", size = 1652465, upload-time = "2025-04-03T20:35:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d8/08823d496b7dd142a7b5d2da04337df6673a14677cfdb72f2604c64ead69/rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d8cf5f7cd6e4d5eb272baf6a54e182b2c237548d048e2882258336533f3f02b7", size = 1616590, upload-time = "2025-04-03T20:35:20.482Z" }, + { url = "https://files.pythonhosted.org/packages/38/d4/5cfbc9a997e544f07f301c54d42aac9e0d28d457d543169e4ec859b8ce0d/rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9256218ac8f1a957806ec2fb9a6ddfc6c32ea937c0429e88cf16362a20ed8602", size = 3086956, upload-time = "2025-04-03T20:35:22.756Z" }, + { url = "https://files.pythonhosted.org/packages/25/1e/06d8932a72fa9576095234a15785136407acf8f9a7dbc8136389a3429da1/rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e1bdd2e6d0c5f9706ef7595773a81ca2b40f3b33fd7f9840b726fb00c6c4eb2e", size = 2494220, upload-time = "2025-04-03T20:35:25.563Z" }, + { url = "https://files.pythonhosted.org/packages/03/16/5acf15df63119d5ca3d9a54b82807866ff403461811d077201ca351a40c3/rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5280be8fd7e2bee5822e254fe0a5763aa0ad57054b85a32a3d9970e9b09bbcbf", size = 7585481, upload-time = "2025-04-03T20:35:27.426Z" }, + { url = "https://files.pythonhosted.org/packages/e1/cf/ebade4009431ea8e715e59e882477a970834ddaacd1a670095705b86bd0d/rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd742c03885db1fce798a1cd87a20f47f144ccf26d75d52feb6f2bae3d57af05", size = 2894842, upload-time = "2025-04-03T20:35:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bd/0732632bd3f906bf613229ee1b7cbfba77515db714a0e307becfa8a970ae/rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5435fcac94c9ecf0504bf88a8a60c55482c32e18e108d6079a0089c47f3f8cf6", size = 3438517, upload-time = "2025-04-03T20:35:31.381Z" }, + { url = "https://files.pythonhosted.org/packages/83/89/d3bd47ec9f4b0890f62aea143a1e35f78f3d8329b93d9495b4fa8a3cbfc3/rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:93a755266856599be4ab6346273f192acde3102d7aa0735e2f48b456397a041f", size = 4412773, upload-time = "2025-04-03T20:35:33.425Z" }, + { url = "https://files.pythonhosted.org/packages/b3/57/1a152a07883e672fc117c7f553f5b933f6e43c431ac3fd0e8dae5008f481/rapidfuzz-3.13.0-cp310-cp310-win32.whl", hash = "sha256:3abe6a4e8eb4cfc4cda04dd650a2dc6d2934cbdeda5def7e6fd1c20f6e7d2a0b", size = 1842334, upload-time = "2025-04-03T20:35:35.648Z" }, + { url = "https://files.pythonhosted.org/packages/a7/68/7248addf95b6ca51fc9d955161072285da3059dd1472b0de773cff910963/rapidfuzz-3.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:e8ddb58961401da7d6f55f185512c0d6bd24f529a637078d41dd8ffa5a49c107", size = 1624392, upload-time = "2025-04-03T20:35:37.294Z" }, + { url = "https://files.pythonhosted.org/packages/68/23/f41c749f2c61ed1ed5575eaf9e73ef9406bfedbf20a3ffa438d15b5bf87e/rapidfuzz-3.13.0-cp310-cp310-win_arm64.whl", hash = "sha256:c523620d14ebd03a8d473c89e05fa1ae152821920c3ff78b839218ff69e19ca3", size = 865584, upload-time = "2025-04-03T20:35:39.005Z" }, + { url = "https://files.pythonhosted.org/packages/87/17/9be9eff5a3c7dfc831c2511262082c6786dca2ce21aa8194eef1cb71d67a/rapidfuzz-3.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d395a5cad0c09c7f096433e5fd4224d83b53298d53499945a9b0e5a971a84f3a", size = 1999453, upload-time = "2025-04-03T20:35:40.804Z" }, + { url = "https://files.pythonhosted.org/packages/75/67/62e57896ecbabe363f027d24cc769d55dd49019e576533ec10e492fcd8a2/rapidfuzz-3.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7b3eda607a019169f7187328a8d1648fb9a90265087f6903d7ee3a8eee01805", size = 1450881, upload-time = "2025-04-03T20:35:42.734Z" }, + { url = "https://files.pythonhosted.org/packages/96/5c/691c5304857f3476a7b3df99e91efc32428cbe7d25d234e967cc08346c13/rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98e0bfa602e1942d542de077baf15d658bd9d5dcfe9b762aff791724c1c38b70", size = 1422990, upload-time = "2025-04-03T20:35:45.158Z" }, + { url = "https://files.pythonhosted.org/packages/46/81/7a7e78f977496ee2d613154b86b203d373376bcaae5de7bde92f3ad5a192/rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bef86df6d59667d9655905b02770a0c776d2853971c0773767d5ef8077acd624", size = 5342309, upload-time = "2025-04-03T20:35:46.952Z" }, + { url = "https://files.pythonhosted.org/packages/51/44/12fdd12a76b190fe94bf38d252bb28ddf0ab7a366b943e792803502901a2/rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fedd316c165beed6307bf754dee54d3faca2c47e1f3bcbd67595001dfa11e969", size = 1656881, upload-time = "2025-04-03T20:35:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/27/ae/0d933e660c06fcfb087a0d2492f98322f9348a28b2cc3791a5dbadf6e6fb/rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5158da7f2ec02a930be13bac53bb5903527c073c90ee37804090614cab83c29e", size = 1608494, upload-time = "2025-04-03T20:35:51.646Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2c/4b2f8aafdf9400e5599b6ed2f14bc26ca75f5a923571926ccbc998d4246a/rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b6f913ee4618ddb6d6f3e387b76e8ec2fc5efee313a128809fbd44e65c2bbb2", size = 3072160, upload-time = "2025-04-03T20:35:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/60/7d/030d68d9a653c301114101c3003b31ce01cf2c3224034cd26105224cd249/rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d25fdbce6459ccbbbf23b4b044f56fbd1158b97ac50994eaae2a1c0baae78301", size = 2491549, upload-time = "2025-04-03T20:35:55.391Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cd/7040ba538fc6a8ddc8816a05ecf46af9988b46c148ddd7f74fb0fb73d012/rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:25343ccc589a4579fbde832e6a1e27258bfdd7f2eb0f28cb836d6694ab8591fc", size = 7584142, upload-time = "2025-04-03T20:35:57.71Z" }, + { url = "https://files.pythonhosted.org/packages/c1/96/85f7536fbceb0aa92c04a1c37a3fc4fcd4e80649e9ed0fb585382df82edc/rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a9ad1f37894e3ffb76bbab76256e8a8b789657183870be11aa64e306bb5228fd", size = 2896234, upload-time = "2025-04-03T20:35:59.969Z" }, + { url = "https://files.pythonhosted.org/packages/55/fd/460e78438e7019f2462fe9d4ecc880577ba340df7974c8a4cfe8d8d029df/rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5dc71ef23845bb6b62d194c39a97bb30ff171389c9812d83030c1199f319098c", size = 3437420, upload-time = "2025-04-03T20:36:01.91Z" }, + { url = "https://files.pythonhosted.org/packages/cc/df/c3c308a106a0993befd140a414c5ea78789d201cf1dfffb8fd9749718d4f/rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b7f4c65facdb94f44be759bbd9b6dda1fa54d0d6169cdf1a209a5ab97d311a75", size = 4410860, upload-time = "2025-04-03T20:36:04.352Z" }, + { url = "https://files.pythonhosted.org/packages/75/ee/9d4ece247f9b26936cdeaae600e494af587ce9bf8ddc47d88435f05cfd05/rapidfuzz-3.13.0-cp311-cp311-win32.whl", hash = "sha256:b5104b62711565e0ff6deab2a8f5dbf1fbe333c5155abe26d2cfd6f1849b6c87", size = 1843161, upload-time = "2025-04-03T20:36:06.802Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5a/d00e1f63564050a20279015acb29ecaf41646adfacc6ce2e1e450f7f2633/rapidfuzz-3.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:9093cdeb926deb32a4887ebe6910f57fbcdbc9fbfa52252c10b56ef2efb0289f", size = 1629962, upload-time = "2025-04-03T20:36:09.133Z" }, + { url = "https://files.pythonhosted.org/packages/3b/74/0a3de18bc2576b794f41ccd07720b623e840fda219ab57091897f2320fdd/rapidfuzz-3.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:f70f646751b6aa9d05be1fb40372f006cc89d6aad54e9d79ae97bd1f5fce5203", size = 866631, upload-time = "2025-04-03T20:36:11.022Z" }, + { url = "https://files.pythonhosted.org/packages/13/4b/a326f57a4efed8f5505b25102797a58e37ee11d94afd9d9422cb7c76117e/rapidfuzz-3.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a1a6a906ba62f2556372282b1ef37b26bca67e3d2ea957277cfcefc6275cca7", size = 1989501, upload-time = "2025-04-03T20:36:13.43Z" }, + { url = "https://files.pythonhosted.org/packages/b7/53/1f7eb7ee83a06c400089ec7cb841cbd581c2edd7a4b21eb2f31030b88daa/rapidfuzz-3.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2fd0975e015b05c79a97f38883a11236f5a24cca83aa992bd2558ceaa5652b26", size = 1445379, upload-time = "2025-04-03T20:36:16.439Z" }, + { url = "https://files.pythonhosted.org/packages/07/09/de8069a4599cc8e6d194e5fa1782c561151dea7d5e2741767137e2a8c1f0/rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d4e13593d298c50c4f94ce453f757b4b398af3fa0fd2fde693c3e51195b7f69", size = 1405986, upload-time = "2025-04-03T20:36:18.447Z" }, + { url = "https://files.pythonhosted.org/packages/5d/77/d9a90b39c16eca20d70fec4ca377fbe9ea4c0d358c6e4736ab0e0e78aaf6/rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed6f416bda1c9133000009d84d9409823eb2358df0950231cc936e4bf784eb97", size = 5310809, upload-time = "2025-04-03T20:36:20.324Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7d/14da291b0d0f22262d19522afaf63bccf39fc027c981233fb2137a57b71f/rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1dc82b6ed01acb536b94a43996a94471a218f4d89f3fdd9185ab496de4b2a981", size = 1629394, upload-time = "2025-04-03T20:36:22.256Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/79ed7e4fa58f37c0f8b7c0a62361f7089b221fe85738ae2dbcfb815e985a/rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9d824de871daa6e443b39ff495a884931970d567eb0dfa213d234337343835f", size = 1600544, upload-time = "2025-04-03T20:36:24.207Z" }, + { url = "https://files.pythonhosted.org/packages/4e/20/e62b4d13ba851b0f36370060025de50a264d625f6b4c32899085ed51f980/rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d18228a2390375cf45726ce1af9d36ff3dc1f11dce9775eae1f1b13ac6ec50f", size = 3052796, upload-time = "2025-04-03T20:36:26.279Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8d/55fdf4387dec10aa177fe3df8dbb0d5022224d95f48664a21d6b62a5299d/rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5fe634c9482ec5d4a6692afb8c45d370ae86755e5f57aa6c50bfe4ca2bdd87", size = 2464016, upload-time = "2025-04-03T20:36:28.525Z" }, + { url = "https://files.pythonhosted.org/packages/9b/be/0872f6a56c0f473165d3b47d4170fa75263dc5f46985755aa9bf2bbcdea1/rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:694eb531889f71022b2be86f625a4209c4049e74be9ca836919b9e395d5e33b3", size = 7556725, upload-time = "2025-04-03T20:36:30.629Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f3/6c0750e484d885a14840c7a150926f425d524982aca989cdda0bb3bdfa57/rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11b47b40650e06147dee5e51a9c9ad73bb7b86968b6f7d30e503b9f8dd1292db", size = 2859052, upload-time = "2025-04-03T20:36:32.836Z" }, + { url = "https://files.pythonhosted.org/packages/6f/98/5a3a14701b5eb330f444f7883c9840b43fb29c575e292e09c90a270a6e07/rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:98b8107ff14f5af0243f27d236bcc6e1ef8e7e3b3c25df114e91e3a99572da73", size = 3390219, upload-time = "2025-04-03T20:36:35.062Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7d/f4642eaaeb474b19974332f2a58471803448be843033e5740965775760a5/rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b836f486dba0aceb2551e838ff3f514a38ee72b015364f739e526d720fdb823a", size = 4377924, upload-time = "2025-04-03T20:36:37.363Z" }, + { url = "https://files.pythonhosted.org/packages/8e/83/fa33f61796731891c3e045d0cbca4436a5c436a170e7f04d42c2423652c3/rapidfuzz-3.13.0-cp312-cp312-win32.whl", hash = "sha256:4671ee300d1818d7bdfd8fa0608580d7778ba701817216f0c17fb29e6b972514", size = 1823915, upload-time = "2025-04-03T20:36:39.451Z" }, + { url = "https://files.pythonhosted.org/packages/03/25/5ee7ab6841ca668567d0897905eebc79c76f6297b73bf05957be887e9c74/rapidfuzz-3.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e2065f68fb1d0bf65adc289c1bdc45ba7e464e406b319d67bb54441a1b9da9e", size = 1616985, upload-time = "2025-04-03T20:36:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/5e/3f0fb88db396cb692aefd631e4805854e02120a2382723b90dcae720bcc6/rapidfuzz-3.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:65cc97c2fc2c2fe23586599686f3b1ceeedeca8e598cfcc1b7e56dc8ca7e2aa7", size = 860116, upload-time = "2025-04-03T20:36:43.915Z" }, + { url = "https://files.pythonhosted.org/packages/0a/76/606e71e4227790750f1646f3c5c873e18d6cfeb6f9a77b2b8c4dec8f0f66/rapidfuzz-3.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:09e908064d3684c541d312bd4c7b05acb99a2c764f6231bd507d4b4b65226c23", size = 1982282, upload-time = "2025-04-03T20:36:46.149Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/d0b48c6b902607a59fd5932a54e3518dae8223814db8349b0176e6e9444b/rapidfuzz-3.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:57c390336cb50d5d3bfb0cfe1467478a15733703af61f6dffb14b1cd312a6fae", size = 1439274, upload-time = "2025-04-03T20:36:48.323Z" }, + { url = "https://files.pythonhosted.org/packages/59/cf/c3ac8c80d8ced6c1f99b5d9674d397ce5d0e9d0939d788d67c010e19c65f/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0da54aa8547b3c2c188db3d1c7eb4d1bb6dd80baa8cdaeaec3d1da3346ec9caa", size = 1399854, upload-time = "2025-04-03T20:36:50.294Z" }, + { url = "https://files.pythonhosted.org/packages/09/5d/ca8698e452b349c8313faf07bfa84e7d1c2d2edf7ccc67bcfc49bee1259a/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df8e8c21e67afb9d7fbe18f42c6111fe155e801ab103c81109a61312927cc611", size = 5308962, upload-time = "2025-04-03T20:36:52.421Z" }, + { url = "https://files.pythonhosted.org/packages/66/0a/bebada332854e78e68f3d6c05226b23faca79d71362509dbcf7b002e33b7/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:461fd13250a2adf8e90ca9a0e1e166515cbcaa5e9c3b1f37545cbbeff9e77f6b", size = 1625016, upload-time = "2025-04-03T20:36:54.639Z" }, + { url = "https://files.pythonhosted.org/packages/de/0c/9e58d4887b86d7121d1c519f7050d1be5eb189d8a8075f5417df6492b4f5/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2b3dd5d206a12deca16870acc0d6e5036abeb70e3cad6549c294eff15591527", size = 1600414, upload-time = "2025-04-03T20:36:56.669Z" }, + { url = "https://files.pythonhosted.org/packages/9b/df/6096bc669c1311568840bdcbb5a893edc972d1c8d2b4b4325c21d54da5b1/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1343d745fbf4688e412d8f398c6e6d6f269db99a54456873f232ba2e7aeb4939", size = 3053179, upload-time = "2025-04-03T20:36:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/5179c583b75fce3e65a5cd79a3561bd19abd54518cb7c483a89b284bf2b9/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b1b065f370d54551dcc785c6f9eeb5bd517ae14c983d2784c064b3aa525896df", size = 2456856, upload-time = "2025-04-03T20:37:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/6b/64/e9804212e3286d027ac35bbb66603c9456c2bce23f823b67d2f5cabc05c1/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:11b125d8edd67e767b2295eac6eb9afe0b1cdc82ea3d4b9257da4b8e06077798", size = 7567107, upload-time = "2025-04-03T20:37:04.521Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f2/7d69e7bf4daec62769b11757ffc31f69afb3ce248947aadbb109fefd9f65/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c33f9c841630b2bb7e69a3fb5c84a854075bb812c47620978bddc591f764da3d", size = 2854192, upload-time = "2025-04-03T20:37:06.905Z" }, + { url = "https://files.pythonhosted.org/packages/05/21/ab4ad7d7d0f653e6fe2e4ccf11d0245092bef94cdff587a21e534e57bda8/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae4574cb66cf1e85d32bb7e9ec45af5409c5b3970b7ceb8dea90168024127566", size = 3398876, upload-time = "2025-04-03T20:37:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/45bba94c2489cb1ee0130dcb46e1df4fa2c2b25269e21ffd15240a80322b/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e05752418b24bbd411841b256344c26f57da1148c5509e34ea39c7eb5099ab72", size = 4377077, upload-time = "2025-04-03T20:37:11.929Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f3/5e0c6ae452cbb74e5436d3445467447e8c32f3021f48f93f15934b8cffc2/rapidfuzz-3.13.0-cp313-cp313-win32.whl", hash = "sha256:0e1d08cb884805a543f2de1f6744069495ef527e279e05370dd7c83416af83f8", size = 1822066, upload-time = "2025-04-03T20:37:14.425Z" }, + { url = "https://files.pythonhosted.org/packages/96/e3/a98c25c4f74051df4dcf2f393176b8663bfd93c7afc6692c84e96de147a2/rapidfuzz-3.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9a7c6232be5f809cd39da30ee5d24e6cadd919831e6020ec6c2391f4c3bc9264", size = 1615100, upload-time = "2025-04-03T20:37:16.611Z" }, + { url = "https://files.pythonhosted.org/packages/60/b1/05cd5e697c00cd46d7791915f571b38c8531f714832eff2c5e34537c49ee/rapidfuzz-3.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:3f32f15bacd1838c929b35c84b43618481e1b3d7a61b5ed2db0291b70ae88b53", size = 858976, upload-time = "2025-04-03T20:37:19.336Z" }, + { url = "https://files.pythonhosted.org/packages/24/23/fceeab4ed5d0ecddd573b19502547fdc9be80418628bb8947fc22e905844/rapidfuzz-3.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cc64da907114d7a18b5e589057e3acaf2fec723d31c49e13fedf043592a3f6a7", size = 2002049, upload-time = "2025-04-03T20:37:21.715Z" }, + { url = "https://files.pythonhosted.org/packages/f4/20/189c716da9e3c5a907b4620b6c326fc09c47dab10bf025b9482932b972ba/rapidfuzz-3.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4d9d7f84c8e992a8dbe5a3fdbea73d733da39bf464e62c912ac3ceba9c0cff93", size = 1452832, upload-time = "2025-04-03T20:37:24.008Z" }, + { url = "https://files.pythonhosted.org/packages/e3/3c/195f8c4b4a76e00c4d2f5f4ebec2c2108a81afbb1339a3378cf9b370bd02/rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a79a2f07786a2070669b4b8e45bd96a01c788e7a3c218f531f3947878e0f956", size = 1426492, upload-time = "2025-04-03T20:37:26.25Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8e/e1eca4b25ecdfed51750008e9b0f5d3539bbd897f8ea14f525738775d1b6/rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9f338e71c45b69a482de8b11bf4a029993230760120c8c6e7c9b71760b6825a1", size = 5343427, upload-time = "2025-04-03T20:37:28.959Z" }, + { url = "https://files.pythonhosted.org/packages/48/0d/366b972b54d7d6edd83c86ebcdf5ca446f35fba72d8b283a3629f0677b7f/rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb40ca8ddfcd4edd07b0713a860be32bdf632687f656963bcbce84cea04b8d8", size = 1649583, upload-time = "2025-04-03T20:37:31.435Z" }, + { url = "https://files.pythonhosted.org/packages/93/1b/7f5841392bae67e645dc39e49b37824028a400c489e8afb16eb1e5095da8/rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48719f7dcf62dfb181063b60ee2d0a39d327fa8ad81b05e3e510680c44e1c078", size = 1615186, upload-time = "2025-04-03T20:37:33.686Z" }, + { url = "https://files.pythonhosted.org/packages/5e/00/861a4601e4685efd8161966cf35728806fb9df112b6951585bb194f74379/rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9327a4577f65fc3fb712e79f78233815b8a1c94433d0c2c9f6bc5953018b3565", size = 3080994, upload-time = "2025-04-03T20:37:35.935Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5a/19c03bc9a550f63875d8db25c3d9b2e6d98757bd28ea1a1fd40ec6b22ee1/rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:200030dfc0a1d5d6ac18e993c5097c870c97c41574e67f227300a1fb74457b1d", size = 2492755, upload-time = "2025-04-03T20:37:38.665Z" }, + { url = "https://files.pythonhosted.org/packages/f0/44/5b860b4dcab7ee6f4ded818d5b0bf548772519386418ab84e9f395c7e995/rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cc269e74cad6043cb8a46d0ce580031ab642b5930562c2bb79aa7fbf9c858d26", size = 7577160, upload-time = "2025-04-03T20:37:41.056Z" }, + { url = "https://files.pythonhosted.org/packages/d0/64/22aab1c17c96ae344a06e5be692a62977d6acd5dd7f8470a8e068111282a/rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:e62779c6371bd2b21dbd1fdce89eaec2d93fd98179d36f61130b489f62294a92", size = 2891173, upload-time = "2025-04-03T20:37:43.647Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/e4928f158c5cebe2877dc11dea62d230cc02bd977992cf4bf33c41ae6ffe/rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f4797f821dc5d7c2b6fc818b89f8a3f37bcc900dd9e4369e6ebf1e525efce5db", size = 3434650, upload-time = "2025-04-03T20:37:47.015Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d7/a126c0f4ae2b7927d2b7a4206e2b98db2940591d4edcb350d772b97d18ba/rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d21f188f6fe4fbf422e647ae9d5a68671d00218e187f91859c963d0738ccd88c", size = 4414291, upload-time = "2025-04-03T20:37:49.55Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b0/3ad076cd513f5562b99c9e62760f7c451cd29f3d47d80ae40c8070e813f4/rapidfuzz-3.13.0-cp39-cp39-win32.whl", hash = "sha256:45dd4628dd9c21acc5c97627dad0bb791764feea81436fb6e0a06eef4c6dceaa", size = 1845012, upload-time = "2025-04-03T20:37:52.423Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0f/b6a37389f33c777de96b26f0ae1362d3524cad3fb84468a46346c24b6a98/rapidfuzz-3.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:624a108122039af89ddda1a2b7ab2a11abe60c1521956f142f5d11bcd42ef138", size = 1627071, upload-time = "2025-04-03T20:37:54.757Z" }, + { url = "https://files.pythonhosted.org/packages/89/10/ce1083b678db3e39b9a42244471501fb4d925b7cab0a771790d2ca3b3c27/rapidfuzz-3.13.0-cp39-cp39-win_arm64.whl", hash = "sha256:435071fd07a085ecbf4d28702a66fd2e676a03369ee497cc38bcb69a46bc77e2", size = 867233, upload-time = "2025-04-03T20:37:57.825Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e1/f5d85ae3c53df6f817ca70dbdd37c83f31e64caced5bb867bec6b43d1fdf/rapidfuzz-3.13.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fe5790a36d33a5d0a6a1f802aa42ecae282bf29ac6f7506d8e12510847b82a45", size = 1904437, upload-time = "2025-04-03T20:38:00.255Z" }, + { url = "https://files.pythonhosted.org/packages/db/d7/ded50603dddc5eb182b7ce547a523ab67b3bf42b89736f93a230a398a445/rapidfuzz-3.13.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:cdb33ee9f8a8e4742c6b268fa6bd739024f34651a06b26913381b1413ebe7590", size = 1383126, upload-time = "2025-04-03T20:38:02.676Z" }, + { url = "https://files.pythonhosted.org/packages/c4/48/6f795e793babb0120b63a165496d64f989b9438efbeed3357d9a226ce575/rapidfuzz-3.13.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c99b76b93f7b495eee7dcb0d6a38fb3ce91e72e99d9f78faa5664a881cb2b7d", size = 1365565, upload-time = "2025-04-03T20:38:06.646Z" }, + { url = "https://files.pythonhosted.org/packages/f0/50/0062a959a2d72ed17815824e40e2eefdb26f6c51d627389514510a7875f3/rapidfuzz-3.13.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6af42f2ede8b596a6aaf6d49fdee3066ca578f4856b85ab5c1e2145de367a12d", size = 5251719, upload-time = "2025-04-03T20:38:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/e7/02/bd8b70cd98b7a88e1621264778ac830c9daa7745cd63e838bd773b1aeebd/rapidfuzz-3.13.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c0efa73afbc5b265aca0d8a467ae2a3f40d6854cbe1481cb442a62b7bf23c99", size = 2991095, upload-time = "2025-04-03T20:38:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8d/632d895cdae8356826184864d74a5f487d40cb79f50a9137510524a1ba86/rapidfuzz-3.13.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7ac21489de962a4e2fc1e8f0b0da4aa1adc6ab9512fd845563fecb4b4c52093a", size = 1553888, upload-time = "2025-04-03T20:38:15.357Z" }, + { url = "https://files.pythonhosted.org/packages/88/df/6060c5a9c879b302bd47a73fc012d0db37abf6544c57591bcbc3459673bd/rapidfuzz-3.13.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1ba007f4d35a45ee68656b2eb83b8715e11d0f90e5b9f02d615a8a321ff00c27", size = 1905935, upload-time = "2025-04-03T20:38:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6c/a0b819b829e20525ef1bd58fc776fb8d07a0c38d819e63ba2b7c311a2ed4/rapidfuzz-3.13.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d7a217310429b43be95b3b8ad7f8fc41aba341109dc91e978cd7c703f928c58f", size = 1383714, upload-time = "2025-04-03T20:38:20.628Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/3da3466cc8a9bfb9cd345ad221fac311143b6a9664b5af4adb95b5e6ce01/rapidfuzz-3.13.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:558bf526bcd777de32b7885790a95a9548ffdcce68f704a81207be4a286c1095", size = 1367329, upload-time = "2025-04-03T20:38:23.01Z" }, + { url = "https://files.pythonhosted.org/packages/da/f0/9f2a9043bfc4e66da256b15d728c5fc2d865edf0028824337f5edac36783/rapidfuzz-3.13.0-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:202a87760f5145140d56153b193a797ae9338f7939eb16652dd7ff96f8faf64c", size = 5251057, upload-time = "2025-04-03T20:38:25.52Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ff/af2cb1d8acf9777d52487af5c6b34ce9d13381a753f991d95ecaca813407/rapidfuzz-3.13.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cfcccc08f671646ccb1e413c773bb92e7bba789e3a1796fd49d23c12539fe2e4", size = 2992401, upload-time = "2025-04-03T20:38:28.196Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c5/c243b05a15a27b946180db0d1e4c999bef3f4221505dff9748f1f6c917be/rapidfuzz-3.13.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1f219f1e3c3194d7a7de222f54450ce12bc907862ff9a8962d83061c1f923c86", size = 1553782, upload-time = "2025-04-03T20:38:30.778Z" }, + { url = "https://files.pythonhosted.org/packages/67/28/76470c1da02ea9c0ff299aa06d87057122e94b55db60c4f57acbce7b0432/rapidfuzz-3.13.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ccbd0e7ea1a216315f63ffdc7cd09c55f57851afc8fe59a74184cb7316c0598b", size = 1908943, upload-time = "2025-04-03T20:38:33.632Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ff/fde4ebbc55da03a6319106eb287d87e2bc5e177e0c90c95c735086993c40/rapidfuzz-3.13.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a50856f49a4016ef56edd10caabdaf3608993f9faf1e05c3c7f4beeac46bd12a", size = 1387875, upload-time = "2025-04-03T20:38:36.536Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a1/ef21859170e9d7e7e7ee818e9541b71da756189586f87e129c7b13c79dd3/rapidfuzz-3.13.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fd05336db4d0b8348d7eaaf6fa3c517b11a56abaa5e89470ce1714e73e4aca7", size = 1373040, upload-time = "2025-04-03T20:38:39.294Z" }, + { url = "https://files.pythonhosted.org/packages/58/c7/2361a8787f12166212c7d4ad4d2a01b640164686ea39ee26b24fd12acd3e/rapidfuzz-3.13.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:573ad267eb9b3f6e9b04febce5de55d8538a87c56c64bf8fd2599a48dc9d8b77", size = 5254220, upload-time = "2025-04-03T20:38:42.201Z" }, + { url = "https://files.pythonhosted.org/packages/1d/55/a965d98d5acf4a27ddd1d6621f086231dd243820e8108e8da7fa8a01ca1f/rapidfuzz-3.13.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30fd1451f87ccb6c2f9d18f6caa483116bbb57b5a55d04d3ddbd7b86f5b14998", size = 2990908, upload-time = "2025-04-03T20:38:44.794Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/e49988ee08ddb6ca8757785577da0fe2302cf759a5b246f50eded8d66fdd/rapidfuzz-3.13.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6dd36d4916cf57ddb05286ed40b09d034ca5d4bca85c17be0cb6a21290597d9", size = 1555134, upload-time = "2025-04-03T20:38:47.337Z" }, +] + +[[package]] +name = "readchar" +version = "4.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/f8/8657b8cbb4ebeabfbdf991ac40eca8a1d1bd012011bd44ad1ed10f5cb494/readchar-4.2.1.tar.gz", hash = "sha256:91ce3faf07688de14d800592951e5575e9c7a3213738ed01d394dcc949b79adb", size = 9685, upload-time = "2024-11-04T18:28:07.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/10/e4b1e0e5b6b6745c8098c275b69bc9d73e9542d5c7da4f137542b499ed44/readchar-4.2.1-py3-none-any.whl", hash = "sha256:a769305cd3994bb5fa2764aa4073452dc105a4ec39068ffe6efd3c20c60acc77", size = 9350, upload-time = "2024-11-04T18:28:02.859Z" }, +] + +[[package]] +name = "requests" +version = "2.32.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, +] + +[[package]] +name = "rich-click" +version = "1.8.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "rich", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/a8/dcc0a8ec9e91d76ecad9413a84b6d3a3310c6111cfe012d75ed385c78d96/rich_click-1.8.9.tar.gz", hash = "sha256:fd98c0ab9ddc1cf9c0b7463f68daf28b4d0033a74214ceb02f761b3ff2af3136", size = 39378, upload-time = "2025-05-19T21:33:05.569Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/c2/9fce4c8a9587c4e90500114d742fe8ef0fd92d7bad29d136bb9941add271/rich_click-1.8.9-py3-none-any.whl", hash = "sha256:c3fa81ed8a671a10de65a9e20abf642cfdac6fdb882db1ef465ee33919fbcfe2", size = 36082, upload-time = "2025-05-19T21:33:04.195Z" }, +] + +[[package]] +name = "ruff" +version = "0.11.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/da/9c6f995903b4d9474b39da91d2d626659af3ff1eeb43e9ae7c119349dba6/ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514", size = 4282054, upload-time = "2025-06-05T21:00:15.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ce/a11d381192966e0b4290842cc8d4fac7dc9214ddf627c11c1afff87da29b/ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46", size = 10292516, upload-time = "2025-06-05T20:59:32.944Z" }, + { url = "https://files.pythonhosted.org/packages/78/db/87c3b59b0d4e753e40b6a3b4a2642dfd1dcaefbff121ddc64d6c8b47ba00/ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48", size = 11106083, upload-time = "2025-06-05T20:59:37.03Z" }, + { url = "https://files.pythonhosted.org/packages/77/79/d8cec175856ff810a19825d09ce700265f905c643c69f45d2b737e4a470a/ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b", size = 10436024, upload-time = "2025-06-05T20:59:39.741Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5b/f6d94f2980fa1ee854b41568368a2e1252681b9238ab2895e133d303538f/ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a", size = 10646324, upload-time = "2025-06-05T20:59:42.185Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9c/b4c2acf24ea4426016d511dfdc787f4ce1ceb835f3c5fbdbcb32b1c63bda/ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc", size = 10174416, upload-time = "2025-06-05T20:59:44.319Z" }, + { url = "https://files.pythonhosted.org/packages/f3/10/e2e62f77c65ede8cd032c2ca39c41f48feabedb6e282bfd6073d81bb671d/ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629", size = 11724197, upload-time = "2025-06-05T20:59:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f0/466fe8469b85c561e081d798c45f8a1d21e0b4a5ef795a1d7f1a9a9ec182/ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933", size = 12511615, upload-time = "2025-06-05T20:59:49.534Z" }, + { url = "https://files.pythonhosted.org/packages/17/0e/cefe778b46dbd0cbcb03a839946c8f80a06f7968eb298aa4d1a4293f3448/ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165", size = 12117080, upload-time = "2025-06-05T20:59:51.654Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2c/caaeda564cbe103bed145ea557cb86795b18651b0f6b3ff6a10e84e5a33f/ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71", size = 11326315, upload-time = "2025-06-05T20:59:54.469Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9", size = 11555640, upload-time = "2025-06-05T20:59:56.986Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d4/3d580c616316c7f07fb3c99dbecfe01fbaea7b6fd9a82b801e72e5de742a/ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc", size = 10507364, upload-time = "2025-06-05T20:59:59.154Z" }, + { url = "https://files.pythonhosted.org/packages/5a/dc/195e6f17d7b3ea6b12dc4f3e9de575db7983db187c378d44606e5d503319/ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7", size = 10141462, upload-time = "2025-06-05T21:00:01.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8e/39a094af6967faa57ecdeacb91bedfb232474ff8c3d20f16a5514e6b3534/ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432", size = 11121028, upload-time = "2025-06-05T21:00:04.06Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c0/b0b508193b0e8a1654ec683ebab18d309861f8bd64e3a2f9648b80d392cb/ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492", size = 11602992, upload-time = "2025-06-05T21:00:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/7c/91/263e33ab93ab09ca06ce4f8f8547a858cc198072f873ebc9be7466790bae/ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250", size = 10474944, upload-time = "2025-06-05T21:00:08.459Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/7c27734ac2073aae8efb0119cae6931b6fb48017adf048fdf85c19337afc/ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3", size = 11548669, upload-time = "2025-06-05T21:00:11.147Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" }, +] + +[[package]] +name = "runs" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "xmod" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/6d/b9aace390f62db5d7d2c77eafce3d42774f27f1829d24fa9b6f598b3ef71/runs-1.2.2.tar.gz", hash = "sha256:9dc1815e2895cfb3a48317b173b9f1eac9ba5549b36a847b5cc60c3bf82ecef1", size = 5474, upload-time = "2024-01-25T14:44:01.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/d6/17caf2e4af1dec288477a0cbbe4a96fbc9b8a28457dce3f1f452630ce216/runs-1.2.2-py3-none-any.whl", hash = "sha256:0980dcbc25aba1505f307ac4f0e9e92cbd0be2a15a1e983ee86c24c87b839dfd", size = 7033, upload-time = "2024-01-25T14:43:59.959Z" }, +] + +[[package]] +name = "sanic" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "html5tagger" }, + { name = "httptools" }, + { name = "multidict" }, + { name = "sanic-routing" }, + { name = "setuptools" }, + { name = "tracerite" }, + { name = "typing-extensions" }, + { name = "ujson", marker = "implementation_name == 'cpython' and sys_platform != 'win32'" }, + { name = "uvloop", marker = "implementation_name == 'cpython' and sys_platform != 'win32'" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/8b/08dc376390fe854ef32984973883b646ee68c6727da72ffcc65340d8f192/sanic-25.3.0.tar.gz", hash = "sha256:775d522001ec81f034ec8e4d7599e2175bfc097b8d57884f5e4c9322f5e369bb", size = 353027, upload-time = "2025-03-31T21:22:29.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/e1/b36ddc16862d63d22986ae21b04a79c8fb7ec48d5d664acdfd1c2acf78ac/sanic-25.3.0-py3-none-any.whl", hash = "sha256:fb519b38b4c220569b0e2e868583ffeaffaab96a78b2e42ae78bc56a644a4cd7", size = 246416, upload-time = "2025-03-31T21:22:27.946Z" }, +] + +[[package]] +name = "sanic-routing" +version = "23.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/5c/2a7edd14fbccca3719a8d680951d4b25f986752c781c61ccf156a6d1ebff/sanic-routing-23.12.0.tar.gz", hash = "sha256:1dcadc62c443e48c852392dba03603f9862b6197fc4cba5bbefeb1ace0848b04", size = 29473, upload-time = "2023-12-31T09:28:36.992Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/e3/3425c9a8773807ac2c01d6a56c8521733f09b627e5827e733c5cd36b9ac5/sanic_routing-23.12.0-py3-none-any.whl", hash = "sha256:1558a72afcb9046ed3134a5edae02fc1552cff08f0fff2e8d5de0877ea43ed73", size = 25522, upload-time = "2023-12-31T09:28:35.233Z" }, +] + +[[package]] +name = "sanic-testing" +version = "23.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/a0/379a5083a1289795697101f722703a26e9a119c2648a128b85d2eb6cf19a/sanic-testing-23.12.0.tar.gz", hash = "sha256:2b9c52b7314b7e1807958f41581e18b8254c5161c953e70fcf492e0dd2fe133f", size = 10675, upload-time = "2023-12-31T10:14:41.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/34/7914665b25f02e16f70d6f6120c90e262a61f6150e1ce8f226cc9625ab38/sanic_testing-23.12.0-py3-none-any.whl", hash = "sha256:d809911fca49cba93e1df9de5c6ab8d95d91bdc03b18ba8a25b4e0b66c4e4c73", size = 10342, upload-time = "2023-12-31T10:14:40.483Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739, upload-time = "2022-08-13T16:22:46.976Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99", size = 15221, upload-time = "2022-08-13T16:22:44.457Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/4c/af31e0201b48469786ddeb1bf6fd3dfa3a291cc613a0fe6a60163a7535f9/sentry_sdk-2.30.0.tar.gz", hash = "sha256:436369b02afef7430efb10300a344fb61a11fe6db41c2b11f41ee037d2dd7f45", size = 326767, upload-time = "2025-06-12T10:34:34.733Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/99/31ac6faaae33ea698086692638f58d14f121162a8db0039e68e94135e7f1/sentry_sdk-2.30.0-py2.py3-none-any.whl", hash = "sha256:59391db1550662f746ea09b483806a631c3ae38d6340804a1a4c0605044f6877", size = 343149, upload-time = "2025-06-12T10:34:32.896Z" }, +] + +[[package]] +name = "service-identity" +version = "24.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cryptography" }, + { name = "pyasn1" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/a5/dfc752b979067947261dbbf2543470c58efe735c3c1301dd870ef27830ee/service_identity-24.2.0.tar.gz", hash = "sha256:b8683ba13f0d39c6cd5d625d2c5f65421d6d707b013b375c355751557cbe8e09", size = 39245, upload-time = "2024-10-26T07:21:57.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/2c/ca6dd598b384bc1ce581e24aaae0f2bed4ccac57749d5c3befbb5e742081/service_identity-24.2.0-py3-none-any.whl", hash = "sha256:6b047fbd8a84fd0bb0d55ebce4031e400562b9196e1e0d3e0fe2b8a59f6d4a85", size = 11364, upload-time = "2024-10-26T07:21:56.302Z" }, +] + +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" }, +] + +[[package]] +name = "starlette" +version = "0.46.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, +] + +[[package]] +name = "taskgroup" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/b1/74babcc824a57904e919f3af16d86c08b524c0691504baf038ef2d7f655c/taskgroup-0.2.2-py2.py3-none-any.whl", hash = "sha256:e2c53121609f4ae97303e9ea1524304b4de6faf9eb2c9280c7f87976479a52fb", size = 14237, upload-time = "2025-01-03T09:24:11.41Z" }, +] + +[[package]] +name = "timeout-decorator" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/f8/0802dd14c58b5d3d72bb9caa4315535f58787a1dc50b81bbbcaaa15451be/timeout-decorator-0.5.0.tar.gz", hash = "sha256:6a2f2f58db1c5b24a2cc79de6345760377ad8bdc13813f5265f6c3e63d16b3d7", size = 4754, upload-time = "2020-11-15T00:53:06.506Z" } + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, +] + +[[package]] +name = "tracerite" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "html5tagger" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/b2/37b825b881f23bc56384c3142214ccbe5d9de7e7c5fe3d155fa032738b98/tracerite-1.1.3.tar.gz", hash = "sha256:119fc006f240aa03fffb41cf99cf82fda5c0449c7d4b6fe42c6340403578b31e", size = 269646, upload-time = "2025-06-19T17:47:42.289Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/bf/c65d36ec5a93048dd55b3247be26059970daad72263e35ecace2f3188b2c/tracerite-1.1.3-py3-none-any.whl", hash = "sha256:811d8e2e0fb563b77340eebe2e9f7b324acfe01e09ea58db8bcaecb24327c823", size = 12422, upload-time = "2025-06-19T17:47:40.173Z" }, +] + +[[package]] +name = "trove-classifiers" +version = "2025.5.9.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/04/1cd43f72c241fedcf0d9a18d0783953ee301eac9e5d9db1df0f0f089d9af/trove_classifiers-2025.5.9.12.tar.gz", hash = "sha256:7ca7c8a7a76e2cd314468c677c69d12cc2357711fcab4a60f87994c1589e5cb5", size = 16940, upload-time = "2025-05-09T12:04:48.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/ef/c6deb083748be3bcad6f471b6ae983950c161890bf5ae1b2af80cc56c530/trove_classifiers-2025.5.9.12-py3-none-any.whl", hash = "sha256:e381c05537adac78881c8fa345fd0e9970159f4e4a04fcc42cfd3129cca640ce", size = 14119, upload-time = "2025-05-09T12:04:46.38Z" }, +] + +[[package]] +name = "twisted" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "automat" }, + { name = "constantly" }, + { name = "hyperlink" }, + { name = "incremental" }, + { name = "typing-extensions" }, + { name = "zope-interface" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/0f/82716ed849bf7ea4984c21385597c949944f0f9b428b5710f79d0afc084d/twisted-25.5.0.tar.gz", hash = "sha256:1deb272358cb6be1e3e8fc6f9c8b36f78eb0fa7c2233d2dbe11ec6fee04ea316", size = 3545725, upload-time = "2025-06-07T09:52:24.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/66/ab7efd8941f0bc7b2bd555b0f0471bff77df4c88e0cc31120c82737fec77/twisted-25.5.0-py3-none-any.whl", hash = "sha256:8559f654d01a54a8c3efe66d533d43f383531ebf8d81d9f9ab4769d91ca15df7", size = 3204767, upload-time = "2025-06-07T09:52:21.428Z" }, +] + +[package.optional-dependencies] +tls = [ + { name = "idna" }, + { name = "pyopenssl" }, + { name = "service-identity" }, +] + +[[package]] +name = "txaio" +version = "23.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/91/bc9fd5aa84703f874dea27313b11fde505d343f3ef3ad702bddbe20bfd6e/txaio-23.1.1.tar.gz", hash = "sha256:f9a9216e976e5e3246dfd112ad7ad55ca915606b60b84a757ac769bd404ff704", size = 53704, upload-time = "2023-01-15T14:11:27.475Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/6c/a53cc9a97c2da76d9cd83c03f377468599a28f2d4ad9fc71c3b99640e71e/txaio-23.1.1-py2.py3-none-any.whl", hash = "sha256:aaea42f8aad50e0ecfb976130ada140797e9dcb85fad2cf72b0f37f8cefcb490", size = 30512, upload-time = "2023-01-15T14:11:24.999Z" }, +] + +[[package]] +name = "typer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/8c/7d682431efca5fd290017663ea4588bf6f2c6aad085c7f108c5dbc316e70/typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b", size = 102625, upload-time = "2025-05-26T14:30:31.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/42/3efaf858001d2c2913de7f354563e3a3a2f0decae3efe98427125a8f441e/typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", size = 46317, upload-time = "2025-05-26T14:30:30.523Z" }, +] + +[[package]] +name = "types-deprecated" +version = "1.2.15.20250304" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/67/eeefaaabb03b288aad85483d410452c8bbcbf8b2bd876b0e467ebd97415b/types_deprecated-1.2.15.20250304.tar.gz", hash = "sha256:c329030553029de5cc6cb30f269c11f4e00e598c4241290179f63cda7d33f719", size = 8015, upload-time = "2025-03-04T02:48:17.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/e3/c18aa72ab84e0bc127a3a94e93be1a6ac2cb281371d3a45376ab7cfdd31c/types_deprecated-1.2.15.20250304-py3-none-any.whl", hash = "sha256:86a65aa550ea8acf49f27e226b8953288cd851de887970fbbdf2239c116c3107", size = 8553, upload-time = "2025-03-04T02:48:16.666Z" }, +] + +[[package]] +name = "types-six" +version = "1.17.0.20250515" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/78/344047eeced8d230140aa3d9503aa969acb61c6095e7308bbc1ff1de3865/types_six-1.17.0.20250515.tar.gz", hash = "sha256:f4f7f0398cb79304e88397336e642b15e96fbeacf5b96d7625da366b069d2d18", size = 15598, upload-time = "2025-05-15T03:04:19.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/85/5ee1c8e35b33b9c8ea1816d5a4e119c27f8bb1539b73b1f636f07aa64750/types_six-1.17.0.20250515-py3-none-any.whl", hash = "sha256:adfaa9568caf35e03d80ffa4ed765c33b282579c869b40bf4b6009c7d8db3fb1", size = 19987, upload-time = "2025-05-15T03:04:18.556Z" }, +] + +[[package]] +name = "types-toml" +version = "0.10.8.20240310" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/47/3e4c75042792bff8e90d7991aa5c51812cc668828cc6cce711e97f63a607/types-toml-0.10.8.20240310.tar.gz", hash = "sha256:3d41501302972436a6b8b239c850b26689657e25281b48ff0ec06345b8830331", size = 4392, upload-time = "2024-03-10T02:18:37.518Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/a2/d32ab58c0b216912638b140ab2170ee4b8644067c293b170e19fba340ccc/types_toml-0.10.8.20240310-py3-none-any.whl", hash = "sha256:627b47775d25fa29977d9c70dc0cbab3f314f32c8d8d0c012f2ef5de7aaec05d", size = 4777, upload-time = "2024-03-10T02:18:36.568Z" }, +] + +[[package]] +name = "typeshed-client" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-resources" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/1e/f20e33447be772486acf028295cdd21437454a051adb602d52ddb5334f9e/typeshed_client-2.7.0.tar.gz", hash = "sha256:e63df1e738588ad39f1226de042f4407ab6a99c456f0837063afd83b1415447c", size = 433569, upload-time = "2024-07-16T17:01:17.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/39/4702c2901899c018189b9aa7eb75aa8eb54527aed71c3f285895190dc664/typeshed_client-2.7.0-py3-none-any.whl", hash = "sha256:97084e5abc58a76ace2c4618ecaebd625f2d19bbd85aa1b3fb86216bf174bbea", size = 624417, upload-time = "2024-07-16T17:01:15.246Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, +] + +[[package]] +name = "typing-inspect" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "ujson" +version = "5.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/00/3110fd566786bfa542adb7932d62035e0c0ef662a8ff6544b6643b3d6fd7/ujson-5.10.0.tar.gz", hash = "sha256:b3cd8f3c5d8c7738257f1018880444f7b7d9b66232c64649f562d7ba86ad4bc1", size = 7154885, upload-time = "2024-05-14T02:02:34.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/91/91678e49a9194f527e60115db84368c237ac7824992224fac47dcb23a5c6/ujson-5.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2601aa9ecdbee1118a1c2065323bda35e2c5a2cf0797ef4522d485f9d3ef65bd", size = 55354, upload-time = "2024-05-14T02:00:27.054Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/1ed8c9b782fa4f44c26c1c4ec686d728a4865479da5712955daeef0b2e7b/ujson-5.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:348898dd702fc1c4f1051bc3aacbf894caa0927fe2c53e68679c073375f732cf", size = 51808, upload-time = "2024-05-14T02:00:29.461Z" }, + { url = "https://files.pythonhosted.org/packages/51/bf/a3a38b2912288143e8e613c6c4c3f798b5e4e98c542deabf94c60237235f/ujson-5.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22cffecf73391e8abd65ef5f4e4dd523162a3399d5e84faa6aebbf9583df86d6", size = 51995, upload-time = "2024-05-14T02:00:30.93Z" }, + { url = "https://files.pythonhosted.org/packages/b4/6d/0df8f7a6f1944ba619d93025ce468c9252aa10799d7140e07014dfc1a16c/ujson-5.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26b0e2d2366543c1bb4fbd457446f00b0187a2bddf93148ac2da07a53fe51569", size = 53566, upload-time = "2024-05-14T02:00:33.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ec/370741e5e30d5f7dc7f31a478d5bec7537ce6bfb7f85e72acefbe09aa2b2/ujson-5.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:caf270c6dba1be7a41125cd1e4fc7ba384bf564650beef0df2dd21a00b7f5770", size = 58499, upload-time = "2024-05-14T02:00:34.742Z" }, + { url = "https://files.pythonhosted.org/packages/fe/29/72b33a88f7fae3c398f9ba3e74dc2e5875989b25f1c1f75489c048a2cf4e/ujson-5.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a245d59f2ffe750446292b0094244df163c3dc96b3ce152a2c837a44e7cda9d1", size = 997881, upload-time = "2024-05-14T02:00:36.492Z" }, + { url = "https://files.pythonhosted.org/packages/70/5c/808fbf21470e7045d56a282cf5e85a0450eacdb347d871d4eb404270ee17/ujson-5.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:94a87f6e151c5f483d7d54ceef83b45d3a9cca7a9cb453dbdbb3f5a6f64033f5", size = 1140631, upload-time = "2024-05-14T02:00:38.995Z" }, + { url = "https://files.pythonhosted.org/packages/8f/6a/e1e8281408e6270d6ecf2375af14d9e2f41c402ab6b161ecfa87a9727777/ujson-5.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29b443c4c0a113bcbb792c88bea67b675c7ca3ca80c3474784e08bba01c18d51", size = 1043511, upload-time = "2024-05-14T02:00:41.352Z" }, + { url = "https://files.pythonhosted.org/packages/23/ec/3c551ecfe048bcb3948725251fb0214b5844a12aa60bee08d78315bb1c39/ujson-5.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a5b366812c90e69d0f379a53648be10a5db38f9d4ad212b60af00bd4048d0f00", size = 55353, upload-time = "2024-05-14T02:00:48.04Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9f/4731ef0671a0653e9f5ba18db7c4596d8ecbf80c7922dd5fe4150f1aea76/ujson-5.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:502bf475781e8167f0f9d0e41cd32879d120a524b22358e7f205294224c71126", size = 51813, upload-time = "2024-05-14T02:00:49.28Z" }, + { url = "https://files.pythonhosted.org/packages/1f/2b/44d6b9c1688330bf011f9abfdb08911a9dc74f76926dde74e718d87600da/ujson-5.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b91b5d0d9d283e085e821651184a647699430705b15bf274c7896f23fe9c9d8", size = 51988, upload-time = "2024-05-14T02:00:50.484Z" }, + { url = "https://files.pythonhosted.org/packages/29/45/f5f5667427c1ec3383478092a414063ddd0dfbebbcc533538fe37068a0a3/ujson-5.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:129e39af3a6d85b9c26d5577169c21d53821d8cf68e079060602e861c6e5da1b", size = 53561, upload-time = "2024-05-14T02:00:52.146Z" }, + { url = "https://files.pythonhosted.org/packages/26/21/a0c265cda4dd225ec1be595f844661732c13560ad06378760036fc622587/ujson-5.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f77b74475c462cb8b88680471193064d3e715c7c6074b1c8c412cb526466efe9", size = 58497, upload-time = "2024-05-14T02:00:53.366Z" }, + { url = "https://files.pythonhosted.org/packages/28/36/8fde862094fd2342ccc427a6a8584fed294055fdee341661c78660f7aef3/ujson-5.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ec0ca8c415e81aa4123501fee7f761abf4b7f386aad348501a26940beb1860f", size = 997877, upload-time = "2024-05-14T02:00:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/90/37/9208e40d53baa6da9b6a1c719e0670c3f474c8fc7cc2f1e939ec21c1bc93/ujson-5.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ab13a2a9e0b2865a6c6db9271f4b46af1c7476bfd51af1f64585e919b7c07fd4", size = 1140632, upload-time = "2024-05-14T02:00:57.099Z" }, + { url = "https://files.pythonhosted.org/packages/89/d5/2626c87c59802863d44d19e35ad16b7e658e4ac190b0dead17ff25460b4c/ujson-5.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:57aaf98b92d72fc70886b5a0e1a1ca52c2320377360341715dd3933a18e827b1", size = 1043513, upload-time = "2024-05-14T02:00:58.488Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a6/fd3f8bbd80842267e2d06c3583279555e8354c5986c952385199d57a5b6c/ujson-5.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:98ba15d8cbc481ce55695beee9f063189dce91a4b08bc1d03e7f0152cd4bbdd5", size = 55642, upload-time = "2024-05-14T02:01:04.055Z" }, + { url = "https://files.pythonhosted.org/packages/a8/47/dd03fd2b5ae727e16d5d18919b383959c6d269c7b948a380fdd879518640/ujson-5.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9d2edbf1556e4f56e50fab7d8ff993dbad7f54bac68eacdd27a8f55f433578e", size = 51807, upload-time = "2024-05-14T02:01:05.25Z" }, + { url = "https://files.pythonhosted.org/packages/25/23/079a4cc6fd7e2655a473ed9e776ddbb7144e27f04e8fc484a0fb45fe6f71/ujson-5.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6627029ae4f52d0e1a2451768c2c37c0c814ffc04f796eb36244cf16b8e57043", size = 51972, upload-time = "2024-05-14T02:01:06.458Z" }, + { url = "https://files.pythonhosted.org/packages/04/81/668707e5f2177791869b624be4c06fb2473bf97ee33296b18d1cf3092af7/ujson-5.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8ccb77b3e40b151e20519c6ae6d89bfe3f4c14e8e210d910287f778368bb3d1", size = 53686, upload-time = "2024-05-14T02:01:07.618Z" }, + { url = "https://files.pythonhosted.org/packages/bd/50/056d518a386d80aaf4505ccf3cee1c40d312a46901ed494d5711dd939bc3/ujson-5.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3caf9cd64abfeb11a3b661329085c5e167abbe15256b3b68cb5d914ba7396f3", size = 58591, upload-time = "2024-05-14T02:01:08.901Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d6/aeaf3e2d6fb1f4cfb6bf25f454d60490ed8146ddc0600fae44bfe7eb5a72/ujson-5.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6e32abdce572e3a8c3d02c886c704a38a1b015a1fb858004e03d20ca7cecbb21", size = 997853, upload-time = "2024-05-14T02:01:10.772Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d5/1f2a5d2699f447f7d990334ca96e90065ea7f99b142ce96e85f26d7e78e2/ujson-5.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a65b6af4d903103ee7b6f4f5b85f1bfd0c90ba4eeac6421aae436c9988aa64a2", size = 1140689, upload-time = "2024-05-14T02:01:12.214Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2c/6990f4ccb41ed93744aaaa3786394bca0875503f97690622f3cafc0adfde/ujson-5.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:604a046d966457b6cdcacc5aa2ec5314f0e8c42bae52842c1e6fa02ea4bda42e", size = 1043576, upload-time = "2024-05-14T02:01:14.39Z" }, + { url = "https://files.pythonhosted.org/packages/0d/69/b3e3f924bb0e8820bb46671979770c5be6a7d51c77a66324cdb09f1acddb/ujson-5.10.0-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:618efd84dc1acbd6bff8eaa736bb6c074bfa8b8a98f55b61c38d4ca2c1f7f287", size = 55646, upload-time = "2024-05-14T02:01:19.26Z" }, + { url = "https://files.pythonhosted.org/packages/32/8a/9b748eb543c6cabc54ebeaa1f28035b1bd09c0800235b08e85990734c41e/ujson-5.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38d5d36b4aedfe81dfe251f76c0467399d575d1395a1755de391e58985ab1c2e", size = 51806, upload-time = "2024-05-14T02:01:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/39/50/4b53ea234413b710a18b305f465b328e306ba9592e13a791a6a6b378869b/ujson-5.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67079b1f9fb29ed9a2914acf4ef6c02844b3153913eb735d4bf287ee1db6e557", size = 51975, upload-time = "2024-05-14T02:01:21.904Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9d/8061934f960cdb6dd55f0b3ceeff207fcc48c64f58b43403777ad5623d9e/ujson-5.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7d0e0ceeb8fe2468c70ec0c37b439dd554e2aa539a8a56365fd761edb418988", size = 53693, upload-time = "2024-05-14T02:01:23.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/7bfa84b28519ddbb67efc8410765ca7da55e6b93aba84d97764cd5794dbc/ujson-5.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:59e02cd37bc7c44d587a0ba45347cc815fb7a5fe48de16bf05caa5f7d0d2e816", size = 58594, upload-time = "2024-05-14T02:01:25.554Z" }, + { url = "https://files.pythonhosted.org/packages/48/eb/85d465abafb2c69d9699cfa5520e6e96561db787d36c677370e066c7e2e7/ujson-5.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a890b706b64e0065f02577bf6d8ca3b66c11a5e81fb75d757233a38c07a1f20", size = 997853, upload-time = "2024-05-14T02:01:27.151Z" }, + { url = "https://files.pythonhosted.org/packages/9f/76/2a63409fc05d34dd7d929357b7a45e3a2c96f22b4225cd74becd2ba6c4cb/ujson-5.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:621e34b4632c740ecb491efc7f1fcb4f74b48ddb55e65221995e74e2d00bbff0", size = 1140694, upload-time = "2024-05-14T02:01:29.113Z" }, + { url = "https://files.pythonhosted.org/packages/45/ed/582c4daba0f3e1688d923b5cb914ada1f9defa702df38a1916c899f7c4d1/ujson-5.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9500e61fce0cfc86168b248104e954fead61f9be213087153d272e817ec7b4f", size = 1043580, upload-time = "2024-05-14T02:01:31.447Z" }, + { url = "https://files.pythonhosted.org/packages/97/94/50ff2f1b61d668907f20216873640ab19e0eaa77b51e64ee893f6adfb266/ujson-5.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dfef2814c6b3291c3c5f10065f745a1307d86019dbd7ea50e83504950136ed5b", size = 55421, upload-time = "2024-05-14T02:01:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b3/3d2ca621d8dbeaf6c5afd0725e1b4bbd465077acc69eff1e9302735d1432/ujson-5.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4734ee0745d5928d0ba3a213647f1c4a74a2a28edc6d27b2d6d5bd9fa4319e27", size = 51816, upload-time = "2024-05-14T02:01:51.047Z" }, + { url = "https://files.pythonhosted.org/packages/8d/af/5dc103cb4d08f051f82d162a738adb9da488d1e3fafb9fd9290ea3eabf8e/ujson-5.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d47ebb01bd865fdea43da56254a3930a413f0c5590372a1241514abae8aa7c76", size = 52023, upload-time = "2024-05-14T02:01:53.072Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dd/b9a6027ba782b0072bf24a70929e15a58686668c32a37aebfcfaa9e00bdd/ujson-5.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dee5e97c2496874acbf1d3e37b521dd1f307349ed955e62d1d2f05382bc36dd5", size = 53622, upload-time = "2024-05-14T02:01:54.738Z" }, + { url = "https://files.pythonhosted.org/packages/1f/28/bcf6df25c1a9f1989dc2ddc4ac8a80e246857e089f91a9079fd8a0a01459/ujson-5.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7490655a2272a2d0b072ef16b0b58ee462f4973a8f6bbe64917ce5e0a256f9c0", size = 58563, upload-time = "2024-05-14T02:01:55.991Z" }, + { url = "https://files.pythonhosted.org/packages/9e/82/89404453a102d06d0937f6807c0a7ef2eec68b200b4ce4386127f3c28156/ujson-5.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ba17799fcddaddf5c1f75a4ba3fd6441f6a4f1e9173f8a786b42450851bd74f1", size = 998050, upload-time = "2024-05-14T02:01:57.8Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/2a4ea07165cad217bc842bb684b053bafa8ffdb818c47911c621e97a33fc/ujson-5.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2aff2985cef314f21d0fecc56027505804bc78802c0121343874741650a4d3d1", size = 1140672, upload-time = "2024-05-14T02:01:59.875Z" }, + { url = "https://files.pythonhosted.org/packages/72/53/d7bdf6afabeba3ed899f89d993c7f202481fa291d8c5be031c98a181eda4/ujson-5.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ad88ac75c432674d05b61184178635d44901eb749786c8eb08c102330e6e8996", size = 1043577, upload-time = "2024-05-14T02:02:02.138Z" }, + { url = "https://files.pythonhosted.org/packages/95/53/e5f5e733fc3525e65f36f533b0dbece5e5e2730b760e9beacf7e3d9d8b26/ujson-5.10.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5b6fee72fa77dc172a28f21693f64d93166534c263adb3f96c413ccc85ef6e64", size = 51846, upload-time = "2024-05-14T02:02:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/59/1f/f7bc02a54ea7b47f3dc2d125a106408f18b0f47b14fc737f0913483ae82b/ujson-5.10.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:61d0af13a9af01d9f26d2331ce49bb5ac1fb9c814964018ac8df605b5422dcb3", size = 48103, upload-time = "2024-05-14T02:02:07.777Z" }, + { url = "https://files.pythonhosted.org/packages/1a/3a/d3921b6f29bc744d8d6c56db5f8bbcbe55115fd0f2b79c3c43ff292cc7c9/ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecb24f0bdd899d368b715c9e6664166cf694d1e57be73f17759573a6986dd95a", size = 47257, upload-time = "2024-05-14T02:02:09.46Z" }, + { url = "https://files.pythonhosted.org/packages/f1/04/f4e3883204b786717038064afd537389ba7d31a72b437c1372297cb651ea/ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbd8fd427f57a03cff3ad6574b5e299131585d9727c8c366da4624a9069ed746", size = 48468, upload-time = "2024-05-14T02:02:10.768Z" }, + { url = "https://files.pythonhosted.org/packages/17/cd/9c6547169eb01a22b04cbb638804ccaeb3c2ec2afc12303464e0f9b2ee5a/ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:beeaf1c48e32f07d8820c705ff8e645f8afa690cca1544adba4ebfa067efdc88", size = 54266, upload-time = "2024-05-14T02:02:12.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/a3a2356ca5a4b67fe32a0c31e49226114d5154ba2464bb1220a93eb383e8/ujson-5.10.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ba43cc34cce49cf2d4bc76401a754a81202d8aa926d0e2b79f0ee258cb15d3a4", size = 51855, upload-time = "2024-05-14T02:02:22.164Z" }, + { url = "https://files.pythonhosted.org/packages/73/3d/41e78e7500e75eb6b5a7ab06907a6df35603b92ac6f939b86f40e9fe2c06/ujson-5.10.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ac56eb983edce27e7f51d05bc8dd820586c6e6be1c5216a6809b0c668bb312b8", size = 48059, upload-time = "2024-05-14T02:02:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/be/14/e435cbe5b5189483adbba5fe328e88418ccd54b2b1f74baa4172384bb5cd/ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44bd4b23a0e723bf8b10628288c2c7c335161d6840013d4d5de20e48551773b", size = 47238, upload-time = "2024-05-14T02:02:24.873Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d9/b6f4d1e6bec20a3b582b48f64eaa25209fd70dc2892b21656b273bc23434/ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c10f4654e5326ec14a46bcdeb2b685d4ada6911050aa8baaf3501e57024b804", size = 48457, upload-time = "2024-05-14T02:02:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/23/1c/cfefabb5996e21a1a4348852df7eb7cfc69299143739e86e5b1071c78735/ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0de4971a89a762398006e844ae394bd46991f7c385d7a6a3b93ba229e6dac17e", size = 54238, upload-time = "2024-05-14T02:02:28.468Z" }, +] + +[[package]] +name = "unidiff" +version = "0.7.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/48/81be0ac96e423a877754153699731ef439fd7b80b4c8b5425c94ed079ebd/unidiff-0.7.5.tar.gz", hash = "sha256:2e5f0162052248946b9f0970a40e9e124236bf86c82b70821143a6fc1dea2574", size = 20931, upload-time = "2023-03-10T01:05:39.185Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/54/57c411a6e8f7bd7848c8b66e4dcaffa586bf4c02e63f2280db0327a4e6eb/unidiff-0.7.5-py2.py3-none-any.whl", hash = "sha256:c93bf2265cc1ba2a520e415ab05da587370bc2a3ae9e0414329f54f0c2fc09e8", size = 14386, upload-time = "2023-03-10T01:05:36.594Z" }, +] + +[[package]] +name = "unittest-xml-reporting" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/40/3bf1afc96e93c7322520981ac4593cbb29daa21b48d32746f05ab5563dca/unittest-xml-reporting-3.2.0.tar.gz", hash = "sha256:edd8d3170b40c3a81b8cf910f46c6a304ae2847ec01036d02e9c0f9b85762d28", size = 18002, upload-time = "2022-01-20T19:09:55.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/88/f6e9b87428584a3c62cac768185c438ca6d561367a5d267b293259d76075/unittest_xml_reporting-3.2.0-py2.py3-none-any.whl", hash = "sha256:f3d7402e5b3ac72a5ee3149278339db1a8f932ee405f48bcb9c681372f2717d5", size = 20936, upload-time = "2022-01-20T19:09:53.824Z" }, +] + +[[package]] +name = "urllib3" +version = "1.26.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/e8/6ff5e6bc22095cfc59b6ea711b687e2b7ed4bdb373f7eeec370a97d7392f/urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32", size = 307380, upload-time = "2024-08-29T15:43:11.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/cf/8435d5a7159e2a9c83a95896ed596f68cf798005fe107cc655b5c5c14704/urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", size = 144225, upload-time = "2024-08-29T15:43:08.921Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.34.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/ad/713be230bcda622eaa35c28f0d328c3675c371238470abdea52417f17a8e/uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a", size = 76631, upload-time = "2025-06-01T07:48:17.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/0d/8adfeaa62945f90d19ddc461c55f4a50c258af7662d34b6a3d5d1f8646f6/uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885", size = 62431, upload-time = "2025-06-01T07:48:15.664Z" }, +] + +[[package]] +name = "uvloop" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/76/44a55515e8c9505aa1420aebacf4dd82552e5e15691654894e90d0bd051a/uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f", size = 1442019, upload-time = "2024-10-14T23:37:20.068Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/62d5800358a78cc25c8a6c72ef8b10851bdb8cca22e14d9c74167b7f86da/uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d", size = 801898, upload-time = "2024-10-14T23:37:22.663Z" }, + { url = "https://files.pythonhosted.org/packages/f3/96/63695e0ebd7da6c741ccd4489b5947394435e198a1382349c17b1146bb97/uvloop-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f38b2e090258d051d68a5b14d1da7203a3c3677321cf32a95a6f4db4dd8b6f26", size = 3827735, upload-time = "2024-10-14T23:37:25.129Z" }, + { url = "https://files.pythonhosted.org/packages/61/e0/f0f8ec84979068ffae132c58c79af1de9cceeb664076beea86d941af1a30/uvloop-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c43e0f13022b998eb9b973b5e97200c8b90823454d4bc06ab33829e09fb9bb", size = 3825126, upload-time = "2024-10-14T23:37:27.59Z" }, + { url = "https://files.pythonhosted.org/packages/bf/fe/5e94a977d058a54a19df95f12f7161ab6e323ad49f4dabc28822eb2df7ea/uvloop-0.21.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10d66943def5fcb6e7b37310eb6b5639fd2ccbc38df1177262b0640c3ca68c1f", size = 3705789, upload-time = "2024-10-14T23:37:29.385Z" }, + { url = "https://files.pythonhosted.org/packages/26/dd/c7179618e46092a77e036650c1f056041a028a35c4d76945089fcfc38af8/uvloop-0.21.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:67dd654b8ca23aed0a8e99010b4c34aca62f4b7fce88f39d452ed7622c94845c", size = 3800523, upload-time = "2024-10-14T23:37:32.048Z" }, + { url = "https://files.pythonhosted.org/packages/57/a7/4cf0334105c1160dd6819f3297f8700fda7fc30ab4f61fbf3e725acbc7cc/uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8", size = 1447410, upload-time = "2024-10-14T23:37:33.612Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7c/1517b0bbc2dbe784b563d6ab54f2ef88c890fdad77232c98ed490aa07132/uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0", size = 805476, upload-time = "2024-10-14T23:37:36.11Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ea/0bfae1aceb82a503f358d8d2fa126ca9dbdb2ba9c7866974faec1cb5875c/uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e", size = 3960855, upload-time = "2024-10-14T23:37:37.683Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ca/0864176a649838b838f36d44bf31c451597ab363b60dc9e09c9630619d41/uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a375441696e2eda1c43c44ccb66e04d61ceeffcd76e4929e527b7fa401b90fb", size = 3973185, upload-time = "2024-10-14T23:37:40.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/bf/08ad29979a936d63787ba47a540de2132169f140d54aa25bc8c3df3e67f4/uvloop-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:baa0e6291d91649c6ba4ed4b2f982f9fa165b5bbd50a9e203c416a2797bab3c6", size = 3820256, upload-time = "2024-10-14T23:37:42.839Z" }, + { url = "https://files.pythonhosted.org/packages/da/e2/5cf6ef37e3daf2f06e651aae5ea108ad30df3cb269102678b61ebf1fdf42/uvloop-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4509360fcc4c3bd2c70d87573ad472de40c13387f5fda8cb58350a1d7475e58d", size = 3937323, upload-time = "2024-10-14T23:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284, upload-time = "2024-10-14T23:37:47.833Z" }, + { url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349, upload-time = "2024-10-14T23:37:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089, upload-time = "2024-10-14T23:37:51.703Z" }, + { url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770, upload-time = "2024-10-14T23:37:54.122Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321, upload-time = "2024-10-14T23:37:55.766Z" }, + { url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022, upload-time = "2024-10-14T23:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload-time = "2024-10-14T23:38:00.688Z" }, + { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload-time = "2024-10-14T23:38:02.309Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload-time = "2024-10-14T23:38:04.711Z" }, + { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload-time = "2024-10-14T23:38:06.385Z" }, + { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload-time = "2024-10-14T23:38:08.416Z" }, + { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a4/646a9d0edff7cde25fc1734695d3dfcee0501140dd0e723e4df3f0a50acb/uvloop-0.21.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c097078b8031190c934ed0ebfee8cc5f9ba9642e6eb88322b9958b649750f72b", size = 1439646, upload-time = "2024-10-14T23:38:24.656Z" }, + { url = "https://files.pythonhosted.org/packages/01/2e/e128c66106af9728f86ebfeeb52af27ecd3cb09336f3e2f3e06053707a15/uvloop-0.21.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:46923b0b5ee7fc0020bef24afe7836cb068f5050ca04caf6b487c513dc1a20b2", size = 800931, upload-time = "2024-10-14T23:38:26.087Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1a/9fbc2b1543d0df11f7aed1632f64bdf5ecc4053cf98cdc9edb91a65494f9/uvloop-0.21.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53e420a3afe22cdcf2a0f4846e377d16e718bc70103d7088a4f7623567ba5fb0", size = 3829660, upload-time = "2024-10-14T23:38:27.905Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c0/392e235e4100ae3b95b5c6dac77f82b529d2760942b1e7e0981e5d8e895d/uvloop-0.21.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88cb67cdbc0e483da00af0b2c3cdad4b7c61ceb1ee0f33fe00e09c81e3a6cb75", size = 3827185, upload-time = "2024-10-14T23:38:29.458Z" }, + { url = "https://files.pythonhosted.org/packages/e1/24/a5da6aba58f99aed5255eca87d58d1760853e8302d390820cc29058408e3/uvloop-0.21.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:221f4f2a1f46032b403bf3be628011caf75428ee3cc204a22addf96f586b19fd", size = 3705833, upload-time = "2024-10-14T23:38:31.155Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5c/6ba221bb60f1e6474474102e17e38612ec7a06dc320e22b687ab563d877f/uvloop-0.21.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2d1f581393673ce119355d56da84fe1dd9d2bb8b3d13ce792524e1607139feff", size = 3804696, upload-time = "2024-10-14T23:38:33.633Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.31.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c3a104fd0c495184c4f2336d65baf398e3c75d72ea94/virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af", size = 6076316, upload-time = "2025-05-08T17:58:23.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982, upload-time = "2025-05-08T17:58:21.15Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/36/db/3fff0bcbe339a6fa6a3b9e3fbc2bfb321ec2f4cd233692272c5a8d6cf801/websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5", size = 175424, upload-time = "2025-03-05T20:02:56.505Z" }, + { url = "https://files.pythonhosted.org/packages/46/e6/519054c2f477def4165b0ec060ad664ed174e140b0d1cbb9fafa4a54f6db/websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a", size = 173077, upload-time = "2025-03-05T20:02:58.37Z" }, + { url = "https://files.pythonhosted.org/packages/1a/21/c0712e382df64c93a0d16449ecbf87b647163485ca1cc3f6cbadb36d2b03/websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b", size = 173324, upload-time = "2025-03-05T20:02:59.773Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cb/51ba82e59b3a664df54beed8ad95517c1b4dc1a913730e7a7db778f21291/websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770", size = 182094, upload-time = "2025-03-05T20:03:01.827Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0f/bf3788c03fec679bcdaef787518dbe60d12fe5615a544a6d4cf82f045193/websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb", size = 181094, upload-time = "2025-03-05T20:03:03.123Z" }, + { url = "https://files.pythonhosted.org/packages/5e/da/9fb8c21edbc719b66763a571afbaf206cb6d3736d28255a46fc2fe20f902/websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054", size = 181397, upload-time = "2025-03-05T20:03:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/2e/65/65f379525a2719e91d9d90c38fe8b8bc62bd3c702ac651b7278609b696c4/websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee", size = 181794, upload-time = "2025-03-05T20:03:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/d9/26/31ac2d08f8e9304d81a1a7ed2851c0300f636019a57cbaa91342015c72cc/websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed", size = 181194, upload-time = "2025-03-05T20:03:08.844Z" }, + { url = "https://files.pythonhosted.org/packages/98/72/1090de20d6c91994cd4b357c3f75a4f25ee231b63e03adea89671cc12a3f/websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880", size = 181164, upload-time = "2025-03-05T20:03:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/2d/37/098f2e1c103ae8ed79b0e77f08d83b0ec0b241cf4b7f2f10edd0126472e1/websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411", size = 176381, upload-time = "2025-03-05T20:03:12.77Z" }, + { url = "https://files.pythonhosted.org/packages/75/8b/a32978a3ab42cebb2ebdd5b05df0696a09f4d436ce69def11893afa301f0/websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4", size = 176841, upload-time = "2025-03-05T20:03:14.367Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/48/4b67623bac4d79beb3a6bb27b803ba75c1bdedc06bd827e465803690a4b2/websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940", size = 173106, upload-time = "2025-03-05T20:03:29.404Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f0/adb07514a49fe5728192764e04295be78859e4a537ab8fcc518a3dbb3281/websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e", size = 173339, upload-time = "2025-03-05T20:03:30.755Z" }, + { url = "https://files.pythonhosted.org/packages/87/28/bd23c6344b18fb43df40d0700f6d3fffcd7cef14a6995b4f976978b52e62/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9", size = 174597, upload-time = "2025-03-05T20:03:32.247Z" }, + { url = "https://files.pythonhosted.org/packages/6d/79/ca288495863d0f23a60f546f0905ae8f3ed467ad87f8b6aceb65f4c013e4/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b", size = 174205, upload-time = "2025-03-05T20:03:33.731Z" }, + { url = "https://files.pythonhosted.org/packages/04/e4/120ff3180b0872b1fe6637f6f995bcb009fb5c87d597c1fc21456f50c848/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f", size = 174150, upload-time = "2025-03-05T20:03:35.757Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/30e2f9c539b8da8b1d76f64012f3b19253271a63413b2d3adb94b143407f/websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123", size = 176877, upload-time = "2025-03-05T20:03:37.199Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925, upload-time = "2024-11-08T15:52:18.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" }, +] + +[[package]] +name = "wheel" +version = "0.45.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/98/2d9906746cdc6a6ef809ae6338005b3f21bb568bea3165cfc6a243fdc25c/wheel-0.45.1.tar.gz", hash = "sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729", size = 107545, upload-time = "2024-11-23T00:18:23.513Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/2c/87f3254fd8ffd29e4c02732eee68a83a1d3c346ae39bc6822dcbcb697f2b/wheel-0.45.1-py3-none-any.whl", hash = "sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248", size = 72494, upload-time = "2024-11-23T00:18:21.207Z" }, +] + +[[package]] +name = "wsproto" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", size = 53425, upload-time = "2022-08-23T19:58:21.447Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736", size = 24226, upload-time = "2022-08-23T19:58:19.96Z" }, +] + +[[package]] +name = "xattr" +version = "1.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/bf/8b98081f9f8fd56d67b9478ff1e0f8c337cde08bcb92f0d592f0a7958983/xattr-1.1.4.tar.gz", hash = "sha256:b7b02ecb2270da5b7e7deaeea8f8b528c17368401c2b9d5f63e91f545b45d372", size = 16729, upload-time = "2025-01-06T19:19:32.557Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/9d/99cf83aa9e02604e88ad5e843b0f7a003740e24a60de71e7089acf54bee6/xattr-1.1.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:acb85b6249e9f3ea10cbb56df1021d43f4027212f0d004304bc9075dc7f54769", size = 23923, upload-time = "2025-01-06T19:17:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/2e/89/bf59d0b7b718823ae5535cdb367195c50681625e275896eb8eed7cfd4100/xattr-1.1.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1a848ab125c0fafdc501ccd83b4c9018bba576a037a4ca5960a22f39e295552e", size = 18886, upload-time = "2025-01-06T19:17:28.77Z" }, + { url = "https://files.pythonhosted.org/packages/33/e3/b5aeaa2ff5f4ee08024eb6b271f37f59a088849b1338e29836afb318df12/xattr-1.1.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:467ee77471d26ae5187ee7081b82175b5ca56ead4b71467ec2e6119d1b08beed", size = 19220, upload-time = "2025-01-06T19:17:34.285Z" }, + { url = "https://files.pythonhosted.org/packages/78/5b/f64ba0f93e6447e1997068959f22ff99e08d77dd88d9edcf97ddcb9e9016/xattr-1.1.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bb4bbe37ba95542081890dd34fa5347bef4651e276647adaa802d5d0d7d86452", size = 23920, upload-time = "2025-01-06T19:17:48.234Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/ad66655f0b1317b0a297aa2d6ed7d6e5d5343495841fad535bee37a56471/xattr-1.1.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3da489ecef798705f9a39ea8cea4ead0d1eeed55f92c345add89740bd930bab6", size = 18883, upload-time = "2025-01-06T19:17:49.46Z" }, + { url = "https://files.pythonhosted.org/packages/4d/5d/7d5154570bbcb898e6123c292f697c87c33e12258a1a8b9741539f952681/xattr-1.1.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:798dd0cbe696635a6f74b06fc430818bf9c3b24314e1502eadf67027ab60c9b0", size = 19221, upload-time = "2025-01-06T19:17:51.654Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2a/d0f9e46de4cec5e4aa45fd939549b977c49dd68202fa844d07cb24ce5f17/xattr-1.1.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ae6579dea05bf9f335a082f711d5924a98da563cac72a2d550f5b940c401c0e9", size = 23917, upload-time = "2025-01-06T19:18:00.868Z" }, + { url = "https://files.pythonhosted.org/packages/83/e0/a5764257cd9c9eb598f4648a3658d915dd3520ec111ecbd251b685de6546/xattr-1.1.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd6038ec9df2e67af23c212693751481d5f7e858156924f14340376c48ed9ac7", size = 18891, upload-time = "2025-01-06T19:18:02.029Z" }, + { url = "https://files.pythonhosted.org/packages/8b/83/a81a147987387fd2841a28f767efedb099cf90e23553ead458f2330e47c5/xattr-1.1.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:608b2877526674eb15df4150ef4b70b7b292ae00e65aecaae2f192af224be200", size = 19213, upload-time = "2025-01-06T19:18:03.303Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/3b8e82ba6f5d24753314ef9922390d9c8e78f157159621bb01f4741d3240/xattr-1.1.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878df1b38cfdadf3184ad8c7b0f516311128d5597b60ac0b3486948953658a83", size = 23910, upload-time = "2025-01-06T19:18:14.745Z" }, + { url = "https://files.pythonhosted.org/packages/77/8d/30b04121b42537aa969a797b89138bb1abd213d5777e9d4289284ebc7dee/xattr-1.1.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0c9b8350244a1c5454f93a8d572628ff71d7e2fc2f7480dcf4c4f0e8af3150fe", size = 18890, upload-time = "2025-01-06T19:18:17.68Z" }, + { url = "https://files.pythonhosted.org/packages/fe/94/a95c7db010265a449935452db54d614afb1e5e91b1530c61485fc0fea4b5/xattr-1.1.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a46bf48fb662b8bd745b78bef1074a1e08f41a531168de62b5d7bd331dadb11a", size = 19211, upload-time = "2025-01-06T19:18:24.625Z" }, + { url = "https://files.pythonhosted.org/packages/e7/59/367c9311e503a12899b2c7e5c931b3b6fd4b219943e9977cd212a7bc1a10/xattr-1.1.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e9f00315e6c02943893b77f544776b49c756ac76960bea7cb8d7e1b96aefc284", size = 23918, upload-time = "2025-01-06T19:18:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d1/a529542047e3922162c32c16268f0fb6268b8d0d5975bdca7cdb825fee1e/xattr-1.1.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c8f98775065260140efb348b1ff8d50fd66ddcbf0c685b76eb1e87b380aaffb3", size = 18882, upload-time = "2025-01-06T19:18:52.01Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8d/cb84dcb9ee687fed1e04f60d1674612db0a7736665735571d829ac488c0e/xattr-1.1.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b471c6a515f434a167ca16c5c15ff34ee42d11956baa749173a8a4e385ff23e7", size = 19216, upload-time = "2025-01-06T19:18:53.181Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c9/abcc190a7e24de9feead2404f3bd6dbaceda28034277ffc96ad21b2134f8/xattr-1.1.4-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c72667f19d3a9acf324aed97f58861d398d87e42314731e7c6ab3ac7850c971", size = 15610, upload-time = "2025-01-06T19:19:03.772Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e8/aa3b2db13f12f9fcbeb79c69a0e8a6dc420845e0a78a37a52bf392bc8471/xattr-1.1.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:67ae934d75ea2563fc48a27c5945749575c74a6de19fdd38390917ddcb0e4f24", size = 16100, upload-time = "2025-01-06T19:19:07.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/c1/d988495674cd86343a4bdce0217e78677550a5518204dfb39d9213fd6746/xattr-1.1.4-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee9455c501d19f065527afda974418b3ef7c61e85d9519d122cd6eb3cb7a00", size = 15608, upload-time = "2025-01-06T19:19:24.346Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/dd3d3f9308c8d9d66701fcfc58412c7ed1880161aa270807ce89111fbff7/xattr-1.1.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:89ed62ce430f5789e15cfc1ccabc172fd8b349c3a17c52d9e6c64ecedf08c265", size = 16098, upload-time = "2025-01-06T19:19:25.714Z" }, +] + +[[package]] +name = "xmod" +version = "1.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/b2/e3edc608823348e628a919e1d7129e641997afadd946febdd704aecc5881/xmod-1.8.1.tar.gz", hash = "sha256:38c76486b9d672c546d57d8035df0beb7f4a9b088bc3fb2de5431ae821444377", size = 3988, upload-time = "2024-01-04T18:03:17.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/0dc75b64a764ea1cb8e4c32d1fb273c147304d4e5483cd58be482dc62e45/xmod-1.8.1-py3-none-any.whl", hash = "sha256:a24e9458a4853489042522bdca9e50ee2eac5ab75c809a91150a8a7f40670d48", size = 4610, upload-time = "2024-01-04T18:03:16.078Z" }, +] + +[[package]] +name = "yarl" +version = "1.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/65/7fed0d774abf47487c64be14e9223749468922817b5e8792b8a64792a1bb/yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4", size = 132910, upload-time = "2025-06-10T00:42:31.108Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/988f55a52da99df9e56dc733b8e4e5a6ae2090081dc2754fc8fd34e60aa0/yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a", size = 90644, upload-time = "2025-06-10T00:42:33.851Z" }, + { url = "https://files.pythonhosted.org/packages/f7/de/30d98f03e95d30c7e3cc093759982d038c8833ec2451001d45ef4854edc1/yarl-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c869f2651cc77465f6cd01d938d91a11d9ea5d798738c1dc077f3de0b5e5fed", size = 89322, upload-time = "2025-06-10T00:42:35.688Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7a/f2f314f5ebfe9200724b0b748de2186b927acb334cf964fd312eb86fc286/yarl-1.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62915e6688eb4d180d93840cda4110995ad50c459bf931b8b3775b37c264af1e", size = 323786, upload-time = "2025-06-10T00:42:37.817Z" }, + { url = "https://files.pythonhosted.org/packages/15/3f/718d26f189db96d993d14b984ce91de52e76309d0fd1d4296f34039856aa/yarl-1.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41ebd28167bc6af8abb97fec1a399f412eec5fd61a3ccbe2305a18b84fb4ca73", size = 319627, upload-time = "2025-06-10T00:42:39.937Z" }, + { url = "https://files.pythonhosted.org/packages/a5/76/8fcfbf5fa2369157b9898962a4a7d96764b287b085b5b3d9ffae69cdefd1/yarl-1.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21242b4288a6d56f04ea193adde174b7e347ac46ce6bc84989ff7c1b1ecea84e", size = 339149, upload-time = "2025-06-10T00:42:42.627Z" }, + { url = "https://files.pythonhosted.org/packages/3c/95/d7fc301cc4661785967acc04f54a4a42d5124905e27db27bb578aac49b5c/yarl-1.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bea21cdae6c7eb02ba02a475f37463abfe0a01f5d7200121b03e605d6a0439f8", size = 333327, upload-time = "2025-06-10T00:42:44.842Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/e21269718349582eee81efc5c1c08ee71c816bfc1585b77d0ec3f58089eb/yarl-1.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f8a891e4a22a89f5dde7862994485e19db246b70bb288d3ce73a34422e55b23", size = 326054, upload-time = "2025-06-10T00:42:47.149Z" }, + { url = "https://files.pythonhosted.org/packages/32/ae/8616d1f07853704523519f6131d21f092e567c5af93de7e3e94b38d7f065/yarl-1.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd803820d44c8853a109a34e3660e5a61beae12970da479cf44aa2954019bf70", size = 315035, upload-time = "2025-06-10T00:42:48.852Z" }, + { url = "https://files.pythonhosted.org/packages/48/aa/0ace06280861ef055855333707db5e49c6e3a08840a7ce62682259d0a6c0/yarl-1.20.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b982fa7f74c80d5c0c7b5b38f908971e513380a10fecea528091405f519b9ebb", size = 338962, upload-time = "2025-06-10T00:42:51.024Z" }, + { url = "https://files.pythonhosted.org/packages/20/52/1e9d0e6916f45a8fb50e6844f01cb34692455f1acd548606cbda8134cd1e/yarl-1.20.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:33f29ecfe0330c570d997bcf1afd304377f2e48f61447f37e846a6058a4d33b2", size = 335399, upload-time = "2025-06-10T00:42:53.007Z" }, + { url = "https://files.pythonhosted.org/packages/f2/65/60452df742952c630e82f394cd409de10610481d9043aa14c61bf846b7b1/yarl-1.20.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:835ab2cfc74d5eb4a6a528c57f05688099da41cf4957cf08cad38647e4a83b30", size = 338649, upload-time = "2025-06-10T00:42:54.964Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f5/6cd4ff38dcde57a70f23719a838665ee17079640c77087404c3d34da6727/yarl-1.20.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46b5e0ccf1943a9a6e766b2c2b8c732c55b34e28be57d8daa2b3c1d1d4009309", size = 358563, upload-time = "2025-06-10T00:42:57.28Z" }, + { url = "https://files.pythonhosted.org/packages/d1/90/c42eefd79d0d8222cb3227bdd51b640c0c1d0aa33fe4cc86c36eccba77d3/yarl-1.20.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:df47c55f7d74127d1b11251fe6397d84afdde0d53b90bedb46a23c0e534f9d24", size = 357609, upload-time = "2025-06-10T00:42:59.055Z" }, + { url = "https://files.pythonhosted.org/packages/03/c8/cea6b232cb4617514232e0f8a718153a95b5d82b5290711b201545825532/yarl-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76d12524d05841276b0e22573f28d5fbcb67589836772ae9244d90dd7d66aa13", size = 350224, upload-time = "2025-06-10T00:43:01.248Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a3/eaa0ab9712f1f3d01faf43cf6f1f7210ce4ea4a7e9b28b489a2261ca8db9/yarl-1.20.1-cp310-cp310-win32.whl", hash = "sha256:6c4fbf6b02d70e512d7ade4b1f998f237137f1417ab07ec06358ea04f69134f8", size = 81753, upload-time = "2025-06-10T00:43:03.486Z" }, + { url = "https://files.pythonhosted.org/packages/8f/34/e4abde70a9256465fe31c88ed02c3f8502b7b5dead693a4f350a06413f28/yarl-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef6c4d69554d44b7f9d923245f8ad9a707d971e6209d51279196d8e8fe1ae16", size = 86817, upload-time = "2025-06-10T00:43:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b1/18/893b50efc2350e47a874c5c2d67e55a0ea5df91186b2a6f5ac52eff887cd/yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e", size = 133833, upload-time = "2025-06-10T00:43:07.393Z" }, + { url = "https://files.pythonhosted.org/packages/89/ed/b8773448030e6fc47fa797f099ab9eab151a43a25717f9ac043844ad5ea3/yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b", size = 91070, upload-time = "2025-06-10T00:43:09.538Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e3/409bd17b1e42619bf69f60e4f031ce1ccb29bd7380117a55529e76933464/yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b", size = 89818, upload-time = "2025-06-10T00:43:11.575Z" }, + { url = "https://files.pythonhosted.org/packages/f8/77/64d8431a4d77c856eb2d82aa3de2ad6741365245a29b3a9543cd598ed8c5/yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4", size = 347003, upload-time = "2025-06-10T00:43:14.088Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d2/0c7e4def093dcef0bd9fa22d4d24b023788b0a33b8d0088b51aa51e21e99/yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1", size = 336537, upload-time = "2025-06-10T00:43:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f3/fc514f4b2cf02cb59d10cbfe228691d25929ce8f72a38db07d3febc3f706/yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833", size = 362358, upload-time = "2025-06-10T00:43:18.704Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6d/a313ac8d8391381ff9006ac05f1d4331cee3b1efaa833a53d12253733255/yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d", size = 357362, upload-time = "2025-06-10T00:43:20.888Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/8f78a95d6935a70263d46caa3dd18e1f223cf2f2ff2037baa01a22bc5b22/yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8", size = 348979, upload-time = "2025-06-10T00:43:23.169Z" }, + { url = "https://files.pythonhosted.org/packages/cb/05/42773027968968f4f15143553970ee36ead27038d627f457cc44bbbeecf3/yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf", size = 337274, upload-time = "2025-06-10T00:43:27.111Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/665634aa196954156741ea591d2f946f1b78ceee8bb8f28488bf28c0dd62/yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e", size = 363294, upload-time = "2025-06-10T00:43:28.96Z" }, + { url = "https://files.pythonhosted.org/packages/eb/90/73448401d36fa4e210ece5579895731f190d5119c4b66b43b52182e88cd5/yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389", size = 358169, upload-time = "2025-06-10T00:43:30.701Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b0/fce922d46dc1eb43c811f1889f7daa6001b27a4005587e94878570300881/yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f", size = 362776, upload-time = "2025-06-10T00:43:32.51Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0d/b172628fce039dae8977fd22caeff3eeebffd52e86060413f5673767c427/yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845", size = 381341, upload-time = "2025-06-10T00:43:34.543Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9b/5b886d7671f4580209e855974fe1cecec409aa4a89ea58b8f0560dc529b1/yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1", size = 379988, upload-time = "2025-06-10T00:43:36.489Z" }, + { url = "https://files.pythonhosted.org/packages/73/be/75ef5fd0fcd8f083a5d13f78fd3f009528132a1f2a1d7c925c39fa20aa79/yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e", size = 371113, upload-time = "2025-06-10T00:43:38.592Z" }, + { url = "https://files.pythonhosted.org/packages/50/4f/62faab3b479dfdcb741fe9e3f0323e2a7d5cd1ab2edc73221d57ad4834b2/yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773", size = 81485, upload-time = "2025-06-10T00:43:41.038Z" }, + { url = "https://files.pythonhosted.org/packages/f0/09/d9c7942f8f05c32ec72cd5c8e041c8b29b5807328b68b4801ff2511d4d5e/yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e", size = 86686, upload-time = "2025-06-10T00:43:42.692Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload-time = "2025-06-10T00:43:44.369Z" }, + { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload-time = "2025-06-10T00:43:46.295Z" }, + { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload-time = "2025-06-10T00:43:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287, upload-time = "2025-06-10T00:43:49.924Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429, upload-time = "2025-06-10T00:43:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429, upload-time = "2025-06-10T00:43:53.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862, upload-time = "2025-06-10T00:43:55.766Z" }, + { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616, upload-time = "2025-06-10T00:43:58.056Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954, upload-time = "2025-06-10T00:43:59.773Z" }, + { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575, upload-time = "2025-06-10T00:44:02.051Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061, upload-time = "2025-06-10T00:44:04.196Z" }, + { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142, upload-time = "2025-06-10T00:44:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894, upload-time = "2025-06-10T00:44:08.379Z" }, + { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378, upload-time = "2025-06-10T00:44:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069, upload-time = "2025-06-10T00:44:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249, upload-time = "2025-06-10T00:44:14.731Z" }, + { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, + { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, + { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, + { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, + { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, + { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, + { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, + { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, + { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, + { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, + { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, + { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, + { url = "https://files.pythonhosted.org/packages/01/75/0d37402d208d025afa6b5b8eb80e466d267d3fd1927db8e317d29a94a4cb/yarl-1.20.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e42ba79e2efb6845ebab49c7bf20306c4edf74a0b20fc6b2ccdd1a219d12fad3", size = 134259, upload-time = "2025-06-10T00:45:29.882Z" }, + { url = "https://files.pythonhosted.org/packages/73/84/1fb6c85ae0cf9901046f07d0ac9eb162f7ce6d95db541130aa542ed377e6/yarl-1.20.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:41493b9b7c312ac448b7f0a42a089dffe1d6e6e981a2d76205801a023ed26a2b", size = 91269, upload-time = "2025-06-10T00:45:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9c/eae746b24c4ea29a5accba9a06c197a70fa38a49c7df244e0d3951108861/yarl-1.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f5a5928ff5eb13408c62a968ac90d43f8322fd56d87008b8f9dabf3c0f6ee983", size = 89995, upload-time = "2025-06-10T00:45:35.066Z" }, + { url = "https://files.pythonhosted.org/packages/fb/30/693e71003ec4bc1daf2e4cf7c478c417d0985e0a8e8f00b2230d517876fc/yarl-1.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30c41ad5d717b3961b2dd785593b67d386b73feca30522048d37298fee981805", size = 325253, upload-time = "2025-06-10T00:45:37.052Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a2/5264dbebf90763139aeb0b0b3154763239398400f754ae19a0518b654117/yarl-1.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:59febc3969b0781682b469d4aca1a5cab7505a4f7b85acf6db01fa500fa3f6ba", size = 320897, upload-time = "2025-06-10T00:45:39.962Z" }, + { url = "https://files.pythonhosted.org/packages/e7/17/77c7a89b3c05856489777e922f41db79ab4faf58621886df40d812c7facd/yarl-1.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2b6fb3622b7e5bf7a6e5b679a69326b4279e805ed1699d749739a61d242449e", size = 340696, upload-time = "2025-06-10T00:45:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/6d/55/28409330b8ef5f2f681f5b478150496ec9cf3309b149dab7ec8ab5cfa3f0/yarl-1.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:749d73611db8d26a6281086f859ea7ec08f9c4c56cec864e52028c8b328db723", size = 335064, upload-time = "2025-06-10T00:45:43.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/58/cb0257cbd4002828ff735f44d3c5b6966c4fd1fc8cc1cd3cd8a143fbc513/yarl-1.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9427925776096e664c39e131447aa20ec738bdd77c049c48ea5200db2237e000", size = 327256, upload-time = "2025-06-10T00:45:46.393Z" }, + { url = "https://files.pythonhosted.org/packages/53/f6/c77960370cfa46f6fb3d6a5a79a49d3abfdb9ef92556badc2dcd2748bc2a/yarl-1.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff70f32aa316393eaf8222d518ce9118148eddb8a53073c2403863b41033eed5", size = 316389, upload-time = "2025-06-10T00:45:48.358Z" }, + { url = "https://files.pythonhosted.org/packages/64/ab/be0b10b8e029553c10905b6b00c64ecad3ebc8ace44b02293a62579343f6/yarl-1.20.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c7ddf7a09f38667aea38801da8b8d6bfe81df767d9dfc8c88eb45827b195cd1c", size = 340481, upload-time = "2025-06-10T00:45:50.663Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c3/3f327bd3905a4916029bf5feb7f86dcf864c7704f099715f62155fb386b2/yarl-1.20.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:57edc88517d7fc62b174fcfb2e939fbc486a68315d648d7e74d07fac42cec240", size = 336941, upload-time = "2025-06-10T00:45:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/d1/42/040bdd5d3b3bb02b4a6ace4ed4075e02f85df964d6e6cb321795d2a6496a/yarl-1.20.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:dab096ce479d5894d62c26ff4f699ec9072269d514b4edd630a393223f45a0ee", size = 339936, upload-time = "2025-06-10T00:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1c/911867b8e8c7463b84dfdc275e0d99b04b66ad5132b503f184fe76be8ea4/yarl-1.20.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14a85f3bd2d7bb255be7183e5d7d6e70add151a98edf56a770d6140f5d5f4010", size = 360163, upload-time = "2025-06-10T00:45:56.87Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/8c389f6c6ca0379b57b2da87f1f126c834777b4931c5ee8427dd65d0ff6b/yarl-1.20.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c89b5c792685dd9cd3fa9761c1b9f46fc240c2a3265483acc1565769996a3f8", size = 359108, upload-time = "2025-06-10T00:45:58.869Z" }, + { url = "https://files.pythonhosted.org/packages/7f/09/ae4a649fb3964324c70a3e2b61f45e566d9ffc0affd2b974cbf628957673/yarl-1.20.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:69e9b141de5511021942a6866990aea6d111c9042235de90e08f94cf972ca03d", size = 351875, upload-time = "2025-06-10T00:46:01.45Z" }, + { url = "https://files.pythonhosted.org/packages/8d/43/bbb4ed4c34d5bb62b48bf957f68cd43f736f79059d4f85225ab1ef80f4b9/yarl-1.20.1-cp39-cp39-win32.whl", hash = "sha256:b5f307337819cdfdbb40193cad84978a029f847b0a357fbe49f712063cfc4f06", size = 82293, upload-time = "2025-06-10T00:46:03.763Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cd/ce185848a7dba68ea69e932674b5c1a42a1852123584bccc5443120f857c/yarl-1.20.1-cp39-cp39-win_amd64.whl", hash = "sha256:eae7bfe2069f9c1c5b05fc7fe5d612e5bbc089a39309904ee8b829e322dcad00", size = 87385, upload-time = "2025-06-10T00:46:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, +] + +[[package]] +name = "z3-solver" +version = "4.15.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/c5481ef8e1fb64f398cb81caca0a808b4eee845091d41fb6e72bf06a9ee2/z3_solver-4.15.1.0.tar.gz", hash = "sha256:e8522602a76f6e45c45e78eec7bff5cbaa44fa51e94dce0d5432b0f9ab3f7064", size = 5054686, upload-time = "2025-06-08T18:54:41.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8b/e47ed5d6e3b565e400f2948549a9d633bdeea0eb081ddb3047bd04266d92/z3_solver-4.15.1.0-py3-none-macosx_13_0_arm64.whl", hash = "sha256:4fdf8675500f32b03114670a8c734fa9fc9f8c9bd1047d575449ca69fa397ac5", size = 37542839, upload-time = "2025-06-08T18:54:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/f0/10/b9828d71ac9a65f9ddf75a94b95f269c063dc052ccb200ecfcd81cf5557a/z3_solver-4.15.1.0-py3-none-macosx_13_0_x86_64.whl", hash = "sha256:878814bef41ca3d9957923d07fc3084967d14dff1a3c039d00f76324461bb11b", size = 40356020, upload-time = "2025-06-08T18:54:28.354Z" }, + { url = "https://files.pythonhosted.org/packages/96/95/b37b98fa23811559987e8403729093b8fae1d0c5321286667768956e31da/z3_solver-4.15.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f1d15073c78d793be56ff334f3d4770fe66d57808fdad2780e25c936d8fab0a", size = 29530873, upload-time = "2025-06-08T18:54:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/54/c9/117858dc7396435026988fb3ab59c6634887488511cc1014007a81fa3b0e/z3_solver-4.15.1.0-py3-none-manylinux_2_34_aarch64.whl", hash = "sha256:1ac01865e9b07e35b8856157fa95259b1741529c05ef019f599675c7b0caab42", size = 27517613, upload-time = "2025-06-08T18:54:33.89Z" }, + { url = "https://files.pythonhosted.org/packages/32/c2/1cb7df76d243f33f99416e9fcfefc76195cf9305e23fc9296edf6d5fb6be/z3_solver-4.15.1.0-py3-none-win32.whl", hash = "sha256:0b41c73ed6ea30514210853e31b432c3654b36e7e7a74db23906ddba345cb654", size = 13363408, upload-time = "2025-06-08T18:54:36.521Z" }, + { url = "https://files.pythonhosted.org/packages/28/ee/110ee33282331c5dab4e63bb570b345d85b2ed5ee1d30a54a987903e22fe/z3_solver-4.15.1.0-py3-none-win_amd64.whl", hash = "sha256:1d858c5b7ecd60788576ec6ae62cc7b9ae142e9ed38dff3dfd415e2fe230c712", size = 16428380, upload-time = "2025-06-08T18:54:38.872Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] + +[[package]] +name = "zope-interface" +version = "7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/93/9210e7606be57a2dfc6277ac97dcc864fd8d39f142ca194fdc186d596fda/zope.interface-7.2.tar.gz", hash = "sha256:8b49f1a3d1ee4cdaf5b32d2e738362c7f5e40ac8b46dd7d1a65e82a4872728fe", size = 252960, upload-time = "2024-11-28T08:45:39.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/71/e6177f390e8daa7e75378505c5ab974e0bf59c1d3b19155638c7afbf4b2d/zope.interface-7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce290e62229964715f1011c3dbeab7a4a1e4971fd6f31324c4519464473ef9f2", size = 208243, upload-time = "2024-11-28T08:47:29.781Z" }, + { url = "https://files.pythonhosted.org/packages/52/db/7e5f4226bef540f6d55acfd95cd105782bc6ee044d9b5587ce2c95558a5e/zope.interface-7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:05b910a5afe03256b58ab2ba6288960a2892dfeef01336dc4be6f1b9ed02ab0a", size = 208759, upload-time = "2024-11-28T08:47:31.908Z" }, + { url = "https://files.pythonhosted.org/packages/28/ea/fdd9813c1eafd333ad92464d57a4e3a82b37ae57c19497bcffa42df673e4/zope.interface-7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:550f1c6588ecc368c9ce13c44a49b8d6b6f3ca7588873c679bd8fd88a1b557b6", size = 254922, upload-time = "2024-11-28T09:18:11.795Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d3/0000a4d497ef9fbf4f66bb6828b8d0a235e690d57c333be877bec763722f/zope.interface-7.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ef9e2f865721553c6f22a9ff97da0f0216c074bd02b25cf0d3af60ea4d6931d", size = 249367, upload-time = "2024-11-28T08:48:24.238Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e5/0b359e99084f033d413419eff23ee9c2bd33bca2ca9f4e83d11856f22d10/zope.interface-7.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27f926f0dcb058211a3bb3e0e501c69759613b17a553788b2caeb991bed3b61d", size = 254488, upload-time = "2024-11-28T08:48:28.816Z" }, + { url = "https://files.pythonhosted.org/packages/7b/90/12d50b95f40e3b2fc0ba7f7782104093b9fd62806b13b98ef4e580f2ca61/zope.interface-7.2-cp310-cp310-win_amd64.whl", hash = "sha256:144964649eba4c5e4410bb0ee290d338e78f179cdbfd15813de1a664e7649b3b", size = 211947, upload-time = "2024-11-28T08:48:18.831Z" }, + { url = "https://files.pythonhosted.org/packages/98/7d/2e8daf0abea7798d16a58f2f3a2bf7588872eee54ac119f99393fdd47b65/zope.interface-7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1909f52a00c8c3dcab6c4fad5d13de2285a4b3c7be063b239b8dc15ddfb73bd2", size = 208776, upload-time = "2024-11-28T08:47:53.009Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/0c03c7170fe61d0d371e4c7ea5b62b8cb79b095b3d630ca16719bf8b7b18/zope.interface-7.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:80ecf2451596f19fd607bb09953f426588fc1e79e93f5968ecf3367550396b22", size = 209296, upload-time = "2024-11-28T08:47:57.993Z" }, + { url = "https://files.pythonhosted.org/packages/49/b4/451f19448772b4a1159519033a5f72672221e623b0a1bd2b896b653943d8/zope.interface-7.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:033b3923b63474800b04cba480b70f6e6243a62208071fc148354f3f89cc01b7", size = 260997, upload-time = "2024-11-28T09:18:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/5aa4461c10718062c8f8711161faf3249d6d3679c24a0b81dd6fc8ba1dd3/zope.interface-7.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a102424e28c6b47c67923a1f337ede4a4c2bba3965b01cf707978a801fc7442c", size = 255038, upload-time = "2024-11-28T08:48:26.381Z" }, + { url = "https://files.pythonhosted.org/packages/9f/aa/1a28c02815fe1ca282b54f6705b9ddba20328fabdc37b8cf73fc06b172f0/zope.interface-7.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25e6a61dcb184453bb00eafa733169ab6d903e46f5c2ace4ad275386f9ab327a", size = 259806, upload-time = "2024-11-28T08:48:30.78Z" }, + { url = "https://files.pythonhosted.org/packages/a7/2c/82028f121d27c7e68632347fe04f4a6e0466e77bb36e104c8b074f3d7d7b/zope.interface-7.2-cp311-cp311-win_amd64.whl", hash = "sha256:3f6771d1647b1fc543d37640b45c06b34832a943c80d1db214a37c31161a93f1", size = 212305, upload-time = "2024-11-28T08:49:14.525Z" }, + { url = "https://files.pythonhosted.org/packages/68/0b/c7516bc3bad144c2496f355e35bd699443b82e9437aa02d9867653203b4a/zope.interface-7.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:086ee2f51eaef1e4a52bd7d3111a0404081dadae87f84c0ad4ce2649d4f708b7", size = 208959, upload-time = "2024-11-28T08:47:47.788Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e9/1463036df1f78ff8c45a02642a7bf6931ae4a38a4acd6a8e07c128e387a7/zope.interface-7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:21328fcc9d5b80768bf051faa35ab98fb979080c18e6f84ab3f27ce703bce465", size = 209357, upload-time = "2024-11-28T08:47:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/07/a8/106ca4c2add440728e382f1b16c7d886563602487bdd90004788d45eb310/zope.interface-7.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6dd02ec01f4468da0f234da9d9c8545c5412fef80bc590cc51d8dd084138a89", size = 264235, upload-time = "2024-11-28T09:18:15.56Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ca/57286866285f4b8a4634c12ca1957c24bdac06eae28fd4a3a578e30cf906/zope.interface-7.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e7da17f53e25d1a3bde5da4601e026adc9e8071f9f6f936d0fe3fe84ace6d54", size = 259253, upload-time = "2024-11-28T08:48:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/96/08/2103587ebc989b455cf05e858e7fbdfeedfc3373358320e9c513428290b1/zope.interface-7.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cab15ff4832580aa440dc9790b8a6128abd0b88b7ee4dd56abacbc52f212209d", size = 264702, upload-time = "2024-11-28T08:48:37.363Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c7/3c67562e03b3752ba4ab6b23355f15a58ac2d023a6ef763caaca430f91f2/zope.interface-7.2-cp312-cp312-win_amd64.whl", hash = "sha256:29caad142a2355ce7cfea48725aa8bcf0067e2b5cc63fcf5cd9f97ad12d6afb5", size = 212466, upload-time = "2024-11-28T08:49:14.397Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3b/e309d731712c1a1866d61b5356a069dd44e5b01e394b6cb49848fa2efbff/zope.interface-7.2-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:3e0350b51e88658d5ad126c6a57502b19d5f559f6cb0a628e3dc90442b53dd98", size = 208961, upload-time = "2024-11-28T08:48:29.865Z" }, + { url = "https://files.pythonhosted.org/packages/49/65/78e7cebca6be07c8fc4032bfbb123e500d60efdf7b86727bb8a071992108/zope.interface-7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15398c000c094b8855d7d74f4fdc9e73aa02d4d0d5c775acdef98cdb1119768d", size = 209356, upload-time = "2024-11-28T08:48:33.297Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/627384b745310d082d29e3695db5f5a9188186676912c14b61a78bbc6afe/zope.interface-7.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:802176a9f99bd8cc276dcd3b8512808716492f6f557c11196d42e26c01a69a4c", size = 264196, upload-time = "2024-11-28T09:18:17.584Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f6/54548df6dc73e30ac6c8a7ff1da73ac9007ba38f866397091d5a82237bd3/zope.interface-7.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb23f58a446a7f09db85eda09521a498e109f137b85fb278edb2e34841055398", size = 259237, upload-time = "2024-11-28T08:48:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/b6/66/ac05b741c2129fdf668b85631d2268421c5cd1a9ff99be1674371139d665/zope.interface-7.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a71a5b541078d0ebe373a81a3b7e71432c61d12e660f1d67896ca62d9628045b", size = 264696, upload-time = "2024-11-28T08:48:41.161Z" }, + { url = "https://files.pythonhosted.org/packages/0a/2f/1bccc6f4cc882662162a1158cda1a7f616add2ffe322b28c99cb031b4ffc/zope.interface-7.2-cp313-cp313-win_amd64.whl", hash = "sha256:4893395d5dd2ba655c38ceb13014fd65667740f09fa5bb01caa1e6284e48c0cd", size = 212472, upload-time = "2024-11-28T08:49:56.587Z" }, + { url = "https://files.pythonhosted.org/packages/8c/2c/1f49dc8b4843c4f0848d8e43191aed312bad946a1563d1bf9e46cf2816ee/zope.interface-7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bd449c306ba006c65799ea7912adbbfed071089461a19091a228998b82b1fdb", size = 208349, upload-time = "2024-11-28T08:49:28.872Z" }, + { url = "https://files.pythonhosted.org/packages/ed/7d/83ddbfc8424c69579a90fc8edc2b797223da2a8083a94d8dfa0e374c5ed4/zope.interface-7.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a19a6cc9c6ce4b1e7e3d319a473cf0ee989cbbe2b39201d7c19e214d2dfb80c7", size = 208799, upload-time = "2024-11-28T08:49:30.616Z" }, + { url = "https://files.pythonhosted.org/packages/36/22/b1abd91854c1be03f5542fe092e6a745096d2eca7704d69432e119100583/zope.interface-7.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72cd1790b48c16db85d51fbbd12d20949d7339ad84fd971427cf00d990c1f137", size = 254267, upload-time = "2024-11-28T09:18:21.059Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dd/fcd313ee216ad0739ae00e6126bc22a0af62a74f76a9ca668d16cd276222/zope.interface-7.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52e446f9955195440e787596dccd1411f543743c359eeb26e9b2c02b077b0519", size = 248614, upload-time = "2024-11-28T08:48:41.953Z" }, + { url = "https://files.pythonhosted.org/packages/88/d4/4ba1569b856870527cec4bf22b91fe704b81a3c1a451b2ccf234e9e0666f/zope.interface-7.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ad9913fd858274db8dd867012ebe544ef18d218f6f7d1e3c3e6d98000f14b75", size = 253800, upload-time = "2024-11-28T08:48:46.637Z" }, + { url = "https://files.pythonhosted.org/packages/69/da/c9cfb384c18bd3a26d9fc6a9b5f32ccea49ae09444f097eaa5ca9814aff9/zope.interface-7.2-cp39-cp39-win_amd64.whl", hash = "sha256:1090c60116b3da3bfdd0c03406e2f14a1ff53e5771aebe33fec1edc0a350175d", size = 211980, upload-time = "2024-11-28T08:50:35.681Z" }, +] + +[[package]] +name = "zstandard" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation == 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/f6/2ac0287b442160a89d726b17a9184a4c615bb5237db763791a7fd16d9df1/zstandard-0.23.0.tar.gz", hash = "sha256:b2d8c62d08e7255f68f7a740bae85b3c9b8e5466baa9cbf7f57f1cde0ac6bc09", size = 681701, upload-time = "2024-07-15T00:18:06.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/55/bd0487e86679db1823fc9ee0d8c9c78ae2413d34c0b461193b5f4c31d22f/zstandard-0.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bf0a05b6059c0528477fba9054d09179beb63744355cab9f38059548fedd46a9", size = 788701, upload-time = "2024-07-15T00:13:27.351Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8a/ccb516b684f3ad987dfee27570d635822e3038645b1a950c5e8022df1145/zstandard-0.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fc9ca1c9718cb3b06634c7c8dec57d24e9438b2aa9a0f02b8bb36bf478538880", size = 633678, upload-time = "2024-07-15T00:13:30.24Z" }, + { url = "https://files.pythonhosted.org/packages/12/89/75e633d0611c028e0d9af6df199423bf43f54bea5007e6718ab7132e234c/zstandard-0.23.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77da4c6bfa20dd5ea25cbf12c76f181a8e8cd7ea231c673828d0386b1740b8dc", size = 4941098, upload-time = "2024-07-15T00:13:32.526Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7a/bd7f6a21802de358b63f1ee636ab823711c25ce043a3e9f043b4fcb5ba32/zstandard-0.23.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2170c7e0367dde86a2647ed5b6f57394ea7f53545746104c6b09fc1f4223573", size = 5308798, upload-time = "2024-07-15T00:13:34.925Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/775f851a4a65013e88ca559c8ae42ac1352db6fcd96b028d0df4d7d1d7b4/zstandard-0.23.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c16842b846a8d2a145223f520b7e18b57c8f476924bda92aeee3a88d11cfc391", size = 5341840, upload-time = "2024-07-15T00:13:37.376Z" }, + { url = "https://files.pythonhosted.org/packages/09/4f/0cc49570141dd72d4d95dd6fcf09328d1b702c47a6ec12fbed3b8aed18a5/zstandard-0.23.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:157e89ceb4054029a289fb504c98c6a9fe8010f1680de0201b3eb5dc20aa6d9e", size = 5440337, upload-time = "2024-07-15T00:13:39.772Z" }, + { url = "https://files.pythonhosted.org/packages/e7/7c/aaa7cd27148bae2dc095191529c0570d16058c54c4597a7d118de4b21676/zstandard-0.23.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:203d236f4c94cd8379d1ea61db2fce20730b4c38d7f1c34506a31b34edc87bdd", size = 4861182, upload-time = "2024-07-15T00:13:42.495Z" }, + { url = "https://files.pythonhosted.org/packages/ac/eb/4b58b5c071d177f7dc027129d20bd2a44161faca6592a67f8fcb0b88b3ae/zstandard-0.23.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dc5d1a49d3f8262be192589a4b72f0d03b72dcf46c51ad5852a4fdc67be7b9e4", size = 4932936, upload-time = "2024-07-15T00:13:44.234Z" }, + { url = "https://files.pythonhosted.org/packages/44/f9/21a5fb9bb7c9a274b05ad700a82ad22ce82f7ef0f485980a1e98ed6e8c5f/zstandard-0.23.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:752bf8a74412b9892f4e5b58f2f890a039f57037f52c89a740757ebd807f33ea", size = 5464705, upload-time = "2024-07-15T00:13:46.822Z" }, + { url = "https://files.pythonhosted.org/packages/49/74/b7b3e61db3f88632776b78b1db597af3f44c91ce17d533e14a25ce6a2816/zstandard-0.23.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:80080816b4f52a9d886e67f1f96912891074903238fe54f2de8b786f86baded2", size = 4857882, upload-time = "2024-07-15T00:13:49.297Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7f/d8eb1cb123d8e4c541d4465167080bec88481ab54cd0b31eb4013ba04b95/zstandard-0.23.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84433dddea68571a6d6bd4fbf8ff398236031149116a7fff6f777ff95cad3df9", size = 4697672, upload-time = "2024-07-15T00:13:51.447Z" }, + { url = "https://files.pythonhosted.org/packages/5e/05/f7dccdf3d121309b60342da454d3e706453a31073e2c4dac8e1581861e44/zstandard-0.23.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ab19a2d91963ed9e42b4e8d77cd847ae8381576585bad79dbd0a8837a9f6620a", size = 5206043, upload-time = "2024-07-15T00:13:53.587Z" }, + { url = "https://files.pythonhosted.org/packages/86/9d/3677a02e172dccd8dd3a941307621c0cbd7691d77cb435ac3c75ab6a3105/zstandard-0.23.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:59556bf80a7094d0cfb9f5e50bb2db27fefb75d5138bb16fb052b61b0e0eeeb0", size = 5667390, upload-time = "2024-07-15T00:13:56.137Z" }, + { url = "https://files.pythonhosted.org/packages/41/7e/0012a02458e74a7ba122cd9cafe491facc602c9a17f590367da369929498/zstandard-0.23.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:27d3ef2252d2e62476389ca8f9b0cf2bbafb082a3b6bfe9d90cbcbb5529ecf7c", size = 5198901, upload-time = "2024-07-15T00:13:58.584Z" }, + { url = "https://files.pythonhosted.org/packages/65/3a/8f715b97bd7bcfc7342d8adcd99a026cb2fb550e44866a3b6c348e1b0f02/zstandard-0.23.0-cp310-cp310-win32.whl", hash = "sha256:5d41d5e025f1e0bccae4928981e71b2334c60f580bdc8345f824e7c0a4c2a813", size = 430596, upload-time = "2024-07-15T00:14:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/19/b7/b2b9eca5e5a01111e4fe8a8ffb56bdcdf56b12448a24effe6cfe4a252034/zstandard-0.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:519fbf169dfac1222a76ba8861ef4ac7f0530c35dd79ba5727014613f91613d4", size = 495498, upload-time = "2024-07-15T00:14:02.741Z" }, + { url = "https://files.pythonhosted.org/packages/9e/40/f67e7d2c25a0e2dc1744dd781110b0b60306657f8696cafb7ad7579469bd/zstandard-0.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:34895a41273ad33347b2fc70e1bff4240556de3c46c6ea430a7ed91f9042aa4e", size = 788699, upload-time = "2024-07-15T00:14:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/e8/46/66d5b55f4d737dd6ab75851b224abf0afe5774976fe511a54d2eb9063a41/zstandard-0.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:77ea385f7dd5b5676d7fd943292ffa18fbf5c72ba98f7d09fc1fb9e819b34c23", size = 633681, upload-time = "2024-07-15T00:14:13.99Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/677e65c095d8e12b66b8f862b069bcf1f1d781b9c9c6f12eb55000d57583/zstandard-0.23.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:983b6efd649723474f29ed42e1467f90a35a74793437d0bc64a5bf482bedfa0a", size = 4944328, upload-time = "2024-07-15T00:14:16.588Z" }, + { url = "https://files.pythonhosted.org/packages/59/cc/e76acb4c42afa05a9d20827116d1f9287e9c32b7ad58cc3af0721ce2b481/zstandard-0.23.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80a539906390591dd39ebb8d773771dc4db82ace6372c4d41e2d293f8e32b8db", size = 5311955, upload-time = "2024-07-15T00:14:19.389Z" }, + { url = "https://files.pythonhosted.org/packages/78/e4/644b8075f18fc7f632130c32e8f36f6dc1b93065bf2dd87f03223b187f26/zstandard-0.23.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:445e4cb5048b04e90ce96a79b4b63140e3f4ab5f662321975679b5f6360b90e2", size = 5344944, upload-time = "2024-07-15T00:14:22.173Z" }, + { url = "https://files.pythonhosted.org/packages/76/3f/dbafccf19cfeca25bbabf6f2dd81796b7218f768ec400f043edc767015a6/zstandard-0.23.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd30d9c67d13d891f2360b2a120186729c111238ac63b43dbd37a5a40670b8ca", size = 5442927, upload-time = "2024-07-15T00:14:24.825Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/d24a01a19b6733b9f218e94d1a87c477d523237e07f94899e1c10f6fd06c/zstandard-0.23.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d20fd853fbb5807c8e84c136c278827b6167ded66c72ec6f9a14b863d809211c", size = 4864910, upload-time = "2024-07-15T00:14:26.982Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a9/cf8f78ead4597264f7618d0875be01f9bc23c9d1d11afb6d225b867cb423/zstandard-0.23.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ed1708dbf4d2e3a1c5c69110ba2b4eb6678262028afd6c6fbcc5a8dac9cda68e", size = 4935544, upload-time = "2024-07-15T00:14:29.582Z" }, + { url = "https://files.pythonhosted.org/packages/2c/96/8af1e3731b67965fb995a940c04a2c20997a7b3b14826b9d1301cf160879/zstandard-0.23.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:be9b5b8659dff1f913039c2feee1aca499cfbc19e98fa12bc85e037c17ec6ca5", size = 5467094, upload-time = "2024-07-15T00:14:40.126Z" }, + { url = "https://files.pythonhosted.org/packages/ff/57/43ea9df642c636cb79f88a13ab07d92d88d3bfe3e550b55a25a07a26d878/zstandard-0.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:65308f4b4890aa12d9b6ad9f2844b7ee42c7f7a4fd3390425b242ffc57498f48", size = 4860440, upload-time = "2024-07-15T00:14:42.786Z" }, + { url = "https://files.pythonhosted.org/packages/46/37/edb78f33c7f44f806525f27baa300341918fd4c4af9472fbc2c3094be2e8/zstandard-0.23.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:98da17ce9cbf3bfe4617e836d561e433f871129e3a7ac16d6ef4c680f13a839c", size = 4700091, upload-time = "2024-07-15T00:14:45.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f1/454ac3962671a754f3cb49242472df5c2cced4eb959ae203a377b45b1a3c/zstandard-0.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8ed7d27cb56b3e058d3cf684d7200703bcae623e1dcc06ed1e18ecda39fee003", size = 5208682, upload-time = "2024-07-15T00:14:47.407Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/1734b0fff1634390b1b887202d557d2dd542de84a4c155c258cf75da4773/zstandard-0.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:b69bb4f51daf461b15e7b3db033160937d3ff88303a7bc808c67bbc1eaf98c78", size = 5669707, upload-time = "2024-07-15T00:15:03.529Z" }, + { url = "https://files.pythonhosted.org/packages/52/5a/87d6971f0997c4b9b09c495bf92189fb63de86a83cadc4977dc19735f652/zstandard-0.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:034b88913ecc1b097f528e42b539453fa82c3557e414b3de9d5632c80439a473", size = 5201792, upload-time = "2024-07-15T00:15:28.372Z" }, + { url = "https://files.pythonhosted.org/packages/79/02/6f6a42cc84459d399bd1a4e1adfc78d4dfe45e56d05b072008d10040e13b/zstandard-0.23.0-cp311-cp311-win32.whl", hash = "sha256:f2d4380bf5f62daabd7b751ea2339c1a21d1c9463f1feb7fc2bdcea2c29c3160", size = 430586, upload-time = "2024-07-15T00:15:32.26Z" }, + { url = "https://files.pythonhosted.org/packages/be/a2/4272175d47c623ff78196f3c10e9dc7045c1b9caf3735bf041e65271eca4/zstandard-0.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:62136da96a973bd2557f06ddd4e8e807f9e13cbb0bfb9cc06cfe6d98ea90dfe0", size = 495420, upload-time = "2024-07-15T00:15:34.004Z" }, + { url = "https://files.pythonhosted.org/packages/7b/83/f23338c963bd9de687d47bf32efe9fd30164e722ba27fb59df33e6b1719b/zstandard-0.23.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b4567955a6bc1b20e9c31612e615af6b53733491aeaa19a6b3b37f3b65477094", size = 788713, upload-time = "2024-07-15T00:15:35.815Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b3/1a028f6750fd9227ee0b937a278a434ab7f7fdc3066c3173f64366fe2466/zstandard-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e172f57cd78c20f13a3415cc8dfe24bf388614324d25539146594c16d78fcc8", size = 633459, upload-time = "2024-07-15T00:15:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/26/af/36d89aae0c1f95a0a98e50711bc5d92c144939efc1f81a2fcd3e78d7f4c1/zstandard-0.23.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0e166f698c5a3e914947388c162be2583e0c638a4703fc6a543e23a88dea3c1", size = 4945707, upload-time = "2024-07-15T00:15:39.872Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2e/2051f5c772f4dfc0aae3741d5fc72c3dcfe3aaeb461cc231668a4db1ce14/zstandard-0.23.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12a289832e520c6bd4dcaad68e944b86da3bad0d339ef7989fb7e88f92e96072", size = 5306545, upload-time = "2024-07-15T00:15:41.75Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9e/a11c97b087f89cab030fa71206963090d2fecd8eb83e67bb8f3ffb84c024/zstandard-0.23.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d50d31bfedd53a928fed6707b15a8dbeef011bb6366297cc435accc888b27c20", size = 5337533, upload-time = "2024-07-15T00:15:44.114Z" }, + { url = "https://files.pythonhosted.org/packages/fc/79/edeb217c57fe1bf16d890aa91a1c2c96b28c07b46afed54a5dcf310c3f6f/zstandard-0.23.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72c68dda124a1a138340fb62fa21b9bf4848437d9ca60bd35db36f2d3345f373", size = 5436510, upload-time = "2024-07-15T00:15:46.509Z" }, + { url = "https://files.pythonhosted.org/packages/81/4f/c21383d97cb7a422ddf1ae824b53ce4b51063d0eeb2afa757eb40804a8ef/zstandard-0.23.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53dd9d5e3d29f95acd5de6802e909ada8d8d8cfa37a3ac64836f3bc4bc5512db", size = 4859973, upload-time = "2024-07-15T00:15:49.939Z" }, + { url = "https://files.pythonhosted.org/packages/ab/15/08d22e87753304405ccac8be2493a495f529edd81d39a0870621462276ef/zstandard-0.23.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:6a41c120c3dbc0d81a8e8adc73312d668cd34acd7725f036992b1b72d22c1772", size = 4936968, upload-time = "2024-07-15T00:15:52.025Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fa/f3670a597949fe7dcf38119a39f7da49a8a84a6f0b1a2e46b2f71a0ab83f/zstandard-0.23.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:40b33d93c6eddf02d2c19f5773196068d875c41ca25730e8288e9b672897c105", size = 5467179, upload-time = "2024-07-15T00:15:54.971Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a9/dad2ab22020211e380adc477a1dbf9f109b1f8d94c614944843e20dc2a99/zstandard-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9206649ec587e6b02bd124fb7799b86cddec350f6f6c14bc82a2b70183e708ba", size = 4848577, upload-time = "2024-07-15T00:15:57.634Z" }, + { url = "https://files.pythonhosted.org/packages/08/03/dd28b4484b0770f1e23478413e01bee476ae8227bbc81561f9c329e12564/zstandard-0.23.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76e79bc28a65f467e0409098fa2c4376931fd3207fbeb6b956c7c476d53746dd", size = 4693899, upload-time = "2024-07-15T00:16:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/2b/64/3da7497eb635d025841e958bcd66a86117ae320c3b14b0ae86e9e8627518/zstandard-0.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:66b689c107857eceabf2cf3d3fc699c3c0fe8ccd18df2219d978c0283e4c508a", size = 5199964, upload-time = "2024-07-15T00:16:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/43/a4/d82decbab158a0e8a6ebb7fc98bc4d903266bce85b6e9aaedea1d288338c/zstandard-0.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9c236e635582742fee16603042553d276cca506e824fa2e6489db04039521e90", size = 5655398, upload-time = "2024-07-15T00:16:06.694Z" }, + { url = "https://files.pythonhosted.org/packages/f2/61/ac78a1263bc83a5cf29e7458b77a568eda5a8f81980691bbc6eb6a0d45cc/zstandard-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8fffdbd9d1408006baaf02f1068d7dd1f016c6bcb7538682622c556e7b68e35", size = 5191313, upload-time = "2024-07-15T00:16:09.758Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/967c478314e16af5baf849b6ee9d6ea724ae5b100eb506011f045d3d4e16/zstandard-0.23.0-cp312-cp312-win32.whl", hash = "sha256:dc1d33abb8a0d754ea4763bad944fd965d3d95b5baef6b121c0c9013eaf1907d", size = 430877, upload-time = "2024-07-15T00:16:11.758Z" }, + { url = "https://files.pythonhosted.org/packages/75/37/872d74bd7739639c4553bf94c84af7d54d8211b626b352bc57f0fd8d1e3f/zstandard-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:64585e1dba664dc67c7cdabd56c1e5685233fbb1fc1966cfba2a340ec0dfff7b", size = 495595, upload-time = "2024-07-15T00:16:13.731Z" }, + { url = "https://files.pythonhosted.org/packages/80/f1/8386f3f7c10261fe85fbc2c012fdb3d4db793b921c9abcc995d8da1b7a80/zstandard-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:576856e8594e6649aee06ddbfc738fec6a834f7c85bf7cadd1c53d4a58186ef9", size = 788975, upload-time = "2024-07-15T00:16:16.005Z" }, + { url = "https://files.pythonhosted.org/packages/16/e8/cbf01077550b3e5dc86089035ff8f6fbbb312bc0983757c2d1117ebba242/zstandard-0.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38302b78a850ff82656beaddeb0bb989a0322a8bbb1bf1ab10c17506681d772a", size = 633448, upload-time = "2024-07-15T00:16:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/06/27/4a1b4c267c29a464a161aeb2589aff212b4db653a1d96bffe3598f3f0d22/zstandard-0.23.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2240ddc86b74966c34554c49d00eaafa8200a18d3a5b6ffbf7da63b11d74ee2", size = 4945269, upload-time = "2024-07-15T00:16:20.136Z" }, + { url = "https://files.pythonhosted.org/packages/7c/64/d99261cc57afd9ae65b707e38045ed8269fbdae73544fd2e4a4d50d0ed83/zstandard-0.23.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ef230a8fd217a2015bc91b74f6b3b7d6522ba48be29ad4ea0ca3a3775bf7dd5", size = 5306228, upload-time = "2024-07-15T00:16:23.398Z" }, + { url = "https://files.pythonhosted.org/packages/7a/cf/27b74c6f22541f0263016a0fd6369b1b7818941de639215c84e4e94b2a1c/zstandard-0.23.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:774d45b1fac1461f48698a9d4b5fa19a69d47ece02fa469825b442263f04021f", size = 5336891, upload-time = "2024-07-15T00:16:26.391Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/89ac62eac46b69948bf35fcd90d37103f38722968e2981f752d69081ec4d/zstandard-0.23.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f77fa49079891a4aab203d0b1744acc85577ed16d767b52fc089d83faf8d8ed", size = 5436310, upload-time = "2024-07-15T00:16:29.018Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a8/5ca5328ee568a873f5118d5b5f70d1f36c6387716efe2e369010289a5738/zstandard-0.23.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac184f87ff521f4840e6ea0b10c0ec90c6b1dcd0bad2f1e4a9a1b4fa177982ea", size = 4859912, upload-time = "2024-07-15T00:16:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ca/3781059c95fd0868658b1cf0440edd832b942f84ae60685d0cfdb808bca1/zstandard-0.23.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c363b53e257246a954ebc7c488304b5592b9c53fbe74d03bc1c64dda153fb847", size = 4936946, upload-time = "2024-07-15T00:16:34.593Z" }, + { url = "https://files.pythonhosted.org/packages/ce/11/41a58986f809532742c2b832c53b74ba0e0a5dae7e8ab4642bf5876f35de/zstandard-0.23.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e7792606d606c8df5277c32ccb58f29b9b8603bf83b48639b7aedf6df4fe8171", size = 5466994, upload-time = "2024-07-15T00:16:36.887Z" }, + { url = "https://files.pythonhosted.org/packages/83/e3/97d84fe95edd38d7053af05159465d298c8b20cebe9ccb3d26783faa9094/zstandard-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a0817825b900fcd43ac5d05b8b3079937073d2b1ff9cf89427590718b70dd840", size = 4848681, upload-time = "2024-07-15T00:16:39.709Z" }, + { url = "https://files.pythonhosted.org/packages/6e/99/cb1e63e931de15c88af26085e3f2d9af9ce53ccafac73b6e48418fd5a6e6/zstandard-0.23.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9da6bc32faac9a293ddfdcb9108d4b20416219461e4ec64dfea8383cac186690", size = 4694239, upload-time = "2024-07-15T00:16:41.83Z" }, + { url = "https://files.pythonhosted.org/packages/ab/50/b1e703016eebbc6501fc92f34db7b1c68e54e567ef39e6e59cf5fb6f2ec0/zstandard-0.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fd7699e8fd9969f455ef2926221e0233f81a2542921471382e77a9e2f2b57f4b", size = 5200149, upload-time = "2024-07-15T00:16:44.287Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e0/932388630aaba70197c78bdb10cce2c91fae01a7e553b76ce85471aec690/zstandard-0.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d477ed829077cd945b01fc3115edd132c47e6540ddcd96ca169facff28173057", size = 5655392, upload-time = "2024-07-15T00:16:46.423Z" }, + { url = "https://files.pythonhosted.org/packages/02/90/2633473864f67a15526324b007a9f96c96f56d5f32ef2a56cc12f9548723/zstandard-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ce8b52c5987b3e34d5674b0ab529a4602b632ebab0a93b07bfb4dfc8f8a33", size = 5191299, upload-time = "2024-07-15T00:16:49.053Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4c/315ca5c32da7e2dc3455f3b2caee5c8c2246074a61aac6ec3378a97b7136/zstandard-0.23.0-cp313-cp313-win32.whl", hash = "sha256:a9b07268d0c3ca5c170a385a0ab9fb7fdd9f5fd866be004c4ea39e44edce47dd", size = 430862, upload-time = "2024-07-15T00:16:51.003Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/c6aaba098e2d04781e8f4f7c0ba3c7aa73d00e4c436bcc0cf059a66691d1/zstandard-0.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:f3513916e8c645d0610815c257cbfd3242adfd5c4cfa78be514e5a3ebb42a41b", size = 495578, upload-time = "2024-07-15T00:16:53.135Z" }, + { url = "https://files.pythonhosted.org/packages/fb/96/4fcafeb7e013a2386d22f974b5b97a0b9a65004ed58c87ae001599bfbd48/zstandard-0.23.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3aa014d55c3af933c1315eb4bb06dd0459661cc0b15cd61077afa6489bec63bb", size = 788697, upload-time = "2024-07-15T00:17:31.236Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/a52ce725be69b86a2967ecba0497a8184540cc284c0991125515449e54e2/zstandard-0.23.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7f0804bb3799414af278e9ad51be25edf67f78f916e08afdb983e74161b916", size = 633679, upload-time = "2024-07-15T00:17:32.911Z" }, + { url = "https://files.pythonhosted.org/packages/34/0f/3dc62db122f6a9c481c335fff6fc9f4e88d8f6e2d47321ee3937328addb4/zstandard-0.23.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb2b1ecfef1e67897d336de3a0e3f52478182d6a47eda86cbd42504c5cbd009a", size = 4940416, upload-time = "2024-07-15T00:17:34.849Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e5/9fe0dd8c85fdc2f635e6660d07872a5dc4b366db566630161e39f9f804e1/zstandard-0.23.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:837bb6764be6919963ef41235fd56a6486b132ea64afe5fafb4cb279ac44f259", size = 5307693, upload-time = "2024-07-15T00:17:37.355Z" }, + { url = "https://files.pythonhosted.org/packages/73/bf/fe62c0cd865c171ee8ed5bc83174b5382a2cb729c8d6162edfb99a83158b/zstandard-0.23.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1516c8c37d3a053b01c1c15b182f3b5f5eef19ced9b930b684a73bad121addf4", size = 5341236, upload-time = "2024-07-15T00:17:40.213Z" }, + { url = "https://files.pythonhosted.org/packages/39/86/4fe79b30c794286110802a6cd44a73b6a314ac8196b9338c0fbd78c2407d/zstandard-0.23.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48ef6a43b1846f6025dde6ed9fee0c24e1149c1c25f7fb0a0585572b2f3adc58", size = 5439101, upload-time = "2024-07-15T00:17:42.284Z" }, + { url = "https://files.pythonhosted.org/packages/72/ed/cacec235c581ebf8c608c7fb3d4b6b70d1b490d0e5128ea6996f809ecaef/zstandard-0.23.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11e3bf3c924853a2d5835b24f03eeba7fc9b07d8ca499e247e06ff5676461a15", size = 4860320, upload-time = "2024-07-15T00:17:44.21Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1e/2c589a2930f93946b132fc852c574a19d5edc23fad2b9e566f431050c7ec/zstandard-0.23.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2fb4535137de7e244c230e24f9d1ec194f61721c86ebea04e1581d9d06ea1269", size = 4931933, upload-time = "2024-07-15T00:17:46.455Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f5/30eadde3686d902b5d4692bb5f286977cbc4adc082145eb3f49d834b2eae/zstandard-0.23.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8c24f21fa2af4bb9f2c492a86fe0c34e6d2c63812a839590edaf177b7398f700", size = 5463878, upload-time = "2024-07-15T00:17:48.866Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c8/8aed1f0ab9854ef48e5ad4431367fcb23ce73f0304f7b72335a8edc66556/zstandard-0.23.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a8c86881813a78a6f4508ef9daf9d4995b8ac2d147dcb1a450448941398091c9", size = 4857192, upload-time = "2024-07-15T00:17:51.558Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/55e666cfbcd032b9e271865e8578fec56e5594d4faeac379d371526514f5/zstandard-0.23.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fe3b385d996ee0822fd46528d9f0443b880d4d05528fd26a9119a54ec3f91c69", size = 4696513, upload-time = "2024-07-15T00:17:53.924Z" }, + { url = "https://files.pythonhosted.org/packages/dc/bd/720b65bea63ec9de0ac7414c33b9baf271c8de8996e5ff324dc93fc90ff1/zstandard-0.23.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:82d17e94d735c99621bf8ebf9995f870a6b3e6d14543b99e201ae046dfe7de70", size = 5204823, upload-time = "2024-07-15T00:17:55.948Z" }, + { url = "https://files.pythonhosted.org/packages/d8/40/d678db1556e3941d330cd4e95623a63ef235b18547da98fa184cbc028ecf/zstandard-0.23.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c7c517d74bea1a6afd39aa612fa025e6b8011982a0897768a2f7c8ab4ebb78a2", size = 5666490, upload-time = "2024-07-15T00:17:58.327Z" }, + { url = "https://files.pythonhosted.org/packages/ed/cc/c89329723d7515898a1fc7ef5d251264078548c505719d13e9511800a103/zstandard-0.23.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fd7e0f1cfb70eb2f95a19b472ee7ad6d9a0a992ec0ae53286870c104ca939e5", size = 5196622, upload-time = "2024-07-15T00:18:00.404Z" }, + { url = "https://files.pythonhosted.org/packages/78/4c/634289d41e094327a94500dfc919e58841b10ea3a9efdfafbac614797ec2/zstandard-0.23.0-cp39-cp39-win32.whl", hash = "sha256:43da0f0092281bf501f9c5f6f3b4c975a8a0ea82de49ba3f7100e64d422a1274", size = 430620, upload-time = "2024-07-15T00:18:02.613Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e2/0b0c5a0f4f7699fecd92c1ba6278ef9b01f2b0b0dd46f62bfc6729c05659/zstandard-0.23.0-cp39-cp39-win_amd64.whl", hash = "sha256:f8346bfa098532bc1fb6c7ef06783e969d87a99dd1d2a5a18a892c1d7a643c58", size = 495528, upload-time = "2024-07-15T00:18:04.452Z" }, +] From c1215293c2530aabca3fa89e7b9ae345bd7d4e99 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Sun, 22 Jun 2025 20:41:36 +0200 Subject: [PATCH 074/108] Various improvements on the django path --- .../channels/handlers/http_handler.py | 6 +++--- src/graphql_server/channels/router.py | 16 ++++++++++++++-- src/graphql_server/django/__init__.py | 3 +++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/graphql_server/channels/handlers/http_handler.py b/src/graphql_server/channels/handlers/http_handler.py index 15a2e51..3245430 100644 --- a/src/graphql_server/channels/handlers/http_handler.py +++ b/src/graphql_server/channels/handlers/http_handler.py @@ -244,17 +244,17 @@ class GraphQLHTTPConsumer( To use this, place it in your ProtocolTypeRouter for your channels project: ``` - from graphql_server.channels import GraphQLHttpRouter + 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", GraphQLHTTPRouter(schema=schema)), + re_path("^graphql", GraphQLHTTPConsumer.as_asgi(schema=schema)), re_path("^", get_asgi_application()), ]), "websocket": URLRouter([ - re_path("^ws/graphql", GraphQLWebSocketRouter(schema=schema)), + re_path("^ws/graphql", GraphQLWSConsumer.as_asgi(schema=schema)), ]), }) ``` diff --git a/src/graphql_server/channels/router.py b/src/graphql_server/channels/router.py index 0ebf00f..4694d99 100644 --- a/src/graphql_server/channels/router.py +++ b/src/graphql_server/channels/router.py @@ -49,8 +49,17 @@ def __init__( 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, GraphQLHTTPConsumer.as_asgi(schema=schema))] + 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)) @@ -59,7 +68,10 @@ def __init__( "http": URLRouter(http_urls), "websocket": URLRouter( [ - re_path(url_pattern, GraphQLWSConsumer.as_asgi(schema=schema)), + re_path( + url_pattern, + ws_consumer_class.as_asgi(*args, **kwargs), + ), ] ), } diff --git a/src/graphql_server/django/__init__.py b/src/graphql_server/django/__init__.py index e69de29..a95776c 100644 --- a/src/graphql_server/django/__init__.py +++ b/src/graphql_server/django/__init__.py @@ -0,0 +1,3 @@ +from .views import GraphQLView, AsyncGraphQLView + +__all__ = ["GraphQLView", "AsyncGraphQLView"] From 7795355290955b26c96dc9fc54d6e08d10713c98 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Sun, 22 Jun 2025 23:15:00 +0200 Subject: [PATCH 075/108] Added GraphQLRequestData argument to the render_graphql_ide method --- src/graphql_server/aiohttp/views.py | 5 +- src/graphql_server/asgi/__init__.py | 5 +- src/graphql_server/chalice/views.py | 5 +- .../channels/handlers/http_handler.py | 9 +++- .../channels/handlers/ws_handler.py | 5 +- src/graphql_server/django/views.py | 9 +++- src/graphql_server/fastapi/router.py | 5 +- src/graphql_server/flask/views.py | 9 +++- src/graphql_server/http/async_base_view.py | 54 ++++++++++++------- src/graphql_server/http/sync_base_view.py | 48 +++++++++++------ src/graphql_server/litestar/controller.py | 3 +- src/graphql_server/quart/views.py | 5 +- src/graphql_server/sanic/views.py | 5 +- 13 files changed, 116 insertions(+), 51 deletions(-) diff --git a/src/graphql_server/aiohttp/views.py b/src/graphql_server/aiohttp/views.py index 93aa01d..fd6f6b3 100644 --- a/src/graphql_server/aiohttp/views.py +++ b/src/graphql_server/aiohttp/views.py @@ -28,6 +28,7 @@ NonTextMessageReceived, WebSocketDisconnected, ) +from graphql_server.http import GraphQLRequestData from graphql_server.http.types import FormData, HTTPMethod, QueryParams from graphql_server.http.typevars import ( Context, @@ -174,7 +175,9 @@ def __init__( else: self.graphql_ide = graphql_ide - async def render_graphql_ide(self, request: web.Request) -> web.Response: + async def render_graphql_ide( + self, request: web.Request, request_data: GraphQLRequestData + ) -> web.Response: return web.Response(text=self.graphql_ide_html, content_type="text/html") async def get_sub_response(self, request: web.Request) -> web.Response: diff --git a/src/graphql_server/asgi/__init__.py b/src/graphql_server/asgi/__init__.py index 2ad6223..a47ad61 100644 --- a/src/graphql_server/asgi/__init__.py +++ b/src/graphql_server/asgi/__init__.py @@ -22,6 +22,7 @@ ) from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState +from graphql_server.http import GraphQLRequestData from graphql_server.http.async_base_view import ( AsyncBaseHTTPView, AsyncHTTPRequestAdapter, @@ -204,7 +205,9 @@ async def get_sub_response( return sub_response - async def render_graphql_ide(self, request: Request) -> Response: + async def render_graphql_ide( + self, request: Request, request_data: GraphQLRequestData + ) -> Response: return HTMLResponse(self.graphql_ide_html) def create_response( diff --git a/src/graphql_server/chalice/views.py b/src/graphql_server/chalice/views.py index c3d8361..88608f9 100644 --- a/src/graphql_server/chalice/views.py +++ b/src/graphql_server/chalice/views.py @@ -4,6 +4,7 @@ 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 @@ -80,7 +81,9 @@ def __init__( def get_root_value(self, request: Request) -> Optional[RootValue]: return None - def render_graphql_ide(self, request: Request) -> Response: + def render_graphql_ide( + self, request: Request, request_data: GraphQLRequestData + ) -> Response: return Response( self.graphql_ide_html, headers={"Content-Type": "text/html"}, diff --git a/src/graphql_server/channels/handlers/http_handler.py b/src/graphql_server/channels/handlers/http_handler.py index 3245430..43daf5d 100644 --- a/src/graphql_server/channels/handlers/http_handler.py +++ b/src/graphql_server/channels/handlers/http_handler.py @@ -25,6 +25,7 @@ AsyncBaseHTTPView, AsyncHTTPRequestAdapter, ) +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 @@ -295,7 +296,9 @@ async def create_streaming_response( stream=stream, status=status, headers=response_headers ) - async def render_graphql_ide(self, request: ChannelsRequest) -> ChannelsResponse: + async def render_graphql_ide( + self, request: ChannelsRequest, request_data: GraphQLRequestData + ) -> ChannelsResponse: return ChannelsResponse( content=self.graphql_ide_html.encode(), content_type="text/html; charset=utf-8", @@ -351,7 +354,9 @@ def get_context( def get_sub_response(self, request: ChannelsRequest) -> TemporalResponse: return TemporalResponse() - def render_graphql_ide(self, request: ChannelsRequest) -> ChannelsResponse: + def render_graphql_ide( + self, request: ChannelsRequest, request_data: GraphQLRequestData + ) -> ChannelsResponse: return ChannelsResponse( content=self.graphql_ide_html.encode(), content_type="text/html; charset=utf-8", diff --git a/src/graphql_server/channels/handlers/ws_handler.py b/src/graphql_server/channels/handlers/ws_handler.py index 7187f8f..c7bc906 100644 --- a/src/graphql_server/channels/handlers/ws_handler.py +++ b/src/graphql_server/channels/handlers/ws_handler.py @@ -11,6 +11,7 @@ ) 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, @@ -175,7 +176,9 @@ def create_response( ) -> GraphQLWSConsumer: raise NotImplementedError - async def render_graphql_ide(self, request: GraphQLWSConsumer) -> GraphQLWSConsumer: + async def render_graphql_ide( + self, request: GraphQLWSConsumer, request_data: GraphQLRequestData + ) -> GraphQLWSConsumer: raise NotImplementedError def is_websocket_request( diff --git a/src/graphql_server/django/views.py b/src/graphql_server/django/views.py index 1a7d8f9..12218fb 100644 --- a/src/graphql_server/django/views.py +++ b/src/graphql_server/django/views.py @@ -27,6 +27,7 @@ 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, @@ -240,7 +241,9 @@ def dispatch( status=e.status_code, ) - def render_graphql_ide(self, request: HttpRequest) -> HttpResponse: + def render_graphql_ide( + self, request: HttpRequest, request_data: GraphQLRequestData + ) -> HttpResponse: try: content = render_to_string("graphql/graphiql.html", request=request) except TemplateDoesNotExist: @@ -300,7 +303,9 @@ async def dispatch( # pyright: ignore status=e.status_code, ) - async def render_graphql_ide(self, request: HttpRequest) -> HttpResponse: + async def render_graphql_ide( + self, request: HttpRequest, request_data: GraphQLRequestData + ) -> HttpResponse: try: content = render_to_string("graphql/graphiql.html", request=request) except TemplateDoesNotExist: diff --git a/src/graphql_server/fastapi/router.py b/src/graphql_server/fastapi/router.py index 472c484..38d08fa 100644 --- a/src/graphql_server/fastapi/router.py +++ b/src/graphql_server/fastapi/router.py @@ -29,6 +29,7 @@ from fastapi.datastructures import Default from fastapi.routing import APIRoute from fastapi.utils import generate_unique_id +from graphql_server.http import GraphQLRequestData from graphql_server.asgi import ASGIRequestAdapter, ASGIWebSocketAdapter from graphql_server.fastapi.context import BaseContext, CustomContext from graphql_server.http.async_base_view import AsyncBaseHTTPView @@ -261,7 +262,9 @@ async def websocket_endpoint( # pyright: ignore ) -> None: await self.run(request=websocket, context=context, root_value=root_value) - async def render_graphql_ide(self, request: Request) -> HTMLResponse: + async def render_graphql_ide( + self, request: Request, request_data: GraphQLRequestData + ) -> HTMLResponse: return HTMLResponse(self.graphql_ide_html) async def get_context( diff --git a/src/graphql_server/flask/views.py b/src/graphql_server/flask/views.py index 8ea0578..a1ac778 100644 --- a/src/graphql_server/flask/views.py +++ b/src/graphql_server/flask/views.py @@ -13,6 +13,7 @@ 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, @@ -129,7 +130,9 @@ def dispatch_request(self) -> ResponseReturnValue: status=e.status_code, ) - def render_graphql_ide(self, request: Request) -> Response: + def render_graphql_ide( + self, request: Request, request_data: GraphQLRequestData + ) -> Response: return render_template_string(self.graphql_ide_html) # type: ignore @@ -192,7 +195,9 @@ async def dispatch_request(self) -> ResponseReturnValue: # type: ignore status=e.status_code, ) - async def render_graphql_ide(self, request: Request) -> Response: + async def render_graphql_ide( + self, request: Request, request_data: GraphQLRequestData + ) -> Response: content = render_template_string(self.graphql_ide_html) return Response(content, status=200, content_type="text/html") diff --git a/src/graphql_server/http/async_base_view.py b/src/graphql_server/http/async_base_view.py index 9038aaf..541bd65 100644 --- a/src/graphql_server/http/async_base_view.py +++ b/src/graphql_server/http/async_base_view.py @@ -16,7 +16,7 @@ from typing_extensions import Literal, TypeGuard from graphql import ExecutionResult, GraphQLError -from graphql.language import OperationType +from graphql.language import OperationType, DocumentNode from graphql.type import GraphQLSchema from graphql_server import execute, subscribe @@ -171,7 +171,9 @@ def create_response( ) -> Response: ... @abc.abstractmethod - async def render_graphql_ide(self, request: Request) -> Response: ... + async def render_graphql_ide( + self, request: Request, request_data: GraphQLRequestData + ) -> Response: ... async def create_streaming_response( self, @@ -198,18 +200,14 @@ async def create_websocket_response( ) -> WebSocketResponse: ... async def execute_operation( - self, request: Request, context: Context, root_value: Optional[RootValue] + self, + request: Request, + request_data: GraphQLRequestData, + context: Context, + root_value: Optional[RootValue], ) -> ExecutionResult: request_adapter = self.request_adapter_class(request) - try: - request_data = await self.parse_http_body(request_adapter) - 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 - allowed_operation_types = operation_type_from_http(request_adapter.method) if not self.allow_queries_via_get and request_adapter.method == "GET": @@ -343,14 +341,25 @@ async def run( 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) + 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 self.should_render_graphql_ide(request_adapter): if self.graphql_ide: - return await self.render_graphql_ide(request) + return await self.render_graphql_ide(request, request_data) raise HTTPException(404, "Not Found") try: result = await self.execute_operation( - request=request, context=context, root_value=root_value + request=request, + request_data=request_data, + context=context, + root_value=root_value, ) except GraphQLValidationError as e: result = ExecutionResult(data=None, errors=e.errors) @@ -529,6 +538,17 @@ async def parse_multipart_subscriptions( return self.parse_json(await request.get_body()) + async def get_graphql_request_data( + self, data: dict[str, Any], protocol: Literal["http", "multipart-subscription"] + ) -> GraphQLRequestData: + return GraphQLRequestData( + query=data.get("query"), + variables=data.get("variables"), + operation_name=data.get("operationName"), + extensions=data.get("extensions"), + protocol=protocol, + ) + async def parse_http_body( self, request: AsyncHTTPRequestAdapter ) -> GraphQLRequestData: @@ -550,13 +570,7 @@ async def parse_http_body( else: raise HTTPException(400, "Unsupported content type") - return GraphQLRequestData( - query=data.get("query"), - variables=data.get("variables"), - operation_name=data.get("operationName"), - extensions=data.get("extensions"), - protocol=protocol, - ) + return await self.get_graphql_request_data(data, protocol) async def process_result( self, request: Request, result: ExecutionResult diff --git a/src/graphql_server/http/sync_base_view.py b/src/graphql_server/http/sync_base_view.py index 4117a95..22825eb 100644 --- a/src/graphql_server/http/sync_base_view.py +++ b/src/graphql_server/http/sync_base_view.py @@ -7,6 +7,7 @@ Generic, Optional, Union, + Literal, ) from graphql import ExecutionResult, GraphQLError @@ -93,21 +94,19 @@ def create_response( ) -> Response: ... @abc.abstractmethod - def render_graphql_ide(self, request: Request) -> Response: ... + def render_graphql_ide( + self, request: Request, request_data: GraphQLRequestData + ) -> Response: ... def execute_operation( - self, request: Request, context: Context, root_value: Optional[RootValue] + self, + request: Request, + request_data: GraphQLRequestData, + context: Context, + root_value: Optional[RootValue], ) -> ExecutionResult: request_adapter = self.request_adapter_class(request) - try: - request_data = self.parse_http_body(request_adapter) - 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 - allowed_operation_types = operation_type_from_http(request_adapter.method) if not self.allow_queries_via_get and request_adapter.method == "GET": @@ -135,6 +134,17 @@ def parse_multipart(self, request: SyncHTTPRequestAdapter) -> dict[str, str]: except KeyError as e: raise HTTPException(400, "File(s) missing in form data") from e + def get_graphql_request_data( + self, data: dict[str, Any], protocol: Literal["http", "multipart-subscription"] + ) -> GraphQLRequestData: + return GraphQLRequestData( + query=data.get("query"), + variables=data.get("variables"), + operation_name=data.get("operationName"), + extensions=data.get("extensions"), + protocol=protocol, + ) + def parse_http_body(self, request: SyncHTTPRequestAdapter) -> GraphQLRequestData: content_type, params = parse_content_type(request.content_type or "") @@ -152,12 +162,7 @@ def parse_http_body(self, request: SyncHTTPRequestAdapter) -> GraphQLRequestData else: raise HTTPException(400, "Unsupported content type") - return GraphQLRequestData( - query=data.get("query"), - variables=data.get("variables"), - operation_name=data.get("operationName"), - extensions=data.get("extensions"), - ) + return self.get_graphql_request_data(data, "http") def _handle_errors( self, errors: list[GraphQLError], response_data: GraphQLHTTPResponse @@ -175,9 +180,17 @@ def run( if not self.is_request_allowed(request_adapter): raise HTTPException(405, "GraphQL only supports GET and POST requests.") + try: + request_data = self.parse_http_body(request_adapter) + 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 self.should_render_graphql_ide(request_adapter): if self.graphql_ide: - return self.render_graphql_ide(request) + return self.render_graphql_ide(request, request_data) raise HTTPException(404, "Not Found") sub_response = self.get_sub_response(request) @@ -191,6 +204,7 @@ def run( try: result = self.execute_operation( request=request, + request_data=request_data, context=context, root_value=root_value, ) diff --git a/src/graphql_server/litestar/controller.py b/src/graphql_server/litestar/controller.py index 5f5ccd1..d3c91ba 100644 --- a/src/graphql_server/litestar/controller.py +++ b/src/graphql_server/litestar/controller.py @@ -19,6 +19,7 @@ from msgspec import Struct +from graphql_server.http import GraphQLRequestData from graphql_server.http.async_base_view import ( AsyncBaseHTTPView, AsyncHTTPRequestAdapter, @@ -300,7 +301,7 @@ async def execute_request( ) async def render_graphql_ide( - self, request: Request[Any, Any, Any] + self, request: Request[Any, Any, Any], request_data: GraphQLRequestData ) -> Response[str]: return Response(self.graphql_ide_html, media_type=MediaType.HTML) diff --git a/src/graphql_server/quart/views.py b/src/graphql_server/quart/views.py index 783b3f4..c0606a8 100644 --- a/src/graphql_server/quart/views.py +++ b/src/graphql_server/quart/views.py @@ -6,6 +6,7 @@ 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, @@ -149,7 +150,9 @@ def __init__( else: self.graphql_ide = graphql_ide - async def render_graphql_ide(self, request: Request) -> Response: + async def render_graphql_ide( + self, request: Request, request_data: GraphQLRequestData + ) -> Response: return Response(self.graphql_ide_html) def create_response( diff --git a/src/graphql_server/sanic/views.py b/src/graphql_server/sanic/views.py index 7c2b952..1c8a588 100644 --- a/src/graphql_server/sanic/views.py +++ b/src/graphql_server/sanic/views.py @@ -11,6 +11,7 @@ ) from typing_extensions import TypeGuard +from graphql_server.http import GraphQLRequestData from graphql_server.http.async_base_view import ( AsyncBaseHTTPView, AsyncHTTPRequestAdapter, @@ -155,7 +156,9 @@ async def get_context( ) -> Context: return {"request": request, "response": response} # type: ignore - async def render_graphql_ide(self, request: Request) -> HTTPResponse: + async def render_graphql_ide( + self, request: Request, request_data: GraphQLRequestData + ) -> HTTPResponse: return html(self.graphql_ide_html) async def get_sub_response(self, request: Request) -> TemporalResponse: From c7788cb26e4e1e6b80a0666e858e273b45e4f7dc Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Mon, 23 Jun 2025 00:07:20 +0200 Subject: [PATCH 076/108] Make subscriptions use as well the document node --- src/graphql_server/http/__init__.py | 4 +- src/graphql_server/http/async_base_view.py | 9 +++-- src/graphql_server/http/sync_base_view.py | 3 +- .../graphql_transport_ws/handlers.py | 39 ++++++++++++------- 4 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/graphql_server/http/__init__.py b/src/graphql_server/http/__init__.py index a257a85..6a29995 100644 --- a/src/graphql_server/http/__init__.py +++ b/src/graphql_server/http/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from graphql.language import DocumentNode from typing import TYPE_CHECKING, Any, Optional from typing_extensions import Literal, TypedDict @@ -30,10 +31,11 @@ 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", "multipart-subscription"] = "http" + protocol: Literal["http", "multipart-subscription", "subscription"] = "http" __all__ = [ diff --git a/src/graphql_server/http/async_base_view.py b/src/graphql_server/http/async_base_view.py index 541bd65..c8e36c7 100644 --- a/src/graphql_server/http/async_base_view.py +++ b/src/graphql_server/http/async_base_view.py @@ -218,7 +218,7 @@ async def execute_operation( if request_data.protocol == "multipart-subscription": return await subscribe( schema=self.schema, - query=request_data.query, # type: ignore + query=request_data.document or request_data.query, # type: ignore variable_values=request_data.variables, context_value=context, root_value=root_value, @@ -228,7 +228,7 @@ async def execute_operation( return await execute( schema=self.schema, - query=request_data.query, + query=request_data.document or request_data.query, root_value=root_value, variable_values=request_data.variables, context_value=context, @@ -539,10 +539,13 @@ async def parse_multipart_subscriptions( return self.parse_json(await request.get_body()) async def get_graphql_request_data( - self, data: dict[str, Any], protocol: Literal["http", "multipart-subscription"] + self, + data: dict[str, Any], + protocol: Literal["http", "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"), diff --git a/src/graphql_server/http/sync_base_view.py b/src/graphql_server/http/sync_base_view.py index 22825eb..23e4cf4 100644 --- a/src/graphql_server/http/sync_base_view.py +++ b/src/graphql_server/http/sync_base_view.py @@ -116,7 +116,7 @@ def execute_operation( return execute_sync( schema=self.schema, - query=request_data.query, + query=request_data.document or request_data.query, root_value=root_value, variable_values=request_data.variables, context_value=context, @@ -139,6 +139,7 @@ def get_graphql_request_data( ) -> GraphQLRequestData: return GraphQLRequestData( query=data.get("query"), + document=None, variables=data.get("variables"), operation_name=data.get("operationName"), extensions=data.get("extensions"), diff --git a/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py index 6bad4ab..98ed665 100644 --- a/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py +++ b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py @@ -18,6 +18,7 @@ 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, @@ -253,13 +254,15 @@ async def handle_subscribe(self, message: SubscribeMessage) -> None: message["payload"].get("variables"), ) + request_data = await self.view.get_graphql_request_data( + message["payload"], "subscription" + ) + operation = Operation( self, message["id"], operation_type, - message["payload"]["query"], - message["payload"].get("variables"), - message["payload"].get("operationName"), + request_data, ) operation.task = asyncio.create_task(self.run_operation(operation)) @@ -274,7 +277,8 @@ async def run_operation(self, operation: Operation[Context, RootValue]) -> None: if operation.operation_type == OperationType.SUBSCRIPTION: result_source = await subscribe( schema=self.schema, - query=operation.query, + query=operation.request_data.document + or operation.request_data.query, variable_values=operation.variables, operation_name=operation.operation_name, context_value=self.context, @@ -284,7 +288,8 @@ async def run_operation(self, operation: Operation[Context, RootValue]) -> None: else: result_source = await execute( schema=self.schema, - query=operation.query, + query=operation.request_data.document + or operation.request_data.query, variable_values=operation.variables, context_value=self.context, root_value=self.root_value, @@ -378,11 +383,9 @@ class Operation(Generic[Context, RootValue]): "completed", "handler", "id", - "operation_name", "operation_type", - "query", + "request_data", "task", - "variables", ] def __init__( @@ -390,19 +393,27 @@ def __init__( handler: BaseGraphQLTransportWSHandler[Context, RootValue], id: str, operation_type: OperationType, - query: str, - variables: Optional[dict[str, object]], - operation_name: Optional[str], + request_data: GraphQLRequestData, ) -> None: self.handler = handler self.id = id self.operation_type = operation_type - self.query = query - self.variables = variables - self.operation_name = operation_name + 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 From 0e507d759376eae1346f7102f98e550317b198f6 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Mon, 23 Jun 2025 00:10:55 +0200 Subject: [PATCH 077/108] Fix ide rendering --- src/graphql_server/http/base.py | 2 +- src/tests/http/test_graphql_ide.py | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/graphql_server/http/base.py b/src/graphql_server/http/base.py index f9eb22a..2d725fa 100644 --- a/src/graphql_server/http/base.py +++ b/src/graphql_server/http/base.py @@ -31,7 +31,7 @@ def should_render_graphql_ide(self, request: BaseRequestProtocol) -> bool: and request.query_params.get("query") is None and any( supported_header in request.headers.get("accept", "") - for supported_header in ("text/html", "*/*") + for supported_header in ("text/html",) ) ) diff --git a/src/tests/http/test_graphql_ide.py b/src/tests/http/test_graphql_ide.py index 85d19e7..8792602 100644 --- a/src/tests/http/test_graphql_ide.py +++ b/src/tests/http/test_graphql_ide.py @@ -6,7 +6,12 @@ from .clients.base import HttpClient -@pytest.mark.parametrize("header_value", ["text/html", "*/*"]) +@pytest.mark.parametrize( + "header_value", + [ + "text/html", + ], +) @pytest.mark.parametrize( "graphql_ide_and_title", [ @@ -44,7 +49,12 @@ async def test_renders_graphql_ide( assert "unpkg.com/graphiql" in response.text -@pytest.mark.parametrize("header_value", ["text/html", "*/*"]) +@pytest.mark.parametrize( + "header_value", + [ + "text/html", + ], +) async def test_renders_graphql_ide_deprecated( header_value: str, http_client_class: type[HttpClient] ): From 97e2e249af4ba2cb9405dedcd43976ccb5c30ecd Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Mon, 23 Jun 2025 00:24:14 +0200 Subject: [PATCH 078/108] Make django and flask use templates --- src/graphql_server/django/views.py | 12 ++++++++++-- src/graphql_server/flask/views.py | 13 +++++++++++-- src/graphql_server/http/__init__.py | 7 +++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/graphql_server/django/views.py b/src/graphql_server/django/views.py index 12218fb..bef093a 100644 --- a/src/graphql_server/django/views.py +++ b/src/graphql_server/django/views.py @@ -245,7 +245,11 @@ def render_graphql_ide( self, request: HttpRequest, request_data: GraphQLRequestData ) -> HttpResponse: try: - content = render_to_string("graphql/graphiql.html", request=request) + content = render_to_string( + "graphql/graphiql.html", + request=request, + context=request_data.to_template_context(), + ) except TemplateDoesNotExist: content = self.graphql_ide_html @@ -307,7 +311,11 @@ async def render_graphql_ide( self, request: HttpRequest, request_data: GraphQLRequestData ) -> HttpResponse: try: - content = render_to_string("graphql/graphiql.html", request=request) + content = render_to_string( + "graphql/graphiql.html", + request=request, + context=request_data.to_template_context(), + ) except TemplateDoesNotExist: content = self.graphql_ide_html diff --git a/src/graphql_server/flask/views.py b/src/graphql_server/flask/views.py index a1ac778..d44475e 100644 --- a/src/graphql_server/flask/views.py +++ b/src/graphql_server/flask/views.py @@ -133,7 +133,14 @@ def dispatch_request(self) -> ResponseReturnValue: def render_graphql_ide( self, request: Request, request_data: GraphQLRequestData ) -> Response: - return render_template_string(self.graphql_ide_html) # type: ignore + 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): @@ -198,7 +205,9 @@ async def dispatch_request(self) -> ResponseReturnValue: # type: ignore async def render_graphql_ide( self, request: Request, request_data: GraphQLRequestData ) -> Response: - content = render_template_string(self.graphql_ide_html) + 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]: diff --git a/src/graphql_server/http/__init__.py b/src/graphql_server/http/__init__.py index 6a29995..4f06e43 100644 --- a/src/graphql_server/http/__init__.py +++ b/src/graphql_server/http/__init__.py @@ -37,6 +37,13 @@ class GraphQLRequestData: extensions: Optional[dict[str, Any]] protocol: Literal["http", "multipart-subscription", "subscription"] = "http" + def to_template_context(self) -> dict[str, Any]: + return { + "query": self.query, + "variables": self.variables, + "operationName": self.operation_name, + } + __all__ = [ "GraphQLHTTPResponse", From ff08d18386e88c753c0ee31029f1d52bf57cb801 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Mon, 23 Jun 2025 00:59:13 +0200 Subject: [PATCH 079/108] Improved GraphiQL renderer with variables --- src/graphql_server/aiohttp/views.py | 5 +- src/graphql_server/asgi/__init__.py | 2 +- src/graphql_server/chalice/views.py | 2 +- .../channels/handlers/http_handler.py | 4 +- src/graphql_server/django/views.py | 4 +- src/graphql_server/fastapi/router.py | 2 +- src/graphql_server/http/__init__.py | 29 ++++++++- src/graphql_server/http/base.py | 10 +-- src/graphql_server/litestar/controller.py | 5 +- src/graphql_server/quart/views.py | 2 +- src/graphql_server/sanic/views.py | 2 +- src/graphql_server/static/graphiql.html | 65 ++++++++++++++++++- src/tests/http/test_graphql_ide.py | 49 ++++++++++++++ 13 files changed, 158 insertions(+), 23 deletions(-) diff --git a/src/graphql_server/aiohttp/views.py b/src/graphql_server/aiohttp/views.py index fd6f6b3..4b08a02 100644 --- a/src/graphql_server/aiohttp/views.py +++ b/src/graphql_server/aiohttp/views.py @@ -178,7 +178,10 @@ def __init__( async def render_graphql_ide( self, request: web.Request, request_data: GraphQLRequestData ) -> web.Response: - return web.Response(text=self.graphql_ide_html, content_type="text/html") + 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() diff --git a/src/graphql_server/asgi/__init__.py b/src/graphql_server/asgi/__init__.py index a47ad61..4e589fd 100644 --- a/src/graphql_server/asgi/__init__.py +++ b/src/graphql_server/asgi/__init__.py @@ -208,7 +208,7 @@ async def get_sub_response( async def render_graphql_ide( self, request: Request, request_data: GraphQLRequestData ) -> Response: - return HTMLResponse(self.graphql_ide_html) + return HTMLResponse(request_data.to_template_string(self.graphql_ide_html)) def create_response( self, response_data: GraphQLHTTPResponse, sub_response: Response diff --git a/src/graphql_server/chalice/views.py b/src/graphql_server/chalice/views.py index 88608f9..8fed46d 100644 --- a/src/graphql_server/chalice/views.py +++ b/src/graphql_server/chalice/views.py @@ -85,7 +85,7 @@ def render_graphql_ide( self, request: Request, request_data: GraphQLRequestData ) -> Response: return Response( - self.graphql_ide_html, + request_data.to_template_string(self.graphql_ide_html), headers={"Content-Type": "text/html"}, ) diff --git a/src/graphql_server/channels/handlers/http_handler.py b/src/graphql_server/channels/handlers/http_handler.py index 43daf5d..3ec9c3e 100644 --- a/src/graphql_server/channels/handlers/http_handler.py +++ b/src/graphql_server/channels/handlers/http_handler.py @@ -300,7 +300,7 @@ async def render_graphql_ide( self, request: ChannelsRequest, request_data: GraphQLRequestData ) -> ChannelsResponse: return ChannelsResponse( - content=self.graphql_ide_html.encode(), + content=request_data.to_template_string(self.graphql_ide_html).encode(), content_type="text/html; charset=utf-8", ) @@ -358,7 +358,7 @@ def render_graphql_ide( self, request: ChannelsRequest, request_data: GraphQLRequestData ) -> ChannelsResponse: return ChannelsResponse( - content=self.graphql_ide_html.encode(), + content=request_data.to_template_string(self.graphql_ide_html).encode(), content_type="text/html; charset=utf-8", ) diff --git a/src/graphql_server/django/views.py b/src/graphql_server/django/views.py index bef093a..23cf1b0 100644 --- a/src/graphql_server/django/views.py +++ b/src/graphql_server/django/views.py @@ -251,7 +251,7 @@ def render_graphql_ide( context=request_data.to_template_context(), ) except TemplateDoesNotExist: - content = self.graphql_ide_html + content = request_data.to_template_string(self.graphql_ide_html) return HttpResponse(content) @@ -317,7 +317,7 @@ async def render_graphql_ide( context=request_data.to_template_context(), ) except TemplateDoesNotExist: - content = self.graphql_ide_html + content = request_data.to_template_string(self.graphql_ide_html) return HttpResponse(content=content) diff --git a/src/graphql_server/fastapi/router.py b/src/graphql_server/fastapi/router.py index 38d08fa..70a3ec8 100644 --- a/src/graphql_server/fastapi/router.py +++ b/src/graphql_server/fastapi/router.py @@ -265,7 +265,7 @@ async def websocket_endpoint( # pyright: ignore async def render_graphql_ide( self, request: Request, request_data: GraphQLRequestData ) -> HTMLResponse: - return HTMLResponse(self.graphql_ide_html) + return HTMLResponse(request_data.to_template_string(self.graphql_ide_html)) async def get_context( self, request: Union[Request, WebSocket], response: Union[Response, WebSocket] diff --git a/src/graphql_server/http/__init__.py b/src/graphql_server/http/__init__.py index 4f06e43..7d6dacb 100644 --- a/src/graphql_server/http/__init__.py +++ b/src/graphql_server/http/__init__.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json +import re from dataclasses import dataclass from graphql.language import DocumentNode from typing import TYPE_CHECKING, Any, Optional @@ -26,6 +28,24 @@ def process_result(result: ExecutionResult) -> GraphQLHTTPResponse: 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, "") + 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 @@ -39,11 +59,14 @@ class GraphQLRequestData: def to_template_context(self) -> dict[str, Any]: return { - "query": self.query, - "variables": self.variables, - "operationName": self.operation_name, + "query": tojson(self.query), + "variables": tojson(self.variables), + "operation_name": tojson(self.operation_name), } + def to_template_string(self, template: str) -> str: + return simple_renderer(template, **self.to_template_context()) + __all__ = [ "GraphQLHTTPResponse", diff --git a/src/graphql_server/http/base.py b/src/graphql_server/http/base.py index 2d725fa..aba934e 100644 --- a/src/graphql_server/http/base.py +++ b/src/graphql_server/http/base.py @@ -26,13 +26,9 @@ class BaseView(Generic[Request]): multipart_uploads_enabled: bool = False def should_render_graphql_ide(self, request: BaseRequestProtocol) -> bool: - return ( - request.method == "GET" - and request.query_params.get("query") is None - and any( - supported_header in request.headers.get("accept", "") - for supported_header in ("text/html",) - ) + 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: diff --git a/src/graphql_server/litestar/controller.py b/src/graphql_server/litestar/controller.py index d3c91ba..528770c 100644 --- a/src/graphql_server/litestar/controller.py +++ b/src/graphql_server/litestar/controller.py @@ -303,7 +303,10 @@ async def execute_request( async def render_graphql_ide( self, request: Request[Any, Any, Any], request_data: GraphQLRequestData ) -> Response[str]: - return Response(self.graphql_ide_html, media_type=MediaType.HTML) + 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] diff --git a/src/graphql_server/quart/views.py b/src/graphql_server/quart/views.py index c0606a8..33559cb 100644 --- a/src/graphql_server/quart/views.py +++ b/src/graphql_server/quart/views.py @@ -153,7 +153,7 @@ def __init__( async def render_graphql_ide( self, request: Request, request_data: GraphQLRequestData ) -> Response: - return Response(self.graphql_ide_html) + return Response(request_data.to_template_string(self.graphql_ide_html)) def create_response( self, response_data: "GraphQLHTTPResponse", sub_response: Response diff --git a/src/graphql_server/sanic/views.py b/src/graphql_server/sanic/views.py index 1c8a588..e4df08d 100644 --- a/src/graphql_server/sanic/views.py +++ b/src/graphql_server/sanic/views.py @@ -159,7 +159,7 @@ async def get_context( async def render_graphql_ide( self, request: Request, request_data: GraphQLRequestData ) -> HTTPResponse: - return html(self.graphql_ide_html) + return html(request_data.to_template_string(self.graphql_ide_html)) async def get_sub_response(self, request: Request) -> TemporalResponse: return TemporalResponse() diff --git a/src/graphql_server/static/graphiql.html b/src/graphql_server/static/graphiql.html index 583d5b8..5492095 100644 --- a/src/graphql_server/static/graphiql.html +++ b/src/graphql_server/static/graphiql.html @@ -93,7 +93,7 @@ integrity="sha384-2oonKe47vfHIZnmB6ZZ10vl7T0Y+qrHQF2cmNTaFDuPshpKqpUMGMc9jgj9MLDZ9" > diff --git a/src/tests/http/test_graphql_ide.py b/src/tests/http/test_graphql_ide.py index 8792602..d7120e3 100644 --- a/src/tests/http/test_graphql_ide.py +++ b/src/tests/http/test_graphql_ide.py @@ -1,3 +1,4 @@ +from urllib.parse import quote from typing import Union from typing_extensions import Literal @@ -108,3 +109,51 @@ async def test_renders_graphiql_disabled_deprecated( response = await http_client.get("/graphql", headers={"Accept": "text/html"}) assert response.status_code == 404 + + +@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 From 77342dea3b622fabdafe2adab393681d0d5261b7 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Mon, 23 Jun 2025 01:07:56 +0200 Subject: [PATCH 080/108] Added extra validation --- src/graphql_server/runtime.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/graphql_server/runtime.py b/src/graphql_server/runtime.py index a958a05..b47e714 100644 --- a/src/graphql_server/runtime.py +++ b/src/graphql_server/runtime.py @@ -163,6 +163,10 @@ def _parse_and_validate( # async with extensions_runner.parsing(): if not query: raise GraphQLError("No GraphQL query found in the request") + elif 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): From 8fc9f37d5d6e03583e08dd3db0ee539ca006d3ff Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Mon, 23 Jun 2025 01:14:20 +0200 Subject: [PATCH 081/108] Fixed headers --- src/graphql_server/static/graphiql.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/graphql_server/static/graphiql.html b/src/graphql_server/static/graphiql.html index 5492095..7335632 100644 --- a/src/graphql_server/static/graphiql.html +++ b/src/graphql_server/static/graphiql.html @@ -214,7 +214,7 @@ onEditOperationName: onEditOperationName, query: {{query}}, variables: {{variables}}, - headers: {{headers}}, + headers: parameters.headers, operationName: {{operation_name}}, defaultQuery: EXAMPLE_QUERY, }), From 5f539ec49b598d541358a4a95c4aae2de0057ed6 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Mon, 23 Jun 2025 01:15:42 +0200 Subject: [PATCH 082/108] Fixed issue if variable is not defined --- src/graphql_server/http/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/graphql_server/http/__init__.py b/src/graphql_server/http/__init__.py index 7d6dacb..1456fc5 100644 --- a/src/graphql_server/http/__init__.py +++ b/src/graphql_server/http/__init__.py @@ -39,7 +39,7 @@ 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, "") + return values.get(var_name) or tojson("") return "" pattern = r"{{\s*([^}]+)\s*}}" From 4077d9617b72682bb5f137eb3442e0a796071c5a Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Mon, 23 Jun 2025 01:27:19 +0200 Subject: [PATCH 083/108] Fixed url loading --- src/graphql_server/static/graphiql.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/graphql_server/static/graphiql.html b/src/graphql_server/static/graphiql.html index 7335632..dc46b03 100644 --- a/src/graphql_server/static/graphiql.html +++ b/src/graphql_server/static/graphiql.html @@ -151,10 +151,10 @@ otherParams[k] = parameters[k]; } } - var fetchURL = locationQuery(otherParams); + var fetchURL = window.location.pathname + locationQuery(otherParams); function httpUrlToWebSockeUrl(url) { - const parsedURL = new URL(url); + const parsedURL = new URL(url, window.location.href); const protocol = parsedURL.protocol === "http:" ? "ws:" : "wss:"; parsedURL.protocol = protocol; parsedURL.hash = ""; From caef47d79cde984c02163a3a084ee40a94eeead8 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Mon, 23 Jun 2025 01:41:47 +0200 Subject: [PATCH 084/108] Allow optionally validate the document --- src/graphql_server/runtime.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/graphql_server/runtime.py b/src/graphql_server/runtime.py index b47e714..2f57211 100644 --- a/src/graphql_server/runtime.py +++ b/src/graphql_server/runtime.py @@ -155,6 +155,7 @@ def _parse_and_validate( 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: @@ -171,8 +172,14 @@ def _parse_and_validate( 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 @@ -211,6 +218,7 @@ async def execute( 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 @@ -234,6 +242,7 @@ async def execute( allowed_operation_types, validation_rules, operation_name, + validate_document, ) # async with extensions_runner.executing(): @@ -274,6 +283,7 @@ def execute_sync( 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 = {} @@ -296,6 +306,7 @@ def execute_sync( allowed_operation_types, validation_rules, operation_name, + validate_document, ) # with extensions_runner.executing(): @@ -344,6 +355,7 @@ async def subscribe( 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, @@ -354,6 +366,7 @@ async def subscribe( allowed_operation_types, validation_rules, operation_name, + validate_document, ) return _subscribe_generator( schema, From f70388ce213f424ee8135721443369b51e173d67 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Mon, 23 Jun 2025 01:42:59 +0200 Subject: [PATCH 085/108] Fixed DocumentNode import --- src/graphql_server/runtime.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/graphql_server/runtime.py b/src/graphql_server/runtime.py index 2f57211..2a6a438 100644 --- a/src/graphql_server/runtime.py +++ b/src/graphql_server/runtime.py @@ -40,7 +40,7 @@ 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 OperationType +from graphql.language import DocumentNode, OperationType from graphql.type import GraphQLSchema from graphql.type.directives import specified_directives from graphql.validation import validate @@ -56,7 +56,6 @@ from typing_extensions import TypeAlias from graphql.execution.collect_fields import FieldGroup # type: ignore - from graphql.language import DocumentNode from graphql.pyutils import Path from graphql.type import GraphQLResolveInfo from graphql.validation import ASTValidationRule From b93c73aeb4b2d58e585c5c6e55b874e72fc1e009 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Mon, 23 Jun 2025 14:47:12 +0200 Subject: [PATCH 086/108] Make base views more resilient --- src/graphql_server/http/async_base_view.py | 39 ++++++++++++++-------- src/graphql_server/http/sync_base_view.py | 23 ++++++++----- 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/src/graphql_server/http/async_base_view.py b/src/graphql_server/http/async_base_view.py index c8e36c7..0b9dac6 100644 --- a/src/graphql_server/http/async_base_view.py +++ b/src/graphql_server/http/async_base_view.py @@ -205,14 +205,10 @@ async def execute_operation( request_data: GraphQLRequestData, context: Context, root_value: Optional[RootValue], + allowed_operation_types: set[OperationType], ) -> ExecutionResult: request_adapter = self.request_adapter_class(request) - allowed_operation_types = operation_type_from_http(request_adapter.method) - - if not self.allow_queries_via_get and request_adapter.method == "GET": - allowed_operation_types = allowed_operation_types - {OperationType.QUERY} - assert self.schema if request_data.protocol == "multipart-subscription": @@ -331,12 +327,6 @@ async def run( request = cast("Request", request) request_adapter = self.request_adapter_class(request) - sub_response = await self.get_sub_response(request) - 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.") @@ -349,10 +339,30 @@ async def run( except KeyError as e: raise HTTPException(400, "File(s) missing in form data") from e - if self.should_render_graphql_ide(request_adapter): + allowed_operation_types = operation_type_from_http(request_adapter.method) + + if not self.allow_queries_via_get and request_adapter.method == "GET": + allowed_operation_types = allowed_operation_types - {OperationType.QUERY} + + if request_adapter.method == "GET": + if not self.allow_queries_via_get: + allowed_operation_types = allowed_operation_types - { + OperationType.QUERY + } + + should_render_graphql_ide = self.should_render_graphql_ide(request_adapter) if self.graphql_ide: - return await self.render_graphql_ide(request, request_data) - raise HTTPException(404, "Not Found") + if should_render_graphql_ide: + return await self.render_graphql_ide(request, request_data) + elif should_render_graphql_ide: + raise HTTPException(404, "Not Found") # pragma: no cover + + sub_response = await self.get_sub_response(request) + context = ( + await self.get_context(request, response=sub_response) + if context is UNSET + else context + ) try: result = await self.execute_operation( @@ -360,6 +370,7 @@ async def run( request_data=request_data, context=context, root_value=root_value, + allowed_operation_types=allowed_operation_types, ) except GraphQLValidationError as e: result = ExecutionResult(data=None, errors=e.errors) diff --git a/src/graphql_server/http/sync_base_view.py b/src/graphql_server/http/sync_base_view.py index 23e4cf4..c7e5555 100644 --- a/src/graphql_server/http/sync_base_view.py +++ b/src/graphql_server/http/sync_base_view.py @@ -104,14 +104,10 @@ def execute_operation( request_data: GraphQLRequestData, context: Context, root_value: Optional[RootValue], + allowed_operation_types: set[OperationType], ) -> ExecutionResult: request_adapter = self.request_adapter_class(request) - allowed_operation_types = operation_type_from_http(request_adapter.method) - - if not self.allow_queries_via_get and request_adapter.method == "GET": - allowed_operation_types = allowed_operation_types - {OperationType.QUERY} - assert self.schema return execute_sync( @@ -189,10 +185,20 @@ def run( except KeyError as e: raise HTTPException(400, "File(s) missing in form data") from e - if self.should_render_graphql_ide(request_adapter): + 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 + } + + should_render_graphql_ide = self.should_render_graphql_ide(request_adapter) if self.graphql_ide: - return self.render_graphql_ide(request, request_data) - raise HTTPException(404, "Not Found") + if should_render_graphql_ide: + return self.render_graphql_ide(request, request_data) + elif should_render_graphql_ide: + raise HTTPException(404, "Not Found") # pragma: no cover sub_response = self.get_sub_response(request) context = ( @@ -208,6 +214,7 @@ def run( request_data=request_data, context=context, root_value=root_value, + allowed_operation_types=allowed_operation_types, ) except HTTPException: raise From c471860076d66945768e51c56178c5635606b432 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Mon, 23 Jun 2025 14:57:51 +0200 Subject: [PATCH 087/108] Simplified logic a bit more --- src/graphql_server/http/async_base_view.py | 11 +++++------ src/graphql_server/http/sync_base_view.py | 8 +++++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/graphql_server/http/async_base_view.py b/src/graphql_server/http/async_base_view.py index 0b9dac6..bc24300 100644 --- a/src/graphql_server/http/async_base_view.py +++ b/src/graphql_server/http/async_base_view.py @@ -341,20 +341,19 @@ async def run( allowed_operation_types = operation_type_from_http(request_adapter.method) - if not self.allow_queries_via_get and request_adapter.method == "GET": - allowed_operation_types = allowed_operation_types - {OperationType.QUERY} - if request_adapter.method == "GET": if not self.allow_queries_via_get: allowed_operation_types = allowed_operation_types - { OperationType.QUERY } - should_render_graphql_ide = self.should_render_graphql_ide(request_adapter) if self.graphql_ide: - if should_render_graphql_ide: + if self.should_render_graphql_ide(request_adapter): return await self.render_graphql_ide(request, request_data) - elif should_render_graphql_ide: + elif ( + not request_adapter.content_type + or "application/json" not in request_adapter.content_type + ): raise HTTPException(404, "Not Found") # pragma: no cover sub_response = await self.get_sub_response(request) diff --git a/src/graphql_server/http/sync_base_view.py b/src/graphql_server/http/sync_base_view.py index c7e5555..85cf2c2 100644 --- a/src/graphql_server/http/sync_base_view.py +++ b/src/graphql_server/http/sync_base_view.py @@ -193,11 +193,13 @@ def run( OperationType.QUERY } - should_render_graphql_ide = self.should_render_graphql_ide(request_adapter) if self.graphql_ide: - if should_render_graphql_ide: + if self.should_render_graphql_ide(request_adapter): return self.render_graphql_ide(request, request_data) - elif should_render_graphql_ide: + elif ( + not request_adapter.content_type + or "application/json" not in request_adapter.content_type + ): raise HTTPException(404, "Not Found") # pragma: no cover sub_response = self.get_sub_response(request) From 3b282e20ec3e9be6224c33e3af130147423376e5 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Mon, 23 Jun 2025 15:10:35 +0200 Subject: [PATCH 088/108] Reenable more tests --- src/graphql_server/http/async_base_view.py | 10 ++-------- src/graphql_server/http/sync_base_view.py | 10 ++-------- src/tests/http/test_graphql_ide.py | 8 +++----- src/tests/http/test_graphql_over_http_spec.py | 4 ---- 4 files changed, 7 insertions(+), 25 deletions(-) diff --git a/src/graphql_server/http/async_base_view.py b/src/graphql_server/http/async_base_view.py index bc24300..edac88f 100644 --- a/src/graphql_server/http/async_base_view.py +++ b/src/graphql_server/http/async_base_view.py @@ -347,14 +347,8 @@ async def run( OperationType.QUERY } - if self.graphql_ide: - if self.should_render_graphql_ide(request_adapter): - return await self.render_graphql_ide(request, request_data) - elif ( - not request_adapter.content_type - or "application/json" not in request_adapter.content_type - ): - raise HTTPException(404, "Not Found") # pragma: no cover + if self.graphql_ide and self.should_render_graphql_ide(request_adapter): + return await self.render_graphql_ide(request, request_data) sub_response = await self.get_sub_response(request) context = ( diff --git a/src/graphql_server/http/sync_base_view.py b/src/graphql_server/http/sync_base_view.py index 85cf2c2..58588c5 100644 --- a/src/graphql_server/http/sync_base_view.py +++ b/src/graphql_server/http/sync_base_view.py @@ -193,14 +193,8 @@ def run( OperationType.QUERY } - if self.graphql_ide: - if self.should_render_graphql_ide(request_adapter): - return self.render_graphql_ide(request, request_data) - elif ( - not request_adapter.content_type - or "application/json" not in request_adapter.content_type - ): - raise HTTPException(404, "Not Found") # pragma: no cover + if self.graphql_ide and self.should_render_graphql_ide(request_adapter): + return self.render_graphql_ide(request, request_data) sub_response = self.get_sub_response(request) context = ( diff --git a/src/tests/http/test_graphql_ide.py b/src/tests/http/test_graphql_ide.py index d7120e3..f2648ce 100644 --- a/src/tests/http/test_graphql_ide.py +++ b/src/tests/http/test_graphql_ide.py @@ -83,9 +83,7 @@ async def test_does_not_render_graphiql_if_wrong_accept( http_client = http_client_class() response = await http_client.get("/graphql", headers={"Accept": "text/xml"}) - # THIS might need to be changed to 404 - - assert response.status_code == 400 + assert response.status_code != 200 @pytest.mark.parametrize("graphql_ide", [False, None]) @@ -96,7 +94,7 @@ async def test_renders_graphiql_disabled( http_client = http_client_class(graphql_ide=graphql_ide) response = await http_client.get("/graphql", headers={"Accept": "text/html"}) - assert response.status_code == 404 + assert response.status_code != 200 async def test_renders_graphiql_disabled_deprecated( @@ -108,7 +106,7 @@ async def test_renders_graphiql_disabled_deprecated( http_client = http_client_class(graphiql=False) response = await http_client.get("/graphql", headers={"Accept": "text/html"}) - assert response.status_code == 404 + assert response.status_code != 200 @pytest.mark.parametrize( diff --git a/src/tests/http/test_graphql_over_http_spec.py b/src/tests/http/test_graphql_over_http_spec.py index d4360fe..9c00610 100644 --- a/src/tests/http/test_graphql_over_http_spec.py +++ b/src/tests/http/test_graphql_over_http_spec.py @@ -214,10 +214,6 @@ async def test_423l(http_client): assert response.status_code == 400 -@pytest.mark.xfail( - reason="OPTIONAL - Currently results in lots of TypeErrors", - raises=AssertionError, -) @pytest.mark.parametrize( "invalid", [{"obj": "ect"}, 0, False, ["array"]], From 578453f55f70adf03d2935f80c33c63612e1f006 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Mon, 23 Jun 2025 17:30:40 +0200 Subject: [PATCH 089/108] More improvements --- src/graphql_server/http/async_base_view.py | 20 +++++----- src/graphql_server/http/sync_base_view.py | 26 ++++++++----- src/graphql_server/static/graphiql.html | 37 ++++++------------- .../graphql_transport_ws/handlers.py | 2 +- .../websockets/test_graphql_transport_ws.py | 4 -- 5 files changed, 38 insertions(+), 51 deletions(-) diff --git a/src/graphql_server/http/async_base_view.py b/src/graphql_server/http/async_base_view.py index edac88f..10e3f5e 100644 --- a/src/graphql_server/http/async_base_view.py +++ b/src/graphql_server/http/async_base_view.py @@ -327,12 +327,18 @@ async def run( request = cast("Request", request) request_adapter = self.request_adapter_class(request) + sub_response = await self.get_sub_response(request) + 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) + request_data = await self.parse_http_body(context, request_adapter) except json.decoder.JSONDecodeError as e: raise HTTPException(400, "Unable to parse request body as JSON") from e # DO this only when doing files @@ -350,13 +356,6 @@ async def run( if self.graphql_ide and self.should_render_graphql_ide(request_adapter): return await self.render_graphql_ide(request, request_data) - sub_response = await self.get_sub_response(request) - context = ( - await self.get_context(request, response=sub_response) - if context is UNSET - else context - ) - try: result = await self.execute_operation( request=request, @@ -544,6 +543,7 @@ async def parse_multipart_subscriptions( async def get_graphql_request_data( self, + context: Context, data: dict[str, Any], protocol: Literal["http", "multipart-subscription", "subscription"], ) -> GraphQLRequestData: @@ -557,7 +557,7 @@ async def get_graphql_request_data( ) async def parse_http_body( - self, request: AsyncHTTPRequestAdapter + self, context: Context, request: AsyncHTTPRequestAdapter ) -> GraphQLRequestData: headers = {key.lower(): value for key, value in request.headers.items()} content_type, _ = parse_content_type(request.content_type or "") @@ -577,7 +577,7 @@ async def parse_http_body( else: raise HTTPException(400, "Unsupported content type") - return await self.get_graphql_request_data(data, protocol) + return await self.get_graphql_request_data(context, data, protocol) async def process_result( self, request: Request, result: ExecutionResult diff --git a/src/graphql_server/http/sync_base_view.py b/src/graphql_server/http/sync_base_view.py index 58588c5..809a786 100644 --- a/src/graphql_server/http/sync_base_view.py +++ b/src/graphql_server/http/sync_base_view.py @@ -131,7 +131,10 @@ def parse_multipart(self, request: SyncHTTPRequestAdapter) -> dict[str, str]: raise HTTPException(400, "File(s) missing in form data") from e def get_graphql_request_data( - self, data: dict[str, Any], protocol: Literal["http", "multipart-subscription"] + self, + context: Context, + data: dict[str, Any], + protocol: Literal["http", "multipart-subscription"], ) -> GraphQLRequestData: return GraphQLRequestData( query=data.get("query"), @@ -142,7 +145,9 @@ def get_graphql_request_data( protocol=protocol, ) - def parse_http_body(self, request: SyncHTTPRequestAdapter) -> GraphQLRequestData: + def parse_http_body( + self, context: Context, request: SyncHTTPRequestAdapter + ) -> GraphQLRequestData: content_type, params = parse_content_type(request.content_type or "") if request.method == "GET": @@ -159,7 +164,7 @@ def parse_http_body(self, request: SyncHTTPRequestAdapter) -> GraphQLRequestData else: raise HTTPException(400, "Unsupported content type") - return self.get_graphql_request_data(data, "http") + return self.get_graphql_request_data(context, data, "http") def _handle_errors( self, errors: list[GraphQLError], response_data: GraphQLHTTPResponse @@ -177,8 +182,15 @@ def run( 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) + request_data = self.parse_http_body(context, request_adapter) except json.decoder.JSONDecodeError as e: raise HTTPException(400, "Unable to parse request body as JSON") from e # DO this only when doing files @@ -196,12 +208,6 @@ def run( if self.graphql_ide and self.should_render_graphql_ide(request_adapter): return self.render_graphql_ide(request, request_data) - sub_response = self.get_sub_response(request) - context = ( - self.get_context(request, response=sub_response) - if context is UNSET - else context - ) root_value = self.get_root_value(request) if root_value is UNSET else root_value try: diff --git a/src/graphql_server/static/graphiql.html b/src/graphql_server/static/graphiql.html index dc46b03..f73a36b 100644 --- a/src/graphql_server/static/graphiql.html +++ b/src/graphql_server/static/graphiql.html @@ -123,35 +123,20 @@ // Collect the URL parameters var parameters = {}; - window.location.search.substr(1).split('&').forEach(function (entry) { - var eq = entry.indexOf('='); - if (eq >= 0) { - parameters[decodeURIComponent(entry.slice(0, eq))] = - decodeURIComponent(entry.slice(eq + 1)); - } - }); - // Produce a Location query string from a parameter object. - function locationQuery(params) { - return '?' + Object.keys(params).filter(function (key) { - return Boolean(params[key]); - }).map(function (key) { - return encodeURIComponent(key) + '=' + - encodeURIComponent(params[key]); - }).join('&'); - } // Derive a fetch URL from the current URL, sans the GraphQL parameters. var graphqlParamNames = { query: true, variables: true, operationName: true }; - var otherParams = {}; - for (var k in parameters) { - if (parameters.hasOwnProperty(k) && graphqlParamNames[k] !== true) { - otherParams[k] = parameters[k]; + var currentURL = new URL(window.location.href); + var newParams = new URLSearchParams(); + for (var [k, v] of currentURL.searchParams.entries()) { + if (graphqlParamNames[k] !== true) { + newParams.append(k, v); } } - var fetchURL = window.location.pathname + locationQuery(otherParams); + var fetchURL = window.location.pathname + '?' + newParams.toString(); function httpUrlToWebSockeUrl(url) { const parsedURL = new URL(url, window.location.href); @@ -208,15 +193,15 @@ defaultEditorToolsVisibility: true, plugins: [explorerPlugin], inputValueDeprecation: true, - onEditQuery: onEditQuery, - onEditVariables: onEditVariables, - onEditHeaders: onEditHeaders, - onEditOperationName: onEditOperationName, query: {{query}}, - variables: {{variables}}, + variables: '{{variables}}', headers: parameters.headers, operationName: {{operation_name}}, defaultQuery: EXAMPLE_QUERY, + onEditQuery: onEditQuery, + onEditVariables: onEditVariables, + onEditHeaders: onEditHeaders, + onEditOperationName: onEditOperationName, }), ); diff --git a/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py index 98ed665..483b1d7 100644 --- a/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py +++ b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py @@ -255,7 +255,7 @@ async def handle_subscribe(self, message: SubscribeMessage) -> None: ) request_data = await self.view.get_graphql_request_data( - message["payload"], "subscription" + self.context, message["payload"], "subscription" ) operation = Operation( diff --git a/src/tests/websockets/test_graphql_transport_ws.py b/src/tests/websockets/test_graphql_transport_ws.py index ff0d53e..e792ff8 100644 --- a/src/tests/websockets/test_graphql_transport_ws.py +++ b/src/tests/websockets/test_graphql_transport_ws.py @@ -194,7 +194,6 @@ async def test_connection_init_timeout_cancellation( ) -@pytest.mark.xfail(reason="This test is flaky") 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) @@ -1049,9 +1048,6 @@ async def test_rejects_connection_params_with_wrong_type( assert ws.close_reason == "Invalid connection init payload" -# timings can sometimes fail currently. Until this test is rewritten when -# generator based subscriptions are implemented, mark it as flaky -@pytest.mark.xfail(reason="This test is flaky, see comment above") 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. From 90ddc1b5a3457de341fe5aa7acee13a86ef8cde8 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Mon, 23 Jun 2025 17:54:10 +0200 Subject: [PATCH 090/108] More improvements --- src/graphql_server/http/async_base_view.py | 9 ++++++--- src/graphql_server/http/sync_base_view.py | 9 ++++++--- .../protocols/graphql_transport_ws/handlers.py | 2 +- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/graphql_server/http/async_base_view.py b/src/graphql_server/http/async_base_view.py index 10e3f5e..8219da4 100644 --- a/src/graphql_server/http/async_base_view.py +++ b/src/graphql_server/http/async_base_view.py @@ -338,7 +338,7 @@ async def run( raise HTTPException(405, "GraphQL only supports GET and POST requests.") try: - request_data = await self.parse_http_body(context, request_adapter) + 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 @@ -543,6 +543,7 @@ async def parse_multipart_subscriptions( async def get_graphql_request_data( self, + request: Union[AsyncHTTPRequestAdapter, WebSocketRequest], context: Context, data: dict[str, Any], protocol: Literal["http", "multipart-subscription", "subscription"], @@ -557,7 +558,9 @@ async def get_graphql_request_data( ) async def parse_http_body( - self, context: Context, request: AsyncHTTPRequestAdapter + 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 "") @@ -577,7 +580,7 @@ async def parse_http_body( else: raise HTTPException(400, "Unsupported content type") - return await self.get_graphql_request_data(context, data, protocol) + return await self.get_graphql_request_data(request, context, data, protocol) async def process_result( self, request: Request, result: ExecutionResult diff --git a/src/graphql_server/http/sync_base_view.py b/src/graphql_server/http/sync_base_view.py index 809a786..a42032e 100644 --- a/src/graphql_server/http/sync_base_view.py +++ b/src/graphql_server/http/sync_base_view.py @@ -132,6 +132,7 @@ def parse_multipart(self, request: SyncHTTPRequestAdapter) -> dict[str, str]: def get_graphql_request_data( self, + request: SyncHTTPRequestAdapter, context: Context, data: dict[str, Any], protocol: Literal["http", "multipart-subscription"], @@ -146,7 +147,9 @@ def get_graphql_request_data( ) def parse_http_body( - self, context: Context, request: SyncHTTPRequestAdapter + self, + request: SyncHTTPRequestAdapter, + context: Context, ) -> GraphQLRequestData: content_type, params = parse_content_type(request.content_type or "") @@ -164,7 +167,7 @@ def parse_http_body( else: raise HTTPException(400, "Unsupported content type") - return self.get_graphql_request_data(context, data, "http") + return self.get_graphql_request_data(request, context, data, "http") def _handle_errors( self, errors: list[GraphQLError], response_data: GraphQLHTTPResponse @@ -190,7 +193,7 @@ def run( ) try: - request_data = self.parse_http_body(context, request_adapter) + 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 diff --git a/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py index 483b1d7..5691b85 100644 --- a/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py +++ b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py @@ -255,7 +255,7 @@ async def handle_subscribe(self, message: SubscribeMessage) -> None: ) request_data = await self.view.get_graphql_request_data( - self.context, message["payload"], "subscription" + self.websocket, self.context, message["payload"], "subscription" ) operation = Operation( From 28387f5bcfff05a77f910e7c76572bdcda2583da Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Mon, 23 Jun 2025 18:30:16 +0200 Subject: [PATCH 091/108] Make more tests pass --- src/graphql_server/http/async_base_view.py | 3 +++ src/graphql_server/http/sync_base_view.py | 3 +++ .../subscriptions/protocols/graphql_transport_ws/handlers.py | 1 - src/tests/http/test_graphql_over_http_spec.py | 3 --- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/graphql_server/http/async_base_view.py b/src/graphql_server/http/async_base_view.py index 8219da4..9d8ee71 100644 --- a/src/graphql_server/http/async_base_view.py +++ b/src/graphql_server/http/async_base_view.py @@ -345,6 +345,9 @@ async def run( 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") + allowed_operation_types = operation_type_from_http(request_adapter.method) if request_adapter.method == "GET": diff --git a/src/graphql_server/http/sync_base_view.py b/src/graphql_server/http/sync_base_view.py index a42032e..9fa676d 100644 --- a/src/graphql_server/http/sync_base_view.py +++ b/src/graphql_server/http/sync_base_view.py @@ -200,6 +200,9 @@ def run( 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") + allowed_operation_types = operation_type_from_http(request_adapter.method) if request_adapter.method == "GET": diff --git a/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py index 5691b85..c17c581 100644 --- a/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py +++ b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py @@ -307,7 +307,6 @@ async def run_operation(self, operation: Operation[Context, RootValue]) -> None: else: is_first_result = True async for result in result_source: - print("RESULT SOURCE", result, is_first_result) if ( is_first_result and result.errors diff --git a/src/tests/http/test_graphql_over_http_spec.py b/src/tests/http/test_graphql_over_http_spec.py index 9c00610..4ddaa74 100644 --- a/src/tests/http/test_graphql_over_http_spec.py +++ b/src/tests/http/test_graphql_over_http_spec.py @@ -369,9 +369,6 @@ async def test_022_(http_client, parameter): assert "errors" not in response.json -@pytest.mark.xfail( - reason="OPTIONAL - Currently results in lots of TypeErrors", raises=AssertionError -) @pytest.mark.parametrize( "invalid", ["string", 0, False, ["array"]], From dd72f96adea22c59412793d44f3698cc0751cfd7 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Mon, 23 Jun 2025 19:31:00 +0200 Subject: [PATCH 092/108] Multiple tests enabled --- src/graphql_server/aiohttp/views.py | 11 ++++--- src/graphql_server/asgi/__init__.py | 8 ++--- src/graphql_server/chalice/views.py | 7 +---- src/graphql_server/django/views.py | 2 +- src/graphql_server/http/__init__.py | 13 +++++++-- src/graphql_server/http/async_base_view.py | 29 ++++++++++++++----- src/graphql_server/http/sync_base_view.py | 27 ++++++++++++----- src/graphql_server/http/temporal_response.py | 2 +- src/tests/http/clients/aiohttp.py | 4 +-- src/tests/http/clients/asgi.py | 4 +-- src/tests/http/clients/async_django.py | 4 +-- src/tests/http/clients/async_flask.py | 4 +-- src/tests/http/clients/chalice.py | 4 +-- src/tests/http/clients/channels.py | 8 ++--- src/tests/http/clients/django.py | 4 +-- src/tests/http/clients/fastapi.py | 4 +-- src/tests/http/clients/flask.py | 4 +-- src/tests/http/clients/litestar.py | 4 +-- src/tests/http/clients/quart.py | 4 +-- src/tests/http/clients/sanic.py | 4 +-- src/tests/http/test_graphql_over_http_spec.py | 21 -------------- 21 files changed, 89 insertions(+), 83 deletions(-) diff --git a/src/graphql_server/aiohttp/views.py b/src/graphql_server/aiohttp/views.py index 4b08a02..d08e573 100644 --- a/src/graphql_server/aiohttp/views.py +++ b/src/graphql_server/aiohttp/views.py @@ -222,10 +222,13 @@ async def get_context( def create_response( self, response_data: GraphQLHTTPResponse, sub_response: web.Response ) -> web.Response: - sub_response.text = self.encode_json(response_data) - sub_response.content_type = "application/json" - - return sub_response + status_code = getattr(sub_response, "status_code", None) + return web.Response( + text=self.encode_json(response_data), + content_type="application/json", + headers=sub_response.headers, + status=status_code or sub_response.status, + ) async def create_streaming_response( self, diff --git a/src/graphql_server/asgi/__init__.py b/src/graphql_server/asgi/__init__.py index 4e589fd..e50501d 100644 --- a/src/graphql_server/asgi/__init__.py +++ b/src/graphql_server/asgi/__init__.py @@ -215,18 +215,14 @@ def create_response( ) -> Response: response = Response( self.encode_json(response_data), - status_code=status.HTTP_200_OK, + status_code=sub_response.status_code or status.HTTP_200_OK, + headers=sub_response.headers, media_type="application/json", ) - response.headers.raw.extend(sub_response.headers.raw) - if sub_response.background: response.background = sub_response.background - if sub_response.status_code: - response.status_code = sub_response.status_code - return response async def create_streaming_response( diff --git a/src/graphql_server/chalice/views.py b/src/graphql_server/chalice/views.py index 8fed46d..a2a8757 100644 --- a/src/graphql_server/chalice/views.py +++ b/src/graphql_server/chalice/views.py @@ -120,14 +120,9 @@ def get_context(self, request: Request, response: TemporalResponse) -> Context: def create_response( self, response_data: GraphQLHTTPResponse, sub_response: TemporalResponse ) -> Response: - status_code = 200 - - if sub_response.status_code != 200: - status_code = sub_response.status_code - return Response( body=self.encode_json(response_data), - status_code=status_code, + status_code=sub_response.status_code, headers={ "Content-Type": "application/json", **sub_response.headers, diff --git a/src/graphql_server/django/views.py b/src/graphql_server/django/views.py index 23cf1b0..e00b175 100644 --- a/src/graphql_server/django/views.py +++ b/src/graphql_server/django/views.py @@ -170,10 +170,10 @@ def create_response( self, response_data: GraphQLHTTPResponse, sub_response: HttpResponse ) -> HttpResponseBase: data = self.encode_json(response_data) - response = HttpResponse( data, content_type="application/json", + status=sub_response.status_code, ) for name, value in sub_response.items(): diff --git a/src/graphql_server/http/__init__.py b/src/graphql_server/http/__init__.py index 1456fc5..349fe96 100644 --- a/src/graphql_server/http/__init__.py +++ b/src/graphql_server/http/__init__.py @@ -17,8 +17,13 @@ class GraphQLHTTPResponse(TypedDict, total=False): extensions: Optional[dict[str, object]] -def process_result(result: ExecutionResult) -> GraphQLHTTPResponse: - data: GraphQLHTTPResponse = {"data": result.data} +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] @@ -55,7 +60,9 @@ class GraphQLRequestData: variables: Optional[dict[str, Any]] operation_name: Optional[str] extensions: Optional[dict[str, Any]] - protocol: Literal["http", "multipart-subscription", "subscription"] = "http" + protocol: Literal[ + "http", "http-strict", "multipart-subscription", "subscription" + ] = "http" def to_template_context(self) -> dict[str, Any]: return { diff --git a/src/graphql_server/http/async_base_view.py b/src/graphql_server/http/async_base_view.py index 9d8ee71..a09271b 100644 --- a/src/graphql_server/http/async_base_view.py +++ b/src/graphql_server/http/async_base_view.py @@ -345,7 +345,9 @@ async def run( 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): + if request_data.variables is not None and not isinstance( + request_data.variables, dict + ): raise HTTPException(400, "Variables must be a JSON object") allowed_operation_types = operation_type_from_http(request_adapter.method) @@ -359,6 +361,7 @@ async def run( 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=request, @@ -368,6 +371,9 @@ async def run( allowed_operation_types=allowed_operation_types, ) except GraphQLValidationError as e: + if is_strict: + sub_response.status_code = 400 # type: ignore + # sub_response.headers["content-type"] = "application/graphql-response+json" result = ExecutionResult(data=None, errors=e.errors) except HTTPException: raise @@ -391,7 +397,9 @@ async def run( }, ) - response_data = await self.process_result(request=request, result=result) + response_data = await self.process_result( + request=request, result=result, strict=is_strict + ) if result.errors: self._handle_errors(result.errors, response_data) @@ -549,7 +557,9 @@ async def get_graphql_request_data( request: Union[AsyncHTTPRequestAdapter, WebSocketRequest], context: Context, data: dict[str, Any], - protocol: Literal["http", "multipart-subscription", "subscription"], + protocol: Literal[ + "http", "http-strict", "multipart-subscription", "subscription" + ], ) -> GraphQLRequestData: return GraphQLRequestData( query=data.get("query"), @@ -567,12 +577,15 @@ async def parse_http_body( ) -> 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", "") + accept = headers.get("accept", "") or headers.get("http-accept", "") - protocol: Literal["http", "multipart-subscription"] = "http" + accept_type = parse_content_type(accept) + protocol: Literal["http", "http-strict", "multipart-subscription"] = "http" - if self._is_multipart_subscriptions(*parse_content_type(accept)): + 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) @@ -586,9 +599,9 @@ async def parse_http_body( return await self.get_graphql_request_data(request, context, data, protocol) async def process_result( - self, request: Request, result: ExecutionResult + self, request: Request, result: ExecutionResult, strict: bool = False ) -> GraphQLHTTPResponse: - return process_result(result) + return process_result(result, strict) async def on_ws_connect( self, context: Context diff --git a/src/graphql_server/http/sync_base_view.py b/src/graphql_server/http/sync_base_view.py index 9fa676d..3846036 100644 --- a/src/graphql_server/http/sync_base_view.py +++ b/src/graphql_server/http/sync_base_view.py @@ -135,7 +135,7 @@ def get_graphql_request_data( request: SyncHTTPRequestAdapter, context: Context, data: dict[str, Any], - protocol: Literal["http", "multipart-subscription"], + protocol: Literal["http", "http-strict", "multipart-subscription"], ) -> GraphQLRequestData: return GraphQLRequestData( query=data.get("query"), @@ -151,8 +151,15 @@ def parse_http_body( 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: @@ -167,7 +174,7 @@ def parse_http_body( else: raise HTTPException(400, "Unsupported content type") - return self.get_graphql_request_data(request, context, data, "http") + return self.get_graphql_request_data(request, context, data, protocol) def _handle_errors( self, errors: list[GraphQLError], response_data: GraphQLHTTPResponse @@ -200,7 +207,9 @@ def run( 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): + if request_data.variables is not None and not isinstance( + request_data.variables, dict + ): raise HTTPException(400, "Variables must be a JSON object") allowed_operation_types = operation_type_from_http(request_adapter.method) @@ -215,7 +224,7 @@ def run( 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=request, @@ -227,6 +236,8 @@ def run( 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( @@ -235,7 +246,9 @@ def run( except Exception as e: raise HTTPException(400, str(e)) from e - response_data = self.process_result(request=request, result=result) + response_data = self.process_result( + request=request, result=result, strict=is_strict + ) if result.errors: self._handle_errors(result.errors, response_data) @@ -245,9 +258,9 @@ def run( ) def process_result( - self, request: Request, result: ExecutionResult + self, request: Request, result: ExecutionResult, strict: bool = False ) -> GraphQLHTTPResponse: - return process_result(result) + 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 index 8c93a54..7212a61 100644 --- a/src/graphql_server/http/temporal_response.py +++ b/src/graphql_server/http/temporal_response.py @@ -4,7 +4,7 @@ @dataclass class TemporalResponse: status_code: int = 200 - headers: dict[str, str] = field(default_factory=dict) + headers: dict[str, str | list[str]] = field(default_factory=dict) __all__ = ["TemporalResponse"] diff --git a/src/tests/http/clients/aiohttp.py b/src/tests/http/clients/aiohttp.py index b528735..0cc2edf 100644 --- a/src/tests/http/clients/aiohttp.py +++ b/src/tests/http/clients/aiohttp.py @@ -54,12 +54,12 @@ async def get_root_value(self, request: web.Request) -> Query: return Query() async def process_result( - self, request: web.Request, result: ExecutionResult + 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) + return await super().process_result(request, result, strict) class AioHttpClient(HttpClient): diff --git a/src/tests/http/clients/asgi.py b/src/tests/http/clients/asgi.py index 212ff72..441359b 100644 --- a/src/tests/http/clients/asgi.py +++ b/src/tests/http/clients/asgi.py @@ -55,12 +55,12 @@ async def get_context( return get_context(context) async def process_result( - self, request: Request, result: ExecutionResult + 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) + return await super().process_result(request, result, strict) class AsgiHttpClient(HttpClient): diff --git a/src/tests/http/clients/async_django.py b/src/tests/http/clients/async_django.py index 0617844..e197038 100644 --- a/src/tests/http/clients/async_django.py +++ b/src/tests/http/clients/async_django.py @@ -32,12 +32,12 @@ async def get_context( return get_context(context) async def process_result( - self, request: HttpRequest, result: ExecutionResult + 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) + return await super().process_result(request, result, strict) class AsyncDjangoHttpClient(DjangoHttpClient): diff --git a/src/tests/http/clients/async_flask.py b/src/tests/http/clients/async_flask.py index 2e86d30..f8683b6 100644 --- a/src/tests/http/clients/async_flask.py +++ b/src/tests/http/clients/async_flask.py @@ -38,12 +38,12 @@ async def get_context( return get_context(context) async def process_result( - self, request: FlaskRequest, result: ExecutionResult + 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) + return await super().process_result(request, result, strict) class AsyncFlaskHttpClient(FlaskHttpClient): diff --git a/src/tests/http/clients/chalice.py b/src/tests/http/clients/chalice.py index b1baf44..2b1904b 100644 --- a/src/tests/http/clients/chalice.py +++ b/src/tests/http/clients/chalice.py @@ -36,12 +36,12 @@ def get_context( return get_context(context) def process_result( - self, request: ChaliceRequest, result: ExecutionResult + self, request: ChaliceRequest, result: ExecutionResult, strict: bool = False ) -> GraphQLHTTPResponse: if self.result_override: return self.result_override(result) - return super().process_result(request, result) + return super().process_result(request, result, strict) class ChaliceHttpClient(HttpClient): diff --git a/src/tests/http/clients/channels.py b/src/tests/http/clients/channels.py index 4f786fc..a134b0a 100644 --- a/src/tests/http/clients/channels.py +++ b/src/tests/http/clients/channels.py @@ -94,12 +94,12 @@ async def get_context(self, request: ChannelsRequest, response: TemporalResponse return get_context(context) async def process_result( - self, request: ChannelsRequest, result: ExecutionResult + 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) + return await super().process_result(request, result, strict) class DebuggableSyncGraphQLHTTPConsumer( @@ -120,12 +120,12 @@ def get_context(self, request: ChannelsRequest, response: TemporalResponse): return get_context(context) def process_result( - self, request: ChannelsRequest, result: ExecutionResult + self, request: ChannelsRequest, result: ExecutionResult, strict: bool = False ) -> GraphQLHTTPResponse: if self.result_override: return self.result_override(result) - return super().process_result(request, result) + return super().process_result(request, result, strict) class DebuggableGraphQLWSConsumer( diff --git a/src/tests/http/clients/django.py b/src/tests/http/clients/django.py index 796c76d..262c3b9 100644 --- a/src/tests/http/clients/django.py +++ b/src/tests/http/clients/django.py @@ -35,12 +35,12 @@ def get_context( return get_context(context) def process_result( - self, request: HttpRequest, result: ExecutionResult + self, request: HttpRequest, result: ExecutionResult, strict: bool = False ) -> GraphQLHTTPResponse: if self.result_override: return self.result_override(result) - return super().process_result(request, result) + return super().process_result(request, result, strict) class DjangoHttpClient(HttpClient): diff --git a/src/tests/http/clients/fastapi.py b/src/tests/http/clients/fastapi.py index 88be77a..76672fb 100644 --- a/src/tests/http/clients/fastapi.py +++ b/src/tests/http/clients/fastapi.py @@ -66,12 +66,12 @@ class GraphQLRouter(OnWSConnectMixin, BaseGraphQLRouter[dict[str, object], objec graphql_ws_handler_class = DebuggableGraphQLWSHandler async def process_result( - self, request: Request, result: ExecutionResult + 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) + return await super().process_result(request, result, strict) class FastAPIHttpClient(HttpClient): diff --git a/src/tests/http/clients/flask.py b/src/tests/http/clients/flask.py index 78a9e17..3f87c70 100644 --- a/src/tests/http/clients/flask.py +++ b/src/tests/http/clients/flask.py @@ -47,12 +47,12 @@ def get_context( return get_context(context) def process_result( - self, request: FlaskRequest, result: ExecutionResult + self, request: FlaskRequest, result: ExecutionResult, strict: bool = False ) -> GraphQLHTTPResponse: if self.result_override: return self.result_override(result) - return super().process_result(request, result) + return super().process_result(request, result, strict) class FlaskHttpClient(HttpClient): diff --git a/src/tests/http/clients/litestar.py b/src/tests/http/clients/litestar.py index 7117c66..8317364 100644 --- a/src/tests/http/clients/litestar.py +++ b/src/tests/http/clients/litestar.py @@ -87,12 +87,12 @@ class GraphQLController(OnWSConnectMixin, BaseGraphQLController): graphql_ws_handler_class = DebuggableGraphQLWSHandler async def process_result( - self, request: Request, result: ExecutionResult + self, request: Request, result: ExecutionResult, strict: bool = False ) -> GraphQLHTTPResponse: if result_override: return result_override(result) - return await super().process_result(request, result) + return await super().process_result(request, result, strict) self.app = Litestar(route_handlers=[GraphQLController]) self.client = TestClient(self.app) diff --git a/src/tests/http/clients/quart.py b/src/tests/http/clients/quart.py index fe92093..b92920d 100644 --- a/src/tests/http/clients/quart.py +++ b/src/tests/http/clients/quart.py @@ -63,12 +63,12 @@ async def get_context( return get_context(context) async def process_result( - self, request: QuartRequest, result: ExecutionResult + 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) + return await super().process_result(request, result, strict) class QuartAsgiAppAdapter: diff --git a/src/tests/http/clients/sanic.py b/src/tests/http/clients/sanic.py index d49344b..835bc31 100644 --- a/src/tests/http/clients/sanic.py +++ b/src/tests/http/clients/sanic.py @@ -39,12 +39,12 @@ async def get_context( return get_context(context) async def process_result( - self, request: SanicRequest, result: ExecutionResult + 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) + return await super().process_result(request, result, strict) class SanicHttpClient(HttpClient): diff --git a/src/tests/http/test_graphql_over_http_spec.py b/src/tests/http/test_graphql_over_http_spec.py index 4ddaa74..a09b112 100644 --- a/src/tests/http/test_graphql_over_http_spec.py +++ b/src/tests/http/test_graphql_over_http_spec.py @@ -634,9 +634,6 @@ async def test_7b9b(http_client): assert response.status_code == 200 -@pytest.mark.xfail( - reason="Currently results in status 200 with GraphQL errors", raises=AssertionError -) async def test_865d(http_client): """ SHOULD use 4xx or 5xx status codes on document parsing failure when accepting application/graphql-response+json @@ -652,9 +649,6 @@ async def test_865d(http_client): assert 400 <= response.status_code <= 599 -@pytest.mark.xfail( - reason="Currently results in status 200 with GraphQL errors", raises=AssertionError -) async def test_556a(http_client): """ SHOULD use 400 status code on document parsing failure when accepting application/graphql-response+json @@ -670,9 +664,6 @@ async def test_556a(http_client): assert response.status_code == 400 -@pytest.mark.xfail( - reason="Currently results in status 200 with GraphQL errors", raises=AssertionError -) async def test_d586(http_client): """ SHOULD NOT contain the data entry on document parsing failure when accepting application/graphql-response+json @@ -689,9 +680,6 @@ async def test_d586(http_client): assert "data" not in response.json -@pytest.mark.xfail( - reason="Currently results in status 200 with GraphQL errors", raises=AssertionError -) async def test_51fe(http_client): """ SHOULD use 4xx or 5xx status codes on document validation failure when accepting application/graphql-response+json @@ -709,9 +697,6 @@ async def test_51fe(http_client): assert 400 <= response.status_code <= 599 -@pytest.mark.xfail( - reason="Currently results in status 200 with GraphQL errors", raises=AssertionError -) async def test_74ff(http_client): """ SHOULD use 400 status code on document validation failure when accepting application/graphql-response+json @@ -729,9 +714,6 @@ async def test_74ff(http_client): assert response.status_code == 400 -@pytest.mark.xfail( - reason="Currently results in status 200 with GraphQL errors", raises=AssertionError -) async def test_5e5b(http_client): """ SHOULD NOT contain the data entry on document validation failure when accepting application/graphql-response+json @@ -750,9 +732,6 @@ async def test_5e5b(http_client): assert "data" not in response.json -@pytest.mark.xfail( - reason="Currently results in status 200 with GraphQL errors", raises=AssertionError -) async def test_86ee(http_client): """ SHOULD use a status code of 400 on variable coercion failure when accepting application/graphql-response+json From df01ef1e485281ea25d0930803c76ad8d47245c2 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Mon, 23 Jun 2025 19:38:16 +0200 Subject: [PATCH 093/108] Make more tests pass --- src/graphql_server/http/async_base_view.py | 5 +++++ src/graphql_server/http/sync_base_view.py | 5 +++++ src/tests/http/test_graphql_over_http_spec.py | 3 --- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/graphql_server/http/async_base_view.py b/src/graphql_server/http/async_base_view.py index a09271b..aa71108 100644 --- a/src/graphql_server/http/async_base_view.py +++ b/src/graphql_server/http/async_base_view.py @@ -350,6 +350,11 @@ async def run( ): 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": diff --git a/src/graphql_server/http/sync_base_view.py b/src/graphql_server/http/sync_base_view.py index 3846036..430921e 100644 --- a/src/graphql_server/http/sync_base_view.py +++ b/src/graphql_server/http/sync_base_view.py @@ -212,6 +212,11 @@ def run( ): 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": diff --git a/src/tests/http/test_graphql_over_http_spec.py b/src/tests/http/test_graphql_over_http_spec.py index a09b112..884f947 100644 --- a/src/tests/http/test_graphql_over_http_spec.py +++ b/src/tests/http/test_graphql_over_http_spec.py @@ -455,9 +455,6 @@ async def test_6a70(http_client): assert "errors" not in response.json -@pytest.mark.xfail( - reason="OPTIONAL - Currently not supported by GraphQL-Server", raises=AssertionError -) @pytest.mark.parametrize( "invalid", ["string", 0, False, ["array"]], From 6daea213b4abb2d16e438413a98739e68e8c8ee7 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Mon, 23 Jun 2025 19:48:47 +0200 Subject: [PATCH 094/108] Fix more tests --- src/tests/http/test_graphql_over_http_spec.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/tests/http/test_graphql_over_http_spec.py b/src/tests/http/test_graphql_over_http_spec.py index 884f947..cf32b21 100644 --- a/src/tests/http/test_graphql_over_http_spec.py +++ b/src/tests/http/test_graphql_over_http_spec.py @@ -160,10 +160,6 @@ async def test_9c48(http_client): assert 400 <= response.status_code <= 499 -@pytest.mark.xfail( - reason="OPTIONAL - currently supported by Channels, Chalice, Django, and Sanic", - raises=AssertionError, -) async def test_9abe(http_client): """ MAY respond with 4xx status code if content-type is not supplied on POST requests @@ -171,7 +167,7 @@ async def test_9abe(http_client): response = await http_client.post( url="/graphql", headers={}, - json={"query": "{ __typename }"}, + data=b'{"query": "{ __typename }"}', ) assert 400 <= response.status_code <= 499 From f55f110011897fa3cdb918bbe72711de761e431f Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Mon, 23 Jun 2025 21:52:34 +0200 Subject: [PATCH 095/108] Got all tests fully working --- src/graphql_server/http/async_base_view.py | 3 --- .../protocols/graphql_transport_ws/handlers.py | 6 ------ .../protocols/graphql_ws/handlers.py | 14 +++++++++++++- .../websockets/test_graphql_transport_ws.py | 16 ++++++++-------- src/tests/websockets/test_graphql_ws.py | 9 +++++---- 5 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/graphql_server/http/async_base_view.py b/src/graphql_server/http/async_base_view.py index aa71108..1aee33c 100644 --- a/src/graphql_server/http/async_base_view.py +++ b/src/graphql_server/http/async_base_view.py @@ -145,9 +145,6 @@ async def setup_connection_params( context: Context, root_value: Optional[RootValue], ) -> None: - if connection_params is None: - return - if isinstance(context, dict): context["connection_params"] = connection_params elif hasattr(context, "connection_params"): diff --git a/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py index c17c581..da0bb6a 100644 --- a/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py +++ b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py @@ -296,13 +296,7 @@ async def run_operation(self, operation: Operation[Context, RootValue]) -> None: operation_name=operation.operation_name, ) - # TODO: maybe change PreExecutionError to an exception that can be caught - if isinstance(result_source, ExecutionResult): - # if isinstance(result_source, PreExecutionError): - # assert result_source.errors - # await operation.send_initial_errors(result_source.errors) - # else: await operation.send_next(result_source) else: is_first_result = True diff --git a/src/graphql_server/subscriptions/protocols/graphql_ws/handlers.py b/src/graphql_server/subscriptions/protocols/graphql_ws/handlers.py index 128729a..db63026 100644 --- a/src/graphql_server/subscriptions/protocols/graphql_ws/handlers.py +++ b/src/graphql_server/subscriptions/protocols/graphql_ws/handlers.py @@ -205,15 +205,27 @@ async def handle_async_results( 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={"message": str(e)}, + 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: diff --git a/src/tests/websockets/test_graphql_transport_ws.py b/src/tests/websockets/test_graphql_transport_ws.py index e792ff8..8b5d303 100644 --- a/src/tests/websockets/test_graphql_transport_ws.py +++ b/src/tests/websockets/test_graphql_transport_ws.py @@ -1095,14 +1095,14 @@ 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 + # 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 isinstance(http_client, ChannelsHttpClient): + # pytest.skip("Can't patch on_init for this client") - if not AsyncMock: - pytest.skip("Don't have AsyncMock") + # if not AsyncMock: + # pytest.skip("Don't have AsyncMock") ws = ws_raw handler = None @@ -1210,11 +1210,11 @@ async def test_unexpected_client_disconnects_are_gracefully_handled( "payload": {"query": 'subscription { infinity(message: "Hi") }'}, } ) - await ws.receive(timeout=2) + await ws.receive(timeout=1) assert Subscription.active_infinity_subscriptions == 1 await ws.close() - await asyncio.sleep(1) + 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 index c4054e1..4ae4438 100644 --- a/src/tests/websockets/test_graphql_ws.py +++ b/src/tests/websockets/test_graphql_ws.py @@ -21,6 +21,7 @@ ErrorMessage, StartMessage, ) +from tests.views.schema import Subscription if TYPE_CHECKING: from tests.http.clients.base import HttpClient, WebSocketClient @@ -48,7 +49,7 @@ async def ws(ws_raw: WebSocketClient) -> AsyncGenerator[WebSocketClient, None]: await ws.send_legacy_message({"type": "connection_terminate"}) # make sure the WebSocket is disconnected now - await ws.receive(timeout=2) # receive close + await ws.receive(timeout=1) # receive close assert ws.closed @@ -400,7 +401,7 @@ async def test_subscription_errors(ws: WebSocketClient): 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 data_message["payload"]["data"]["error"] is None assert "errors" in data_message["payload"] assert data_message["payload"]["errors"] is not None @@ -653,7 +654,7 @@ async def test_resolving_enums(ws: WebSocketClient): assert complete_message["id"] == "demo" -@pytest.mark.xfail(reason="flaky test") +# @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. @@ -862,7 +863,7 @@ async def test_unexpected_client_disconnects_are_gracefully_handled( assert Subscription.active_infinity_subscriptions == 1 await ws.close() - await asyncio.sleep(1) + await asyncio.sleep(0.5) assert not process_errors.called assert Subscription.active_infinity_subscriptions == 0 From 41fe9834cddd464fb0dbe3b048c048e0d0d964d7 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Mon, 23 Jun 2025 21:54:03 +0200 Subject: [PATCH 096/108] Remove unuseful comment --- src/tests/http/test_multipart_subscription.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tests/http/test_multipart_subscription.py b/src/tests/http/test_multipart_subscription.py index ec6861f..4cd4107 100644 --- a/src/tests/http/test_multipart_subscription.py +++ b/src/tests/http/test_multipart_subscription.py @@ -26,7 +26,6 @@ def http_client(http_client_class: type[HttpClient]) -> HttpClient: with contextlib.suppress(ImportError): from .clients.channels import SyncChannelsHttpClient - # TODO: why do we have a sync channels client? if http_client_class is SyncChannelsHttpClient: pytest.skip( reason="SyncChannelsHttpClient doesn't support multipart subscriptions" From dcc20a958e90ea0ed12441c6de547c60a30110b2 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Mon, 23 Jun 2025 21:56:49 +0200 Subject: [PATCH 097/108] =?UTF-8?q?Fix=20task=20getter=20if=20there?= =?UTF-8?q?=E2=80=99s=20no=20longer=20a=20running=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../protocols/graphql_transport_ws/handlers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py index da0bb6a..355f669 100644 --- a/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py +++ b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py @@ -334,7 +334,11 @@ async def run_operation(self, operation: Operation[Context, RootValue]) -> None: raise finally: # add this task to a list to be reaped later - task = asyncio.current_task() + try: + task = asyncio.current_task() + except RuntimeError: + # If there's no running loop + return assert task is not None self.completed_tasks.append(task) From 408b9bc7412243314d0bace222431b56308d5c39 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Tue, 24 Jun 2025 07:04:43 +0300 Subject: [PATCH 098/108] Improved variables --- src/graphql_server/http/__init__.py | 4 +++- src/graphql_server/static/graphiql.html | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/graphql_server/http/__init__.py b/src/graphql_server/http/__init__.py index 349fe96..78af7c4 100644 --- a/src/graphql_server/http/__init__.py +++ b/src/graphql_server/http/__init__.py @@ -67,7 +67,9 @@ class GraphQLRequestData: def to_template_context(self) -> dict[str, Any]: return { "query": tojson(self.query), - "variables": tojson(self.variables), + "variables": tojson( + tojson(self.variables) if self.variables is not None else "" + ), "operation_name": tojson(self.operation_name), } diff --git a/src/graphql_server/static/graphiql.html b/src/graphql_server/static/graphiql.html index f73a36b..7b3b2fd 100644 --- a/src/graphql_server/static/graphiql.html +++ b/src/graphql_server/static/graphiql.html @@ -194,7 +194,7 @@ plugins: [explorerPlugin], inputValueDeprecation: true, query: {{query}}, - variables: '{{variables}}', + variables: {{variables}}, headers: parameters.headers, operationName: {{operation_name}}, defaultQuery: EXAMPLE_QUERY, From a694bc0cc6dda217a8fbd1d9d044a470ee076a34 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Tue, 24 Jun 2025 19:52:12 +0300 Subject: [PATCH 099/108] Added support for options/cors --- src/graphql_server/fastapi/router.py | 7 +++++++ src/graphql_server/http/async_base_view.py | 24 ++++++++++++++-------- src/graphql_server/http/sync_base_view.py | 10 +++++---- src/graphql_server/litestar/controller.py | 13 ------------ src/graphql_server/sanic/views.py | 3 +++ src/tests/http/test_http.py | 7 +++++++ src/tests/litestar/app.py | 5 +++++ 7 files changed, 44 insertions(+), 25 deletions(-) diff --git a/src/graphql_server/fastapi/router.py b/src/graphql_server/fastapi/router.py index 70a3ec8..296d360 100644 --- a/src/graphql_server/fastapi/router.py +++ b/src/graphql_server/fastapi/router.py @@ -254,6 +254,13 @@ async def handle_http_post( # pyright: ignore 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, diff --git a/src/graphql_server/http/async_base_view.py b/src/graphql_server/http/async_base_view.py index 1aee33c..89b5e37 100644 --- a/src/graphql_server/http/async_base_view.py +++ b/src/graphql_server/http/async_base_view.py @@ -198,14 +198,12 @@ async def create_websocket_response( async def execute_operation( self, - request: Request, + request_adapter: AsyncHTTPRequestAdapter, request_data: GraphQLRequestData, context: Context, root_value: Optional[RootValue], allowed_operation_types: set[OperationType], ) -> ExecutionResult: - request_adapter = self.request_adapter_class(request) - assert self.schema if request_data.protocol == "multipart-subscription": @@ -279,10 +277,6 @@ async def run( context: Context = UNSET, root_value: Optional[RootValue] = UNSET, ) -> Union[Response, WebSocketResponse]: - root_value = ( - await self.get_root_value(request) if root_value is UNSET else root_value - ) - if self.is_websocket_request(request): websocket_subprotocol = await self.pick_websocket_subprotocol(request) websocket_response = await self.create_websocket_response( @@ -290,6 +284,11 @@ async def run( ) 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 @@ -324,7 +323,16 @@ async def run( 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 @@ -366,7 +374,7 @@ async def run( is_strict = request_data.protocol == "http-strict" try: result = await self.execute_operation( - request=request, + request_adapter=request_adapter, request_data=request_data, context=context, root_value=root_value, diff --git a/src/graphql_server/http/sync_base_view.py b/src/graphql_server/http/sync_base_view.py index 430921e..287e002 100644 --- a/src/graphql_server/http/sync_base_view.py +++ b/src/graphql_server/http/sync_base_view.py @@ -100,14 +100,12 @@ def render_graphql_ide( def execute_operation( self, - request: Request, + request_adapter: SyncHTTPRequestAdapter, request_data: GraphQLRequestData, context: Context, root_value: Optional[RootValue], allowed_operation_types: set[OperationType], ) -> ExecutionResult: - request_adapter = self.request_adapter_class(request) - assert self.schema return execute_sync( @@ -188,6 +186,10 @@ def run( 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.") @@ -232,7 +234,7 @@ def run( is_strict = request_data.protocol == "http-strict" try: result = self.execute_operation( - request=request, + request_adapter=request_adapter, request_data=request_data, context=context, root_value=root_value, diff --git a/src/graphql_server/litestar/controller.py b/src/graphql_server/litestar/controller.py index 528770c..cf2a4fc 100644 --- a/src/graphql_server/litestar/controller.py +++ b/src/graphql_server/litestar/controller.py @@ -374,19 +374,6 @@ async def handle_http_post( 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], diff --git a/src/graphql_server/sanic/views.py b/src/graphql_server/sanic/views.py index e4df08d..f2a37e9 100644 --- a/src/graphql_server/sanic/views.py +++ b/src/graphql_server/sanic/views.py @@ -194,6 +194,9 @@ async def get(self, request: Request) -> HTTPResponse: 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, diff --git a/src/tests/http/test_http.py b/src/tests/http/test_http.py index e7e0f53..589c8c7 100644 --- a/src/tests/http/test_http.py +++ b/src/tests/http/test_http.py @@ -30,3 +30,10 @@ async def test_the_http_handler_uses_the_views_decode_json_method( assert data["hello"] == "Hello world" assert spy.call_count == 1 + + +async def test_does_allow_http_options( + http_client: HttpClient, +): + response = await http_client.request(url="/graphql", method="options") + assert response.status_code in (200, 204) diff --git a/src/tests/litestar/app.py b/src/tests/litestar/app.py index a76a2c7..fb99a37 100644 --- a/src/tests/litestar/app.py +++ b/src/tests/litestar/app.py @@ -3,6 +3,8 @@ from graphql_server.litestar import make_graphql_controller from litestar import Litestar, Request from litestar.di import Provide +from litestar.config.cors import CORSConfig + from tests.views.schema import schema @@ -27,9 +29,12 @@ def create_app(schema=schema, **kwargs: Any): **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, ) From 76d28397b782ecf593d2338bb7131383b9e0363a Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Sat, 5 Jul 2025 00:16:25 +0200 Subject: [PATCH 100/108] Fixed tests --- src/graphql_server/__init__.py | 15 +- src/graphql_server/aiohttp/views.py | 2 +- .../channels/handlers/http_handler.py | 2 +- src/graphql_server/django/__init__.py | 4 +- src/graphql_server/fastapi/router.py | 2 +- src/graphql_server/flask/views.py | 6 +- src/graphql_server/http/__init__.py | 3 +- src/graphql_server/http/async_base_view.py | 2 +- src/graphql_server/http/sync_base_view.py | 2 +- src/graphql_server/litestar/controller.py | 13 ++ src/graphql_server/runtime.py | 24 +-- .../graphql_transport_ws/handlers.py | 1 - .../protocols/graphql_ws/handlers.py | 1 - src/tests/http/test_async_base_view.py | 4 +- src/tests/http/test_graphql_ide.py | 2 +- src/tests/http/test_graphql_over_http_spec.py | 138 ++++++------------ src/tests/http/test_http.py | 5 + src/tests/litestar/app.py | 3 +- .../websockets/test_graphql_transport_ws.py | 4 +- 19 files changed, 89 insertions(+), 144 deletions(-) diff --git a/src/graphql_server/__init__.py b/src/graphql_server/__init__.py index e9f4a4e..b8df3d7 100644 --- a/src/graphql_server/__init__.py +++ b/src/graphql_server/__init__.py @@ -1,5 +1,4 @@ -""" -GraphQL-Server +"""GraphQL-Server =================== GraphQL-Server is a base library that serves as a helper @@ -10,10 +9,10 @@ from .runtime import ( execute, execute_sync, + introspect, + process_errors, subscribe, validate_document, - process_errors, - introspect, ) from .version import version, version_info @@ -23,12 +22,12 @@ __version_info__ = version_info __all__ = [ - "version", - "version_info", "execute", "execute_sync", + "introspect", + "process_errors", "subscribe", "validate_document", - "process_errors", - "introspect", + "version", + "version_info", ] diff --git a/src/graphql_server/aiohttp/views.py b/src/graphql_server/aiohttp/views.py index d08e573..dbab69b 100644 --- a/src/graphql_server/aiohttp/views.py +++ b/src/graphql_server/aiohttp/views.py @@ -17,6 +17,7 @@ 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, @@ -28,7 +29,6 @@ NonTextMessageReceived, WebSocketDisconnected, ) -from graphql_server.http import GraphQLRequestData from graphql_server.http.types import FormData, HTTPMethod, QueryParams from graphql_server.http.typevars import ( Context, diff --git a/src/graphql_server/channels/handlers/http_handler.py b/src/graphql_server/channels/handlers/http_handler.py index 3ec9c3e..154c804 100644 --- a/src/graphql_server/channels/handlers/http_handler.py +++ b/src/graphql_server/channels/handlers/http_handler.py @@ -21,11 +21,11 @@ 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 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 diff --git a/src/graphql_server/django/__init__.py b/src/graphql_server/django/__init__.py index a95776c..7612f2d 100644 --- a/src/graphql_server/django/__init__.py +++ b/src/graphql_server/django/__init__.py @@ -1,3 +1,3 @@ -from .views import GraphQLView, AsyncGraphQLView +from .views import AsyncGraphQLView, GraphQLView -__all__ = ["GraphQLView", "AsyncGraphQLView"] +__all__ = ["AsyncGraphQLView", "GraphQLView"] diff --git a/src/graphql_server/fastapi/router.py b/src/graphql_server/fastapi/router.py index 296d360..cf32f5d 100644 --- a/src/graphql_server/fastapi/router.py +++ b/src/graphql_server/fastapi/router.py @@ -29,9 +29,9 @@ from fastapi.datastructures import Default from fastapi.routing import APIRoute from fastapi.utils import generate_unique_id -from graphql_server.http import GraphQLRequestData 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 diff --git a/src/graphql_server/flask/views.py b/src/graphql_server/flask/views.py index d44475e..5b5ca9f 100644 --- a/src/graphql_server/flask/views.py +++ b/src/graphql_server/flask/views.py @@ -135,11 +135,7 @@ def render_graphql_ide( ) -> Response: return render_template_string( self.graphql_ide_html, - **{ - "query": request_data.query, - "variables": request_data.variables, - "operationName": request_data.operation_name, - }, + query=request_data.query, variables=request_data.variables, operationName=request_data.operation_name, ) # type: ignore diff --git a/src/graphql_server/http/__init__.py b/src/graphql_server/http/__init__.py index 78af7c4..2af8274 100644 --- a/src/graphql_server/http/__init__.py +++ b/src/graphql_server/http/__init__.py @@ -3,10 +3,11 @@ import json import re from dataclasses import dataclass -from graphql.language import DocumentNode 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 diff --git a/src/graphql_server/http/async_base_view.py b/src/graphql_server/http/async_base_view.py index 89b5e37..17423bc 100644 --- a/src/graphql_server/http/async_base_view.py +++ b/src/graphql_server/http/async_base_view.py @@ -16,7 +16,7 @@ from typing_extensions import Literal, TypeGuard from graphql import ExecutionResult, GraphQLError -from graphql.language import OperationType, DocumentNode +from graphql.language import OperationType from graphql.type import GraphQLSchema from graphql_server import execute, subscribe diff --git a/src/graphql_server/http/sync_base_view.py b/src/graphql_server/http/sync_base_view.py index 287e002..141fb72 100644 --- a/src/graphql_server/http/sync_base_view.py +++ b/src/graphql_server/http/sync_base_view.py @@ -5,9 +5,9 @@ Any, Callable, Generic, + Literal, Optional, Union, - Literal, ) from graphql import ExecutionResult, GraphQLError diff --git a/src/graphql_server/litestar/controller.py b/src/graphql_server/litestar/controller.py index cf2a4fc..528770c 100644 --- a/src/graphql_server/litestar/controller.py +++ b/src/graphql_server/litestar/controller.py @@ -374,6 +374,19 @@ async def handle_http_post( 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], diff --git a/src/graphql_server/runtime.py b/src/graphql_server/runtime.py index 2a6a438..c4e6f84 100644 --- a/src/graphql_server/runtime.py +++ b/src/graphql_server/runtime.py @@ -1,15 +1,12 @@ from __future__ import annotations -import warnings from asyncio import ensure_future -from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Iterable -from functools import cached_property, lru_cache +from collections.abc import AsyncGenerator, AsyncIterator, Awaitable from inspect import isawaitable from typing import ( TYPE_CHECKING, Any, Callable, - NamedTuple, Optional, Set, Union, @@ -19,21 +16,12 @@ from graphql import ( ExecutionContext, ExecutionResult, - FieldNode, - FragmentDefinitionNode, - GraphQLBoolean, GraphQLError, - GraphQLField, - GraphQLNamedType, - GraphQLNonNull, - GraphQLObjectType, - GraphQLOutputType, GraphQLSchema, OperationDefinitionNode, get_introspection_query, parse, print_schema, - validate_schema, ) from graphql.error import GraphQLError from graphql.execution import execute as graphql_execute @@ -42,7 +30,6 @@ from graphql.execution.middleware import MiddlewareManager from graphql.language import DocumentNode, OperationType from graphql.type import GraphQLSchema -from graphql.type.directives import specified_directives from graphql.validation import validate from graphql_server.exceptions import GraphQLValidationError, InvalidOperationTypeError @@ -52,12 +39,8 @@ from graphql_server.utils.logs import GraphQLServerLogger if TYPE_CHECKING: - from collections.abc import Iterable, Mapping from typing_extensions import TypeAlias - from graphql.execution.collect_fields import FieldGroup # type: ignore - from graphql.pyutils import Path - from graphql.type import GraphQLResolveInfo from graphql.validation import ASTValidationRule SubscriptionResult: TypeAlias = AsyncGenerator[ExecutionResult, None] @@ -163,7 +146,7 @@ def _parse_and_validate( # async with extensions_runner.parsing(): if not query: raise GraphQLError("No GraphQL query found in the request") - elif not isinstance(query, str) and not isinstance(query, DocumentNode): + 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)}" ) @@ -187,7 +170,8 @@ def _parse_and_validate( raise InvalidOperationTypeError(operation_type, allowed_operation_types) # async with extensions_runner.validation(): - _run_validation(schema, document_node, validation_rules) + if validate_document: + _run_validation(schema, document_node, validation_rules) return document_node diff --git a/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py index 355f669..b790d1e 100644 --- a/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py +++ b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py @@ -13,7 +13,6 @@ ) from graphql import ExecutionResult, GraphQLError, GraphQLSyntaxError, parse - from graphql.language import OperationType from graphql_server import execute, subscribe diff --git a/src/graphql_server/subscriptions/protocols/graphql_ws/handlers.py b/src/graphql_server/subscriptions/protocols/graphql_ws/handlers.py index db63026..9b94137 100644 --- a/src/graphql_server/subscriptions/protocols/graphql_ws/handlers.py +++ b/src/graphql_server/subscriptions/protocols/graphql_ws/handlers.py @@ -25,7 +25,6 @@ StartMessage, StopMessage, ) - from graphql_server.types.unset import UnsetType from graphql_server.utils.debug import pretty_print_graphql_operation diff --git a/src/tests/http/test_async_base_view.py b/src/tests/http/test_async_base_view.py index 8e84aae..e0c21dc 100644 --- a/src/tests/http/test_async_base_view.py +++ b/src/tests/http/test_async_base_view.py @@ -21,8 +21,7 @@ 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. + """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) @@ -34,7 +33,6 @@ async def test_stream_with_heartbeat_should_yield_items_correctly( 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: diff --git a/src/tests/http/test_graphql_ide.py b/src/tests/http/test_graphql_ide.py index f2648ce..39bbd2b 100644 --- a/src/tests/http/test_graphql_ide.py +++ b/src/tests/http/test_graphql_ide.py @@ -1,6 +1,6 @@ -from urllib.parse import quote from typing import Union from typing_extensions import Literal +from urllib.parse import quote import pytest diff --git a/src/tests/http/test_graphql_over_http_spec.py b/src/tests/http/test_graphql_over_http_spec.py index cf32b21..fa8a1ca 100644 --- a/src/tests/http/test_graphql_over_http_spec.py +++ b/src/tests/http/test_graphql_over_http_spec.py @@ -1,5 +1,4 @@ -""" -This file essentially mirrors the GraphQL over HTTP audits: +"""This file essentially mirrors the GraphQL over HTTP audits: https://github.com/graphql/graphql-http/blob/main/src/audits/server.ts """ @@ -26,8 +25,7 @@ raises=AssertionError, ) async def test_22eb(http_client): - """ - SHOULD accept application/graphql-response+json and match the content-type + """SHOULD accept application/graphql-response+json and match the content-type """ response = await http_client.query( method="post", @@ -42,8 +40,7 @@ async def test_22eb(http_client): async def test_4655(http_client): - """ - MUST accept application/json and match the content-type + """MUST accept application/json and match the content-type """ response = await http_client.query( method="post", @@ -58,8 +55,7 @@ async def test_4655(http_client): async def test_47de(http_client): - """ - SHOULD accept */* and use application/json for the content-type + """SHOULD accept */* and use application/json for the content-type """ response = await http_client.query( method="post", @@ -74,8 +70,7 @@ async def test_47de(http_client): async def test_80d8(http_client): - """ - SHOULD assume application/json content-type when accept is missing + """SHOULD assume application/json content-type when accept is missing """ response = await http_client.query( method="post", @@ -87,8 +82,7 @@ async def test_80d8(http_client): async def test_82a3(http_client): - """ - MUST use utf-8 encoding when responding + """MUST use utf-8 encoding when responding """ response = await http_client.query( method="post", @@ -105,8 +99,7 @@ async def test_82a3(http_client): async def test_bf61(http_client): - """ - MUST accept utf-8 encoded request + """MUST accept utf-8 encoded request """ response = await http_client.query( method="post", @@ -117,8 +110,7 @@ async def test_bf61(http_client): async def test_78d5(http_client): - """ - MUST assume utf-8 in request if encoding is unspecified + """MUST assume utf-8 in request if encoding is unspecified """ response = await http_client.query( method="post", @@ -129,8 +121,7 @@ async def test_78d5(http_client): async def test_2c94(http_client): - """ - MUST accept POST requests + """MUST accept POST requests """ response = await http_client.query( method="post", @@ -141,16 +132,14 @@ async def test_2c94(http_client): async def test_5a70(http_client): - """ - MAY accept application/x-www-form-urlencoded formatted GET requests + """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 + """MAY NOT allow executing mutations on GET requests """ response = await http_client.query( method="get", @@ -161,8 +150,7 @@ async def test_9c48(http_client): async def test_9abe(http_client): - """ - MAY respond with 4xx status code if content-type is not supplied on POST requests + """MAY respond with 4xx status code if content-type is not supplied on POST requests """ response = await http_client.post( url="/graphql", @@ -173,8 +161,7 @@ async def test_9abe(http_client): async def test_03d4(http_client): - """ - MUST accept application/json POST requests + """MUST accept application/json POST requests """ response = await http_client.query( method="post", @@ -185,8 +172,7 @@ async def test_03d4(http_client): async def test_a5bf(http_client): - """ - MAY use 400 status code when request body is missing on POST + """MAY use 400 status code when request body is missing on POST """ response = await http_client.post( url="/graphql", @@ -196,8 +182,7 @@ async def test_a5bf(http_client): async def test_423l(http_client): - """ - MAY use 400 status code on missing {query} parameter + """MAY use 400 status code on missing {query} parameter """ response = await http_client.post( url="/graphql", @@ -216,8 +201,7 @@ async def test_423l(http_client): ids=["LKJ0", "LKJ1", "LKJ2", "LKJ3"], ) async def test_lkj_(http_client, invalid): - """ - MAY use 400 status code on invalid {query} parameter + """MAY use 400 status code on invalid {query} parameter """ response = await http_client.post( url="/graphql", @@ -228,8 +212,7 @@ async def test_lkj_(http_client, invalid): async def test_34a2(http_client): - """ - SHOULD allow string {query} parameter when accepting application/graphql-response+json + """SHOULD allow string {query} parameter when accepting application/graphql-response+json """ response = await http_client.query( method="post", @@ -243,8 +226,7 @@ async def test_34a2(http_client): async def test_13ee(http_client): - """ - MUST allow string {query} parameter when accepting application/json + """MUST allow string {query} parameter when accepting application/json """ response = await http_client.query( method="post", @@ -265,8 +247,7 @@ async def test_13ee(http_client): ids=["6C00", "6C01", "6C02", "6C03"], ) async def test_6c0_(http_client, invalid): - """ - MAY use 400 status code on invalid {operationName} parameter + """MAY use 400 status code on invalid {operationName} parameter """ response = await http_client.post( url="/graphql", @@ -280,8 +261,7 @@ async def test_6c0_(http_client, invalid): async def test_8161(http_client): - """ - SHOULD allow string {operationName} parameter when accepting application/graphql-response+json + """SHOULD allow string {operationName} parameter when accepting application/graphql-response+json """ response = await http_client.post( url="/graphql", @@ -298,8 +278,7 @@ async def test_8161(http_client): async def test_b8b3(http_client): - """ - MUST allow string {operationName} parameter when accepting application/json + """MUST allow string {operationName} parameter when accepting application/json """ response = await http_client.post( url="/graphql", @@ -323,8 +302,7 @@ async def test_b8b3(http_client): ids=["94B0", "94B1", "94B2"], ) async def test_94b_(http_client, parameter): - """ - SHOULD allow null variables/operationName/extensions parameter when accepting application/graphql-response+json + """SHOULD allow null variables/operationName/extensions parameter when accepting application/graphql-response+json """ response = await http_client.post( url="/graphql", @@ -347,8 +325,7 @@ async def test_94b_(http_client, parameter): ids=["0220", "0221", "0222"], ) async def test_022_(http_client, parameter): - """ - MUST allow null variables/operationName/extensions parameter when accepting application/json + """MUST allow null variables/operationName/extensions parameter when accepting application/json """ response = await http_client.post( url="/graphql", @@ -371,8 +348,7 @@ async def test_022_(http_client, parameter): ids=["4760", "4761", "4762", "4763"], ) async def test_476_(http_client, invalid): - """ - MAY use 400 status code on invalid {variables} parameter + """MAY use 400 status code on invalid {variables} parameter """ response = await http_client.post( url="/graphql", @@ -386,8 +362,7 @@ async def test_476_(http_client, invalid): async def test_2ea1(http_client): - """ - SHOULD allow map {variables} parameter when accepting application/graphql-response+json + """SHOULD allow map {variables} parameter when accepting application/graphql-response+json """ response = await http_client.post( url="/graphql", @@ -404,8 +379,7 @@ async def test_2ea1(http_client): async def test_28b9(http_client): - """ - MUST allow map {variables} parameter when accepting application/json + """MUST allow map {variables} parameter when accepting application/json """ response = await http_client.post( url="/graphql", @@ -424,8 +398,7 @@ async def test_28b9(http_client): async def test_d6d5(http_client): - """ - MAY allow URL-encoded JSON string {variables} parameter in GETs when accepting application/graphql-response+json + """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 } }", @@ -437,8 +410,7 @@ async def test_d6d5(http_client): async def test_6a70(http_client): - """ - MAY allow URL-encoded JSON string {variables} parameter in GETs when accepting application/json + """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 } }", @@ -457,8 +429,7 @@ async def test_6a70(http_client): ids=["58B0", "58B1", "58B2", "58B3"], ) async def test_58b_(http_client, invalid): - """ - MAY use 400 status code on invalid {extensions} parameter + """MAY use 400 status code on invalid {extensions} parameter """ response = await http_client.post( url="/graphql", @@ -472,8 +443,7 @@ async def test_58b_(http_client, invalid): async def test_428f(http_client): - """ - SHOULD allow map {extensions} parameter when accepting application/graphql-response+json + """SHOULD allow map {extensions} parameter when accepting application/graphql-response+json """ response = await http_client.post( url="/graphql", @@ -490,8 +460,7 @@ async def test_428f(http_client): async def test_1b7a(http_client): - """ - MUST allow map {extensions} parameter when accepting application/json + """MUST allow map {extensions} parameter when accepting application/json """ response = await http_client.post( url="/graphql", @@ -510,8 +479,7 @@ async def test_1b7a(http_client): async def test_b6dc(http_client): - """ - MAY use 4xx or 5xx status codes on JSON parsing failure + """MAY use 4xx or 5xx status codes on JSON parsing failure """ response = await http_client.post( url="/graphql", @@ -522,8 +490,7 @@ async def test_b6dc(http_client): async def test_bcf8(http_client): - """ - MAY use 400 status code on JSON parsing failure + """MAY use 400 status code on JSON parsing failure """ response = await http_client.post( url="/graphql", @@ -534,8 +501,7 @@ async def test_bcf8(http_client): async def test_8764(http_client): - """ - MAY use 4xx or 5xx status codes if parameters are invalid + """MAY use 4xx or 5xx status codes if parameters are invalid """ response = await http_client.post( url="/graphql", @@ -546,8 +512,7 @@ async def test_8764(http_client): async def test_3e3a(http_client): - """ - MAY use 400 status code if parameters are invalid + """MAY use 400 status code if parameters are invalid """ response = await http_client.post( url="/graphql", @@ -558,8 +523,7 @@ async def test_3e3a(http_client): async def test_39aa(http_client): - """ - MUST accept a map for the {extensions} parameter + """MUST accept a map for the {extensions} parameter """ response = await http_client.post( url="/graphql", @@ -578,8 +542,7 @@ async def test_39aa(http_client): async def test_572b(http_client): - """ - SHOULD use 200 status code on document parsing failure when accepting application/json + """SHOULD use 200 status code on document parsing failure when accepting application/json """ response = await http_client.post( url="/graphql", @@ -593,8 +556,7 @@ async def test_572b(http_client): async def test_dfe2(http_client): - """ - SHOULD use 200 status code on document validation failure when accepting application/json + """SHOULD use 200 status code on document validation failure when accepting application/json """ response = await http_client.post( url="/graphql", @@ -610,8 +572,7 @@ async def test_dfe2(http_client): async def test_7b9b(http_client): - """ - SHOULD use a status code of 200 on variable coercion failure when accepting application/json + """SHOULD use a status code of 200 on variable coercion failure when accepting application/json """ response = await http_client.post( url="/graphql", @@ -628,8 +589,7 @@ async def test_7b9b(http_client): async def test_865d(http_client): - """ - SHOULD use 4xx or 5xx status codes on document parsing failure when accepting application/graphql-response+json + """SHOULD use 4xx or 5xx status codes on document parsing failure when accepting application/graphql-response+json """ response = await http_client.post( url="/graphql", @@ -643,8 +603,7 @@ async def test_865d(http_client): async def test_556a(http_client): - """ - SHOULD use 400 status code on document parsing failure when accepting application/graphql-response+json + """SHOULD use 400 status code on document parsing failure when accepting application/graphql-response+json """ response = await http_client.post( url="/graphql", @@ -658,8 +617,7 @@ async def test_556a(http_client): async def test_d586(http_client): - """ - SHOULD NOT contain the data entry on document parsing failure when accepting application/graphql-response+json + """SHOULD NOT contain the data entry on document parsing failure when accepting application/graphql-response+json """ response = await http_client.post( url="/graphql", @@ -674,8 +632,7 @@ async def test_d586(http_client): async def test_51fe(http_client): - """ - SHOULD use 4xx or 5xx status codes on document validation failure when accepting application/graphql-response+json + """SHOULD use 4xx or 5xx status codes on document validation failure when accepting application/graphql-response+json """ response = await http_client.post( url="/graphql", @@ -691,8 +648,7 @@ async def test_51fe(http_client): async def test_74ff(http_client): - """ - SHOULD use 400 status code on document validation failure when accepting application/graphql-response+json + """SHOULD use 400 status code on document validation failure when accepting application/graphql-response+json """ response = await http_client.post( url="/graphql", @@ -708,8 +664,7 @@ async def test_74ff(http_client): async def test_5e5b(http_client): - """ - SHOULD NOT contain the data entry on document validation failure when accepting application/graphql-response+json + """SHOULD NOT contain the data entry on document validation failure when accepting application/graphql-response+json """ response = await http_client.post( url="/graphql", @@ -726,8 +681,7 @@ async def test_5e5b(http_client): async def test_86ee(http_client): - """ - SHOULD use a status code of 400 on variable coercion failure when accepting application/graphql-response+json + """SHOULD use a status code of 400 on variable coercion failure when accepting application/graphql-response+json """ response = await http_client.post( url="/graphql", diff --git a/src/tests/http/test_http.py b/src/tests/http/test_http.py index 589c8c7..60de4ef 100644 --- a/src/tests/http/test_http.py +++ b/src/tests/http/test_http.py @@ -35,5 +35,10 @@ async def test_the_http_handler_uses_the_views_decode_json_method( 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/litestar/app.py b/src/tests/litestar/app.py index fb99a37..474dd0b 100644 --- a/src/tests/litestar/app.py +++ b/src/tests/litestar/app.py @@ -2,9 +2,8 @@ from graphql_server.litestar import make_graphql_controller from litestar import Litestar, Request -from litestar.di import Provide from litestar.config.cors import CORSConfig - +from litestar.di import Provide from tests.views.schema import schema diff --git a/src/tests/websockets/test_graphql_transport_ws.py b/src/tests/websockets/test_graphql_transport_ws.py index 8b5d303..1018320 100644 --- a/src/tests/websockets/test_graphql_transport_ws.py +++ b/src/tests/websockets/test_graphql_transport_ws.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import contextlib import json import time from collections.abc import AsyncGenerator @@ -56,8 +55,7 @@ def assert_next( data: dict[str, object], extensions: Optional[dict[str, object]] = None, ): - """ - Assert that the NextMessage payload contains the provided data. + """Assert that the NextMessage payload contains the provided data. If extensions is provided, it will also assert that the extensions are present """ From 68875c5e0f1c6ff2e7c50e411f03e505eba2c41f Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Sat, 5 Jul 2025 00:16:42 +0200 Subject: [PATCH 101/108] Added github workflow --- .github/workflows/test.yml | 104 +++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 .github/workflows/test.yml 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 From 21a1b76d57eee93201388d4079a5c8a646ab9559 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Sat, 5 Jul 2025 00:31:23 +0200 Subject: [PATCH 102/108] Trying GQL Core 3.3 --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index 27c79b2..f84a465 100644 --- a/noxfile.py +++ b/noxfile.py @@ -12,8 +12,8 @@ PYTHON_VERSIONS = ["3.13", "3.12", "3.11", "3.10", "3.9"] GQL_CORE_VERSIONS = [ + "3.3.0a9", "3.2.3", - # "3.3.0a8", ] COMMON_PYTEST_OPTIONS = [ From 67105854ca96083e7b2cf7b629908890b95c124a Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Sat, 5 Jul 2025 00:35:44 +0200 Subject: [PATCH 103/108] Fix CI --- src/graphql_server/flask/views.py | 4 +- src/tests/http/test_graphql_over_http_spec.py | 135 ++++++------------ .../websockets/test_graphql_transport_ws.py | 2 +- 3 files changed, 49 insertions(+), 92 deletions(-) diff --git a/src/graphql_server/flask/views.py b/src/graphql_server/flask/views.py index 5b5ca9f..6bf56d8 100644 --- a/src/graphql_server/flask/views.py +++ b/src/graphql_server/flask/views.py @@ -135,7 +135,9 @@ def render_graphql_ide( ) -> Response: return render_template_string( self.graphql_ide_html, - query=request_data.query, variables=request_data.variables, operationName=request_data.operation_name, + query=request_data.query, + variables=request_data.variables, + operationName=request_data.operation_name, ) # type: ignore diff --git a/src/tests/http/test_graphql_over_http_spec.py b/src/tests/http/test_graphql_over_http_spec.py index fa8a1ca..f605e7b 100644 --- a/src/tests/http/test_graphql_over_http_spec.py +++ b/src/tests/http/test_graphql_over_http_spec.py @@ -25,8 +25,7 @@ raises=AssertionError, ) async def test_22eb(http_client): - """SHOULD accept application/graphql-response+json and match the content-type - """ + """SHOULD accept application/graphql-response+json and match the content-type""" response = await http_client.query( method="post", headers={ @@ -40,8 +39,7 @@ async def test_22eb(http_client): async def test_4655(http_client): - """MUST accept application/json and match the content-type - """ + """MUST accept application/json and match the content-type""" response = await http_client.query( method="post", headers={ @@ -55,8 +53,7 @@ async def test_4655(http_client): async def test_47de(http_client): - """SHOULD accept */* and use application/json for the content-type - """ + """SHOULD accept */* and use application/json for the content-type""" response = await http_client.query( method="post", headers={ @@ -70,8 +67,7 @@ async def test_47de(http_client): async def test_80d8(http_client): - """SHOULD assume application/json content-type when accept is missing - """ + """SHOULD assume application/json content-type when accept is missing""" response = await http_client.query( method="post", headers={"Content-Type": "application/json"}, @@ -82,8 +78,7 @@ async def test_80d8(http_client): async def test_82a3(http_client): - """MUST use utf-8 encoding when responding - """ + """MUST use utf-8 encoding when responding""" response = await http_client.query( method="post", headers={"Content-Type": "application/json"}, @@ -99,8 +94,7 @@ async def test_82a3(http_client): async def test_bf61(http_client): - """MUST accept utf-8 encoded request - """ + """MUST accept utf-8 encoded request""" response = await http_client.query( method="post", headers={"Content-Type": "application/json; charset=utf-8"}, @@ -110,8 +104,7 @@ async def test_bf61(http_client): async def test_78d5(http_client): - """MUST assume utf-8 in request if encoding is unspecified - """ + """MUST assume utf-8 in request if encoding is unspecified""" response = await http_client.query( method="post", headers={"Content-Type": "application/json"}, @@ -121,8 +114,7 @@ async def test_78d5(http_client): async def test_2c94(http_client): - """MUST accept POST requests - """ + """MUST accept POST requests""" response = await http_client.query( method="post", headers={"Content-Type": "application/json"}, @@ -132,15 +124,13 @@ async def test_2c94(http_client): async def test_5a70(http_client): - """MAY accept application/x-www-form-urlencoded formatted GET requests - """ + """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 - """ + """MAY NOT allow executing mutations on GET requests""" response = await http_client.query( method="get", headers={"Accept": "application/graphql-response+json"}, @@ -150,8 +140,7 @@ async def test_9c48(http_client): async def test_9abe(http_client): - """MAY respond with 4xx status code if content-type is not supplied on POST requests - """ + """MAY respond with 4xx status code if content-type is not supplied on POST requests""" response = await http_client.post( url="/graphql", headers={}, @@ -161,8 +150,7 @@ async def test_9abe(http_client): async def test_03d4(http_client): - """MUST accept application/json POST requests - """ + """MUST accept application/json POST requests""" response = await http_client.query( method="post", headers={"Content-Type": "application/json"}, @@ -172,8 +160,7 @@ async def test_03d4(http_client): async def test_a5bf(http_client): - """MAY use 400 status code when request body is missing on POST - """ + """MAY use 400 status code when request body is missing on POST""" response = await http_client.post( url="/graphql", headers={"Content-Type": "application/json"}, @@ -182,8 +169,7 @@ async def test_a5bf(http_client): async def test_423l(http_client): - """MAY use 400 status code on missing {query} parameter - """ + """MAY use 400 status code on missing {query} parameter""" response = await http_client.post( url="/graphql", headers={ @@ -201,8 +187,7 @@ async def test_423l(http_client): ids=["LKJ0", "LKJ1", "LKJ2", "LKJ3"], ) async def test_lkj_(http_client, invalid): - """MAY use 400 status code on invalid {query} parameter - """ + """MAY use 400 status code on invalid {query} parameter""" response = await http_client.post( url="/graphql", headers={"Content-Type": "application/json"}, @@ -212,8 +197,7 @@ async def test_lkj_(http_client, invalid): async def test_34a2(http_client): - """SHOULD allow string {query} parameter when accepting application/graphql-response+json - """ + """SHOULD allow string {query} parameter when accepting application/graphql-response+json""" response = await http_client.query( method="post", headers={ @@ -226,8 +210,7 @@ async def test_34a2(http_client): async def test_13ee(http_client): - """MUST allow string {query} parameter when accepting application/json - """ + """MUST allow string {query} parameter when accepting application/json""" response = await http_client.query( method="post", headers={ @@ -247,8 +230,7 @@ async def test_13ee(http_client): ids=["6C00", "6C01", "6C02", "6C03"], ) async def test_6c0_(http_client, invalid): - """MAY use 400 status code on invalid {operationName} parameter - """ + """MAY use 400 status code on invalid {operationName} parameter""" response = await http_client.post( url="/graphql", headers={"Content-Type": "application/json"}, @@ -261,8 +243,7 @@ async def test_6c0_(http_client, invalid): async def test_8161(http_client): - """SHOULD allow string {operationName} parameter when accepting application/graphql-response+json - """ + """SHOULD allow string {operationName} parameter when accepting application/graphql-response+json""" response = await http_client.post( url="/graphql", headers={ @@ -278,8 +259,7 @@ async def test_8161(http_client): async def test_b8b3(http_client): - """MUST allow string {operationName} parameter when accepting application/json - """ + """MUST allow string {operationName} parameter when accepting application/json""" response = await http_client.post( url="/graphql", headers={ @@ -302,8 +282,7 @@ async def test_b8b3(http_client): ids=["94B0", "94B1", "94B2"], ) async def test_94b_(http_client, parameter): - """SHOULD allow null variables/operationName/extensions parameter when accepting application/graphql-response+json - """ + """SHOULD allow null variables/operationName/extensions parameter when accepting application/graphql-response+json""" response = await http_client.post( url="/graphql", headers={ @@ -325,8 +304,7 @@ async def test_94b_(http_client, parameter): ids=["0220", "0221", "0222"], ) async def test_022_(http_client, parameter): - """MUST allow null variables/operationName/extensions parameter when accepting application/json - """ + """MUST allow null variables/operationName/extensions parameter when accepting application/json""" response = await http_client.post( url="/graphql", headers={ @@ -348,8 +326,7 @@ async def test_022_(http_client, parameter): ids=["4760", "4761", "4762", "4763"], ) async def test_476_(http_client, invalid): - """MAY use 400 status code on invalid {variables} parameter - """ + """MAY use 400 status code on invalid {variables} parameter""" response = await http_client.post( url="/graphql", headers={"Content-Type": "application/json"}, @@ -362,8 +339,7 @@ async def test_476_(http_client, invalid): async def test_2ea1(http_client): - """SHOULD allow map {variables} parameter when accepting application/graphql-response+json - """ + """SHOULD allow map {variables} parameter when accepting application/graphql-response+json""" response = await http_client.post( url="/graphql", headers={ @@ -379,8 +355,7 @@ async def test_2ea1(http_client): async def test_28b9(http_client): - """MUST allow map {variables} parameter when accepting application/json - """ + """MUST allow map {variables} parameter when accepting application/json""" response = await http_client.post( url="/graphql", headers={ @@ -398,8 +373,7 @@ async def test_28b9(http_client): async def test_d6d5(http_client): - """MAY allow URL-encoded JSON string {variables} parameter in GETs when accepting application/graphql-response+json - """ + """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"}, @@ -410,8 +384,7 @@ async def test_d6d5(http_client): async def test_6a70(http_client): - """MAY allow URL-encoded JSON string {variables} parameter in GETs when accepting application/json - """ + """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"}, @@ -429,8 +402,7 @@ async def test_6a70(http_client): ids=["58B0", "58B1", "58B2", "58B3"], ) async def test_58b_(http_client, invalid): - """MAY use 400 status code on invalid {extensions} parameter - """ + """MAY use 400 status code on invalid {extensions} parameter""" response = await http_client.post( url="/graphql", headers={"Content-Type": "application/json"}, @@ -443,8 +415,7 @@ async def test_58b_(http_client, invalid): async def test_428f(http_client): - """SHOULD allow map {extensions} parameter when accepting application/graphql-response+json - """ + """SHOULD allow map {extensions} parameter when accepting application/graphql-response+json""" response = await http_client.post( url="/graphql", headers={ @@ -460,8 +431,7 @@ async def test_428f(http_client): async def test_1b7a(http_client): - """MUST allow map {extensions} parameter when accepting application/json - """ + """MUST allow map {extensions} parameter when accepting application/json""" response = await http_client.post( url="/graphql", headers={ @@ -479,8 +449,7 @@ async def test_1b7a(http_client): async def test_b6dc(http_client): - """MAY use 4xx or 5xx status codes on JSON parsing failure - """ + """MAY use 4xx or 5xx status codes on JSON parsing failure""" response = await http_client.post( url="/graphql", headers={"Content-Type": "application/json"}, @@ -490,8 +459,7 @@ async def test_b6dc(http_client): async def test_bcf8(http_client): - """MAY use 400 status code on JSON parsing failure - """ + """MAY use 400 status code on JSON parsing failure""" response = await http_client.post( url="/graphql", headers={"Content-Type": "application/json"}, @@ -501,8 +469,7 @@ async def test_bcf8(http_client): async def test_8764(http_client): - """MAY use 4xx or 5xx status codes if parameters are invalid - """ + """MAY use 4xx or 5xx status codes if parameters are invalid""" response = await http_client.post( url="/graphql", headers={"Content-Type": "application/json"}, @@ -512,8 +479,7 @@ async def test_8764(http_client): async def test_3e3a(http_client): - """MAY use 400 status code if parameters are invalid - """ + """MAY use 400 status code if parameters are invalid""" response = await http_client.post( url="/graphql", headers={"Content-Type": "application/json"}, @@ -523,8 +489,7 @@ async def test_3e3a(http_client): async def test_39aa(http_client): - """MUST accept a map for the {extensions} parameter - """ + """MUST accept a map for the {extensions} parameter""" response = await http_client.post( url="/graphql", headers={ @@ -542,8 +507,7 @@ async def test_39aa(http_client): async def test_572b(http_client): - """SHOULD use 200 status code on document parsing failure when accepting application/json - """ + """SHOULD use 200 status code on document parsing failure when accepting application/json""" response = await http_client.post( url="/graphql", headers={ @@ -556,8 +520,7 @@ async def test_572b(http_client): async def test_dfe2(http_client): - """SHOULD use 200 status code on document validation failure when accepting application/json - """ + """SHOULD use 200 status code on document validation failure when accepting application/json""" response = await http_client.post( url="/graphql", headers={ @@ -572,8 +535,7 @@ async def test_dfe2(http_client): async def test_7b9b(http_client): - """SHOULD use a status code of 200 on variable coercion failure when accepting application/json - """ + """SHOULD use a status code of 200 on variable coercion failure when accepting application/json""" response = await http_client.post( url="/graphql", headers={ @@ -589,8 +551,7 @@ async def test_7b9b(http_client): async def test_865d(http_client): - """SHOULD use 4xx or 5xx status codes on document parsing failure when accepting application/graphql-response+json - """ + """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={ @@ -603,8 +564,7 @@ async def test_865d(http_client): async def test_556a(http_client): - """SHOULD use 400 status code on document parsing failure when accepting application/graphql-response+json - """ + """SHOULD use 400 status code on document parsing failure when accepting application/graphql-response+json""" response = await http_client.post( url="/graphql", headers={ @@ -617,8 +577,7 @@ async def test_556a(http_client): async def test_d586(http_client): - """SHOULD NOT contain the data entry on document parsing failure when accepting application/graphql-response+json - """ + """SHOULD NOT contain the data entry on document parsing failure when accepting application/graphql-response+json""" response = await http_client.post( url="/graphql", headers={ @@ -632,8 +591,7 @@ async def test_d586(http_client): async def test_51fe(http_client): - """SHOULD use 4xx or 5xx status codes on document validation failure when accepting application/graphql-response+json - """ + """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={ @@ -648,8 +606,7 @@ async def test_51fe(http_client): async def test_74ff(http_client): - """SHOULD use 400 status code on document validation failure when accepting application/graphql-response+json - """ + """SHOULD use 400 status code on document validation failure when accepting application/graphql-response+json""" response = await http_client.post( url="/graphql", headers={ @@ -664,8 +621,7 @@ async def test_74ff(http_client): async def test_5e5b(http_client): - """SHOULD NOT contain the data entry on document validation failure when accepting application/graphql-response+json - """ + """SHOULD NOT contain the data entry on document validation failure when accepting application/graphql-response+json""" response = await http_client.post( url="/graphql", headers={ @@ -681,8 +637,7 @@ async def test_5e5b(http_client): async def test_86ee(http_client): - """SHOULD use a status code of 400 on variable coercion failure when accepting application/graphql-response+json - """ + """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={ diff --git a/src/tests/websockets/test_graphql_transport_ws.py b/src/tests/websockets/test_graphql_transport_ws.py index 1018320..94d89d7 100644 --- a/src/tests/websockets/test_graphql_transport_ws.py +++ b/src/tests/websockets/test_graphql_transport_ws.py @@ -1086,7 +1086,7 @@ async def test_subsciption_cancel_finalization_delay(ws: WebSocketClient): end = time.time() elapsed = end - start - assert elapsed < delay + assert elapsed < delay * 1.25 # adds a 25% to make sure it runs well in CI async def test_error_handler_for_timeout(http_client: HttpClient): From 673cf2a87840f94ec884d641fd46b11f9e1e19f6 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Sat, 5 Jul 2025 00:51:21 +0200 Subject: [PATCH 104/108] =?UTF-8?q?Make=20all=20http=20spec=20tests=20pass?= =?UTF-8?q?=20=F0=9F=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/graphql_server/aiohttp/views.py | 9 +++++++-- src/graphql_server/asgi/__init__.py | 9 +++++++-- src/graphql_server/chalice/views.py | 9 +++++++-- src/graphql_server/channels/handlers/http_handler.py | 8 +++++++- src/graphql_server/channels/handlers/ws_handler.py | 5 ++++- src/graphql_server/django/views.py | 10 ++++++++-- src/graphql_server/fastapi/router.py | 9 +++++++-- src/graphql_server/flask/views.py | 9 +++++++-- src/graphql_server/http/async_base_view.py | 8 +++++--- src/graphql_server/http/sync_base_view.py | 7 +++++-- src/graphql_server/litestar/controller.py | 9 +++++++-- src/graphql_server/quart/views.py | 9 +++++++-- src/graphql_server/sanic/views.py | 9 +++++++-- src/tests/http/test_graphql_over_http_spec.py | 4 ---- 14 files changed, 85 insertions(+), 29 deletions(-) diff --git a/src/graphql_server/aiohttp/views.py b/src/graphql_server/aiohttp/views.py index dbab69b..f777e54 100644 --- a/src/graphql_server/aiohttp/views.py +++ b/src/graphql_server/aiohttp/views.py @@ -220,12 +220,17 @@ async def get_context( return {"request": request, "response": response} # type: ignore def create_response( - self, response_data: GraphQLHTTPResponse, sub_response: web.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/json", + content_type="application/graphql-response+json" + if is_strict + else "application/json", headers=sub_response.headers, status=status_code or sub_response.status, ) diff --git a/src/graphql_server/asgi/__init__.py b/src/graphql_server/asgi/__init__.py index e50501d..da7c41c 100644 --- a/src/graphql_server/asgi/__init__.py +++ b/src/graphql_server/asgi/__init__.py @@ -211,13 +211,18 @@ async def render_graphql_ide( return HTMLResponse(request_data.to_template_string(self.graphql_ide_html)) def create_response( - self, response_data: GraphQLHTTPResponse, sub_response: 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/json", + media_type="application/graphql-response+json" + if is_strict + else "application/json", ) if sub_response.background: diff --git a/src/graphql_server/chalice/views.py b/src/graphql_server/chalice/views.py index a2a8757..399adb5 100644 --- a/src/graphql_server/chalice/views.py +++ b/src/graphql_server/chalice/views.py @@ -118,13 +118,18 @@ 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 + 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/json", + "Content-Type": "application/graphql-response+json" + if is_strict + else "application/json", **sub_response.headers, }, ) diff --git a/src/graphql_server/channels/handlers/http_handler.py b/src/graphql_server/channels/handlers/http_handler.py index 154c804..00e8e27 100644 --- a/src/graphql_server/channels/handlers/http_handler.py +++ b/src/graphql_server/channels/handlers/http_handler.py @@ -191,11 +191,17 @@ def __init__( super().__init__(**kwargs) def create_response( - self, response_data: GraphQLHTTPResponse, sub_response: TemporalResponse + 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()}, ) diff --git a/src/graphql_server/channels/handlers/ws_handler.py b/src/graphql_server/channels/handlers/ws_handler.py index c7bc906..62984f9 100644 --- a/src/graphql_server/channels/handlers/ws_handler.py +++ b/src/graphql_server/channels/handlers/ws_handler.py @@ -172,7 +172,10 @@ async def get_sub_response(self, request: GraphQLWSConsumer) -> GraphQLWSConsume raise NotImplementedError def create_response( - self, response_data: GraphQLHTTPResponse, sub_response: GraphQLWSConsumer + self, + response_data: GraphQLHTTPResponse, + sub_response: GraphQLWSConsumer, + is_strict: bool, ) -> GraphQLWSConsumer: raise NotImplementedError diff --git a/src/graphql_server/django/views.py b/src/graphql_server/django/views.py index e00b175..3449ba0 100644 --- a/src/graphql_server/django/views.py +++ b/src/graphql_server/django/views.py @@ -167,12 +167,14 @@ def __init__( super().__init__(**kwargs) def create_response( - self, response_data: GraphQLHTTPResponse, sub_response: HttpResponse + self, + response_data: GraphQLHTTPResponse, + sub_response: HttpResponse, + is_strict: bool, ) -> HttpResponseBase: data = self.encode_json(response_data) response = HttpResponse( data, - content_type="application/json", status=sub_response.status_code, ) @@ -182,6 +184,10 @@ def create_response( 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 diff --git a/src/graphql_server/fastapi/router.py b/src/graphql_server/fastapi/router.py index cf32f5d..5849cf2 100644 --- a/src/graphql_server/fastapi/router.py +++ b/src/graphql_server/fastapi/router.py @@ -288,11 +288,16 @@ async def get_sub_response(self, request: Request) -> Response: return self.temporal_response def create_response( - self, response_data: GraphQLHTTPResponse, sub_response: Response + self, + response_data: GraphQLHTTPResponse, + sub_response: Response, + is_strict: bool, ) -> Response: response = Response( self.encode_json(response_data), - media_type="application/json", + media_type="application/graphql-response+json" + if is_strict + else "application/json", status_code=sub_response.status_code or status.HTTP_200_OK, ) diff --git a/src/graphql_server/flask/views.py b/src/graphql_server/flask/views.py index 6bf56d8..b637b5e 100644 --- a/src/graphql_server/flask/views.py +++ b/src/graphql_server/flask/views.py @@ -96,10 +96,15 @@ def __init__( self.graphql_ide = graphql_ide def create_response( - self, response_data: GraphQLHTTPResponse, sub_response: 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 diff --git a/src/graphql_server/http/async_base_view.py b/src/graphql_server/http/async_base_view.py index 17423bc..e8aceff 100644 --- a/src/graphql_server/http/async_base_view.py +++ b/src/graphql_server/http/async_base_view.py @@ -164,7 +164,10 @@ async def get_root_value( @abc.abstractmethod def create_response( - self, response_data: GraphQLHTTPResponse, sub_response: SubResponse + self, + response_data: GraphQLHTTPResponse, + sub_response: SubResponse, + is_strict: bool, ) -> Response: ... @abc.abstractmethod @@ -383,7 +386,6 @@ async def run( except GraphQLValidationError as e: if is_strict: sub_response.status_code = 400 # type: ignore - # sub_response.headers["content-type"] = "application/graphql-response+json" result = ExecutionResult(data=None, errors=e.errors) except HTTPException: raise @@ -415,7 +417,7 @@ async def run( self._handle_errors(result.errors, response_data) return self.create_response( - response_data=response_data, sub_response=sub_response + response_data=response_data, sub_response=sub_response, is_strict=is_strict ) def encode_multipart_data(self, data: Any, separator: str) -> str: diff --git a/src/graphql_server/http/sync_base_view.py b/src/graphql_server/http/sync_base_view.py index 141fb72..3b0b0d7 100644 --- a/src/graphql_server/http/sync_base_view.py +++ b/src/graphql_server/http/sync_base_view.py @@ -90,7 +90,10 @@ def get_root_value(self, request: Request) -> Optional[RootValue]: ... @abc.abstractmethod def create_response( - self, response_data: GraphQLHTTPResponse, sub_response: SubResponse + self, + response_data: GraphQLHTTPResponse, + sub_response: SubResponse, + is_strict: bool, ) -> Response: ... @abc.abstractmethod @@ -261,7 +264,7 @@ def run( self._handle_errors(result.errors, response_data) return self.create_response( - response_data=response_data, sub_response=sub_response + response_data=response_data, sub_response=sub_response, is_strict=is_strict ) def process_result( diff --git a/src/graphql_server/litestar/controller.py b/src/graphql_server/litestar/controller.py index 528770c..828c6e2 100644 --- a/src/graphql_server/litestar/controller.py +++ b/src/graphql_server/litestar/controller.py @@ -309,12 +309,17 @@ async def render_graphql_ide( ) def create_response( - self, response_data: GraphQLHTTPResponse, sub_response: Response[bytes] + 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=MediaType.JSON, + media_type="application/graphql-response+json" + if is_strict + else MediaType.JSON, ) response.headers.update(sub_response.headers) diff --git a/src/graphql_server/quart/views.py b/src/graphql_server/quart/views.py index 33559cb..ab82c50 100644 --- a/src/graphql_server/quart/views.py +++ b/src/graphql_server/quart/views.py @@ -156,10 +156,15 @@ async def render_graphql_ide( return Response(request_data.to_template_string(self.graphql_ide_html)) def create_response( - self, response_data: "GraphQLHTTPResponse", sub_response: 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( diff --git a/src/graphql_server/sanic/views.py b/src/graphql_server/sanic/views.py index f2a37e9..cc17b23 100644 --- a/src/graphql_server/sanic/views.py +++ b/src/graphql_server/sanic/views.py @@ -165,7 +165,10 @@ async def get_sub_response(self, request: Request) -> TemporalResponse: return TemporalResponse() def create_response( - self, response_data: GraphQLHTTPResponse, sub_response: TemporalResponse + self, + response_data: GraphQLHTTPResponse, + sub_response: TemporalResponse, + is_strict: bool, ) -> HTTPResponse: status_code = sub_response.status_code @@ -174,7 +177,9 @@ def create_response( return HTTPResponse( data, status=status_code, - content_type="application/json", + content_type="application/graphql-response+json" + if is_strict + else "application/json", headers=sub_response.headers, ) diff --git a/src/tests/http/test_graphql_over_http_spec.py b/src/tests/http/test_graphql_over_http_spec.py index f605e7b..1ce50ef 100644 --- a/src/tests/http/test_graphql_over_http_spec.py +++ b/src/tests/http/test_graphql_over_http_spec.py @@ -20,10 +20,6 @@ SanicHttpClient = type(None) -@pytest.mark.xfail( - reason="Our integrations currently only return application/json", - raises=AssertionError, -) async def test_22eb(http_client): """SHOULD accept application/graphql-response+json and match the content-type""" response = await http_client.query( From 292643d09eef79c162131375e5ae33cc324901ca Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Sat, 5 Jul 2025 00:52:16 +0200 Subject: [PATCH 105/108] Fix CI --- src/tests/websockets/test_graphql_transport_ws.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/websockets/test_graphql_transport_ws.py b/src/tests/websockets/test_graphql_transport_ws.py index 94d89d7..dbfaa3b 100644 --- a/src/tests/websockets/test_graphql_transport_ws.py +++ b/src/tests/websockets/test_graphql_transport_ws.py @@ -1086,7 +1086,7 @@ async def test_subsciption_cancel_finalization_delay(ws: WebSocketClient): end = time.time() elapsed = end - start - assert elapsed < delay * 1.25 # adds a 25% to make sure it runs well in CI + 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): From 8774f3ca3d296646fe1b83b74b395f7d107cb81d Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Sat, 5 Jul 2025 01:24:34 +0200 Subject: [PATCH 106/108] Add WebOb integration (#129) * Add WebOb integration * Include WebOb in integration tests --- README.md | 1 + noxfile.py | 2 + pyproject.toml | 4 +- src/graphql_server/webob/__init__.py | 3 + src/graphql_server/webob/views.py | 123 +++++++++++++++++++++++ src/tests/http/clients/webob.py | 143 +++++++++++++++++++++++++++ src/tests/http/conftest.py | 1 + 7 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 src/graphql_server/webob/__init__.py create mode 100644 src/graphql_server/webob/views.py create mode 100644 src/tests/http/clients/webob.py diff --git a/README.md b/README.md index c7d86e6..cfd2540 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ for building GraphQL servers or integrations into existing web frameworks using | 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) | diff --git a/noxfile.py b/noxfile.py index f84a465..c7cca39 100644 --- a/noxfile.py +++ b/noxfile.py @@ -38,6 +38,7 @@ "django", "fastapi", "flask", + "webob", "quart", "sanic", "litestar", @@ -119,6 +120,7 @@ def tests_starlette(session: Session, gql_core: str) -> None: "channels", "fastapi", "flask", + "webob", "quart", "sanic", "litestar", diff --git a/pyproject.toml b/pyproject.toml index 081666e..ce70fac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ 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", "pyright", "mypy", "codeflash"] +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", @@ -47,6 +47,7 @@ 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" @@ -64,6 +65,7 @@ markers = [ "flaky", "flask", "litestar", + "webob", "pydantic", "quart", "relay", 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/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 index cd8b8b6..2b7a563 100644 --- a/src/tests/http/conftest.py +++ b/src/tests/http/conftest.py @@ -18,6 +18,7 @@ def _get_http_client_classes() -> Generator[Any, None, None]: ("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]), From 759b1c886a59606cd0667b79c66fbc3470de50d0 Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Sat, 5 Jul 2025 01:42:11 +0200 Subject: [PATCH 107/108] Expand AGENTS instructions (#130) --- AGENTS.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 AGENTS.md 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 +``` From c19d43210187ec8130db0845fd3997ffa498ae2d Mon Sep 17 00:00:00 2001 From: Syrus Akbary Date: Tue, 8 Jul 2025 17:13:30 +0200 Subject: [PATCH 108/108] Fixed subscriptions if document and not query is provided --- .../graphql_transport_ws/handlers.py | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py index b790d1e..5871f92 100644 --- a/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py +++ b/src/graphql_server/subscriptions/protocols/graphql_transport_ws/handlers.py @@ -216,13 +216,20 @@ async def handle_subscribe(self, message: SubscribeMessage) -> None: await self.websocket.close(code=4401, reason="Unauthorized") return - try: - graphql_document = parse(message["payload"]["query"]) - except GraphQLSyntaxError as exc: - await self.websocket.close(code=4400, reason=exc.message) - return + request_data = await self.view.get_graphql_request_data( + self.websocket, self.context, message["payload"], "subscription" + ) - operation_name = message["payload"].get("operationName") + 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) @@ -248,15 +255,11 @@ async def handle_subscribe(self, message: SubscribeMessage) -> None: if self.debug: # pragma: no cover pretty_print_graphql_operation( - message["payload"].get("operationName"), - message["payload"]["query"], - message["payload"].get("variables"), + request_data.operation_name, + request_data.query, + request_data.variables, ) - request_data = await self.view.get_graphql_request_data( - self.websocket, self.context, message["payload"], "subscription" - ) - operation = Operation( self, message["id"],