├── tests ├── __init__.py ├── conftest.py ├── helpers.py ├── example.py ├── test_utils.py ├── test_execution.py ├── test_build.py ├── test_schema.py └── test_types.py ├── docs ├── requirements.txt ├── _static │ └── quiz-logo.png ├── index.rst ├── examples.rst ├── Makefile ├── api.rst ├── conf.py ├── guide.rst └── advanced.rst ├── pytest.ini ├── .github ├── dependabot.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── tests.yml ├── .coveragerc ├── .readthedocs.yml ├── Makefile ├── src └── quiz │ ├── __init__.py │ ├── utils.py │ ├── execution.py │ ├── build.py │ ├── types.py │ └── schema.py ├── tox.ini ├── pyproject.toml ├── .gitignore ├── CHANGELOG.rst ├── README.rst ├── LICENSE └── poetry.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx~=8.2.3 2 | furo~=2025.9 3 | -------------------------------------------------------------------------------- /docs/_static/quiz-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ariebovenberg/quiz/HEAD/docs/_static/quiz-logo.png -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs = examples .* 3 | markers = 4 | live: mark test as interacting with the network 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = 3 | quiz 4 | tests 5 | branch = true 6 | [report] 7 | exclude_lines= 8 | pragma: no cover 9 | fail_under=100 10 | show_missing=true 11 | skip_covered=true 12 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Release checklist (if applicable) 2 | 3 | - [ ] Issue(s) referenced (optional) 4 | - [ ] Implementation done 5 | - [ ] Changelog updated 6 | - [ ] Version bump 7 | - [ ] CI checks OK 8 | - [ ] PR merged 9 | - [ ] Branch deleted 10 | - [ ] Tag added 11 | - [ ] Release published 12 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | builder: html 5 | configuration: docs/conf.py 6 | fail_on_warning: true 7 | 8 | build: 9 | os: ubuntu-20.04 10 | tools: 11 | python: "3.11" 12 | 13 | python: 14 | install: 15 | - requirements: docs/requirements.txt 16 | - method: pip 17 | path: . 18 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | Contents 4 | ======== 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | guide.rst 10 | advanced.rst 11 | examples.rst 12 | api.rst 13 | 14 | 15 | .. include:: ../CHANGELOG.rst 16 | 17 | 18 | Indices and tables 19 | ================== 20 | 21 | * :ref:`genindex` 22 | * :ref:`modindex` 23 | * :ref:`search` 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: docs test build publish clean 2 | 3 | init: 4 | poetry install 5 | 6 | test: 7 | tox --parallel auto 8 | 9 | coverage: 10 | pytest --cov --cov-report html --cov-report term --live 11 | 12 | clean: 13 | find . | grep -E "(__pycache__|\.pyc|\.pyo$$)" | xargs rm -rf 14 | 15 | docs: 16 | @touch docs/api.rst 17 | make -C docs/ html 18 | 19 | format: 20 | black src tests 21 | isort src tests 22 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | .. _examples: 2 | 3 | Examples 4 | ======== 5 | 6 | There are several example clients implemented: 7 | 8 | * `hubql `_ for the GitHub API. 9 | * `quiz-yelp `_ for the Yelp API. 10 | * `quiz-swapi `_ for the Star Wars API. 11 | 12 | Have a look at the client as well as the tests for examples. 13 | -------------------------------------------------------------------------------- /src/quiz/__init__.py: -------------------------------------------------------------------------------- 1 | """Root of the quiz API. 2 | 3 | The entire public API is available at root level: 4 | 5 | >>> import quiz 6 | >>> quiz.Schema, quiz.execute, quiz.SelectionError, ... 7 | """ 8 | 9 | from .build import * # noqa 10 | from .execution import * # noqa 11 | from .schema import * # noqa 12 | from .types import * # noqa 13 | 14 | from . import build, schema # isort:skip 15 | 16 | # Single-sourcing the version number with poetry: 17 | # https://github.com/python-poetry/poetry/pull/2366#issuecomment-652418094 18 | __version__ = __import__("importlib.metadata").metadata.version(__name__) 19 | 20 | __all__ = ["build", "schema"] 21 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = Quiz 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "**" 7 | push: 8 | branches: 9 | - master 10 | 11 | 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 20 | 21 | steps: 22 | - uses: actions/checkout@v1 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v2 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install tox tox-gh-actions poetry==2.1.1 31 | - name: Test with tox 32 | run: tox 33 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | from os.path import dirname, join 3 | 4 | import pytest 5 | 6 | 7 | @pytest.fixture(scope="session") 8 | def raw_schema(): 9 | with open(join(dirname(__file__), "example_schema.json")) as rfile: 10 | return json.load(rfile) 11 | 12 | 13 | def pytest_addoption(parser): 14 | parser.addoption( 15 | "--live", action="store_true", default=False, help="run live tests" 16 | ) 17 | 18 | 19 | def pytest_collection_modifyitems(config, items): # pragma: no cover 20 | if config.getoption("--live"): 21 | # --live given in cli: do not skip live tests 22 | return 23 | skip_live = pytest.mark.skip(reason="need --live option to run") 24 | for item in items: 25 | if "live" in item.keywords: 26 | item.add_marker(skip_live) 27 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API reference 2 | ============= 3 | 4 | .. automodule:: quiz 5 | 6 | 7 | ``quiz.build`` 8 | -------------- 9 | 10 | .. automodule:: quiz.build 11 | :members: 12 | :special-members: 13 | :show-inheritance: 14 | :exclude-members: __namedtuple_cls__, __hash__, __weakref__, __getnewargs__, 15 | __new__, __repr__, __eq__, __ne__, __init__, __next_in_mro__ 16 | 17 | 18 | ``quiz.types`` 19 | -------------- 20 | 21 | .. automodule:: quiz.types 22 | :members: 23 | :special-members: 24 | :show-inheritance: 25 | :exclude-members: __namedtuple_cls__, __hash__, __weakref__, __getnewargs__, 26 | __new__, __repr__, __eq__, __ne__, __init__, __next_in_mro__ 27 | 28 | 29 | .. _execution-api: 30 | 31 | ``quiz.execution`` 32 | ------------------ 33 | 34 | .. automodule:: quiz.execution 35 | :members: 36 | 37 | 38 | ``quiz.schema`` 39 | --------------- 40 | 41 | .. automodule:: quiz.schema 42 | :members: 43 | 44 | 45 | ``quiz.utils`` 46 | -------------- 47 | 48 | .. automodule:: quiz.utils 49 | :members: 50 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | import snug 2 | 3 | 4 | class MockClient: 5 | def __init__(self, response): 6 | self.response = response 7 | 8 | def send(self, req): 9 | self.request = req 10 | return self.response 11 | 12 | 13 | class MockAsyncClient: 14 | def __init__(self, response): 15 | self.response = response 16 | 17 | async def send(self, req): 18 | self.request = req 19 | return self.response 20 | 21 | 22 | snug.send.register(MockClient, MockClient.send) 23 | snug.send_async.register(MockAsyncClient, MockAsyncClient.send) 24 | 25 | 26 | class AlwaysEquals: 27 | """useful for testing correct __eq__, __ne__ implementations""" 28 | 29 | def __eq__(self, other): 30 | return True 31 | 32 | def __ne__(self, other): 33 | return False 34 | 35 | 36 | class NeverEquals: 37 | """useful for testing correct __eq__, __ne__ implementations""" 38 | 39 | def __eq__(self, other): 40 | return False 41 | 42 | def __ne__(self, other): 43 | return True 44 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = true 3 | envlist = py{39,310,311,312,313},lint,docs,isort 4 | [testenv] 5 | allowlist_externals = 6 | poetry 7 | setenv= 8 | POETRY_VIRTUALENVS_CREATE=false 9 | commands_pre= 10 | poetry install -n -v --no-root 11 | commands= 12 | poetry run pytest {posargs} 13 | 14 | [testenv:py310] 15 | commands = 16 | poetry run pytest --live --cov=quiz {posargs} 17 | 18 | [testenv:lint] 19 | commands= 20 | poetry run black --check --diff src/ tests/ 21 | poetry run flake8 src/ tests/ 22 | 23 | [testenv:isort] 24 | commands= 25 | poetry run isort --check-only --diff src/ tests/ 26 | 27 | [testenv:docs] 28 | deps= 29 | -rdocs/requirements.txt 30 | commands= 31 | sphinx-build -W -d "{toxworkdir}/docs_doctree" docs "{toxworkdir}/docs_out" \ 32 | --color -bhtml 33 | python -c 'import pathlib; print("documentation available under " \ 34 | + (pathlib.Path(r"{toxworkdir}") / "docs_out" / "index.html").as_uri())' 35 | 36 | [gh-actions] 37 | python = 38 | 3.9: py39 39 | 3.10: py310 40 | 3.11: py311 41 | 3.12: py312, lint, isort, docs 42 | 3.13: py313 43 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | 2 | # -- Project information ----------------------------------------------------- 3 | import importlib.metadata 4 | 5 | metadata = importlib.metadata.metadata("quiz") 6 | project = metadata["Name"] 7 | author = metadata["Author"] 8 | version = metadata["Version"] 9 | 10 | # -- General configuration ------------------------------------------------ 11 | 12 | extensions = [ 13 | "sphinx.ext.autodoc", 14 | "sphinx.ext.intersphinx", 15 | "sphinx.ext.napoleon", 16 | "sphinx.ext.viewcode", 17 | ] 18 | templates_path = ["_templates"] 19 | source_suffix = ".rst" 20 | 21 | master_doc = "index" 22 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 23 | 24 | pygments_style = 'sphinx' 25 | 26 | # -- Options for HTML output ---------------------------------------------- 27 | 28 | autodoc_member_order = "bysource" 29 | html_theme = "furo" 30 | highlight_language = "python3" 31 | intersphinx_mapping = { 32 | "python": ("https://docs.python.org/3", None), 33 | "gentools": ("https://gentools.readthedocs.io/en/latest/", None), 34 | "aiohttp": ("https://docs.aiohttp.org/en/latest/", None), 35 | "requests": ("https://requests.readthedocs.io/en/latest/", None), 36 | } 37 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "quiz" 3 | version = "0.3.2" 4 | description = "Capable GraphQL client" 5 | authors = ["Arie Bovenberg "] 6 | license = "MIT" 7 | classifiers = [ 8 | "Development Status :: 3 - Alpha", 9 | "Intended Audience :: Developers", 10 | "License :: OSI Approved :: Apache Software License", 11 | "Programming Language :: Python :: 3.9", 12 | "Programming Language :: Python :: 3.10", 13 | "Programming Language :: Python :: 3.11", 14 | "Programming Language :: Python :: 3.12", 15 | "Programming Language :: Python :: 3.13", 16 | ] 17 | packages = [ 18 | { include = "quiz", from = "src" }, 19 | ] 20 | readme = "README.rst" 21 | include = ["CHANGELOG.rst", "README.rst"] 22 | repository = "https://github.com/ariebovenberg/quiz" 23 | keywords = [ 24 | "http", 25 | "async", 26 | "graphql", 27 | ] 28 | 29 | [tool.poetry.dependencies] 30 | python = "^3.9" 31 | gentools = "^1.2.0" 32 | snug = "^2.2.0" 33 | 34 | [tool.poetry.dev-dependencies] 35 | flake8 = "^7" 36 | isort = "^6" 37 | mypy = "^1.18" 38 | pytest = "^8" 39 | black = "^25" 40 | pytest-cov = "^7" 41 | pytest-mock = "^3" 42 | hypothesis = "^6.141.1" 43 | 44 | [tool.black] 45 | line-length = 79 46 | include = '\.pyi?$' 47 | exclude = ''' 48 | /( 49 | \.eggs 50 | | \.git 51 | | \.mypy_cache 52 | | \.tox 53 | | \.venv 54 | | _build 55 | | build 56 | | dist 57 | )/ 58 | ''' 59 | 60 | [tool.isort] 61 | line_length = 79 62 | profile = 'black' 63 | 64 | [build-system] 65 | requires = ["poetry-core>=1.0.0"] 66 | build-backend = "poetry.core.masonry.api" 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Release history 2 | --------------- 3 | 4 | development 5 | +++++++++++ 6 | 7 | 0.3.2 (2023-03-02) 8 | ++++++++++++++++++ 9 | 10 | - Relax version constraint on ``importlib-metadata`` backport 11 | 12 | 0.3.1 (2022-11-17) 13 | ++++++++++++++++++ 14 | 15 | - Relax ``importlib`` backport dependency 16 | 17 | 0.3.0 (2022-11-16) 18 | ++++++++++++++++++ 19 | 20 | - Official Python 3.10 and 3.11 support, drop 3.6 21 | - Update docs theme 22 | 23 | 0.2.3 (2021-05-14) 24 | ++++++++++++++++++ 25 | 26 | - Move to use poetry, github actions 27 | 28 | 0.2.2 (2020-12-05) 29 | ++++++++++++++++++ 30 | 31 | - Official Python 3.9 support 32 | 33 | 0.2.1 (2020-09-11) 34 | ++++++++++++++++++ 35 | 36 | - Drop Python 3.5 support 37 | 38 | 0.2.0 (2019-10-28) 39 | ++++++++++++++++++ 40 | 41 | - Drop Python 2 support 42 | - Add Python 3.8 support 43 | 44 | 0.1.6 (2019-04-07) 45 | ++++++++++++++++++ 46 | 47 | - Drop python 3.4 suport 48 | 49 | 0.1.5 (2019-03-16) 50 | ++++++++++++++++++ 51 | 52 | - Include request/response metadata in responses (#95) 53 | 54 | 0.1.4 (2019-03-05) 55 | ++++++++++++++++++ 56 | 57 | - Fixed issue with single-type unions (#100) 58 | 59 | 0.1.3 (2019-02-16) 60 | ++++++++++++++++++ 61 | 62 | - Add request context to `HTTPError` (#82) 63 | 64 | 0.1.2 (2019-01-11) 65 | ++++++++++++++++++ 66 | 67 | - Handle error responses without ``data`` correctly 68 | 69 | 0.1.1 (2018-10-30) 70 | ++++++++++++++++++ 71 | 72 | - Fixed deserialization of ``Enum`` values 73 | 74 | 0.1.0 (2018-10-30) 75 | ++++++++++++++++++ 76 | 77 | - Fixed handling of HTTP error status codes (#10) 78 | - Fix in validation exceptions (#11) 79 | - Implement custom scalars 80 | - Improvements to documentation 81 | 82 | 0.0.4 (2018-10-17) 83 | ++++++++++++++++++ 84 | 85 | - Remove some unneeded fields from introspection query 86 | - Improvements to documentation 87 | - Small fixes to API, tests 88 | 89 | 0.0.3 (2018-09-23) 90 | ++++++++++++++++++ 91 | 92 | - Established initial public API 93 | - Improved documentation, user guide 94 | - Field aliases 95 | - Deserialization 96 | 97 | 0.0.2 (2018-08-21) 98 | ++++++++++++++++++ 99 | 100 | - Execution of basic GraphQL queries 101 | - Convert GraphQL schema to python types (undocumented) 102 | - Write GraphQL in python syntax (undocumented) 103 | 104 | 0.0.1 105 | +++++ 106 | 107 | - initial version 108 | -------------------------------------------------------------------------------- /tests/example.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from functools import partial 3 | 4 | import quiz as q 5 | from quiz.utils import FrozenDict 6 | 7 | mkfield = partial( 8 | q.FieldDefinition, 9 | args=FrozenDict.EMPTY, 10 | is_deprecated=False, 11 | desc="", 12 | deprecation_reason=None, 13 | ) 14 | 15 | 16 | Command = q.Enum( 17 | "Command", {"SIT": "SIT", "DOWN": "DOWN", "ROLL_OVER": "ROLL_OVER"} 18 | ) 19 | 20 | Color = q.Enum( 21 | "Color", {"BROWN": "BROWN", "BLACK": "BLACK", "GOLDEN": "GOLDEN"} 22 | ) 23 | 24 | 25 | class MyDateTime(q.Scalar): 26 | """an example datetime""" 27 | 28 | def __init__(self, dtime): 29 | self.dtime = dtime 30 | 31 | @classmethod 32 | def __gql_load__(cls, data): 33 | return cls(datetime.fromtimestamp(data)) 34 | 35 | def __eq__(self, other): 36 | return self.dtime == other.dtime 37 | 38 | 39 | class Sentient(q.types.Namespace, metaclass=q.Interface): 40 | name = mkfield("name", type=str) 41 | 42 | 43 | class Hobby(q.Object): 44 | name = mkfield("name", type=str) 45 | cool_factor = mkfield("description", type=int) 46 | 47 | 48 | class Human(Sentient, q.Object): 49 | name = mkfield("name", type=str) 50 | hobbies = mkfield("hobbies", type=q.Nullable[q.List[q.Nullable[Hobby]]]) 51 | 52 | 53 | class Alien(Sentient, q.Object): 54 | name = mkfield("name", type=str) 55 | home_planet = mkfield("home_planer", type=q.Nullable[str]) 56 | 57 | 58 | class Dog(Sentient, q.Object): 59 | """An example type""" 60 | 61 | name = mkfield("name", type=str) 62 | color = mkfield("color", type=q.Nullable[Color]) 63 | is_housetrained = mkfield( 64 | "is_housetrained", 65 | args=FrozenDict( 66 | { 67 | "at_other_homes": q.InputValue( 68 | "at_other_homes", "", type=q.Nullable[bool] 69 | ) 70 | } 71 | ), 72 | type=bool, 73 | ) 74 | bark_volume = mkfield("bark_volume", type=int) 75 | knows_command = mkfield( 76 | "knows_command", 77 | args=FrozenDict( 78 | {"command": q.InputValue("command", "the command", type=Command)} 79 | ), 80 | type=bool, 81 | ) 82 | owner = mkfield("owner", type=q.Nullable[Human]) 83 | best_friend = mkfield("best_friend", type=q.Nullable[Sentient]) 84 | age = mkfield( 85 | "age", 86 | type=int, 87 | args=FrozenDict( 88 | { 89 | "on_date": q.InputValue( 90 | "on_date", "", type=q.Nullable[MyDateTime] 91 | ) 92 | } 93 | ), 94 | ) 95 | birthday = mkfield("birthday", type=MyDateTime) 96 | data = mkfield("data", type=q.GenericScalar) 97 | 98 | 99 | class DogQuery(q.Object): 100 | dog = mkfield("dog", type=Dog) 101 | 102 | 103 | class Person(q.Union): 104 | __args__ = (Human, Alien) 105 | 106 | 107 | Human.best_friend = mkfield("best_friend", type=q.Nullable[Person]) 108 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import sys 3 | 4 | import pytest 5 | 6 | from quiz import utils 7 | 8 | from .helpers import AlwaysEquals, NeverEquals 9 | 10 | 11 | class TestMergeMappings: 12 | def test_empty(self): 13 | assert utils.merge() == {} 14 | 15 | def test_single(self): 16 | assert utils.merge({"foo": 4}) == {"foo": 4} 17 | 18 | def test_multiple(self): 19 | result = utils.merge( 20 | utils.FrozenDict({"foo": 5}), {"foo": 9, "bla": 2}, {"blabla": 1} 21 | ) 22 | assert isinstance(result, utils.FrozenDict) 23 | assert result == {"foo": 9, "bla": 2, "blabla": 1} 24 | 25 | 26 | class TestInitList: 27 | def test_simple(self): 28 | assert utils.init_last([1, 2, 3, 4, 5]) == ([1, 2, 3, 4], 5) 29 | 30 | def test_empty(self): 31 | with pytest.raises(utils.Empty): 32 | utils.init_last([]) 33 | 34 | 35 | class TestCompose: 36 | def test_empty(self): 37 | obj = object() 38 | func = utils.compose() 39 | assert func(obj) is obj 40 | assert isinstance(func.funcs, tuple) 41 | assert func.funcs == () 42 | 43 | def test_one_func_with_multiple_args(self): 44 | func = utils.compose(int) 45 | assert func("10", base=5) == 5 46 | assert isinstance(func.funcs, tuple) 47 | assert func.funcs == (int,) 48 | 49 | def test_multiple_funcs(self): 50 | func = utils.compose(str, lambda x: x + 1, int) 51 | assert isinstance(func.funcs, tuple) 52 | assert func("30", base=5) == "16" 53 | 54 | 55 | class TestValueObject: 56 | def test_simple(self): 57 | class MyBase: 58 | pass 59 | 60 | class Foo(MyBase, utils.ValueObject): 61 | """my foo class""" 62 | 63 | __fields__ = [ 64 | ("foo", int, "the foo"), 65 | ("bla", str, "description for bla"), 66 | ] 67 | 68 | assert Foo.__doc__ == "my foo class" 69 | assert issubclass(Foo, utils.ValueObject) 70 | assert issubclass(Foo, MyBase) 71 | 72 | Foo.__qualname__ = "my_module.Foo" 73 | assert inspect.signature(Foo) == inspect.signature( 74 | Foo.__namedtuple_cls__ 75 | ) 76 | 77 | instance = Foo(4, bla="foo") 78 | 79 | assert instance == Foo(4, bla="foo") 80 | assert not instance == Foo(4, bla="blabla") 81 | assert instance == AlwaysEquals() 82 | assert not instance == NeverEquals() 83 | 84 | assert instance != Foo(4, bla="blabla") 85 | assert not instance != Foo(4, bla="foo") 86 | assert instance != NeverEquals() 87 | assert not instance != AlwaysEquals() 88 | 89 | assert instance.replace(foo=5) == Foo(5, bla="foo") 90 | assert instance.replace() == instance 91 | 92 | assert hash(instance) == hash(instance.replace()) 93 | assert hash(instance) != hash(instance.replace(foo=5)) 94 | 95 | assert instance.foo == 4 96 | assert instance.bla == "foo" 97 | 98 | with pytest.raises(AttributeError, match="blabla"): 99 | instance.blabla 100 | 101 | with pytest.raises( 102 | AttributeError, 103 | match="can't set" if sys.version_info < (3, 11) else "no setter", 104 | ): 105 | instance.foo = 6 106 | 107 | assert repr(instance) == "my_module.Foo(foo=4, bla='foo')" 108 | 109 | assert Foo.bla.__doc__ == "description for bla" 110 | 111 | # repr should never fail, even if everything is wrong 112 | del instance._values 113 | repr(instance) 114 | 115 | def test_defaults(self): 116 | class Foo(utils.ValueObject): 117 | __fields__ = [ 118 | ("foo", int, "the foo"), 119 | ("bla", str, "the bla"), 120 | ("qux", float, "another field!"), 121 | ] 122 | __defaults__ = ("", 1.0) 123 | 124 | assert Foo(4) == Foo(4, "", 1.0) 125 | assert Foo(4, "bla", 1.1) == Foo(4, "bla", 1.1) 126 | -------------------------------------------------------------------------------- /docs/guide.rst: -------------------------------------------------------------------------------- 1 | User guide 2 | ========== 3 | 4 | This page gives an introduction on the features of Quiz. 5 | 6 | Executing a simple query 7 | ------------------------ 8 | 9 | Making a simple GraphQL query is easy. We'll use github's API v4 as an example. 10 | 11 | .. code-block:: python3 12 | 13 | >>> import quiz 14 | >>> query = ''' 15 | ... { 16 | ... repository(owner: "octocat", name: "Hello-World") { 17 | ... createdAt 18 | ... description 19 | ... } 20 | ... } 21 | ... ''' 22 | >>> quiz.execute(query, url='https://api.github.com/graphl', 23 | ... auth=('me', 'password')) 24 | {"repository": ...} 25 | 26 | 27 | :func:`~quiz.execution.execute` allows us to specify the query as text (:class:`str`), 28 | along with the target ``url`` and authentication credentials. 29 | Executing such a query returns the result as JSON. 30 | 31 | .. seealso:: 32 | 33 | The :ref:`advanced topics ` section has more information about: 34 | 35 | * :ref:`Alternative authentication methods` 36 | * :ref:`Using alternative HTTP clients ` 37 | (e.g. ``requests``, ``aiohttp``) 38 | * :ref:`Asynchronous execution ` 39 | * :ref:`Keeping everything DRY ` 40 | * :ref:`Acessing request metadata ` 41 | 42 | 43 | Retrieving a schema 44 | ------------------- 45 | 46 | When performing multiple requests to a GraphQL API, 47 | it is useful to retrieve its :class:`~quiz.schema.Schema`. 48 | The schema will allow us to: 49 | 50 | * validate queries 51 | * convert responses into python objects 52 | * introspect types and fields 53 | 54 | The fastest way to retrieve a :class:`~quiz.schema.Schema` 55 | is to grab it right from the API with :meth:`~quiz.schema.Schema.from_url`. 56 | Let's retrieve GitHub's GraphQL schema: 57 | 58 | .. code-block:: python3 59 | 60 | >>> schema = quiz.Schema.from_url('https://api.github.com/graphql', 61 | ... auth=('me', 'password')) 62 | 63 | 64 | The schema contains python classes for GraphQL types. 65 | These can be inspected with python's own :func:`help`: 66 | 67 | 68 | .. code-block:: python3 69 | 70 | >>> help(schema.Repository) 71 | class Repository(Node, ProjectOwner, Subscribable, Starrable, 72 | UniformResourceLocatable, RepositoryInfo, quiz.types.Object) 73 | | A repository contains the content for a project. 74 | | 75 | | Method resolution order: 76 | | ... 77 | | 78 | | Data descriptors defined here: 79 | | 80 | | assignableUsers 81 | | : UserConnection 82 | | A list of users that can be assigned to issues in this repo 83 | | 84 | | codeOfConduct 85 | | : CodeOfConduct or None 86 | | Returns the code of conduct for this repository 87 | (truncated) 88 | 89 | 90 | In the next section, we will see how this will allow us 91 | to easily write and validate queries. 92 | 93 | .. seealso:: 94 | 95 | The :ref:`advanced topics ` section has more information about: 96 | 97 | * :ref:`Caching schemas` 98 | * :ref:`Defining custom scalars` 99 | * :ref:`Building modules with schemas ` 100 | 101 | 102 | Constructing GraphQL 103 | -------------------- 104 | 105 | As we've seen in the first section, 106 | we can execute queries in text form. 107 | Using the :class:`~quiz.schema.Schema`, however, 108 | we can write GraphQL using python syntax. 109 | To do this, we use the :class:`~quiz.build.SELECTOR` object 110 | combined with python's slice syntax. 111 | 112 | The example below shows how we can recreate our original query in this syntax: 113 | 114 | .. code-block:: python3 115 | 116 | >>> from quiz import SELECTOR as _ 117 | >>> query = schema.query[ 118 | ... _ 119 | ... .repository(owner='octocat', name='hello-world')[ 120 | ... _ 121 | ... .createdAt 122 | ... .description 123 | ... ] 124 | ... ] 125 | 126 | We can easily convert this to a GraphQL string: 127 | 128 | .. code-block:: python3 129 | 130 | >>> str(query) 131 | query { 132 | repository(owner: "octocat", name: "Hello-World") { 133 | createdAt 134 | description 135 | } 136 | } 137 | 138 | The main advantage of using python syntax is to catch mistakes 139 | before sending anything to the API. 140 | For example, what would happen if we added a non-existent field? 141 | 142 | .. code-block:: python3 143 | 144 | >>> schema.query[ 145 | ... _ 146 | ... .repository(owner='octocat', name='hello-world')[ 147 | ... _ 148 | ... .createdAt 149 | ... .description 150 | ... .foo 151 | ... ] 152 | ... ] 153 | SelectionError: SelectionError on "Query" at path "repository": 154 | 155 | SelectionError: SelectionError on "Repository" at path "foo": 156 | 157 | NoSuchField: field does not exist 158 | 159 | Now we are confident with our query, we can use :func:`~quiz.execution.execute` 160 | to evaluate the result. 161 | 162 | .. code-block:: python3 163 | 164 | >>> result = quiz.execute(query) 165 | 166 | 167 | Because we've used the schema, the response is automatically loaded into 168 | the correct data types: we can use ``.`` to access fields on the results 169 | 170 | 171 | .. code-block:: python3 172 | 173 | >>> result.repository.description 174 | "My first repository on GitHub!" 175 | >>> isinstance(result.repository, schema.Repository) 176 | True 177 | 178 | .. seealso:: 179 | 180 | The :ref:`advanced topics ` section has more information about: 181 | 182 | * :ref:`The selection API` 183 | -------------------------------------------------------------------------------- /src/quiz/utils.py: -------------------------------------------------------------------------------- 1 | """Common utilities and boilerplate""" 2 | 3 | import sys 4 | import typing as t 5 | from collections import namedtuple 6 | from functools import wraps 7 | from itertools import chain, starmap 8 | from operator import attrgetter, methodcaller 9 | 10 | __all__ = ["JSON", "Empty"] 11 | 12 | T = t.TypeVar("T") 13 | T1 = t.TypeVar("T1") 14 | T2 = t.TypeVar("T2") 15 | 16 | 17 | JSON = t.Union[ 18 | str, int, float, bool, None, t.Dict[str, "JSON"], t.List["JSON"] 19 | ] 20 | 21 | 22 | def identity(obj): 23 | return obj 24 | 25 | 26 | class FrozenDict(t.Mapping[T1, T2]): 27 | # see https://stackoverflow.com/questions/45864273 28 | if not (3, 7) > sys.version_info > (3, 4): # pragma: no cover 29 | __slots__ = "_inner" 30 | 31 | def __init__(self, inner): 32 | self._inner = inner if isinstance(inner, dict) else dict(inner) 33 | 34 | __len__ = property(attrgetter("_inner.__len__")) 35 | __iter__ = property(attrgetter("_inner.__iter__")) 36 | __getitem__ = property(attrgetter("_inner.__getitem__")) 37 | __repr__ = property(attrgetter("_inner.__repr__")) 38 | 39 | def __hash__(self): 40 | return hash(frozenset(self._inner.items())) 41 | 42 | 43 | FrozenDict.EMPTY = FrozenDict({}) 44 | 45 | 46 | def merge(*dicts): 47 | """merge several mappings""" 48 | if dicts: 49 | return type(dicts[0])( 50 | chain.from_iterable(map(methodcaller("items"), dicts)) 51 | ) 52 | else: 53 | return {} 54 | 55 | 56 | class Empty(Exception): 57 | """indicates a given list is unexpectedly empty""" 58 | 59 | 60 | def init_last(items): 61 | # type: (t.List[T]) -> t.Tuple[t.List[T], T] 62 | """Return the first items and last item from a list 63 | 64 | Raises 65 | ------ 66 | Empty 67 | if the given list is empty 68 | """ 69 | try: 70 | return items[:-1], items[-1] 71 | except IndexError: 72 | raise Empty 73 | 74 | 75 | def _make_init_fn(ntuple): 76 | @wraps(ntuple.__new__) 77 | def __init__(self, *args, **kwargs): 78 | self._values = ntuple(*args, **kwargs) 79 | 80 | return __init__ 81 | 82 | 83 | class _ValueObjectMeta(type(t.Generic)): 84 | """Metaclass for ``ValueObject``""" 85 | 86 | # TODO: add parameters to __doc__ 87 | def __new__(self, name, bases, dct): 88 | # skip the ``ValueObject`` class itself 89 | if bases != (object,): 90 | fields = dct["__fields__"] 91 | fieldnames = [n for n, _, _ in dct["__fields__"]] 92 | assert "replace" not in fieldnames 93 | ntuple = namedtuple("_" + name, fieldnames) 94 | ntuple.__new__.__defaults__ = dct.get("__defaults__", ()) 95 | dct.update( 96 | { 97 | "__namedtuple_cls__": ntuple, 98 | "__slots__": "_values", 99 | # For the signature to appear correctly in 100 | # introspection and docs, 101 | # we create the __init__ function for 102 | # each ValueObject class individually 103 | "__init__": _make_init_fn(ntuple), 104 | } 105 | ) 106 | dct.update( 107 | (name, property(attrgetter("_values." + name), doc=doc)) 108 | for name, _, doc in fields 109 | ) 110 | return super(_ValueObjectMeta, self).__new__(self, name, bases, dct) 111 | 112 | 113 | class ValueObject(object, metaclass=_ValueObjectMeta): 114 | """Base class for "value object"-like classes, 115 | similar to frozen dataclasses in python 3.7+. 116 | 117 | Example 118 | ------- 119 | 120 | >>> class Foo(ValueObject, ...): 121 | ... __slots__ = '_values' # optional 122 | ... __fields__ = [ 123 | ... ('foo', int, 'the foo'), 124 | ... ('bla', str, 'description for bla'), 125 | ... ] 126 | ... 127 | >>> f = Foo(4, bla='foo') 128 | >>> f 129 | Foo(foo=4, bla='foo') 130 | 131 | """ 132 | 133 | __slots__ = () 134 | 135 | def replace(self, **kwargs): 136 | """Create a new instance, with certain fields replaced with new values 137 | 138 | Parameters 139 | ---------- 140 | **kwargs 141 | Updated field values 142 | 143 | Example 144 | ------- 145 | >>> my_object 146 | MyObject(a=5, b="qux") 147 | >>> my_object.replace(b="new!") 148 | MyObject(a=5, b="new!") 149 | 150 | """ 151 | new = type(self).__new__(type(self)) 152 | new._values = self._values._replace(**kwargs) 153 | return new 154 | 155 | def __eq__(self, other): 156 | if type(self) is type(other): 157 | return self._values == other._values 158 | return NotImplemented 159 | 160 | def __ne__(self, other): 161 | if type(self) is type(other): 162 | return self._values != other._values 163 | return NotImplemented 164 | 165 | def __repr__(self): 166 | try: 167 | return "{}({})".format( 168 | self.__class__.__qualname__, 169 | ", ".join( 170 | starmap( 171 | "{}={!r}".format, 172 | zip(self._values._fields, self._values), 173 | ) 174 | ), 175 | ) 176 | except Exception: 177 | return object.__repr__(self) 178 | 179 | __hash__ = property(attrgetter("_values.__hash__")) 180 | 181 | 182 | class compose(object): 183 | """compose a function from a chain of functions 184 | Parameters 185 | ---------- 186 | *funcs 187 | callables to compose 188 | Note 189 | ---- 190 | * if given no functions, acts as an identity function 191 | """ 192 | 193 | def __init__(self, *funcs): 194 | self.funcs = funcs 195 | self.__wrapped__ = funcs[-1] if funcs else identity 196 | 197 | def __call__(self, *args, **kwargs): 198 | if not self.funcs: 199 | return identity(*args, **kwargs) 200 | tail, head = self.funcs[:-1], self.funcs[-1] 201 | value = head(*args, **kwargs) 202 | for func in reversed(tail): 203 | value = func(value) 204 | return value 205 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | 🎱 Quiz 2 | ======= 3 | 4 | .. image:: https://img.shields.io/pypi/v/quiz.svg?style=flat-square 5 | :target: https://pypi.python.org/pypi/quiz 6 | 7 | .. image:: https://img.shields.io/pypi/l/quiz.svg?style=flat-square 8 | :target: https://pypi.python.org/pypi/quiz 9 | 10 | .. image:: https://img.shields.io/pypi/pyversions/quiz.svg?style=flat-square 11 | :target: https://pypi.python.org/pypi/quiz 12 | 13 | .. image:: https://img.shields.io/readthedocs/quiz.svg?style=flat-square 14 | :target: http://quiz.readthedocs.io/ 15 | 16 | .. image:: https://img.shields.io/codecov/c/github/ariebovenberg/quiz.svg?style=flat-square&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAaCAMAAACaYWzBAAABX1BMVEUAAAD///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////8P/Ub/AAAAdHRSTlMAAQIDBAUGBw0PERIVFhcZGhsgISQmKSorLS8zODk6PUFDR0lLTE1YXF5fYGRlaHB1dnd4eXt/g4eKjJKVlpeYmZqcnqOkpaanqqusrq+xsrW3ubu8vb7Hzc7Q1dfZ3N3e3+Dh4ujp6uzt7/Hz9fb3+Pv9/mHMvTIAAAFPSURBVHgBbdDXcxJRGIbx55zvnI1ijJI1YuxiEkWNsRjsKPYiFuxFsRdREH3//5Hd2YvMLL/bZ76L9yMTYe74o95g+L57qgaRQiC9qbF+X2N35zBHxmj80bC9mFYqs/sv/NLfZXxWAiekq5soTLekJgaRw9IyxOCd8yHCIekoEbaMdITEKFhCQ/9qwB1dIWGNhIu6D9s0qDJlnoIPU8z0tZ1VXSITDMACmfNq8liL0/X6jnXgIcCGXUsLmxf0jA96q7Fv7RQz5m/kI9/pI1+lTw/vvZC0F1YkPb/V6Uk/0Ot6ANJrn2c4KbVmAbfvlTgNmDmosidf6s2AVYpnmoenOlssjUBw5Iyt+lnByLkAhchB3SZQEjmmM8RJYUXnJoXAkp5glHg2/laNZNLJZXUh8a588l3XHWWB3SO9aVTXW7nMv5TUwblS4cCDLzsx4D/omEB2BXPuawAAAABJRU5ErkJggg== 17 | :target: https://codecov.io/gh/ariebovenberg/quiz 18 | 19 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg?style=flat-square 20 | :target: https://github.com/psf/black 21 | 22 | Capable GraphQL client for Python. 23 | 24 | Features: 25 | 26 | * Sync/async compatible, pluggable HTTP clients. 27 | * Auto-generate typed and documented python APIs 28 | * ORM-like syntax to write GraphQL. 29 | 30 | Note that this project is in an early alpha stage. 31 | Some features are not yet implemented (see the roadmap below), 32 | and it may be a little rough around the edges. 33 | If you encounter a problem or have a feature request, 34 | don't hesitate to open an issue in the `issue tracker `_. 35 | 36 | 37 | Quickstart 38 | ---------- 39 | 40 | A quick 'n dirty request to GitHub's new V4 API: 41 | 42 | .. code-block:: python3 43 | 44 | >>> import quiz 45 | >>> query = ''' 46 | ... { 47 | ... repository(owner: "octocat", name: "Hello-World") { 48 | ... createdAt 49 | ... description 50 | ... } 51 | ... } 52 | ... ''' 53 | >>> quiz.execute(query, url='https://api.github.com/graphql', 54 | ... auth=('me', 'password')) 55 | {"repository": ...} 56 | 57 | 58 | Features 59 | -------- 60 | 61 | 1. **Adaptability**. Built on top of `snug `_, 62 | quiz supports different HTTP clients 63 | 64 | .. code-block:: python3 65 | 66 | import requests 67 | result = quiz.execute(query, ..., client=requests.Session()) 68 | 69 | as well as async execution 70 | (optionally with `aiohttp `_): 71 | 72 | .. code-block:: python3 73 | 74 | result = await quiz.execute_async(query, ...) 75 | 76 | 2. **Typing**. 77 | Convert a GraphQL schema into documented python classes: 78 | 79 | .. code-block:: python3 80 | 81 | >>> schema = quiz.Schema.from_url('https://api.github.com/graphql', 82 | ... auth=('me', 'password')) 83 | >>> help(schema.Repository) 84 | class Repository(Node, ProjectOwner, Subscribable, Starrable, 85 | UniformResourceLocatable, RepositoryInfo, quiz.types.Object) 86 | | A repository contains the content for a project. 87 | | 88 | | Method resolution order: 89 | | ... 90 | | 91 | | Data descriptors defined here: 92 | | 93 | | assignableUsers 94 | | : UserConnection 95 | | A list of users that can be assigned to issues in this repo 96 | | 97 | | codeOfConduct 98 | | : CodeOfConduct or None 99 | | Returns the code of conduct for this repository 100 | ... 101 | 102 | 103 | 3. **GraphQL "ORM"**. Write queries as you would with an ORM: 104 | 105 | .. code-block:: python3 106 | 107 | >>> _ = quiz.SELECTOR 108 | >>> query = schema.query[ 109 | ... _ 110 | ... .repository(owner='octocat', name='Hello-World')[ 111 | ... _ 112 | ... .createdAt 113 | ... .description 114 | ... ] 115 | ... ] 116 | >>> str(query) 117 | query { 118 | repository(owner: "octocat", name: "Hello-World") { 119 | createdAt 120 | description 121 | } 122 | } 123 | 124 | 4. **Offline query validation**. Use the schema to catch errors quickly: 125 | 126 | .. code-block:: python3 127 | 128 | >>> schema.query[ 129 | ... _ 130 | ... .repository(owner='octocat', name='Hello-World')[ 131 | ... _ 132 | ... .createdAt 133 | ... .foo 134 | ... .description 135 | ... ] 136 | ... ] 137 | SelectionError: SelectionError on "Query" at path "repository": 138 | 139 | SelectionError: SelectionError on "Repository" at path "foo": 140 | 141 | NoSuchField: field does not exist 142 | 143 | 5. **Deserialization into python objects**. Responses are loaded into the schema's types. 144 | Use ``.`` to access fields: 145 | 146 | .. code-block:: python3 147 | 148 | >>> r = quiz.execute(query, ...) 149 | >>> r.repository.description 150 | "My first repository on GitHub!" 151 | >>> isinstance(r.repository, schema.Repository) 152 | True 153 | 154 | If you prefer the raw JSON response, you can always do: 155 | 156 | .. code-block:: python3 157 | 158 | >>> quiz.execute(str(query), ...) 159 | {"repository": ...} 160 | 161 | 162 | Installation 163 | ------------ 164 | 165 | ``quiz`` and its dependencies are pure python. Installation is easy as: 166 | 167 | .. code-block:: bash 168 | 169 | pip install quiz 170 | 171 | 172 | Contributing 173 | ------------ 174 | 175 | After you've cloned the repo locally, set up the development environment 176 | with: 177 | 178 | .. code-block:: bash 179 | 180 | make init 181 | 182 | For quick test runs, run: 183 | 184 | .. code-block:: bash 185 | 186 | pytest 187 | 188 | To run all tests and checks on various python versions, run: 189 | 190 | .. code-block:: bash 191 | 192 | make test 193 | 194 | Generate the docs with: 195 | 196 | .. code-block:: bash 197 | 198 | make docs 199 | 200 | 201 | Pull requests welcome! 202 | -------------------------------------------------------------------------------- /src/quiz/execution.py: -------------------------------------------------------------------------------- 1 | """Components for executing GraphQL operations""" 2 | 3 | import json 4 | import typing as t 5 | from functools import partial 6 | 7 | import snug 8 | from gentools import irelay 9 | 10 | from .build import Query 11 | from .types import load 12 | from .utils import JSON, ValueObject 13 | 14 | __all__ = [ 15 | "execute", 16 | "execute_async", 17 | "executor", 18 | "async_executor", 19 | "Executable", 20 | "ErrorResponse", 21 | "HTTPError", 22 | "RawResult", 23 | "QueryMetadata", 24 | ] 25 | 26 | Executable = t.Union[str, Query] 27 | """Anything which can be executed as a GraphQL operation""" 28 | 29 | 30 | def _exec(executable): 31 | # type: (Executable) -> t.Generator 32 | if isinstance(executable, str): 33 | return (yield executable) 34 | elif isinstance(executable, Query): 35 | return load( 36 | executable.cls, executable.selections, (yield str(executable)) 37 | ) 38 | else: 39 | raise NotImplementedError("not executable: " + repr(executable)) 40 | 41 | 42 | def middleware(url, query_str): 43 | # type: (str, str) -> snug.Query[t.Dict[str, JSON]] 44 | request = snug.POST( 45 | url, 46 | content=json.dumps({"query": query_str}).encode("ascii"), 47 | headers={"Content-Type": "application/json"}, 48 | ) 49 | response = yield request 50 | if response.status_code >= 400: 51 | raise HTTPError(response, request) 52 | content = json.loads(response.content.decode("utf-8")) 53 | if "errors" in content: 54 | content.setdefault("data", {}) 55 | raise ErrorResponse(**content) 56 | return RawResult( 57 | content["data"], QueryMetadata(request=request, response=response) 58 | ) 59 | 60 | 61 | def execute(obj, url, **kwargs): 62 | """Execute a GraphQL executable 63 | 64 | Parameters 65 | ---------- 66 | obj: :data:`~quiz.execution.Executable` 67 | The object to execute. 68 | This may be a raw string or a query 69 | url: str 70 | The URL of the target endpoint 71 | **kwargs 72 | ``auth`` and/or ``client``, passed to :func:`snug.query.execute`. 73 | 74 | Returns 75 | ------- 76 | RawResult (a dict) or the schema's return type 77 | In case of a raw string, a raw result. 78 | Otherwise, an instance of the schema's type queried for. 79 | 80 | Raises 81 | ------ 82 | ErrorResponse 83 | If errors are present in the response 84 | HTTPError 85 | If the response has a non 2xx response code 86 | """ 87 | snug_query = irelay(_exec(obj), partial(middleware, url)) 88 | return snug.execute(snug_query, **kwargs) 89 | 90 | 91 | def executor(**kwargs): 92 | """Create a version of :func:`execute` with bound arguments. 93 | Equivalent to ``partial(execute, **kwargs)``. 94 | 95 | Parameters 96 | ---------- 97 | **kwargs 98 | ``url``, ``auth``, and/or ``client``, passed to :func:`execute` 99 | 100 | Returns 101 | ------- 102 | ~typing.Callable[[Executable], JSON] 103 | A callable to execute GraphQL executables 104 | 105 | Example 106 | ------- 107 | 108 | >>> execute = executor(url='https://api.github.com/graphql', 109 | ... auth=('me', 'password')) 110 | >>> result = execute(''' 111 | ... { 112 | ... repository(owner: "octocat" name: "Hello-World") { 113 | ... description 114 | ... } 115 | ... } 116 | ... ''', client=requests.Session()) 117 | """ 118 | return partial(execute, **kwargs) 119 | 120 | 121 | def execute_async(obj, url, **kwargs): 122 | """Execute a GraphQL executable asynchronously 123 | 124 | Parameters 125 | ---------- 126 | obj: Executable 127 | The object to execute. 128 | This may be a raw string or a query 129 | url: str 130 | The URL of the target endpoint 131 | **kwargs 132 | ``auth`` and/or ``client``, 133 | passed to :func:`snug.query.execute_async`. 134 | 135 | Returns 136 | ------- 137 | RawResult (a dict) or the schema's return type 138 | In case of a raw string, a raw result. 139 | Otherwise, an instance of the schema's type queried for. 140 | 141 | 142 | Raises 143 | ------ 144 | ErrorResponse 145 | If errors are present in the response 146 | HTTPError 147 | If the response has a non 2xx response code 148 | """ 149 | snug_query = irelay(_exec(obj), partial(middleware, url)) 150 | return snug.execute_async(snug_query, **kwargs) 151 | 152 | 153 | def async_executor(**kwargs): 154 | """Create a version of :func:`execute_async` with bound arguments. 155 | Equivalent to ``partial(execute_async, **kwargs)``. 156 | 157 | Parameters 158 | ---------- 159 | **kwargs 160 | ``url``, ``auth``, and/or ``client``, passed to :func:`execute_async` 161 | 162 | Returns 163 | ------- 164 | ~typing.Callable[[Executable], ~typing.Awaitable[JSON]] 165 | A callable to asynchronously execute GraphQL executables 166 | 167 | Example 168 | ------- 169 | 170 | >>> execute = async_executor(url='https://api.github.com/graphql', 171 | ... auth=('me', 'password')) 172 | >>> result = await execute(''' 173 | ... { 174 | ... repository(owner: "octocat" name: "Hello-World") { 175 | ... description 176 | ... } 177 | ... } 178 | ... ''') 179 | """ 180 | return partial(execute_async, **kwargs) 181 | 182 | 183 | class ErrorResponse(ValueObject, Exception): 184 | """A response containing errors""" 185 | 186 | __fields__ = [ 187 | ("data", t.Dict[str, JSON], "Data returned in the response"), 188 | ( 189 | "errors", 190 | t.List[t.Dict[str, JSON]], 191 | "Errors returned in the response", 192 | ), 193 | ] 194 | 195 | 196 | class RawResult(dict): 197 | """Dictionary as result of a raw query. 198 | 199 | Contains HTTP :class:`metadata ` 200 | in its ``__metadata__`` attribute. 201 | """ 202 | 203 | __slots__ = "__metadata__" 204 | 205 | def __init__(self, items, meta): 206 | super(RawResult, self).__init__(items) 207 | self.__metadata__ = meta 208 | 209 | 210 | class QueryMetadata(ValueObject): 211 | """HTTP metadata for query""" 212 | 213 | __fields__ = [ 214 | ("response", snug.Response, "The response object"), 215 | ("request", snug.Request, "The original request"), 216 | ] 217 | 218 | 219 | class HTTPError(ValueObject, Exception): 220 | """Indicates a response with a non 2xx status code""" 221 | 222 | __fields__ = [ 223 | ("response", snug.Response, "The response object"), 224 | ("request", snug.Request, "The original request"), 225 | ] 226 | 227 | def __str__(self): 228 | return ( 229 | "Response with status {0.status_code}, content: {0.content!r} " 230 | 'for URL "{1.url}". View this exception\'s `request` and ' 231 | "`response` attributes for detailed info.".format( 232 | self.response, self.request 233 | ) 234 | ) 235 | -------------------------------------------------------------------------------- /tests/test_execution.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from collections.abc import Mapping 4 | 5 | import pytest 6 | import snug 7 | 8 | import quiz 9 | 10 | from .example import Dog, DogQuery 11 | from .helpers import MockAsyncClient, MockClient 12 | 13 | _ = quiz.SELECTOR 14 | 15 | 16 | def token_auth(token): 17 | return snug.header_adder({"Authorization": "token {}".format(token)}) 18 | 19 | 20 | class TestExecute: 21 | def test_simple_string(self): 22 | client = MockClient(snug.Response(200, b'{"data": {"foo": 4}}')) 23 | result = quiz.execute( 24 | "my query", 25 | url="https://my.url/api", 26 | client=client, 27 | auth=token_auth("foo"), 28 | ) 29 | assert result == {"foo": 4} 30 | 31 | request = client.request 32 | assert request.url == "https://my.url/api" 33 | assert request.method == "POST" 34 | assert json.loads(request.content.decode()) == {"query": "my query"} 35 | assert request.headers == { 36 | "Authorization": "token foo", 37 | "Content-Type": "application/json", 38 | } 39 | 40 | def test_query(self): 41 | query = quiz.Query(DogQuery, _.dog[_.name.bark_volume]) 42 | response = snug.Response( 43 | 200, 44 | json.dumps( 45 | {"data": {"dog": {"name": "Fred", "bark_volume": 8}}} 46 | ).encode(), 47 | ) 48 | client = MockClient(response) 49 | result = quiz.execute(query, url="https://my.url/api", client=client) 50 | assert result == DogQuery(dog=Dog(name="Fred", bark_volume=8)) 51 | request = client.request 52 | 53 | assert result.__metadata__ == quiz.QueryMetadata( 54 | response=response, request=request 55 | ) 56 | assert request.url == "https://my.url/api" 57 | assert request.method == "POST" 58 | assert json.loads(request.content.decode()) == { 59 | "query": quiz.gql(query) 60 | } 61 | assert request.headers == {"Content-Type": "application/json"} 62 | 63 | def test_wrong_type(self): 64 | client = MockClient(snug.Response(200, b'{"data": {"foo": 4}}')) 65 | with pytest.raises(NotImplementedError, match="not executable: 17"): 66 | quiz.execute( 67 | 17, 68 | url="https://my.url/api", 69 | client=client, 70 | auth=token_auth("foo"), 71 | ) 72 | 73 | def test_errors(self): 74 | client = MockClient( 75 | snug.Response( 76 | 200, 77 | json.dumps( 78 | {"data": {"foo": 4}, "errors": [{"message": "foo"}]} 79 | ).encode(), 80 | ) 81 | ) 82 | with pytest.raises(quiz.ErrorResponse) as exc: 83 | quiz.execute( 84 | "my query", 85 | url="https://my.url/api", 86 | client=client, 87 | auth=token_auth("foo"), 88 | ) 89 | assert exc.value == quiz.ErrorResponse( 90 | {"foo": 4}, [{"message": "foo"}] 91 | ) 92 | 93 | def test_errors_without_data(self): 94 | client = MockClient( 95 | snug.Response( 96 | 200, json.dumps({"errors": [{"message": "foo"}]}).encode() 97 | ) 98 | ) 99 | with pytest.raises(quiz.ErrorResponse) as exc: 100 | quiz.execute( 101 | "my query", 102 | url="https://my.url/api", 103 | client=client, 104 | auth=token_auth("foo"), 105 | ) 106 | assert exc.value == quiz.ErrorResponse({}, [{"message": "foo"}]) 107 | 108 | def test_http_error(self, mocker): 109 | err_response = snug.Response(403, b"this is an error!") 110 | client = MockClient(err_response) 111 | with pytest.raises(quiz.HTTPError) as exc: 112 | quiz.execute( 113 | "my query", 114 | url="https://my.url/api", 115 | client=client, 116 | auth=token_auth("foo"), 117 | ) 118 | assert exc.value == quiz.HTTPError( 119 | err_response, client.request.replace(headers=mocker.ANY) 120 | ) 121 | 122 | 123 | def test_executor(): 124 | executor = quiz.executor(url="https://my.url/graphql") 125 | assert executor.func is quiz.execute 126 | assert executor.keywords["url"] == "https://my.url/graphql" 127 | 128 | 129 | class TestExecuteAsync: 130 | def test_success(self): 131 | response = snug.Response(200, b'{"data": {"foo": 4, "bar": ""}}') 132 | client = MockAsyncClient(response) 133 | coro = quiz.execute_async( 134 | "my query", 135 | url="https://my.url/api", 136 | auth=token_auth("foo"), 137 | client=client, 138 | ) 139 | result = asyncio.run(coro) 140 | assert isinstance(result, quiz.RawResult) 141 | assert result == {"foo": 4, "bar": ""} 142 | assert len(result) == 2 143 | assert result["foo"] == 4 144 | assert set(result) == {"foo", "bar"} 145 | assert isinstance(result, Mapping) 146 | assert result.__metadata__ == quiz.QueryMetadata( 147 | response=response, 148 | request=snug.POST( 149 | "https://my.url/api", 150 | headers={"Content-Type": "application/json"}, 151 | content=b'{"query": "my query"}', 152 | ), 153 | ) 154 | 155 | request = client.request 156 | assert request.url == "https://my.url/api" 157 | assert request.method == "POST" 158 | assert json.loads(request.content.decode()) == {"query": "my query"} 159 | assert request.headers == { 160 | "Authorization": "token foo", 161 | "Content-Type": "application/json", 162 | } 163 | 164 | def test_non_string(self): 165 | query = quiz.Query(DogQuery, _.dog[_.name.bark_volume]) 166 | client = MockAsyncClient( 167 | snug.Response( 168 | 200, 169 | json.dumps( 170 | {"data": {"dog": {"name": "Fred", "bark_volume": 8}}} 171 | ).encode(), 172 | ) 173 | ) 174 | 175 | coro = quiz.execute_async( 176 | query, url="https://my.url/api", client=client 177 | ) 178 | result = asyncio.run(coro) 179 | assert result == DogQuery(dog=Dog(name="Fred", bark_volume=8)) 180 | 181 | request = client.request 182 | assert request.url == "https://my.url/api" 183 | assert request.method == "POST" 184 | assert json.loads(request.content.decode()) == { 185 | "query": quiz.gql(query) 186 | } 187 | assert request.headers == {"Content-Type": "application/json"} 188 | 189 | def test_errors(self): 190 | client = MockAsyncClient( 191 | snug.Response( 192 | 200, 193 | json.dumps( 194 | {"data": {"foo": 4}, "errors": [{"message": "foo"}]} 195 | ).encode(), 196 | ) 197 | ) 198 | coro = quiz.execute_async( 199 | "my query", 200 | url="https://my.url/api", 201 | client=client, 202 | auth=token_auth("foo"), 203 | ) 204 | with pytest.raises(quiz.ErrorResponse) as exc: 205 | asyncio.run(coro) 206 | 207 | assert exc.value == quiz.ErrorResponse( 208 | {"foo": 4}, [{"message": "foo"}] 209 | ) 210 | 211 | 212 | def test_async_executor(): 213 | executor = quiz.async_executor(url="https://my.url/graphql") 214 | assert executor.func is quiz.execute_async 215 | assert executor.keywords["url"] == "https://my.url/graphql" 216 | 217 | 218 | def test_http_error(): 219 | err = quiz.HTTPError( 220 | snug.Response(404, content=b"not found!\x00"), 221 | snug.Request("POST", "https://my.url/api"), 222 | ) 223 | assert "not found!\\x00" in str(err) 224 | assert "404" in str(err) 225 | assert "my.url" in str(err) 226 | -------------------------------------------------------------------------------- /tests/test_build.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | import pytest 4 | from hypothesis import given, strategies 5 | 6 | import quiz 7 | from quiz import SELECTOR as _ 8 | from quiz import Field, InlineFragment, SelectionSet, gql 9 | from quiz.utils import FrozenDict as fdict 10 | 11 | from .example import Dog 12 | from .helpers import AlwaysEquals, NeverEquals 13 | 14 | 15 | class TestField: 16 | def test_defaults(self): 17 | f = Field("foo") 18 | assert f.kwargs == {} 19 | assert isinstance(f.kwargs, fdict) 20 | assert f.selection_set == SelectionSet() 21 | assert isinstance(f.selection_set, SelectionSet) 22 | 23 | def test_hash(self): 24 | assert hash(Field("foo", fdict({"bar": 3}))) == hash( 25 | Field("foo", fdict({"bar": 3})) 26 | ) 27 | assert hash(Field("bla", fdict({"bla": 4}))) != hash(Field("bla")) 28 | 29 | class TestGQL: 30 | def test_empty(self): 31 | assert gql(Field("foo")) == "foo" 32 | 33 | def test_arguments(self): 34 | field = Field("foo", {"foo": 4, "blabla": "my string!"}) 35 | 36 | # arguments are unordered, multiple valid options 37 | assert gql(field) in [ 38 | 'foo(foo: 4, blabla: "my string!")', 39 | 'foo(blabla: "my string!", foo: 4)', 40 | ] 41 | 42 | def test_alias(self): 43 | field = Field("foo", {"a": 4}, alias="my_alias") 44 | assert gql(field) == "my_alias: foo(a: 4)" 45 | 46 | def test_selection_set(self): 47 | field = Field( 48 | "bla", 49 | fdict({"q": 9}), 50 | selection_set=SelectionSet( 51 | Field("blabla"), 52 | Field("foobar", fdict({"qux": "another string"})), 53 | Field("other", selection_set=SelectionSet(Field("baz"))), 54 | InlineFragment( 55 | on=Dog, 56 | selection_set=SelectionSet( 57 | Field("name"), 58 | Field("bark_volume"), 59 | Field( 60 | "owner", 61 | selection_set=SelectionSet(Field("name")), 62 | ), 63 | ), 64 | ), 65 | ), 66 | ) 67 | assert ( 68 | gql(field) 69 | == dedent( 70 | """ 71 | bla(q: 9) { 72 | blabla 73 | foobar(qux: "another string") 74 | other { 75 | baz 76 | } 77 | ... on Dog { 78 | name 79 | bark_volume 80 | owner { 81 | name 82 | } 83 | } 84 | } 85 | """ 86 | ).strip() 87 | ) 88 | 89 | 90 | class TestSelectionSet: 91 | def test_empty(self): 92 | assert _ == SelectionSet() 93 | 94 | def test_hash(self): 95 | assert hash(SelectionSet()) == hash(SelectionSet()) 96 | assert hash(_.foo.bar) == hash(_.foo.bar) 97 | assert hash(_.bar.foo) != hash(_.foo.bar) 98 | 99 | def test_equality(self): 100 | instance = _.foo.bar 101 | assert instance == _.foo.bar 102 | assert not instance == _.bar.foo 103 | assert instance == AlwaysEquals() 104 | assert not instance == NeverEquals() 105 | 106 | assert instance != _.bar.foo 107 | assert not instance != _.foo.bar 108 | assert instance != NeverEquals() 109 | assert not instance != AlwaysEquals() 110 | 111 | def test_repr(self): 112 | instance = _.foo.bar(bla=3) 113 | rep = repr(instance) 114 | assert "SelectionSet" in rep 115 | assert gql(instance) in rep 116 | 117 | def test_getattr(self): 118 | assert _.foo_field.bla == SelectionSet( 119 | Field("foo_field"), Field("bla") 120 | ) 121 | 122 | def test_iter(self): 123 | items = (Field("foo"), Field("bar")) 124 | assert tuple(SelectionSet(*items)) == items 125 | assert len(SelectionSet(*items)) == 2 126 | 127 | def test_str(self): 128 | instance = _.foo.bar(bla=5) 129 | assert str(instance) == gql(instance) 130 | 131 | class TestGetItem: 132 | def test_simple(self): 133 | assert _.foo.bar.blabla[_.foobar.bing] == SelectionSet( 134 | Field("foo"), 135 | Field("bar"), 136 | Field( 137 | "blabla", 138 | selection_set=SelectionSet(Field("foobar"), Field("bing")), 139 | ), 140 | ) 141 | 142 | def test_empty(self): 143 | with pytest.raises(quiz.utils.Empty): 144 | _["bla"] 145 | 146 | def test_nested(self): 147 | assert _.foo[_.bar.bing[_.blabla]] == SelectionSet( 148 | Field( 149 | "foo", 150 | selection_set=SelectionSet( 151 | Field("bar"), 152 | Field( 153 | "bing", selection_set=SelectionSet(Field("blabla")) 154 | ), 155 | ), 156 | ) 157 | ) 158 | 159 | class TestCall: 160 | def test_simple(self): 161 | assert _.foo(bla=4, bar=None) == SelectionSet( 162 | Field("foo", {"bla": 4, "bar": None}) 163 | ) 164 | 165 | def test_empty(self): 166 | assert _.foo() == SelectionSet(Field("foo")) 167 | 168 | def test_argument_named_self(self): 169 | assert _.foo(self=4, bla=3) == SelectionSet( 170 | Field("foo", fdict({"self": 4, "bla": 3})) 171 | ) 172 | 173 | def test_invalid(self): 174 | with pytest.raises(quiz.utils.Empty): 175 | _() 176 | 177 | def test_alias(self): 178 | assert _("foo").bla(a=4) == SelectionSet( 179 | Field("bla", {"a": 4}, alias="foo") 180 | ) 181 | 182 | def test_combination(self): 183 | assert _.foo.bar[ 184 | _.bing(param1=4.1) 185 | .baz.foo_bar_bla(p2=None, r="")[_.height(unit="cm")] 186 | .oof.qux() 187 | ] == SelectionSet( 188 | Field("foo"), 189 | Field( 190 | "bar", 191 | selection_set=SelectionSet( 192 | Field("bing", fdict({"param1": 4.1})), 193 | Field("baz"), 194 | Field( 195 | "foo_bar_bla", 196 | fdict({"p2": None, "r": ""}), 197 | SelectionSet(Field("height", fdict({"unit": "cm"}))), 198 | ), 199 | Field("oof"), 200 | Field("qux"), 201 | ), 202 | ), 203 | ) 204 | 205 | 206 | class TestArgumentAsGql: 207 | def test_string(self): 208 | assert quiz.argument_as_gql('foo\nb"ar') == '"foo\\nb\\"ar"' 209 | 210 | def test_invalid(self): 211 | class MyClass(object): 212 | pass 213 | 214 | with pytest.raises(TypeError, match="MyClass"): 215 | quiz.argument_as_gql(MyClass()) 216 | 217 | def test_int(self): 218 | assert quiz.argument_as_gql(4) == "4" 219 | 220 | def test_none(self): 221 | assert quiz.argument_as_gql(None) == "null" 222 | 223 | def test_bool(self): 224 | assert quiz.argument_as_gql(True) == "true" 225 | assert quiz.argument_as_gql(False) == "false" 226 | 227 | @pytest.mark.parametrize( 228 | "value, expect", [(1.2, "1.2"), (1.0, "1.0"), (1.234e53, "1.234e+53")] 229 | ) 230 | def test_float(self, value, expect): 231 | assert quiz.argument_as_gql(value) == expect 232 | 233 | def test_enum(self): 234 | class MyEnum(quiz.Enum): 235 | FOO = "FOOVALUE" 236 | BLA = "QUX" 237 | 238 | assert quiz.argument_as_gql(MyEnum.BLA) == "QUX" 239 | 240 | def test_custom_scalar(self): 241 | class MyCustomScalar(quiz.Scalar): 242 | """a custom scalar string""" 243 | 244 | def __init__(self, value): 245 | self.value = value 246 | 247 | def __gql_dump__(self): 248 | return self.value.upper() 249 | 250 | assert quiz.argument_as_gql(MyCustomScalar("Hello")) == "HELLO" 251 | 252 | 253 | class TestQuery: 254 | def test_gql(self): 255 | op = quiz.Query(Dog, quiz.SelectionSet(Field("name"))) 256 | assert ( 257 | quiz.gql(op) 258 | == dedent( 259 | """ 260 | query { 261 | name 262 | } 263 | """ 264 | ).strip() 265 | ) 266 | 267 | assert quiz.gql(op) == str(op) 268 | 269 | 270 | class TestEscape: 271 | def test_empty(self): 272 | assert quiz.escape("") == "" 273 | 274 | @pytest.mark.parametrize( 275 | "value", ["foo", " bla ", " some words-here "] 276 | ) 277 | def test_no_escape_needed(self, value): 278 | assert quiz.escape(value) == value 279 | 280 | @pytest.mark.parametrize( 281 | "value, expect", 282 | [ 283 | ("foo\nbar", "foo\\nbar"), 284 | ('"quoted" --', '\\"quoted\\" --'), 285 | ("foøo", "foøo"), 286 | ], 287 | ) 288 | def test_escape_needed(self, value, expect): 289 | assert quiz.escape(value) == expect 290 | 291 | @given(strategies.text()) 292 | def test_fuzzing(self, value): 293 | assert isinstance(quiz.escape(value), str) 294 | 295 | 296 | class TestRaw: 297 | def test_gql(self): 298 | raw = quiz.Raw("my raw graphql") 299 | assert gql(raw) == "my raw graphql" 300 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /docs/advanced.rst: -------------------------------------------------------------------------------- 1 | .. _advanced: 2 | 3 | Advanced topics 4 | =============== 5 | 6 | .. _custom-auth: 7 | 8 | Custom authentication 9 | --------------------- 10 | 11 | The contents of :func:`~quiz.execution.execute`\'s ``auth`` parameter as passed to :func:`snug.execute() `. 12 | This means that aside from basic authentication, a callable is accepted. 13 | 14 | In most cases, you're just going to be adding a header, for which a convenient shortcut exists: 15 | 16 | .. code-block:: python3 17 | 18 | >>> import snug 19 | >>> my_auth = snug.header_adder({'Authorization': 'My auth header!'}) 20 | >>> execute(query, auth=my_auth) 21 | ... 22 | 23 | See `here `_ 24 | for more detailed information. 25 | 26 | .. _http-clients: 27 | 28 | HTTP Clients 29 | ------------ 30 | 31 | The ``client`` parameter of :func:`~quiz.execution.execute` allows the use 32 | of different HTTP clients. By default, `requests `_ 33 | and `aiohttp `_ are supported. 34 | 35 | Example usage: 36 | 37 | .. code-block:: python3 38 | 39 | >>> import requests 40 | >>> quiz.execute(query, client=requests.Session()) 41 | ... 42 | 43 | To register another HTTP client, see `docs here `_. 44 | 45 | .. _executors: 46 | 47 | Executors 48 | --------- 49 | 50 | To make it easier to call :func:`~quiz.execution.execute()` 51 | repeatedly with specific arguments, the :func:`~quiz.execution.executor()` shortcut can be used. 52 | 53 | .. code-block:: python3 54 | 55 | >>> import requests 56 | >>> exec = quiz.executor(auth=('me', 'password'), 57 | ... client=requests.Session()) 58 | >>> exec(some_query) 59 | >>> exec(other_query) 60 | ... 61 | >>> # we can still override arguments on each call 62 | >>> exec(another_query, auth=('bob', 'hunter2')) 63 | 64 | .. _metadata: 65 | 66 | Response metadata 67 | ----------------- 68 | 69 | Each result contains metadata about the HTTP response and request. 70 | This can be accessed through the ``__metadata__`` attribute, 71 | which contains ``response`` and ``request`` 72 | 73 | .. code-block:: python3 74 | 75 | >>> result = quiz.execute(...) 76 | >>> meta = result.__metadata__ 77 | >>> meta.response 78 | snug.Response(200, ...) 79 | >>> meta.request 80 | snug.Request('POST', ...) 81 | 82 | .. _async: 83 | 84 | Async 85 | ----- 86 | 87 | :func:`~quiz.execution.execute_async` is the asynchronous counterpart of 88 | :func:`~quiz.execution.execute`. 89 | It has a similar API, but works with the whole async/await pattern. 90 | 91 | Here is a simple example: 92 | 93 | .. code-block:: python3 94 | 95 | >>> import asyncio 96 | >>> coro = quiz.execute_async( 97 | ... query, 98 | ... url='https://api.github.com/graphql', 99 | ... auth=('me', 'password'), 100 | ... ) 101 | >>> asyncio.run(coro) 102 | ... 103 | 104 | The async HTTP client used by default is very rudimentary. 105 | Using `aiohttp `_ is highly recommended. 106 | Here is an example usage: 107 | 108 | .. code-block:: python3 109 | 110 | >>> import aiohttp 111 | >>> async def mycode(): 112 | ... async with aiohttp.ClientSession() as s: 113 | ... return await quiz.execute_async( 114 | ... query, 115 | ... url='https://api.github.com/graphql', 116 | ... auth=('me', 'password'), 117 | ... client=s, 118 | ... ) 119 | >>> asyncio.run(mycode()) 120 | ... 121 | 122 | .. note:: 123 | 124 | :func:`~quiz.execution.async_executor` is also available 125 | with a similar API as :func:`~quiz.execution.executor`. 126 | 127 | .. _caching_schemas: 128 | 129 | Caching schemas 130 | --------------- 131 | 132 | We've seen that :meth:`Schema.from_url() ` 133 | allows us to retrieve a schema directly from the API. 134 | It is also possible to store a retrieved schema on the filesystem, 135 | to avoid the need for downloading it every time. 136 | 137 | This can be done with :meth:`~quiz.schema.Schema.to_path`. 138 | 139 | .. code-block:: python3 140 | 141 | >>> schema = quiz.Schema.from_url(...) 142 | >>> schema.to_path('/path/to/schema.json') 143 | 144 | Such a schema can be loaded with :func:`Schema.from_path() `: 145 | 146 | .. code-block:: python3 147 | 148 | >>> schema = quiz.Schema.from_path('/path/to/schema.json') 149 | 150 | .. _modules: 151 | 152 | Populating modules 153 | ------------------ 154 | 155 | As we've seen, a :class:`~quiz.schema.Schema` contains generated classes. 156 | It can be useful to add these classes to a python module: 157 | 158 | * It allows pickling of instances 159 | * A python module is the idiomatic format for exposing classes. 160 | 161 | In order to do this, provide the ``module`` argument 162 | in any of the schema constructors. 163 | Then, use :meth:`~quiz.schema.Schema.populate_module` to add the classes 164 | to this module. 165 | 166 | .. code-block:: python3 167 | 168 | # my_module.py 169 | import quiz 170 | schema = quiz.Schema.from_url(..., module=__name__) 171 | schema.populate_module() 172 | 173 | 174 | .. code-block:: python3 175 | 176 | # my_code.py 177 | import my_module 178 | my_module.MyObject 179 | 180 | 181 | .. seealso:: 182 | 183 | The :ref:`examples ` show some practical applications of this feature. 184 | 185 | .. _scalars: 186 | 187 | Custom scalars 188 | -------------- 189 | 190 | GraphQL APIs often use custom scalars to represent data such as dates or URLs. 191 | By default, custom scalars in the schema 192 | are defined as :class:`~quiz.types.GenericScalar`, 193 | which accepts any of the base scalar types 194 | (``str``, ``bool``, ``float``, ``int``, ``ID``). 195 | 196 | It is recommended to define scalars explicitly. 197 | This can be done by implementing a :class:`~quiz.types.Scalar` subclass 198 | and specifying the :meth:`~quiz.types.Scalar.__gql_dump__` method 199 | and/or the :meth:`~quiz.types.Scalar.__gql_load__` classmethod. 200 | 201 | Below shows an example of a ``URI`` scalar for GitHub's v4 API: 202 | 203 | .. code-block:: python3 204 | 205 | import urllib 206 | 207 | class URI(quiz.Scalar): 208 | """A URI string""" 209 | def __init__(self, url: str): 210 | self.components = urllib.parse.urlparse(url) 211 | 212 | # needed if converting TO GraphQL 213 | def __gql_dump__(self) -> str: 214 | return self.components.geturl() 215 | 216 | # needed if loading FROM GraphQL responses 217 | @classmethod 218 | def __gql_load__(cls, data: str) -> URI: 219 | return cls(data) 220 | 221 | 222 | To make sure this scalar is used in the schema, 223 | pass it to the schema constructor: 224 | 225 | .. code-block:: python3 226 | 227 | # this also works with Schema.from_url() 228 | schema = quiz.Schema.from_path(..., scalars=[URI, MyOtherScalar, ...]) 229 | schema.URI is URI # True 230 | 231 | 232 | .. _selectionset: 233 | 234 | The ``SELECTOR`` API 235 | -------------------- 236 | 237 | The :class:`quiz.SELECTOR ` 238 | object allows writing GraphQL in python syntax. 239 | 240 | It is recommended to import this object as an easy-to-type variable name, 241 | such as ``_``. 242 | 243 | .. code-block:: python3 244 | 245 | import quiz.SELECTOR as _ 246 | 247 | Fields 248 | ~~~~~~ 249 | 250 | A selection with simple fields can be constructed by chaining attribute lookups. 251 | Below shows an example of a selection with 3 fields: 252 | 253 | .. code-block:: python3 254 | 255 | selection = _.field1.field2.foo 256 | 257 | Note that we can write the same across multiple lines, using brackets. 258 | 259 | .. code-block:: python3 260 | 261 | selection = ( 262 | _ 263 | .field1 264 | .field2 265 | .foo 266 | ) 267 | 268 | This makes the selection more readable. We will be using this style from now on. 269 | 270 | How does this look in GraphQL? Let's have a look: 271 | 272 | >>> str(selection) 273 | { 274 | field1 275 | field2 276 | foo 277 | } 278 | 279 | .. note:: 280 | 281 | Newlines between brackets are valid python syntax. 282 | When chaining fields, **do not** add commas: 283 | 284 | .. code-block:: python3 285 | 286 | # THIS IS WRONG: 287 | selection = ( 288 | _, 289 | .field1, 290 | .field2, 291 | .foo, 292 | ) 293 | 294 | 295 | Arguments 296 | ~~~~~~~~~ 297 | 298 | To add arguments to a field, simply use python's function call syntax 299 | with keyword arguments: 300 | 301 | .. code-block:: python3 302 | 303 | selection = ( 304 | _ 305 | .field1 306 | .field2(my_arg=4, qux='my value') 307 | .foo(bar=None) 308 | ) 309 | 310 | This converts to the following GraphQL: 311 | 312 | .. code-block:: python3 313 | 314 | >>> str(selection) 315 | { 316 | field1 317 | field2(my_arg: 4, qux: "my value") 318 | foo(bar: null) 319 | } 320 | 321 | 322 | Selections 323 | ~~~~~~~~~~ 324 | 325 | To add a selection to a field, use python's slicing syntax. 326 | Within the ``[]`` brackets, a new selection can be defined. 327 | 328 | .. code-block:: python3 329 | 330 | selection = ( 331 | _ 332 | .field1 333 | .field2[ 334 | _ 335 | .subfieldA 336 | .subfieldB 337 | .more[ 338 | _ 339 | .nested 340 | .data 341 | ] 342 | .another_field 343 | ] 344 | .foo 345 | ) 346 | 347 | 348 | This converts to the following GraphQL: 349 | 350 | .. code-block:: python3 351 | 352 | >>> str(selection) 353 | { 354 | field1 355 | field2 { 356 | subfieldA 357 | subfieldB 358 | more { 359 | nested 360 | data 361 | } 362 | another_field 363 | } 364 | foo 365 | } 366 | 367 | Aliases 368 | ~~~~~~~ 369 | 370 | To add an alias to a field, add a function call before the field, 371 | specifying the field name: 372 | 373 | .. code-block:: python3 374 | 375 | selection = ( 376 | _ 377 | .field1 378 | ('my_alias').field2 379 | .foo 380 | ) 381 | 382 | This converts to the following GraphQL: 383 | 384 | .. code-block:: python3 385 | 386 | >>> str(selection) 387 | { 388 | field1 389 | my_alias: field2 390 | foo 391 | } 392 | 393 | Fragments & Directives 394 | ~~~~~~~~~~~~~~~~~~~~~~ 395 | 396 | Fragments and directives are not yet supported. 397 | See the roadmap. 398 | 399 | Combinations 400 | ~~~~~~~~~~~~ 401 | 402 | The above features can be combined without restriction. 403 | Here is an example of a complex query to GitHub's v4 API: 404 | 405 | .. code-block:: python3 406 | 407 | selection = ( 408 | _ 409 | .rateLimit[ 410 | _ 411 | .remaining 412 | .resetAt 413 | ] 414 | ('hello_repo').repository(owner='octocat', name='hello-world')[ 415 | _ 416 | .createdAt 417 | ] 418 | .organization(login='github')[ 419 | _ 420 | .location 421 | .members(first=10)[ 422 | _.edges[ 423 | _.node[ 424 | _.id 425 | ] 426 | ] 427 | ('count').totalCount 428 | ] 429 | ] 430 | ) 431 | 432 | This translates in to the following GraphQL: 433 | 434 | .. code-block:: python3 435 | 436 | >>> str(selection) 437 | { 438 | rateLimit { 439 | remaining 440 | resetAt 441 | } 442 | hello_repo: repository(owner: "octocat", name: "hello-world") { 443 | createdAt 444 | } 445 | organization(login: "github") { 446 | location 447 | members(first: 10) { 448 | edges { 449 | node { 450 | id 451 | } 452 | } 453 | count: totalCount 454 | } 455 | } 456 | } 457 | -------------------------------------------------------------------------------- /src/quiz/build.py: -------------------------------------------------------------------------------- 1 | """Main module for constructing graphQL queries""" 2 | 3 | import enum 4 | import re 5 | import typing as t 6 | from functools import singledispatch 7 | from operator import attrgetter, methodcaller 8 | from textwrap import indent 9 | 10 | from .utils import FrozenDict, ValueObject, compose, init_last 11 | 12 | __all__ = [ 13 | # building graphQL documents 14 | "SelectionSet", 15 | "Selection", 16 | "Field", 17 | "InlineFragment", 18 | "Raw", 19 | "Query", 20 | "SELECTOR", 21 | # render 22 | "gql", 23 | "escape", 24 | "argument_as_gql", 25 | ] 26 | 27 | INDENT = " " 28 | 29 | gql = methodcaller("__gql__") 30 | 31 | 32 | class SelectionSet(t.Iterable["Selection"], t.Sized): 33 | """Sequence of selections 34 | 35 | Parameters 36 | ---------- 37 | *selections: Selection 38 | Items in the selection set. 39 | 40 | Notes 41 | ----- 42 | * Instances are immutable. 43 | * Extending selection sets is possible through special methods 44 | (``__getattr__``, ``__call__``, ``__getitem__``) 45 | """ 46 | 47 | # The attribute needs to have a dunder name to prevent 48 | # conflicts with GraphQL field names. 49 | # This is also why we can't just subclass `tuple`. 50 | __slots__ = "__selections__" 51 | 52 | def __init__(self, *selections): 53 | self.__selections__ = selections 54 | 55 | # TODO: check if actually faster 56 | # faster, internal, alternative to __init__ 57 | @classmethod 58 | def _make(cls, selections): 59 | instance = cls.__new__(cls) 60 | instance.__selections__ = tuple(selections) 61 | return instance 62 | 63 | def __getattr__(self, fieldname): 64 | """Add a new field to the selection set. 65 | 66 | Parameters 67 | ---------- 68 | fieldname: str 69 | The name of the field to add. 70 | 71 | Returns 72 | ------- 73 | SelectionSet 74 | A selection set with the new field added to the end. 75 | 76 | Example 77 | ------- 78 | 79 | This functionality can be used to quickly create a sequence of fields: 80 | 81 | >>> _ = SelectionSet() 82 | >>> str( 83 | ... _ 84 | ... .foo 85 | ... .bar 86 | ... .bing 87 | ... ) 88 | { 89 | foo 90 | bar 91 | bing 92 | } 93 | """ 94 | return SelectionSet._make(self.__selections__ + (Field(fieldname),)) 95 | 96 | def __getitem__(self, selections): 97 | """Add a sub-selection to the last field in the selection set 98 | 99 | Parameters 100 | ---------- 101 | selections: SelectionSet 102 | The selection set to nest 103 | 104 | Example 105 | ------- 106 | 107 | >>> _ = SelectionSet() 108 | >>> str( 109 | ... _ 110 | ... .foo 111 | ... .bar[ 112 | ... _ 113 | ... .qux 114 | ... .bing 115 | ... ] 116 | ... .other_field 117 | ... ) 118 | { 119 | foo 120 | bar { 121 | qux 122 | bing 123 | } 124 | other_field 125 | } 126 | 127 | Returns 128 | ------- 129 | SelectionSet 130 | A selection set with selections added to the last field. 131 | 132 | Raises 133 | ------ 134 | utils.Empty 135 | In case the selection set is empty 136 | """ 137 | rest, target = init_last(self.__selections__) 138 | 139 | assert isinstance(selections, SelectionSet) 140 | assert len(selections.__selections__) >= 1 141 | 142 | return SelectionSet._make( 143 | tuple(rest) + (target.replace(selection_set=selections),) 144 | ) 145 | 146 | def __repr__(self): 147 | return " {}".format(gql(self)) 148 | 149 | # Positional arguments are retrieved manually from *args. 150 | # This ensures there can be no conflict with (named) **kwargs. 151 | # Otherwise, something like `self` could not be given as a keyword arg. 152 | def __call__(*args, **kwargs): 153 | """The selection set may be called in two distinct ways: 154 | 155 | 1. With keyword arguments ``**kwargs``. 156 | These will be added as arguments to the last field 157 | in the selection set. 158 | 2. With a single ``alias`` argument. This has the affect of adding 159 | an alias to the next field in the selection set. 160 | 161 | Parameters 162 | ---------- 163 | alias: str, optional 164 | If given, the next field in the selection set will get this alias. 165 | 166 | Example 167 | ------- 168 | 169 | >>> _ = SelectionSet() 170 | >>> str( 171 | ... _ 172 | ... .foo 173 | ... ('my_alias').bla 174 | ... .other_field 175 | ... ) 176 | { 177 | foo 178 | my_alias: bla 179 | other_field 180 | } 181 | 182 | Note 183 | ---- 184 | The alias can only be specified as a :term:`positional argument`, 185 | and may not be combined with ``**kwargs``. 186 | 187 | **kwargs 188 | Adds arguments to the previous field in the chain 189 | 190 | Example 191 | ------- 192 | 193 | >>> _ = SelectionSet() 194 | >>> str( 195 | ... _ 196 | ... .foo 197 | ... .bla(a=4, b='qux') 198 | ... .other_field 199 | ... ) 200 | { 201 | foo 202 | bla(a: 4, b: "qux") 203 | other_field 204 | } 205 | 206 | Note 207 | ---- 208 | Each field argument must be a :term:`keyword argument`. 209 | 210 | Returns 211 | ------- 212 | SelectionSet 213 | The new selection set 214 | 215 | Raises 216 | ------ 217 | utils.Empty 218 | In case field arguments are given, but the selection set is empty 219 | """ 220 | # TODO: check alias validity 221 | try: 222 | self, alias = args 223 | except ValueError: 224 | # alias is *not* given --> case 1 225 | (self,) = args 226 | return self.__add_kwargs(kwargs) 227 | else: 228 | # alias is given --> case 2 229 | return _AliasForNextField(*args) 230 | 231 | def __add_kwargs(self, args): 232 | rest, target = init_last(self.__selections__) 233 | return SelectionSet._make( 234 | tuple(rest) + (target.replace(kwargs=FrozenDict(args)),) 235 | ) 236 | 237 | def __iter__(self): 238 | """Iterate over the selection set contents 239 | 240 | Returns 241 | ------- 242 | Iterator[Selection] 243 | An iterator over selections 244 | """ 245 | return iter(self.__selections__) 246 | 247 | def __len__(self): 248 | """Number of items in the selection set 249 | 250 | Returns 251 | ------- 252 | int 253 | The number of items in the selection set 254 | """ 255 | return len(self.__selections__) 256 | 257 | def __str__(self): 258 | """The selection set as raw graphQL""" 259 | return self.__gql__() 260 | 261 | def __gql__(self): 262 | return ( 263 | "{{\n{}\n}}".format( 264 | "\n".join(indent(gql(f), INDENT) for f in self) 265 | ) 266 | if self.__selections__ 267 | else "" 268 | ) 269 | 270 | def __eq__(self, other): 271 | if isinstance(other, type(self)): 272 | return other.__selections__ == self.__selections__ 273 | return NotImplemented 274 | 275 | def __ne__(self, other): 276 | equality = self.__eq__(other) 277 | return NotImplemented if equality is NotImplemented else not equality 278 | 279 | __hash__ = property(attrgetter("__selections__.__hash__")) 280 | 281 | 282 | class _AliasForNextField(object): 283 | __slots__ = "__selection_set", "__alias" 284 | 285 | def __init__(self, selection_set, alias): 286 | self.__selection_set = selection_set 287 | self.__alias = alias 288 | 289 | def __getattr__(self, fieldname): 290 | return SelectionSet._make( 291 | self.__selection_set.__selections__ 292 | + (Field(fieldname, alias=self.__alias),) 293 | ) 294 | 295 | 296 | SELECTOR = SelectionSet() 297 | """An empty, extendable :class:`SelectionSet`""" 298 | 299 | 300 | class Raw(ValueObject): 301 | __fields__ = [("content", str, "The raw GraphQL content")] 302 | 303 | def __gql__(self): 304 | return self.content 305 | 306 | 307 | class Field(ValueObject): 308 | __fields__ = [ 309 | ("name", str, "Field name"), 310 | ("kwargs", FrozenDict, "Given arguments"), 311 | ("selection_set", SelectionSet, "Selection of subfields"), 312 | ("alias", t.Optional[str], "Field alias"), 313 | # in the future: 314 | # - directives 315 | ] 316 | __defaults__ = (FrozenDict.EMPTY, SelectionSet(), None) 317 | 318 | def __gql__(self): 319 | arguments = ( 320 | "({})".format( 321 | ", ".join( 322 | "{}: {}".format(k, argument_as_gql(v)) 323 | for k, v in self.kwargs.items() 324 | ) 325 | ) 326 | if self.kwargs 327 | else "" 328 | ) 329 | selection_set = ( 330 | " " + gql(self.selection_set) if self.selection_set else "" 331 | ) 332 | alias = self.alias + ": " if self.alias else "" 333 | return alias + self.name + arguments + selection_set 334 | 335 | 336 | class InlineFragment(ValueObject): 337 | __fields__ = [ 338 | ("on", type, "Type of the fragment"), 339 | ("selection_set", SelectionSet, "Subfields of the fragment"), 340 | ] 341 | # in the future: directives 342 | 343 | def __gql__(self): 344 | return "... on {} {}".format(self.on.__name__, gql(self.selection_set)) 345 | 346 | 347 | class Query(ValueObject): 348 | __fields__ = [ 349 | ("cls", type, "The query class"), 350 | ("selections", SelectionSet, "Fields selection"), 351 | ] 352 | # in the future: 353 | # - name (optional) 354 | # - variable_defs (optional) 355 | # - directives (optional) 356 | 357 | def __gql__(self): 358 | return "query " + gql(self.selections) 359 | 360 | def __str__(self): 361 | return self.__gql__() 362 | 363 | 364 | _ESCAPE_PATTERNS = { 365 | "\b": r"\b", 366 | "\f": r"\f", 367 | "\n": r"\n", 368 | "\r": r"\r", 369 | "\t": r"\t", 370 | "\\": r"\\", 371 | '"': r"\"", 372 | } 373 | _ESCAPE_RE = re.compile("|".join(map(re.escape, _ESCAPE_PATTERNS))) 374 | 375 | 376 | def _escape_match(match): 377 | return _ESCAPE_PATTERNS[match.group(0)] 378 | 379 | 380 | def escape(txt): 381 | """Escape a string according to GraphQL specification 382 | 383 | Parameters 384 | ---------- 385 | txt: str 386 | The string to escape 387 | 388 | Returns 389 | ------- 390 | str 391 | the escaped string 392 | """ 393 | return _ESCAPE_RE.sub(_escape_match, txt) 394 | 395 | 396 | @singledispatch 397 | def argument_as_gql(obj): 398 | # type: (object) -> str 399 | try: 400 | # consistent with other dunder methods, we look it up on the class 401 | serializer = type(obj).__gql_dump__ 402 | except AttributeError: 403 | raise TypeError("Cannot serialize to GraphQL: {}".format(type(obj))) 404 | else: 405 | return serializer(obj) 406 | 407 | 408 | argument_as_gql.register(str, compose('"{}"'.format, escape)) 409 | argument_as_gql.register(int, str) 410 | argument_as_gql.register(type(None), "null".format) 411 | argument_as_gql.register(bool, {True: "true", False: "false"}.__getitem__) 412 | argument_as_gql.register(float, str) 413 | 414 | 415 | @argument_as_gql.register(enum.Enum) 416 | def _enum_to_gql(obj): 417 | return obj.value 418 | 419 | 420 | Selection = t.Union[Field, InlineFragment] 421 | """Field or inline fragment""" 422 | -------------------------------------------------------------------------------- /src/quiz/types.py: -------------------------------------------------------------------------------- 1 | """Components for typed GraphQL interactions""" 2 | 3 | import enum 4 | import typing as t 5 | from itertools import starmap 6 | 7 | from .build import Field, InlineFragment, SelectionSet # noqa 8 | from .utils import JSON, FrozenDict, ValueObject # noqa 9 | 10 | __all__ = [ 11 | # types 12 | "Enum", 13 | "Union", 14 | "GenericScalar", 15 | "Scalar", 16 | "List", 17 | "Interface", 18 | "Object", 19 | "Nullable", 20 | "FieldDefinition", 21 | "InputValue", 22 | # TODO: mutation 23 | # TODO: subscription 24 | # validation 25 | "validate", 26 | "ValidationError", 27 | "SelectionError", 28 | "NoSuchField", 29 | "NoSuchArgument", 30 | "SelectionsNotSupported", 31 | "InvalidArgumentType", 32 | "MissingArgument", 33 | "NoValueForField", 34 | "load", 35 | ] 36 | 37 | 38 | InputValue = t.NamedTuple( 39 | "InputValue", [("name", str), ("desc", str), ("type", type)] 40 | ) 41 | 42 | _PRIMITIVE_TYPES = (int, float, bool, str) 43 | 44 | 45 | class HasFields(type): 46 | """metaclass for classes with GraphQL field definitions""" 47 | 48 | def __getitem__(self, selection_set): 49 | # type: (SelectionSet) -> InlineFragment 50 | return InlineFragment(self, validate(self, selection_set)) 51 | 52 | 53 | class Namespace(object): 54 | def __init__(__self, **kwargs): 55 | __self.__dict__.update(kwargs) 56 | 57 | def __fields__(self): 58 | return {k: v for k, v in self.__dict__.items() if k != "__metadata__"} 59 | 60 | def __eq__(self, other): 61 | if type(self) == type(other): # noqa 62 | return self.__fields__() == other.__fields__() 63 | return NotImplemented 64 | 65 | def __repr__(self): 66 | return "{}({})".format( 67 | self.__class__.__qualname__, 68 | ", ".join(starmap("{}={!r}".format, self.__dict__.items())), 69 | ) 70 | 71 | 72 | class Object(Namespace, metaclass=HasFields): 73 | """a graphQL object""" 74 | 75 | 76 | class InputObject(object): 77 | """not yet implemented""" 78 | 79 | 80 | # separate class to distinguish graphql enums from normal Enums 81 | class Enum(enum.Enum): 82 | pass 83 | 84 | 85 | class Interface(HasFields): 86 | """metaclass for interfaces""" 87 | 88 | 89 | class NoValueForField(AttributeError): 90 | """Indicates a value cannot be retrieved for the field""" 91 | 92 | 93 | class FieldDefinition(ValueObject): 94 | __fields__ = [ 95 | ("name", str, "Field name"), 96 | ("desc", str, "Field description"), 97 | ("type", type, "Field data type"), 98 | ("args", FrozenDict[str, InputValue], "Accepted field arguments"), 99 | ("is_deprecated", bool, "Whether the field is deprecated"), 100 | ("deprecation_reason", t.Optional[str], "Reason for deprecation"), 101 | ] 102 | 103 | def __get__(self, obj, objtype=None): 104 | if obj is None: # accessing on class 105 | return self 106 | try: 107 | return obj.__dict__[self.name] 108 | except KeyError: 109 | raise NoValueForField() 110 | 111 | # full descriptor interface is necessary to be displayed nicely in help() 112 | def __set__(self, obj, value): 113 | raise AttributeError("Can't set field value") 114 | 115 | # __doc__ allows descriptor to be displayed nicely in help() 116 | @property 117 | def __doc__(self): 118 | return ": {.__name__}\n {}".format(self.type, self.desc) 119 | 120 | 121 | class ListMeta(type): 122 | def __getitem__(self, arg): 123 | return type("[{.__name__}]".format(arg), (List,), {"__arg__": arg}) 124 | 125 | def __instancecheck__(self, instance): 126 | return isinstance(instance, list) and all( 127 | isinstance(i, self.__arg__) for i in instance 128 | ) 129 | 130 | 131 | # Q: why not typing.List? 132 | # A: it doesn't support __doc__, __name__, or isinstance() 133 | class List(object, metaclass=ListMeta): 134 | __arg__ = object 135 | 136 | 137 | class NullableMeta(type): 138 | def __getitem__(self, arg): 139 | return type( 140 | "{.__name__} or None".format(arg), (Nullable,), {"__arg__": arg} 141 | ) 142 | 143 | def __instancecheck__(self, instance): 144 | return instance is None or isinstance(instance, self.__arg__) 145 | 146 | 147 | # Q: why not typing.Optional? 148 | # A: it is not easily distinguished from Union, 149 | # and doesn't support __doc__, __name__, or isinstance() 150 | class Nullable(object, metaclass=NullableMeta): 151 | __arg__ = object 152 | 153 | 154 | class UnionMeta(type): 155 | def __instancecheck__(self, instance): 156 | return isinstance(instance, self.__args__) 157 | 158 | 159 | # Q: why not typing.Union? 160 | # A: it isn't consistent across python versions, 161 | # and doesn't support __doc__, __name__, or isinstance() 162 | class Union(object, metaclass=UnionMeta): 163 | __args__ = () 164 | 165 | 166 | class Scalar(object): 167 | """Base class for scalars""" 168 | 169 | def __gql_dump__(self): 170 | """Serialize the scalar to a GraphQL primitive value""" 171 | raise NotImplementedError( 172 | "GraphQL serialization is not defined for this scalar" 173 | ) 174 | 175 | @classmethod 176 | def __gql_load__(cls, data): 177 | """Load a scalar instance from GraphQL""" 178 | raise NotImplementedError( 179 | "GraphQL deserialization is not defined for this scalar" 180 | ) 181 | 182 | 183 | class GenericScalarMeta(type): 184 | def __instancecheck__(self, instance): 185 | return isinstance(instance, _PRIMITIVE_TYPES) 186 | 187 | 188 | class GenericScalar(Scalar, metaclass=GenericScalarMeta): 189 | """A generic scalar, accepting any primitive type""" 190 | 191 | 192 | def _unwrap_list_or_nullable(type_): 193 | # type: t.Type[Nullable, List, Scalar, Enum, InputObject] 194 | # -> Type[Scalar | Enum | InputObject] 195 | if issubclass(type_, (Nullable, List)): 196 | return _unwrap_list_or_nullable(type_.__arg__) 197 | return type_ 198 | 199 | 200 | def _validate_args(schema, actual): 201 | # type: (t.Mapping[str, InputValue], t.Mapping[str, object]) 202 | # -> Mapping[str, object] 203 | invalid_args = actual.keys() - schema.keys() 204 | if invalid_args: 205 | raise NoSuchArgument(invalid_args.pop()) 206 | 207 | for input_value in schema.values(): 208 | try: 209 | value = actual[input_value.name] 210 | except KeyError: 211 | if issubclass(input_value.type, Nullable): 212 | continue # arguments of nullable type may be omitted 213 | else: 214 | raise MissingArgument(input_value.name) 215 | 216 | if not isinstance(value, input_value.type): 217 | raise InvalidArgumentType(input_value.name, value) 218 | 219 | return actual 220 | 221 | 222 | def _validate_field(schema, actual): 223 | # type (Optional[FieldDefinition], Field) -> Field 224 | # raises: 225 | # - NoSuchField 226 | # - SelectionsNotSupported 227 | # - NoSuchArgument 228 | # - RequredArgument 229 | # - InvalidArgumentType 230 | if schema is None: 231 | raise NoSuchField() 232 | _validate_args(schema.args, actual.kwargs) 233 | if actual.selection_set: 234 | type_ = _unwrap_list_or_nullable(schema.type) 235 | if not isinstance(type_, HasFields): 236 | raise SelectionsNotSupported() 237 | validate(type_, actual.selection_set) 238 | return actual 239 | 240 | 241 | def validate(cls, selection_set): 242 | """Validate a selection set against a type 243 | 244 | Parameters 245 | ---------- 246 | cls: type 247 | The class to validate against, an ``Object`` or ``Interface`` 248 | selection_set: SelectionSet 249 | The selection set to validate 250 | 251 | Returns 252 | ------- 253 | SelectionSet 254 | The validated selection set 255 | 256 | Raises 257 | ------ 258 | SelectionError 259 | If the selection set is not valid 260 | """ 261 | for field in selection_set: 262 | try: 263 | _validate_field(getattr(cls, field.name, None), field) 264 | except ValidationError as e: 265 | raise SelectionError(cls, field.name, e) 266 | return selection_set 267 | 268 | 269 | T = t.TypeVar("T") 270 | 271 | 272 | # TODO: refactor using singledispatch 273 | # TODO: cleanup this API: ``field`` is often unneeded. unify with ``load``? 274 | def load_field(type_, field, value): 275 | # type: (t.Type[T], Field, JSON) -> T 276 | if issubclass(type_, Namespace): 277 | assert isinstance(value, dict) 278 | return load(type_, field.selection_set, value) 279 | elif issubclass(type_, Nullable): 280 | return ( 281 | None if value is None else load_field(type_.__arg__, field, value) 282 | ) 283 | elif issubclass(type_, List): 284 | assert isinstance(value, list) 285 | return [load_field(type_.__arg__, field, v) for v in value] 286 | elif issubclass(type_, _PRIMITIVE_TYPES): 287 | assert isinstance(value, type_) 288 | return value 289 | elif issubclass(type_, GenericScalar): 290 | assert isinstance(value, type_) 291 | return value 292 | elif issubclass(type_, Scalar): 293 | return type_.__gql_load__(value) 294 | elif issubclass(type_, Enum): 295 | assert value, type_._members_names_ 296 | return type_(value) 297 | else: 298 | raise NotImplementedError() 299 | 300 | 301 | def load(cls, selection_set, response): 302 | """Load a response for a selection set 303 | 304 | Parameters 305 | ---------- 306 | cls: Type[T] 307 | The class to load against, an ``Object`` or ``Interface`` 308 | selection_set: SelectionSet 309 | The selection set to validate 310 | response: t.Mapping[str, JSON] 311 | The JSON response data 312 | 313 | Returns 314 | ------- 315 | T 316 | An instance of ``cls`` 317 | """ 318 | instance = cls( 319 | **{ 320 | field.alias 321 | or field.name: load_field( 322 | getattr(cls, field.name).type, 323 | field, 324 | response[field.alias or field.name], 325 | ) 326 | for field in selection_set 327 | } 328 | ) 329 | # TODO: do this in a cleaner way 330 | if hasattr(response, "__metadata__"): 331 | instance.__metadata__ = response.__metadata__ 332 | return instance 333 | 334 | 335 | class ValidationError(Exception): 336 | """base class for validation errors""" 337 | 338 | 339 | class SelectionError(ValueObject, ValidationError): 340 | __fields__ = [ 341 | ("on", type, "Type on which the error occurred"), 342 | ("path", str, "Path at which the error occurred"), 343 | ("error", ValidationError, "Original error"), 344 | ] 345 | 346 | def __str__(self): 347 | return '{} on "{}" at path "{}":\n\n {}: {}'.format( 348 | self.__class__.__name__, 349 | self.on.__name__, 350 | self.path, 351 | self.error.__class__.__name__, 352 | self.error, 353 | ) 354 | 355 | 356 | class NoSuchField(ValueObject, ValidationError): 357 | __fields__ = [] 358 | 359 | def __str__(self): 360 | return "field does not exist" 361 | 362 | 363 | class NoSuchArgument(ValueObject, ValidationError): 364 | __fields__ = [("name", str, "(Invalid) argument name")] 365 | 366 | def __str__(self): 367 | return 'argument "{}" does not exist'.format(self.name) 368 | 369 | 370 | class InvalidArgumentType(ValueObject, ValidationError): 371 | __fields__ = [ 372 | ("name", str, "Argument name"), 373 | ("value", object, "(Invalid) value"), 374 | ] 375 | 376 | def __str__(self): 377 | return 'invalid value "{}" of type {} for argument "{}"'.format( 378 | self.value, type(self.value), self.name 379 | ) 380 | 381 | 382 | class MissingArgument(ValueObject, ValidationError): 383 | __fields__ = [("name", str, "Missing argument name")] 384 | 385 | def __str__(self): 386 | return 'argument "{}" missing (required)'.format(self.name) 387 | 388 | 389 | class SelectionsNotSupported(ValueObject, ValidationError): 390 | __fields__ = [] 391 | 392 | def __str__(self): 393 | return "selections not supported on this object" 394 | 395 | 396 | BUILTIN_SCALARS = {"Boolean": bool, "String": str, "Float": float, "Int": int} 397 | -------------------------------------------------------------------------------- /tests/test_schema.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import json 3 | import pickle 4 | import pydoc 5 | import sys 6 | import types 7 | 8 | import pytest 9 | import snug 10 | 11 | import quiz 12 | from quiz import SELECTOR as _ 13 | from quiz import schema as s 14 | 15 | from .helpers import MockClient 16 | 17 | 18 | def trim_whitespace(txt): 19 | return "".join(t.rstrip() + "\n" for t in txt.splitlines()) 20 | 21 | 22 | def render_doc(obj): 23 | return trim_whitespace(pydoc.render_doc(obj, renderer=pydoc.plaintext)) 24 | 25 | 26 | @pytest.fixture 27 | def schema(raw_schema): 28 | return quiz.Schema.from_raw(raw_schema, module="mymodule") 29 | 30 | 31 | class TestEnumAsType: 32 | def test_simple(self): 33 | enum_schema = s.Enum( 34 | "MyValues", 35 | "my enum!", 36 | values=[ 37 | s.EnumValue( 38 | "foo", "foo value...", True, "this is deprecated!" 39 | ), 40 | s.EnumValue("blabla", "...", False, None), 41 | s.EnumValue("qux", "qux value.", False, None), 42 | ], 43 | ) 44 | created = s.enum_as_type(enum_schema, module="foo") 45 | assert issubclass(created, quiz.Enum) 46 | assert issubclass(created, enum.Enum) 47 | 48 | assert created.__name__ == "MyValues" 49 | assert created.__doc__ == "my enum!" 50 | assert created.__module__ == "foo" 51 | 52 | assert len(created.__members__) == 3 53 | 54 | for (name, member), member_schema in zip( 55 | created.__members__.items(), enum_schema.values 56 | ): 57 | assert name == member_schema.name 58 | assert member.name == name 59 | assert member.value == name 60 | assert member.__doc__ == member_schema.desc 61 | 62 | 63 | class TestUnionAsType: 64 | def test_one(self): 65 | union_schema = s.Union( 66 | "Foo", "my union!", [s.TypeRef("BlaType", s.Kind.OBJECT, None)] 67 | ) 68 | 69 | objs = {"BlaType": type("BlaType", (), {})} 70 | 71 | created = s.union_as_type(union_schema, objs) 72 | assert created.__name__ == "Foo" 73 | assert created.__doc__ == "my union!" 74 | assert issubclass(created, quiz.Union) 75 | 76 | assert created.__args__ == (objs["BlaType"],) 77 | 78 | def test_simple(self): 79 | union_schema = s.Union( 80 | "Foo", 81 | "my union!", 82 | [ 83 | s.TypeRef("BlaType", s.Kind.OBJECT, None), 84 | s.TypeRef("Quxlike", s.Kind.INTERFACE, None), 85 | s.TypeRef("Foobar", s.Kind.UNION, None), 86 | ], 87 | ) 88 | 89 | objs = { 90 | "BlaType": type("BlaType", (), {}), 91 | "Quxlike": type("Quxlike", (), {}), 92 | "Foobar": type("Foobar", (), {}), 93 | "Bla": type("Bla", (), {}), 94 | } 95 | 96 | created = s.union_as_type(union_schema, objs) 97 | assert created.__name__ == "Foo" 98 | assert created.__doc__ == "my union!" 99 | assert issubclass(created, quiz.Union) 100 | 101 | assert created.__args__ == ( 102 | objs["BlaType"], 103 | objs["Quxlike"], 104 | objs["Foobar"], 105 | ) 106 | 107 | 108 | class TestInterfaceAsType: 109 | def test_simple(self): 110 | interface_schema = s.Interface( 111 | "Foo", 112 | "my interface!", 113 | [ 114 | s.Field( 115 | "blabla", 116 | type=s.TypeRef("String", s.Kind.SCALAR, None), 117 | args=[], 118 | desc="my description", 119 | is_deprecated=False, 120 | deprecation_reason=None, 121 | ) 122 | ], 123 | ) 124 | created = s.interface_as_type(interface_schema, module="mymodule") 125 | 126 | assert isinstance(created, quiz.Interface) 127 | assert issubclass(created, quiz.types.Namespace) 128 | assert created.__name__ == "Foo" 129 | assert created.__doc__ == "my interface!" 130 | assert created.__module__ == "mymodule" 131 | 132 | 133 | class TestObjectAsType: 134 | def test_simple(self): 135 | obj_schema = s.Object( 136 | "Foo", 137 | "the foo description!", 138 | interfaces=[ 139 | s.TypeRef("Interface1", s.Kind.INTERFACE, None), 140 | s.TypeRef("BlaInterface", s.Kind.INTERFACE, None), 141 | ], 142 | input_fields=None, 143 | fields=[ 144 | s.Field( 145 | "blabla", 146 | type=s.TypeRef("String", s.Kind.SCALAR, None), 147 | args=[], 148 | desc="my description", 149 | is_deprecated=False, 150 | deprecation_reason=None, 151 | ) 152 | ], 153 | ) 154 | interfaces = { 155 | "Interface1": type( 156 | "Interface1", (quiz.Interface,), {"__module__": "foo"} 157 | ), 158 | "BlaInterface": type( 159 | "BlaInterface", (quiz.Interface,), {"__module__": "foo"} 160 | ), 161 | "Qux": type("Qux", (quiz.Interface,), {"__module__": "foo"}), 162 | } 163 | created = s.object_as_type(obj_schema, interfaces, module="foo") 164 | assert issubclass(created, quiz.Object) 165 | assert created.__name__ == "Foo" 166 | assert created.__doc__ == "the foo description!" 167 | assert created.__module__ == "foo" 168 | assert issubclass(created, interfaces["Interface1"]) 169 | assert issubclass(created, interfaces["BlaInterface"]) 170 | 171 | 172 | class TestResolveTypeRef: 173 | def test_default(self): 174 | ref = s.TypeRef("Foo", s.Kind.ENUM, None) 175 | 176 | classes = {"Foo": quiz.Enum("Foo", {})} 177 | resolved = s.resolve_typeref(ref, classes) 178 | assert issubclass(resolved, quiz.Nullable) 179 | assert resolved.__arg__ is classes["Foo"] 180 | 181 | def test_non_null(self): 182 | ref = s.TypeRef( 183 | None, s.Kind.NON_NULL, s.TypeRef("Foo", s.Kind.OBJECT, None) 184 | ) 185 | 186 | classes = {"Foo": type("Foo", (), {})} 187 | resolved = s.resolve_typeref(ref, classes) 188 | assert resolved == classes["Foo"] 189 | 190 | def test_list(self): 191 | ref = s.TypeRef( 192 | None, s.Kind.LIST, s.TypeRef("Foo", s.Kind.OBJECT, None) 193 | ) 194 | classes = {"Foo": type("Foo", (), {})} 195 | resolved = s.resolve_typeref(ref, classes) 196 | assert issubclass(resolved, quiz.Nullable) 197 | assert issubclass(resolved.__arg__, quiz.List) 198 | assert issubclass(resolved.__arg__.__arg__, quiz.Nullable) 199 | assert resolved.__arg__.__arg__.__arg__ == classes["Foo"] 200 | 201 | def test_list_non_null(self): 202 | ref = s.TypeRef( 203 | None, 204 | s.Kind.NON_NULL, 205 | s.TypeRef( 206 | None, 207 | s.Kind.LIST, 208 | s.TypeRef( 209 | None, 210 | s.Kind.NON_NULL, 211 | s.TypeRef("Foo", s.Kind.OBJECT, None), 212 | ), 213 | ), 214 | ) 215 | classes = {"Foo": type("Foo", (), {})} 216 | resolved = s.resolve_typeref(ref, classes) 217 | assert issubclass(resolved, quiz.List) 218 | assert resolved.__arg__ == classes["Foo"] 219 | 220 | 221 | class TestSchemaFromRaw: 222 | def test_scalars(self, raw_schema): 223 | class URI(quiz.Scalar): 224 | pass 225 | 226 | schema = quiz.Schema.from_raw(raw_schema, scalars=[URI], module="foo") 227 | 228 | # generic scalars 229 | assert issubclass(schema.DateTime, quiz.GenericScalar) 230 | assert schema.DateTime.__name__ == "DateTime" 231 | assert len(schema.__doc__) > 0 232 | 233 | assert schema.Boolean is bool 234 | assert schema.String is str 235 | assert schema.Float is float 236 | assert schema.Int is int 237 | 238 | assert schema.URI is URI 239 | 240 | def test_defaults(self, raw_schema): 241 | schema = quiz.Schema.from_raw(raw_schema, module="foo") 242 | assert isinstance(schema, quiz.Schema) 243 | assert issubclass(schema.DateTime, quiz.GenericScalar) 244 | assert schema.String is str 245 | assert "Query" in schema.classes 246 | assert schema.query_type == schema.classes["Query"] 247 | assert schema.mutation_type == schema.classes["Mutation"] 248 | assert schema.subscription_type is None 249 | assert schema.raw == raw_schema 250 | 251 | 252 | class TestSchema: 253 | def test_attributes(self, schema): 254 | assert schema.Query is schema.classes["Query"] 255 | assert schema.module == "mymodule" 256 | assert issubclass(schema.classes["Repository"], quiz.Object) 257 | assert "Repository" in dir(schema) 258 | assert "__class__" in dir(schema) 259 | 260 | with pytest.raises(AttributeError, match="foo"): 261 | schema.foo 262 | 263 | def test_populate_module(self, raw_schema, mocker): 264 | mymodule = types.ModuleType("mymodule") 265 | mocker.patch.dict(sys.modules, {"mymodule": mymodule}) 266 | 267 | schema = quiz.Schema.from_raw(raw_schema, module="mymodule") 268 | 269 | with pytest.raises(AttributeError, match="Repository"): 270 | mymodule.Repository 271 | 272 | schema.populate_module() 273 | 274 | assert mymodule.Repository is schema.Repository 275 | 276 | my_obj = mymodule.Repository(description="...", name="my repo") 277 | loaded = pickle.loads(pickle.dumps(my_obj)) 278 | assert loaded == my_obj 279 | 280 | def test_populate_module_no_module(self, raw_schema): 281 | schema = quiz.Schema.from_raw(raw_schema) 282 | assert schema.module is None 283 | 284 | with pytest.raises(RuntimeError, match="module"): 285 | schema.populate_module() 286 | 287 | def test_query(self, schema): 288 | query = schema.query[_.license(key="MIT")] 289 | assert query == quiz.Query( 290 | cls=schema.Query, 291 | selections=quiz.SelectionSet( 292 | quiz.Field("license", {"key": "MIT"}) 293 | ), 294 | ) 295 | with pytest.raises(quiz.SelectionError): 296 | schema.query[_.foo] 297 | 298 | def test_to_path(self, schema, tmpdir): 299 | path = str(tmpdir / "myschema.json") 300 | 301 | class MyPath(object): 302 | def __fspath__(self): 303 | return path 304 | 305 | schema.to_path(MyPath()) 306 | loaded = schema.from_path(MyPath(), module="mymodule") 307 | assert loaded.classes.keys() == schema.classes.keys() 308 | 309 | 310 | class TestSchemaFromUrl: 311 | def test_success(self, raw_schema): 312 | client = MockClient( 313 | snug.Response( 314 | 200, json.dumps({"data": {"__schema": raw_schema}}).encode() 315 | ) 316 | ) 317 | result = quiz.Schema.from_url("https://my.url/graphql", client=client) 318 | 319 | assert client.request.url == "https://my.url/graphql" 320 | assert isinstance(result, quiz.Schema) 321 | assert result.raw == raw_schema 322 | 323 | def test_fails(self, raw_schema): 324 | client = MockClient( 325 | snug.Response( 326 | 200, 327 | json.dumps( 328 | {"data": {"__schema": None}, "errors": "foo"} 329 | ).encode(), 330 | ) 331 | ) 332 | 333 | with pytest.raises(quiz.ErrorResponse): 334 | quiz.Schema.from_url("https://my.url/graphql", client=client) 335 | 336 | @pytest.mark.live 337 | def test_live(self): # pragma: no cover 338 | schema = quiz.Schema.from_url("https://graphqlzero.almansi.me/api") 339 | assert schema.User 340 | 341 | 342 | class TestSchemaFromPath: 343 | def test_defaults(self, raw_schema, tmpdir): 344 | schema_file = tmpdir / "myfile.json" 345 | with schema_file.open("w") as wfile: 346 | json.dump(raw_schema, wfile) 347 | 348 | schema = quiz.Schema.from_path(schema_file) 349 | assert schema.module is None 350 | 351 | def test_success(self, raw_schema, tmpdir): 352 | schema_file = tmpdir / "myfile.json" 353 | with schema_file.open("w") as wfile: 354 | json.dump(raw_schema, wfile) 355 | 356 | class MyPath(object): 357 | def __fspath__(self): 358 | return str(schema_file) 359 | 360 | schema = quiz.Schema.from_path(MyPath(), module="mymodule") 361 | assert isinstance(schema, quiz.Schema) 362 | 363 | def test_does_not_exist(self, tmpdir): 364 | schema_file = tmpdir / "does-not-exist.json" 365 | 366 | with pytest.raises(IOError): 367 | quiz.Schema.from_path(str(schema_file), module="mymodule") 368 | 369 | 370 | def test_end_to_end(raw_schema): 371 | schema = quiz.Schema.from_raw(raw_schema, module="github") 372 | doc = render_doc(schema.Issue) 373 | 374 | assert ( 375 | """\ 376 | | viewerDidAuthor 377 | | : bool 378 | | Did the viewer author this comment.""" 379 | in doc 380 | ) 381 | assert ( 382 | """\ 383 | | publishedAt 384 | | : DateTime or None 385 | | Identifies when the comment was published at.""" 386 | in doc 387 | ) 388 | 389 | assert ( 390 | """\ 391 | | viewerCannotUpdateReasons 392 | | : [CommentCannotUpdateReason] 393 | | Reasons why the current viewer can not update this comment.""" 394 | in doc 395 | ) 396 | 397 | assert schema.Issue.__doc__ in doc 398 | assert "Labelable" in doc 399 | -------------------------------------------------------------------------------- /tests/test_types.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from textwrap import dedent 3 | 4 | import pytest 5 | import snug 6 | 7 | import quiz 8 | from quiz import SELECTOR as _ 9 | from quiz.build import SelectionSet, gql 10 | from quiz.utils import FrozenDict as fdict 11 | 12 | from .example import ( 13 | Color, 14 | Command, 15 | Dog, 16 | DogQuery, 17 | Hobby, 18 | Human, 19 | MyDateTime, 20 | Sentient, 21 | ) 22 | from .helpers import AlwaysEquals, NeverEquals 23 | 24 | 25 | class TestUnion: 26 | def test_instancecheck(self): 27 | class MyUnion(quiz.Union): 28 | __args__ = (str, int) 29 | 30 | assert isinstance("foo", MyUnion) 31 | assert isinstance(5, MyUnion) 32 | assert not isinstance(1.3, MyUnion) 33 | 34 | 35 | class TestOptional: 36 | def test_instancecheck(self): 37 | class MyOptional(quiz.Nullable): 38 | __arg__ = int 39 | 40 | assert isinstance(5, MyOptional) 41 | assert isinstance(None, MyOptional) 42 | assert not isinstance(5.4, MyOptional) 43 | 44 | 45 | class TestList: 46 | def test_isinstancecheck(self): 47 | class MyList(quiz.List): 48 | __arg__ = int 49 | 50 | assert isinstance([1, 2], MyList) 51 | assert isinstance([], MyList) 52 | assert not isinstance(["foo"], MyList) 53 | assert not isinstance([3, "bla"], MyList) 54 | assert not isinstance((1, 2), MyList) 55 | 56 | 57 | class TestScalar: 58 | def test_gql_dump_not_implemented(self): 59 | with pytest.raises( 60 | NotImplementedError, 61 | match="GraphQL serialization is not defined for this scalar", 62 | ): 63 | quiz.Scalar().__gql_dump__() 64 | 65 | def test_gql_load_not_implemented(self): 66 | with pytest.raises( 67 | NotImplementedError, 68 | match="GraphQL deserialization is not defined for this scalar", 69 | ): 70 | quiz.Scalar().__gql_load__(None) 71 | 72 | 73 | class TestGenericScalar: 74 | def test_isinstancecheck(self): 75 | class MyScalar(quiz.GenericScalar): 76 | """foo""" 77 | 78 | assert issubclass(MyScalar, quiz.Scalar) 79 | 80 | assert isinstance(4, MyScalar) 81 | assert isinstance("foo", MyScalar) 82 | assert isinstance(0.1, MyScalar) 83 | assert isinstance(True, MyScalar) 84 | 85 | assert not isinstance([], MyScalar) 86 | assert not isinstance(None, MyScalar) 87 | 88 | 89 | class TestObject: 90 | class TestGetItem: 91 | def test_valid(self): 92 | selection_set = _.name.knows_command( 93 | command=Command.SIT 94 | ).is_housetrained 95 | fragment = Dog[selection_set] 96 | assert fragment == quiz.InlineFragment(Dog, selection_set) 97 | 98 | def test_validates(self): 99 | with pytest.raises(quiz.SelectionError): 100 | Dog[_.name.foo.knows_command(command=Command.SIT)] 101 | 102 | def test_repr(self): 103 | d = Dog(name="rufus", foo=9) 104 | assert "name='rufus'" in repr(d) 105 | assert "foo=9" in repr(d) 106 | assert repr(d).startswith("Dog(") 107 | 108 | def test_equality(self): 109 | class Foo(quiz.Object): 110 | pass 111 | 112 | class Bar(quiz.Object): 113 | pass 114 | 115 | f1 = Foo(bla=9, qux=[]) 116 | assert f1 == Foo(bla=9, qux=[]) 117 | assert not f1 == Foo(bla=9, qux=[], t=0.1) 118 | assert not f1 == Bar(bla=9, qux=[]) 119 | assert f1 == AlwaysEquals() 120 | assert not f1 == NeverEquals() 121 | 122 | assert f1 != Foo(bla=9, qux=[], t=0.1) 123 | assert f1 != Bar(bla=9, qux=[]) 124 | assert not f1 != Foo(bla=9, qux=[]) 125 | assert f1 != NeverEquals() 126 | assert not f1 != AlwaysEquals() 127 | 128 | class TestInit: 129 | def test_simple(self): 130 | d = Dog(foo=4, name="Bello") 131 | assert d.foo == 4 132 | assert d.name == "Bello" 133 | 134 | def test_kwarg_named_self(self): 135 | d = Dog(self="foo") 136 | d.self == "foo" 137 | 138 | def test_positional_arg(self): 139 | with pytest.raises(TypeError, match="argument"): 140 | Dog("foo") 141 | 142 | @pytest.mark.xfail(reason="not yet implemented") 143 | def test_dunder(self): 144 | with pytest.raises(TypeError, match="underscore"): 145 | Dog(__foo__=9) 146 | 147 | 148 | class TestInlineFragment: 149 | def test_gql(self): 150 | fragment = Dog[ 151 | _.name.bark_volume.knows_command( 152 | command=Command.SIT 153 | ).is_housetrained.owner[_.name] 154 | ] 155 | assert ( 156 | gql(fragment) 157 | == dedent( 158 | """\ 159 | ... on Dog { 160 | name 161 | bark_volume 162 | knows_command(command: SIT) 163 | is_housetrained 164 | owner { 165 | name 166 | } 167 | } 168 | """ 169 | ).strip() 170 | ) 171 | 172 | 173 | def test_selection_error_str(): 174 | exc = quiz.SelectionError( 175 | Dog, "best_friend.foo", quiz.NoSuchArgument("bla") 176 | ) 177 | assert str(exc).strip() == dedent( 178 | """\ 179 | SelectionError on "Dog" at path "best_friend.foo": 180 | 181 | NoSuchArgument: argument "bla" does not exist""" 182 | ) 183 | 184 | 185 | @pytest.mark.parametrize("name", ["foo", "bar"]) 186 | def test_no_such_argument_str(name): 187 | exc = quiz.NoSuchArgument(name) 188 | assert str(exc) == 'argument "{}" does not exist'.format(name) 189 | 190 | 191 | def test_no_such_field_str(): 192 | exc = quiz.NoSuchField() 193 | assert str(exc) == "field does not exist" 194 | 195 | 196 | @pytest.mark.parametrize("name", ["foo", "bar"]) 197 | def test_invalid_arg_type_str(name): 198 | exc = quiz.InvalidArgumentType(name, 5) 199 | assert str(exc) == ( 200 | 'invalid value "5" of type {} for argument "{}"'.format(int, name) 201 | ) 202 | 203 | 204 | @pytest.mark.parametrize("name", ["foo", "bar"]) 205 | def test_missing_argument_str(name): 206 | exc = quiz.MissingArgument(name) 207 | assert str(exc) == 'argument "{}" missing (required)'.format(name) 208 | 209 | 210 | def test_selections_not_supported_str(): 211 | exc = quiz.SelectionsNotSupported() 212 | assert str(exc) == "selections not supported on this object" 213 | 214 | 215 | class TestValidate: 216 | def test_empty(self): 217 | selection = SelectionSet() 218 | assert quiz.validate(Dog, selection) == SelectionSet() 219 | 220 | def test_simple_valid(self): 221 | assert quiz.validate(Dog, _.name) == _.name 222 | 223 | def test_complex_valid(self): 224 | selection_set = ( 225 | _.name.knows_command(command=Command.SIT) 226 | .is_housetrained.owner[_.name.hobbies[_.name.cool_factor]] 227 | .best_friend[_.name] 228 | .age(on_date=MyDateTime(datetime.now())) 229 | ) 230 | assert quiz.validate(Dog, selection_set) == selection_set 231 | 232 | def test_no_such_field(self): 233 | with pytest.raises(quiz.SelectionError) as exc: 234 | quiz.validate(Dog, _.name.foo.knows_command(command=Command.SIT)) 235 | assert exc.value == quiz.SelectionError(Dog, "foo", quiz.NoSuchField()) 236 | 237 | def test_invalid_argument(self): 238 | with pytest.raises(quiz.SelectionError) as exc: 239 | quiz.validate(Dog, _.knows_command(foo=1, command=Command.SIT)) 240 | assert exc.value == quiz.SelectionError( 241 | Dog, "knows_command", quiz.NoSuchArgument("foo") 242 | ) 243 | 244 | def test_missing_arguments(self): 245 | selection_set = _.knows_command 246 | with pytest.raises(quiz.SelectionError) as exc: 247 | quiz.validate(Dog, selection_set) 248 | 249 | assert exc.value == quiz.SelectionError( 250 | Dog, "knows_command", quiz.MissingArgument("command") 251 | ) 252 | 253 | def test_invalid_argument_type(self): 254 | selection_set = _.knows_command(command="foobar") 255 | with pytest.raises(quiz.SelectionError) as exc: 256 | quiz.validate(Dog, selection_set) 257 | 258 | assert exc.value == quiz.SelectionError( 259 | Dog, "knows_command", quiz.InvalidArgumentType("command", "foobar") 260 | ) 261 | 262 | def test_invalid_argument_type_optional(self): 263 | selection_set = _.is_housetrained(at_other_homes="foo") 264 | with pytest.raises(quiz.SelectionError) as exc: 265 | quiz.validate(Dog, selection_set) 266 | assert exc.value == quiz.SelectionError( 267 | Dog, 268 | "is_housetrained", 269 | quiz.InvalidArgumentType("at_other_homes", "foo"), 270 | ) 271 | 272 | def test_nested_selection_error(self): 273 | with pytest.raises(quiz.SelectionError) as exc: 274 | quiz.validate(Dog, _.owner[_.hobbies[_.foo]]) 275 | assert exc.value == quiz.SelectionError( 276 | Dog, 277 | "owner", 278 | quiz.SelectionError( 279 | Human, 280 | "hobbies", 281 | quiz.SelectionError(Hobby, "foo", quiz.NoSuchField()), 282 | ), 283 | ) 284 | 285 | def test_selection_set_on_non_object(self): 286 | with pytest.raises(quiz.SelectionError) as exc: 287 | quiz.validate(Dog, _.name[_.foo]) 288 | assert exc.value == quiz.SelectionError( 289 | Dog, "name", quiz.SelectionsNotSupported() 290 | ) 291 | 292 | # TODO: check object types always have selection sets 293 | 294 | # TODO: list input type 295 | 296 | 297 | class TestLoadField: 298 | def test_custom_scalar(self): 299 | result = quiz.types.load_field(MyDateTime, quiz.Field("foo"), 12345) 300 | assert isinstance(result, MyDateTime) 301 | assert result.dtime == datetime.fromtimestamp(12345) 302 | 303 | @pytest.mark.parametrize("value", [1, "a string", 0.4, True]) 304 | def test_generic_scalar(self, value): 305 | result = quiz.types.load_field( 306 | quiz.GenericScalar, quiz.Field("data"), value 307 | ) 308 | assert type(result) == type(value) # noqa 309 | assert result == value 310 | 311 | def test_namespace(self): 312 | field = quiz.Field("dog", selection_set=(_.name.color("hooman").owner)) 313 | result = quiz.types.load_field( 314 | Dog, field, {"name": "Fred", "color": "BROWN", "hooman": None} 315 | ) 316 | assert isinstance(result, Dog) 317 | assert result == Dog(name="Fred", color=Color.BROWN, hooman=None) 318 | 319 | @pytest.mark.parametrize( 320 | "value, expect", [(None, None), ("BLACK", Color.BLACK)] 321 | ) 322 | def test_nullable(self, value, expect): 323 | result = quiz.types.load_field( 324 | quiz.Nullable[Color], quiz.Field("color"), value 325 | ) 326 | assert result is expect 327 | 328 | @pytest.mark.parametrize( 329 | "value, expect", 330 | [ 331 | ([], []), 332 | ( 333 | [{"name": "sailing"}, {"name": "bowling"}, None], 334 | [Hobby(name="sailing"), Hobby(name="bowling"), None], 335 | ), 336 | ], 337 | ) 338 | def test_list(self, value, expect): 339 | field = quiz.Field("foo", selection_set=_.name) 340 | result = quiz.types.load_field( 341 | quiz.List[quiz.Nullable[Hobby]], field, value 342 | ) 343 | assert result == expect 344 | 345 | def test_enum(self): 346 | result = quiz.types.load_field(Color, quiz.Field("data"), "BROWN") 347 | assert result is Color.BROWN 348 | 349 | def test_primitive_type(self): 350 | result = quiz.types.load_field(int, quiz.Field("age"), 4) 351 | assert result == 4 352 | 353 | def test_wrong_type(self): 354 | with pytest.raises(NotImplementedError): 355 | quiz.types.load_field(object, None, None) 356 | 357 | 358 | class TestLoad: 359 | def test_empty(self): 360 | selection = quiz.SelectionSet() 361 | loaded = quiz.load(DogQuery, selection, {}) 362 | assert isinstance(loaded, DogQuery) 363 | 364 | def test_full(self): 365 | metadata = quiz.QueryMetadata( 366 | request=snug.GET("https://my.url/foo"), response=snug.Response(200) 367 | ) 368 | selection = _.dog[ 369 | _.name.color("knows_sit") 370 | .knows_command(command=Command.SIT)("knows_roll") 371 | .knows_command(command=Command.ROLL_OVER) 372 | .is_housetrained.owner[ 373 | _.name.hobbies[_.name("coolness").cool_factor] 374 | ] 375 | .best_friend[_.name] 376 | .age(on_date=MyDateTime(datetime.now())) 377 | .birthday 378 | ] 379 | loaded = quiz.load( 380 | DogQuery, 381 | selection, 382 | quiz.RawResult( 383 | { 384 | "dog": { 385 | "name": "Rufus", 386 | "color": "GOLDEN", 387 | "knows_sit": True, 388 | "knows_roll": False, 389 | "is_housetrained": True, 390 | "owner": { 391 | "name": "Fred", 392 | "hobbies": [ 393 | {"name": "stamp collecting", "coolness": 2}, 394 | {"name": "snowboarding", "coolness": 8}, 395 | ], 396 | }, 397 | "best_friend": {"name": "Sally"}, 398 | "age": 3, 399 | "birthday": 1540731645, 400 | } 401 | }, 402 | meta=metadata, 403 | ), 404 | ) 405 | # TODO: include union types 406 | assert isinstance(loaded, DogQuery) 407 | assert loaded.__metadata__ == metadata 408 | assert loaded == DogQuery( 409 | dog=Dog( 410 | name="Rufus", 411 | color=Color.GOLDEN, 412 | knows_sit=True, 413 | knows_roll=False, 414 | is_housetrained=True, 415 | owner=Human( 416 | name="Fred", 417 | hobbies=[ 418 | Hobby(name="stamp collecting", coolness=2), 419 | Hobby(name="snowboarding", coolness=8), 420 | ], 421 | ), 422 | best_friend=Sentient(name="Sally"), 423 | age=3, 424 | birthday=MyDateTime(datetime.fromtimestamp(1540731645)), 425 | ) 426 | ) 427 | 428 | def test_nulls(self): 429 | selection = _.dog[ 430 | _.name("knows_sit") 431 | .knows_command(command=Command.SIT)("knows_roll") 432 | .knows_command(command=Command.ROLL_OVER) 433 | .is_housetrained.owner[ 434 | _.name.hobbies[_.name("coolness").cool_factor] 435 | ] 436 | .best_friend[_.name] 437 | .age(on_date=MyDateTime(datetime.now())) 438 | .birthday 439 | ] 440 | loaded = quiz.load( 441 | DogQuery, 442 | selection, 443 | { 444 | "dog": { 445 | "name": "Rufus", 446 | "knows_sit": True, 447 | "knows_roll": False, 448 | "is_housetrained": True, 449 | "owner": None, 450 | "best_friend": None, 451 | "age": 3, 452 | "birthday": 1540731645, 453 | } 454 | }, 455 | ) 456 | assert isinstance(loaded, DogQuery) 457 | assert loaded == DogQuery( 458 | dog=Dog( 459 | name="Rufus", 460 | knows_sit=True, 461 | knows_roll=False, 462 | is_housetrained=True, 463 | owner=None, 464 | best_friend=None, 465 | age=3, 466 | birthday=MyDateTime(datetime.fromtimestamp(1540731645)), 467 | ) 468 | ) 469 | 470 | 471 | class TestFieldDefinition: 472 | def test_doc(self): 473 | schema = quiz.FieldDefinition( 474 | "foo", 475 | "my description", 476 | type=quiz.List[str], 477 | args=fdict.EMPTY, 478 | is_deprecated=False, 479 | deprecation_reason=None, 480 | ) 481 | assert "[str]" in schema.__doc__ 482 | 483 | def test_descriptor(self): 484 | class Foo(object): 485 | bla = quiz.FieldDefinition( 486 | "bla", 487 | "my description", 488 | args=fdict.EMPTY, 489 | type=quiz.List[int], 490 | is_deprecated=False, 491 | deprecation_reason=None, 492 | ) 493 | 494 | f = Foo() 495 | 496 | with pytest.raises(quiz.NoValueForField) as exc: 497 | f.bla 498 | 499 | assert isinstance(exc.value, AttributeError) 500 | 501 | f.__dict__.update({"bla": 9, "qux": "foo"}) 502 | 503 | assert f.bla == 9 504 | assert f.qux == "foo" 505 | 506 | with pytest.raises(AttributeError, match="set"): 507 | f.bla = 3 508 | -------------------------------------------------------------------------------- /src/quiz/schema.py: -------------------------------------------------------------------------------- 1 | """Functionality relating to the raw GraphQL schema""" 2 | 3 | import enum 4 | import json 5 | import sys 6 | import typing as t 7 | from collections import defaultdict 8 | from functools import partial 9 | from itertools import chain 10 | from operator import methodcaller 11 | from os import fspath 12 | from types import new_class 13 | 14 | from . import types 15 | from .build import Query 16 | from .execution import execute 17 | from .types import validate 18 | from .utils import JSON, FrozenDict, ValueObject, merge 19 | 20 | __all__ = ["Schema", "INTROSPECTION_QUERY"] 21 | 22 | RawSchema = t.Dict[str, JSON] 23 | ClassDict = t.Dict[str, type] 24 | 25 | 26 | def _namedict(classes): 27 | return {c.__name__: c for c in classes} 28 | 29 | 30 | def object_as_type(typ, interfaces, module): 31 | # type: (Object, t.Mapping[str, types.Interface], str) -> type 32 | # we don't add the fields yet -- these types may not exist yet. 33 | return type( 34 | str(typ.name), 35 | tuple(interfaces[i.name] for i in typ.interfaces) + (types.Object,), 36 | {"__doc__": typ.desc, "__raw__": typ, "__module__": module}, 37 | ) 38 | 39 | 40 | def interface_as_type(typ, module): 41 | # type: (Interface, str) -> type 42 | # we don't add the fields yet -- these types may not exist yet. 43 | return new_class( 44 | str(typ.name), 45 | (types.Namespace,), 46 | kwds={"metaclass": types.Interface}, 47 | exec_body=methodcaller( 48 | "update", 49 | {"__doc__": typ.desc, "__raw__": typ, "__module__": module}, 50 | ), 51 | ) 52 | 53 | 54 | def enum_as_type(typ, module): 55 | # type: (Enum, str) -> Type[types.Enum] 56 | assert len(typ.values) > 0 57 | cls = types.Enum( 58 | typ.name, [(v.name, v.name) for v in typ.values], module=module 59 | ) 60 | cls.__doc__ = typ.desc 61 | for member, conf in zip(cls.__members__.values(), typ.values): 62 | member.__doc__ = conf.desc 63 | return cls 64 | 65 | 66 | def union_as_type(typ, objs): 67 | # type (Union, ClassDict) -> type 68 | assert len(typ.types) >= 1, "Encountered a Union with zero types" 69 | return type( 70 | str(typ.name), 71 | (types.Union,), 72 | { 73 | "__doc__": typ.desc, 74 | "__args__": tuple(objs[o.name] for o in typ.types), 75 | }, 76 | ) 77 | 78 | 79 | def inputobject_as_type(typ): 80 | # type InputObject -> type 81 | return type(str(typ.name), (types.InputObject,), {"__doc__": typ.desc}) 82 | 83 | 84 | def _add_fields(obj, classes): 85 | for f in obj.__raw__.fields: 86 | setattr( 87 | obj, 88 | f.name, 89 | types.FieldDefinition( 90 | name=f.name, 91 | desc=f.desc, 92 | args=FrozenDict( 93 | { 94 | i.name: types.InputValue( 95 | name=i.name, 96 | desc=i.desc, 97 | type=resolve_typeref(i.type, classes), 98 | ) 99 | for i in f.args 100 | } 101 | ), 102 | is_deprecated=f.is_deprecated, 103 | deprecation_reason=f.deprecation_reason, 104 | type=resolve_typeref(f.type, classes), 105 | ), 106 | ) 107 | del obj.__raw__ 108 | return obj 109 | 110 | 111 | def resolve_typeref(ref, classes): 112 | # type: (TypeRef, ClassDict) -> type 113 | if ref.kind is Kind.NON_NULL: 114 | return _resolve_typeref_required(ref.of_type, classes) 115 | else: 116 | return types.Nullable[_resolve_typeref_required(ref, classes)] 117 | 118 | 119 | def _resolve_typeref_required(ref, classes): 120 | assert ref.kind is not Kind.NON_NULL 121 | if ref.kind is Kind.LIST: 122 | return types.List[resolve_typeref(ref.of_type, classes)] 123 | return classes[ref.name] 124 | 125 | 126 | class _QueryCreator(object): 127 | def __init__(self, schema): 128 | self.schema = schema 129 | 130 | def __getitem__(self, selection_set): 131 | cls = self.schema.query_type 132 | return Query(cls, selections=validate(cls, selection_set)) 133 | 134 | 135 | class Schema(ValueObject): 136 | """A GraphQL schema. 137 | 138 | Use :meth:`~Schema.from_path`, :meth:`~Schema.from_url`, 139 | or :meth:`~Schema.from_raw` to instantiate. 140 | """ 141 | 142 | __fields__ = [ 143 | ("classes", ClassDict, "Mapping of classes in the schema"), 144 | ("query_type", type, "The query type of the schema"), 145 | ("mutation_type", t.Optional[type], "The mutation type of the schema"), 146 | ( 147 | "subscription_type", 148 | t.Optional[type], 149 | "The subscription type of the schema", 150 | ), 151 | ( 152 | "module", 153 | t.Optional[str], 154 | "The module to which the classes are namespaced", 155 | ), 156 | ("raw", RawSchema, "The raw schema (JSON). To be deprecated"), 157 | ] 158 | 159 | def __getattr__(self, name): 160 | try: 161 | return self.classes[name] 162 | except KeyError: 163 | raise AttributeError(name) 164 | 165 | def __dir__(self): 166 | return list(self.classes) + dir(super(Schema, self)) 167 | 168 | def populate_module(self): 169 | """Populate the schema's module with the schema's classes 170 | 171 | Note 172 | ---- 173 | The schema's ``module`` must be set (i.e. not ``None``) 174 | 175 | Raises 176 | ------ 177 | RuntimeError 178 | If the schema module is not set 179 | """ 180 | # TODO: this is a bit ugly 181 | if self.module is None: 182 | raise RuntimeError("schema.module is not set") 183 | module_obj = sys.modules[self.module] 184 | for name, cls in self.classes.items(): 185 | setattr(module_obj, name, cls) 186 | 187 | # interim object to allow slice syntax: Schema.query[...] 188 | @property 189 | def query(self): 190 | """Creator for a query operation 191 | 192 | Example 193 | ------- 194 | 195 | >>> from quiz import SELECTOR as _ 196 | >>> str(schema.query[ 197 | ... _ 198 | ... .field1 199 | ... .foo 200 | ... ]) 201 | query { 202 | field1 203 | foo 204 | } 205 | """ 206 | return _QueryCreator(self) 207 | 208 | @classmethod 209 | def from_path(cls, path, module=None, scalars=()): 210 | """Create a :class:`Schema` from a JSON at a path 211 | 212 | Parameters 213 | ---------- 214 | path: str or ~os.PathLike 215 | The path to the raw schema JSON file. 216 | module: ~typing.Optional[str], optional 217 | The name of the module to use when creating the schema's classes. 218 | scalars: ~typing.Iterable[~typing.Type[Scalar]] 219 | :class:`~quiz.types.Scalar` classes to use in the schema. 220 | Scalars in the schema, but not in this sequence, will be defined as 221 | :class:`~quiz.types.GenericScalar` subclasses. 222 | 223 | Returns 224 | ------- 225 | Schema 226 | The generated schema 227 | 228 | Raises 229 | ------ 230 | IOError 231 | If the file at given path cannot be read 232 | """ 233 | with open(fspath(path)) as rfile: 234 | return cls.from_raw( 235 | json.load(rfile), module=module, scalars=scalars 236 | ) 237 | 238 | def to_path(self, path): 239 | """Dump the schema as JSON to a path 240 | 241 | Parameters 242 | ---------- 243 | path: str or ~os.PathLike 244 | The path to write the raw schema to 245 | """ 246 | with open(fspath(path), "w") as wfile: 247 | json.dump(self.raw, wfile) 248 | 249 | @classmethod 250 | def from_raw(cls, raw_schema, module=None, scalars=()): 251 | """Create a :class:`Schema` from a raw JSON schema 252 | 253 | Parameters 254 | ---------- 255 | raw_schema: ~typing.List[~typing.Dict[str, JSON]] 256 | The raw GraphQL schema. 257 | I.e. the result of the :data:`INTROSPECTION_QUERY` 258 | module: ~typing.Optional[str], optional 259 | The name of the module to use when creating classes 260 | scalars: ~typing.Iterable[~typing.Type[Scalar]] 261 | :class:`~quiz.types.Scalar` classes to use in the schema. 262 | Scalars in the schema, but not in this sequence, will be defined as 263 | :class:`~quiz.types.GenericScalar` subclasses. 264 | 265 | Returns 266 | ------- 267 | Schema 268 | The schema constructed from raw data 269 | """ 270 | by_kind = defaultdict(list) 271 | for tp in _load_types(raw_schema): 272 | by_kind[tp.__class__].append(tp) 273 | 274 | scalars_by_name = _namedict(scalars) 275 | scalars_by_name.update(types.BUILTIN_SCALARS) 276 | scalars_by_name.update( 277 | ( 278 | tp.name, 279 | type( 280 | str(tp.name), (types.GenericScalar,), {"__doc__": tp.desc} 281 | ), 282 | ) 283 | for tp in by_kind[Scalar] 284 | if tp.name not in scalars_by_name 285 | ) 286 | 287 | interfaces = _namedict( 288 | map(partial(interface_as_type, module=module), by_kind[Interface]) 289 | ) 290 | enums = _namedict( 291 | map(partial(enum_as_type, module=module), by_kind[Enum]) 292 | ) 293 | objs = _namedict( 294 | map( 295 | partial(object_as_type, interfaces=interfaces, module=module), 296 | by_kind[Object], 297 | ) 298 | ) 299 | unions = _namedict( 300 | map(partial(union_as_type, objs=objs), by_kind[Union]) 301 | ) 302 | input_objects = _namedict( 303 | map(inputobject_as_type, by_kind[InputObject]) 304 | ) 305 | 306 | classes = merge( 307 | scalars_by_name, interfaces, enums, objs, unions, input_objects 308 | ) 309 | 310 | # we can only add fields after all classes have been created. 311 | for obj in chain(objs.values(), interfaces.values()): 312 | _add_fields(obj, classes) 313 | 314 | return cls( 315 | classes, 316 | query_type=classes[raw_schema["queryType"]["name"]], 317 | mutation_type=( 318 | raw_schema["mutationType"] 319 | and classes[raw_schema["mutationType"]["name"]] 320 | ), 321 | subscription_type=( 322 | raw_schema["subscriptionType"] 323 | and classes[raw_schema["subscriptionType"]["name"]] 324 | ), 325 | module=module, 326 | raw=raw_schema, 327 | ) 328 | 329 | @classmethod 330 | def from_url(cls, url, scalars=(), module=None, **kwargs): 331 | """Build a GraphQL schema by introspecting an API 332 | 333 | Parameters 334 | ---------- 335 | url: str 336 | URL of the target GraphQL API 337 | scalars: ~typing.Iterable[~typing.Type[Scalar]] 338 | :class:`~quiz.types.Scalar` classes to use in the schema. 339 | Scalars in the schema, but not in this sequence, will be defined as 340 | :class:`~quiz.types.GenericScalar` subclasses. 341 | 342 | module: ~typing.Optional[str], optional 343 | The module name to set on the generated classes 344 | **kwargs 345 | ``auth`` or ``client``, passed to :func:`~quiz.execution.execute`. 346 | 347 | Returns 348 | ------- 349 | Schema 350 | The generated schema 351 | 352 | Raises 353 | ------ 354 | ~quiz.types.ErrorResponse 355 | If there are errors in the response data 356 | """ 357 | result = execute(INTROSPECTION_QUERY, url=url, **kwargs) 358 | return cls.from_raw(result["__schema"], scalars=scalars, module=module) 359 | 360 | # TODO: from_url_async 361 | 362 | 363 | def _load_types(raw_schema): 364 | # type RawSchema -> Iterable[TypeSchema] 365 | return map( 366 | lambda typ: KIND_CAST[typ.kind](typ), 367 | map(_deserialize_type, raw_schema["types"]), 368 | ) 369 | 370 | 371 | INTROSPECTION_QUERY = """ 372 | { 373 | __schema { 374 | queryType { name } 375 | mutationType { name } 376 | subscriptionType { name } 377 | types { 378 | ...FullType 379 | } 380 | } 381 | } 382 | 383 | fragment FullType on __Type { 384 | kind 385 | name 386 | description 387 | fields(includeDeprecated: true) { 388 | name 389 | description 390 | args { 391 | ...InputValue 392 | } 393 | type { 394 | ...TypeRef 395 | } 396 | isDeprecated 397 | deprecationReason 398 | } 399 | inputFields { 400 | ...InputValue 401 | } 402 | interfaces { 403 | ...TypeRef 404 | } 405 | enumValues(includeDeprecated: true) { 406 | name 407 | description 408 | isDeprecated 409 | deprecationReason 410 | } 411 | possibleTypes { 412 | ...TypeRef 413 | } 414 | } 415 | 416 | fragment InputValue on __InputValue { 417 | name 418 | description 419 | type { ...TypeRef } 420 | defaultValue 421 | } 422 | 423 | fragment TypeRef on __Type { 424 | kind 425 | name 426 | ofType { 427 | kind 428 | name 429 | ofType { 430 | kind 431 | name 432 | ofType { 433 | kind 434 | name 435 | } 436 | } 437 | } 438 | } 439 | """ 440 | """Query to retrieve the raw schema""" 441 | 442 | 443 | class Kind(enum.Enum): 444 | OBJECT = "OBJECT" 445 | SCALAR = "SCALAR" 446 | NON_NULL = "NON_NULL" 447 | LIST = "LIST" 448 | INTERFACE = "INTERFACE" 449 | ENUM = "ENUM" 450 | INPUT_OBJECT = "INPUT_OBJECT" 451 | UNION = "UNION" 452 | 453 | 454 | KIND_CAST = { 455 | Kind.SCALAR: lambda typ: Scalar(name=typ.name, desc=typ.desc), 456 | Kind.OBJECT: lambda typ: Object( 457 | name=typ.name, 458 | desc=typ.desc, 459 | interfaces=typ.interfaces, 460 | input_fields=typ.input_fields, 461 | fields=typ.fields, 462 | ), 463 | Kind.INTERFACE: lambda typ: Interface( 464 | name=typ.name, desc=typ.desc, fields=typ.fields 465 | ), 466 | Kind.ENUM: lambda typ: Enum( 467 | name=typ.name, desc=typ.desc, values=typ.enum_values 468 | ), 469 | Kind.UNION: lambda typ: Union( 470 | name=typ.name, desc=typ.desc, types=typ.possible_types 471 | ), 472 | Kind.INPUT_OBJECT: lambda typ: InputObject( 473 | name=typ.name, desc=typ.desc, input_fields=typ.input_fields 474 | ), 475 | } 476 | 477 | 478 | TypeRef = t.NamedTuple( 479 | "TypeRef", 480 | [ 481 | ("name", t.Optional[str]), 482 | ("kind", Kind), 483 | ("of_type", t.Optional["TypeRef"]), 484 | ], 485 | ) 486 | InputValue = t.NamedTuple( 487 | "InputValue", 488 | [("name", str), ("desc", str), ("type", TypeRef), ("default", object)], 489 | ) 490 | Field = t.NamedTuple( 491 | "Field", 492 | [ 493 | ("name", str), 494 | ("type", TypeRef), 495 | ("args", t.List[InputValue]), 496 | ("desc", str), 497 | ("is_deprecated", bool), 498 | ("deprecation_reason", t.Optional[str]), 499 | ], 500 | ) 501 | Type = t.NamedTuple( 502 | "Type", 503 | [ 504 | ("name", t.Optional[str]), 505 | ("kind", Kind), 506 | ("desc", str), 507 | ("fields", t.Optional[t.List[Field]]), 508 | ("input_fields", t.Optional[t.List["InputValue"]]), 509 | ("interfaces", t.Optional[t.List[TypeRef]]), 510 | ("possible_types", t.Optional[t.List[TypeRef]]), 511 | ("enum_values", t.Optional[t.List]), 512 | ], 513 | ) 514 | EnumValue = t.NamedTuple( 515 | "EnumValue", 516 | [ 517 | ("name", str), 518 | ("desc", str), 519 | ("is_deprecated", bool), 520 | ("deprecation_reason", t.Optional[str]), 521 | ], 522 | ) 523 | 524 | 525 | def make_inputvalue(conf): 526 | return InputValue( 527 | name=conf["name"], 528 | desc=conf["description"], 529 | type=make_typeref(conf["type"]), 530 | default=conf["defaultValue"], 531 | ) 532 | 533 | 534 | def make_typeref(conf): 535 | return TypeRef( 536 | name=conf["name"], 537 | kind=Kind(conf["kind"]), 538 | of_type=conf.get("ofType") and make_typeref(conf["ofType"]), 539 | ) 540 | 541 | 542 | def make_field(conf): 543 | return Field( 544 | name=conf["name"], 545 | type=make_typeref(conf["type"]), 546 | args=list(map(make_inputvalue, conf["args"])), 547 | desc=conf["description"], 548 | is_deprecated=conf["isDeprecated"], 549 | deprecation_reason=conf["deprecationReason"], 550 | ) 551 | 552 | 553 | def make_enumval(conf): 554 | return EnumValue( 555 | name=conf["name"], 556 | desc=conf["description"], 557 | is_deprecated=conf["isDeprecated"], 558 | deprecation_reason=conf["deprecationReason"], 559 | ) 560 | 561 | 562 | def _deserialize_type(conf): 563 | # type: (t.Dict[str, JSON]) -> Type 564 | return Type( 565 | name=conf["name"], 566 | kind=Kind(conf["kind"]), 567 | desc=conf["description"], 568 | fields=conf["fields"] and list(map(make_field, conf["fields"])), 569 | input_fields=conf["inputFields"] 570 | and list(map(make_inputvalue, conf["inputFields"])), 571 | interfaces=conf["interfaces"] 572 | and list(map(make_typeref, conf["interfaces"])), 573 | possible_types=conf["possibleTypes"] 574 | and list(map(make_typeref, conf["possibleTypes"])), 575 | enum_values=conf["enumValues"] 576 | and list(map(make_enumval, conf["enumValues"])), 577 | ) 578 | 579 | 580 | Interface = t.NamedTuple( 581 | "Interface", [("name", str), ("desc", str), ("fields", t.List[Field])] 582 | ) 583 | Object = t.NamedTuple( 584 | "Object", 585 | [ 586 | ("name", str), 587 | ("desc", str), 588 | ("interfaces", t.List[TypeRef]), 589 | ("input_fields", t.Optional[t.List[InputValue]]), 590 | ("fields", t.List[Field]), 591 | ], 592 | ) 593 | Scalar = t.NamedTuple("Scalar", [("name", str), ("desc", str)]) 594 | Enum = t.NamedTuple( 595 | "Enum", [("name", str), ("desc", str), ("values", t.List[EnumValue])] 596 | ) 597 | Union = t.NamedTuple( 598 | "Union", [("name", str), ("desc", str), ("types", t.List[TypeRef])] 599 | ) 600 | InputObject = t.NamedTuple( 601 | "InputObject", 602 | [("name", str), ("desc", str), ("input_fields", t.List[InputValue])], 603 | ) 604 | TypeSchema = t.Union[Interface, Object, Scalar, Enum, Union, InputObject] 605 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "attrs" 5 | version = "25.1.0" 6 | description = "Classes Without Boilerplate" 7 | optional = false 8 | python-versions = ">=3.8" 9 | groups = ["dev"] 10 | files = [ 11 | {file = "attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a"}, 12 | {file = "attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e"}, 13 | ] 14 | 15 | [package.extras] 16 | benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] 17 | cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] 18 | dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] 19 | docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] 20 | tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] 21 | tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] 22 | 23 | [[package]] 24 | name = "black" 25 | version = "25.9.0" 26 | description = "The uncompromising code formatter." 27 | optional = false 28 | python-versions = ">=3.9" 29 | groups = ["dev"] 30 | files = [ 31 | {file = "black-25.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce41ed2614b706fd55fd0b4a6909d06b5bab344ffbfadc6ef34ae50adba3d4f7"}, 32 | {file = "black-25.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ab0ce111ef026790e9b13bd216fa7bc48edd934ffc4cbf78808b235793cbc92"}, 33 | {file = "black-25.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96b6726d690c96c60ba682955199f8c39abc1ae0c3a494a9c62c0184049a713"}, 34 | {file = "black-25.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:d119957b37cc641596063cd7db2656c5be3752ac17877017b2ffcdb9dfc4d2b1"}, 35 | {file = "black-25.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:456386fe87bad41b806d53c062e2974615825c7a52159cde7ccaeb0695fa28fa"}, 36 | {file = "black-25.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a16b14a44c1af60a210d8da28e108e13e75a284bf21a9afa6b4571f96ab8bb9d"}, 37 | {file = "black-25.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaf319612536d502fdd0e88ce52d8f1352b2c0a955cc2798f79eeca9d3af0608"}, 38 | {file = "black-25.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c0372a93e16b3954208417bfe448e09b0de5cc721d521866cd9e0acac3c04a1f"}, 39 | {file = "black-25.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1b9dc70c21ef8b43248f1d86aedd2aaf75ae110b958a7909ad8463c4aa0880b0"}, 40 | {file = "black-25.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e46eecf65a095fa62e53245ae2795c90bdecabd53b50c448d0a8bcd0d2e74c4"}, 41 | {file = "black-25.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9101ee58ddc2442199a25cb648d46ba22cd580b00ca4b44234a324e3ec7a0f7e"}, 42 | {file = "black-25.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:77e7060a00c5ec4b3367c55f39cf9b06e68965a4f2e61cecacd6d0d9b7ec945a"}, 43 | {file = "black-25.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0172a012f725b792c358d57fe7b6b6e8e67375dd157f64fa7a3097b3ed3e2175"}, 44 | {file = "black-25.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3bec74ee60f8dfef564b573a96b8930f7b6a538e846123d5ad77ba14a8d7a64f"}, 45 | {file = "black-25.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b756fc75871cb1bcac5499552d771822fd9db5a2bb8db2a7247936ca48f39831"}, 46 | {file = "black-25.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:846d58e3ce7879ec1ffe816bb9df6d006cd9590515ed5d17db14e17666b2b357"}, 47 | {file = "black-25.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ef69351df3c84485a8beb6f7b8f9721e2009e20ef80a8d619e2d1788b7816d47"}, 48 | {file = "black-25.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e3c1f4cd5e93842774d9ee4ef6cd8d17790e65f44f7cdbaab5f2cf8ccf22a823"}, 49 | {file = "black-25.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:154b06d618233fe468236ba1f0e40823d4eb08b26f5e9261526fde34916b9140"}, 50 | {file = "black-25.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:e593466de7b998374ea2585a471ba90553283fb9beefcfa430d84a2651ed5933"}, 51 | {file = "black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae"}, 52 | {file = "black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619"}, 53 | ] 54 | 55 | [package.dependencies] 56 | click = ">=8.0.0" 57 | mypy-extensions = ">=0.4.3" 58 | packaging = ">=22.0" 59 | pathspec = ">=0.9.0" 60 | platformdirs = ">=2" 61 | pytokens = ">=0.1.10" 62 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 63 | typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} 64 | 65 | [package.extras] 66 | colorama = ["colorama (>=0.4.3)"] 67 | d = ["aiohttp (>=3.10)"] 68 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 69 | uvloop = ["uvloop (>=0.15.2)"] 70 | 71 | [[package]] 72 | name = "click" 73 | version = "8.1.8" 74 | description = "Composable command line interface toolkit" 75 | optional = false 76 | python-versions = ">=3.7" 77 | groups = ["dev"] 78 | files = [ 79 | {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, 80 | {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, 81 | ] 82 | 83 | [package.dependencies] 84 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 85 | 86 | [[package]] 87 | name = "colorama" 88 | version = "0.4.6" 89 | description = "Cross-platform colored terminal text." 90 | optional = false 91 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 92 | groups = ["dev"] 93 | markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" 94 | files = [ 95 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 96 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 97 | ] 98 | 99 | [[package]] 100 | name = "coverage" 101 | version = "7.10.6" 102 | description = "Code coverage measurement for Python" 103 | optional = false 104 | python-versions = ">=3.9" 105 | groups = ["dev"] 106 | files = [ 107 | {file = "coverage-7.10.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356"}, 108 | {file = "coverage-7.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301"}, 109 | {file = "coverage-7.10.6-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c61fc91ab80b23f5fddbee342d19662f3d3328173229caded831aa0bd7595460"}, 110 | {file = "coverage-7.10.6-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10356fdd33a7cc06e8051413140bbdc6f972137508a3572e3f59f805cd2832fd"}, 111 | {file = "coverage-7.10.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80b1695cf7c5ebe7b44bf2521221b9bb8cdf69b1f24231149a7e3eb1ae5fa2fb"}, 112 | {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2e4c33e6378b9d52d3454bd08847a8651f4ed23ddbb4a0520227bd346382bbc6"}, 113 | {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c8a3ec16e34ef980a46f60dc6ad86ec60f763c3f2fa0db6d261e6e754f72e945"}, 114 | {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7d79dabc0a56f5af990cc6da9ad1e40766e82773c075f09cc571e2076fef882e"}, 115 | {file = "coverage-7.10.6-cp310-cp310-win32.whl", hash = "sha256:86b9b59f2b16e981906e9d6383eb6446d5b46c278460ae2c36487667717eccf1"}, 116 | {file = "coverage-7.10.6-cp310-cp310-win_amd64.whl", hash = "sha256:e132b9152749bd33534e5bd8565c7576f135f157b4029b975e15ee184325f528"}, 117 | {file = "coverage-7.10.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c706db3cabb7ceef779de68270150665e710b46d56372455cd741184f3868d8f"}, 118 | {file = "coverage-7.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e0c38dc289e0508ef68ec95834cb5d2e96fdbe792eaccaa1bccac3966bbadcc"}, 119 | {file = "coverage-7.10.6-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:752a3005a1ded28f2f3a6e8787e24f28d6abe176ca64677bcd8d53d6fe2ec08a"}, 120 | {file = "coverage-7.10.6-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:689920ecfd60f992cafca4f5477d55720466ad2c7fa29bb56ac8d44a1ac2b47a"}, 121 | {file = "coverage-7.10.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec98435796d2624d6905820a42f82149ee9fc4f2d45c2c5bc5a44481cc50db62"}, 122 | {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b37201ce4a458c7a758ecc4efa92fa8ed783c66e0fa3c42ae19fc454a0792153"}, 123 | {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2904271c80898663c810a6b067920a61dd8d38341244a3605bd31ab55250dad5"}, 124 | {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5aea98383463d6e1fa4e95416d8de66f2d0cb588774ee20ae1b28df826bcb619"}, 125 | {file = "coverage-7.10.6-cp311-cp311-win32.whl", hash = "sha256:e3fb1fa01d3598002777dd259c0c2e6d9d5e10e7222976fc8e03992f972a2cba"}, 126 | {file = "coverage-7.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:f35ed9d945bece26553d5b4c8630453169672bea0050a564456eb88bdffd927e"}, 127 | {file = "coverage-7.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:99e1a305c7765631d74b98bf7dbf54eeea931f975e80f115437d23848ee8c27c"}, 128 | {file = "coverage-7.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea"}, 129 | {file = "coverage-7.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634"}, 130 | {file = "coverage-7.10.6-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6"}, 131 | {file = "coverage-7.10.6-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9"}, 132 | {file = "coverage-7.10.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c"}, 133 | {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a"}, 134 | {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5"}, 135 | {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972"}, 136 | {file = "coverage-7.10.6-cp312-cp312-win32.whl", hash = "sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d"}, 137 | {file = "coverage-7.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629"}, 138 | {file = "coverage-7.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80"}, 139 | {file = "coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6"}, 140 | {file = "coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80"}, 141 | {file = "coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003"}, 142 | {file = "coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27"}, 143 | {file = "coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4"}, 144 | {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d"}, 145 | {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc"}, 146 | {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc"}, 147 | {file = "coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e"}, 148 | {file = "coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32"}, 149 | {file = "coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2"}, 150 | {file = "coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b"}, 151 | {file = "coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393"}, 152 | {file = "coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27"}, 153 | {file = "coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df"}, 154 | {file = "coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb"}, 155 | {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282"}, 156 | {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4"}, 157 | {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21"}, 158 | {file = "coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0"}, 159 | {file = "coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5"}, 160 | {file = "coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b"}, 161 | {file = "coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e"}, 162 | {file = "coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb"}, 163 | {file = "coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034"}, 164 | {file = "coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1"}, 165 | {file = "coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a"}, 166 | {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb"}, 167 | {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d"}, 168 | {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747"}, 169 | {file = "coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5"}, 170 | {file = "coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713"}, 171 | {file = "coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32"}, 172 | {file = "coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65"}, 173 | {file = "coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6"}, 174 | {file = "coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0"}, 175 | {file = "coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e"}, 176 | {file = "coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5"}, 177 | {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7"}, 178 | {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5"}, 179 | {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0"}, 180 | {file = "coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7"}, 181 | {file = "coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930"}, 182 | {file = "coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b"}, 183 | {file = "coverage-7.10.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90558c35af64971d65fbd935c32010f9a2f52776103a259f1dee865fe8259352"}, 184 | {file = "coverage-7.10.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8953746d371e5695405806c46d705a3cd170b9cc2b9f93953ad838f6c1e58612"}, 185 | {file = "coverage-7.10.6-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c83f6afb480eae0313114297d29d7c295670a41c11b274e6bca0c64540c1ce7b"}, 186 | {file = "coverage-7.10.6-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7eb68d356ba0cc158ca535ce1381dbf2037fa8cb5b1ae5ddfc302e7317d04144"}, 187 | {file = "coverage-7.10.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b15a87265e96307482746d86995f4bff282f14b027db75469c446da6127433b"}, 188 | {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fc53ba868875bfbb66ee447d64d6413c2db91fddcfca57025a0e7ab5b07d5862"}, 189 | {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:efeda443000aa23f276f4df973cb82beca682fd800bb119d19e80504ffe53ec2"}, 190 | {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9702b59d582ff1e184945d8b501ffdd08d2cee38d93a2206aa5f1365ce0b8d78"}, 191 | {file = "coverage-7.10.6-cp39-cp39-win32.whl", hash = "sha256:2195f8e16ba1a44651ca684db2ea2b2d4b5345da12f07d9c22a395202a05b23c"}, 192 | {file = "coverage-7.10.6-cp39-cp39-win_amd64.whl", hash = "sha256:f32ff80e7ef6a5b5b606ea69a36e97b219cd9dc799bcf2963018a4d8f788cfbf"}, 193 | {file = "coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3"}, 194 | {file = "coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90"}, 195 | ] 196 | 197 | [package.dependencies] 198 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 199 | 200 | [package.extras] 201 | toml = ["tomli ; python_full_version <= \"3.11.0a6\""] 202 | 203 | [[package]] 204 | name = "exceptiongroup" 205 | version = "1.2.2" 206 | description = "Backport of PEP 654 (exception groups)" 207 | optional = false 208 | python-versions = ">=3.7" 209 | groups = ["dev"] 210 | markers = "python_version < \"3.11\"" 211 | files = [ 212 | {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, 213 | {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, 214 | ] 215 | 216 | [package.extras] 217 | test = ["pytest (>=6)"] 218 | 219 | [[package]] 220 | name = "flake8" 221 | version = "7.3.0" 222 | description = "the modular source code checker: pep8 pyflakes and co" 223 | optional = false 224 | python-versions = ">=3.9" 225 | groups = ["dev"] 226 | files = [ 227 | {file = "flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e"}, 228 | {file = "flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872"}, 229 | ] 230 | 231 | [package.dependencies] 232 | mccabe = ">=0.7.0,<0.8.0" 233 | pycodestyle = ">=2.14.0,<2.15.0" 234 | pyflakes = ">=3.4.0,<3.5.0" 235 | 236 | [[package]] 237 | name = "gentools" 238 | version = "1.2.2" 239 | description = "Tools for generators, generator functions, and generator-based coroutines" 240 | optional = false 241 | python-versions = ">=3.8.1,<4.0.0" 242 | groups = ["main"] 243 | files = [ 244 | {file = "gentools-1.2.2-py3-none-any.whl", hash = "sha256:75b7b0452691115ad11914e17c8c2b5f9b146b01f20167785e8b6d99b565d190"}, 245 | {file = "gentools-1.2.2.tar.gz", hash = "sha256:c692af51d5a8f7f3406ed8c39d6da686f4f2fe4dbfaa13ddb24035b0471d9305"}, 246 | ] 247 | 248 | [[package]] 249 | name = "hypothesis" 250 | version = "6.141.1" 251 | description = "A library for property-based testing" 252 | optional = false 253 | python-versions = ">=3.9" 254 | groups = ["dev"] 255 | files = [ 256 | {file = "hypothesis-6.141.1-py3-none-any.whl", hash = "sha256:a5b3c39c16d98b7b4c3c5c8d4262e511e3b2255e6814ced8023af49087ad60b3"}, 257 | {file = "hypothesis-6.141.1.tar.gz", hash = "sha256:8ef356e1e18fbeaa8015aab3c805303b7fe4b868e5b506e87ad83c0bf951f46f"}, 258 | ] 259 | 260 | [package.dependencies] 261 | attrs = ">=22.2.0" 262 | exceptiongroup = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 263 | sortedcontainers = ">=2.1.0,<3.0.0" 264 | 265 | [package.extras] 266 | all = ["black (>=20.8b0)", "click (>=7.0)", "crosshair-tool (>=0.0.97)", "django (>=4.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.25)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.19.3)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2025.2) ; sys_platform == \"win32\" or sys_platform == \"emscripten\"", "watchdog (>=4.0.0)"] 267 | cli = ["black (>=20.8b0)", "click (>=7.0)", "rich (>=9.0.0)"] 268 | codemods = ["libcst (>=0.3.16)"] 269 | crosshair = ["crosshair-tool (>=0.0.97)", "hypothesis-crosshair (>=0.0.25)"] 270 | dateutil = ["python-dateutil (>=1.4)"] 271 | django = ["django (>=4.2)"] 272 | dpcontracts = ["dpcontracts (>=0.4)"] 273 | ghostwriter = ["black (>=20.8b0)"] 274 | lark = ["lark (>=0.10.1)"] 275 | numpy = ["numpy (>=1.19.3)"] 276 | pandas = ["pandas (>=1.1)"] 277 | pytest = ["pytest (>=4.6)"] 278 | pytz = ["pytz (>=2014.1)"] 279 | redis = ["redis (>=3.0.0)"] 280 | watchdog = ["watchdog (>=4.0.0)"] 281 | zoneinfo = ["tzdata (>=2025.2) ; sys_platform == \"win32\" or sys_platform == \"emscripten\""] 282 | 283 | [[package]] 284 | name = "importlib-metadata" 285 | version = "8.7.0" 286 | description = "Read metadata from Python packages" 287 | optional = false 288 | python-versions = ">=3.9" 289 | groups = ["dev"] 290 | markers = "python_version == \"3.9\"" 291 | files = [ 292 | {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, 293 | {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, 294 | ] 295 | 296 | [package.dependencies] 297 | zipp = ">=3.20" 298 | 299 | [package.extras] 300 | check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] 301 | cover = ["pytest-cov"] 302 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 303 | enabler = ["pytest-enabler (>=2.2)"] 304 | perf = ["ipython"] 305 | test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] 306 | type = ["pytest-mypy"] 307 | 308 | [[package]] 309 | name = "iniconfig" 310 | version = "2.0.0" 311 | description = "brain-dead simple config-ini parsing" 312 | optional = false 313 | python-versions = ">=3.7" 314 | groups = ["dev"] 315 | files = [ 316 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 317 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 318 | ] 319 | 320 | [[package]] 321 | name = "isort" 322 | version = "6.1.0" 323 | description = "A Python utility / library to sort Python imports." 324 | optional = false 325 | python-versions = ">=3.9.0" 326 | groups = ["dev"] 327 | files = [ 328 | {file = "isort-6.1.0-py3-none-any.whl", hash = "sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784"}, 329 | {file = "isort-6.1.0.tar.gz", hash = "sha256:9b8f96a14cfee0677e78e941ff62f03769a06d412aabb9e2a90487b3b7e8d481"}, 330 | ] 331 | 332 | [package.dependencies] 333 | importlib-metadata = {version = ">=4.6.0", markers = "python_version < \"3.10\""} 334 | 335 | [package.extras] 336 | colors = ["colorama"] 337 | plugins = ["setuptools"] 338 | 339 | [[package]] 340 | name = "mccabe" 341 | version = "0.7.0" 342 | description = "McCabe checker, plugin for flake8" 343 | optional = false 344 | python-versions = ">=3.6" 345 | groups = ["dev"] 346 | files = [ 347 | {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, 348 | {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, 349 | ] 350 | 351 | [[package]] 352 | name = "mypy" 353 | version = "1.18.2" 354 | description = "Optional static typing for Python" 355 | optional = false 356 | python-versions = ">=3.9" 357 | groups = ["dev"] 358 | files = [ 359 | {file = "mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c"}, 360 | {file = "mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e"}, 361 | {file = "mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b"}, 362 | {file = "mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66"}, 363 | {file = "mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428"}, 364 | {file = "mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed"}, 365 | {file = "mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f"}, 366 | {file = "mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341"}, 367 | {file = "mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d"}, 368 | {file = "mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86"}, 369 | {file = "mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37"}, 370 | {file = "mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8"}, 371 | {file = "mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34"}, 372 | {file = "mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764"}, 373 | {file = "mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893"}, 374 | {file = "mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914"}, 375 | {file = "mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8"}, 376 | {file = "mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074"}, 377 | {file = "mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc"}, 378 | {file = "mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e"}, 379 | {file = "mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986"}, 380 | {file = "mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d"}, 381 | {file = "mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba"}, 382 | {file = "mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544"}, 383 | {file = "mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce"}, 384 | {file = "mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d"}, 385 | {file = "mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c"}, 386 | {file = "mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb"}, 387 | {file = "mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075"}, 388 | {file = "mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf"}, 389 | {file = "mypy-1.18.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25a9c8fb67b00599f839cf472713f54249a62efd53a54b565eb61956a7e3296b"}, 390 | {file = "mypy-1.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2b9c7e284ee20e7598d6f42e13ca40b4928e6957ed6813d1ab6348aa3f47133"}, 391 | {file = "mypy-1.18.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6985ed057513e344e43a26cc1cd815c7a94602fb6a3130a34798625bc2f07b6"}, 392 | {file = "mypy-1.18.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f27105f1525ec024b5c630c0b9f36d5c1cc4d447d61fe51ff4bd60633f47ac"}, 393 | {file = "mypy-1.18.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:030c52d0ea8144e721e49b1f68391e39553d7451f0c3f8a7565b59e19fcb608b"}, 394 | {file = "mypy-1.18.2-cp39-cp39-win_amd64.whl", hash = "sha256:aa5e07ac1a60a253445797e42b8b2963c9675563a94f11291ab40718b016a7a0"}, 395 | {file = "mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e"}, 396 | {file = "mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b"}, 397 | ] 398 | 399 | [package.dependencies] 400 | mypy_extensions = ">=1.0.0" 401 | pathspec = ">=0.9.0" 402 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 403 | typing_extensions = ">=4.6.0" 404 | 405 | [package.extras] 406 | dmypy = ["psutil (>=4.0)"] 407 | faster-cache = ["orjson"] 408 | install-types = ["pip"] 409 | mypyc = ["setuptools (>=50)"] 410 | reports = ["lxml"] 411 | 412 | [[package]] 413 | name = "mypy-extensions" 414 | version = "1.0.0" 415 | description = "Type system extensions for programs checked with the mypy type checker." 416 | optional = false 417 | python-versions = ">=3.5" 418 | groups = ["dev"] 419 | files = [ 420 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 421 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 422 | ] 423 | 424 | [[package]] 425 | name = "packaging" 426 | version = "24.2" 427 | description = "Core utilities for Python packages" 428 | optional = false 429 | python-versions = ">=3.8" 430 | groups = ["dev"] 431 | files = [ 432 | {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, 433 | {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, 434 | ] 435 | 436 | [[package]] 437 | name = "pathspec" 438 | version = "0.12.1" 439 | description = "Utility library for gitignore style pattern matching of file paths." 440 | optional = false 441 | python-versions = ">=3.8" 442 | groups = ["dev"] 443 | files = [ 444 | {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, 445 | {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, 446 | ] 447 | 448 | [[package]] 449 | name = "platformdirs" 450 | version = "4.3.6" 451 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." 452 | optional = false 453 | python-versions = ">=3.8" 454 | groups = ["dev"] 455 | files = [ 456 | {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, 457 | {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, 458 | ] 459 | 460 | [package.extras] 461 | docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] 462 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] 463 | type = ["mypy (>=1.11.2)"] 464 | 465 | [[package]] 466 | name = "pluggy" 467 | version = "1.5.0" 468 | description = "plugin and hook calling mechanisms for python" 469 | optional = false 470 | python-versions = ">=3.8" 471 | groups = ["dev"] 472 | files = [ 473 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 474 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 475 | ] 476 | 477 | [package.extras] 478 | dev = ["pre-commit", "tox"] 479 | testing = ["pytest", "pytest-benchmark"] 480 | 481 | [[package]] 482 | name = "pycodestyle" 483 | version = "2.14.0" 484 | description = "Python style guide checker" 485 | optional = false 486 | python-versions = ">=3.9" 487 | groups = ["dev"] 488 | files = [ 489 | {file = "pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d"}, 490 | {file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"}, 491 | ] 492 | 493 | [[package]] 494 | name = "pyflakes" 495 | version = "3.4.0" 496 | description = "passive checker of Python programs" 497 | optional = false 498 | python-versions = ">=3.9" 499 | groups = ["dev"] 500 | files = [ 501 | {file = "pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f"}, 502 | {file = "pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58"}, 503 | ] 504 | 505 | [[package]] 506 | name = "pygments" 507 | version = "2.19.1" 508 | description = "Pygments is a syntax highlighting package written in Python." 509 | optional = false 510 | python-versions = ">=3.8" 511 | groups = ["dev"] 512 | files = [ 513 | {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, 514 | {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, 515 | ] 516 | 517 | [package.extras] 518 | windows-terminal = ["colorama (>=0.4.6)"] 519 | 520 | [[package]] 521 | name = "pytest" 522 | version = "8.4.2" 523 | description = "pytest: simple powerful testing with Python" 524 | optional = false 525 | python-versions = ">=3.9" 526 | groups = ["dev"] 527 | files = [ 528 | {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, 529 | {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, 530 | ] 531 | 532 | [package.dependencies] 533 | colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} 534 | exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} 535 | iniconfig = ">=1" 536 | packaging = ">=20" 537 | pluggy = ">=1.5,<2" 538 | pygments = ">=2.7.2" 539 | tomli = {version = ">=1", markers = "python_version < \"3.11\""} 540 | 541 | [package.extras] 542 | dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] 543 | 544 | [[package]] 545 | name = "pytest-cov" 546 | version = "7.0.0" 547 | description = "Pytest plugin for measuring coverage." 548 | optional = false 549 | python-versions = ">=3.9" 550 | groups = ["dev"] 551 | files = [ 552 | {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, 553 | {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, 554 | ] 555 | 556 | [package.dependencies] 557 | coverage = {version = ">=7.10.6", extras = ["toml"]} 558 | pluggy = ">=1.2" 559 | pytest = ">=7" 560 | 561 | [package.extras] 562 | testing = ["process-tests", "pytest-xdist", "virtualenv"] 563 | 564 | [[package]] 565 | name = "pytest-mock" 566 | version = "3.15.1" 567 | description = "Thin-wrapper around the mock package for easier use with pytest" 568 | optional = false 569 | python-versions = ">=3.9" 570 | groups = ["dev"] 571 | files = [ 572 | {file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"}, 573 | {file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"}, 574 | ] 575 | 576 | [package.dependencies] 577 | pytest = ">=6.2.5" 578 | 579 | [package.extras] 580 | dev = ["pre-commit", "pytest-asyncio", "tox"] 581 | 582 | [[package]] 583 | name = "pytokens" 584 | version = "0.1.10" 585 | description = "A Fast, spec compliant Python 3.12+ tokenizer that runs on older Pythons." 586 | optional = false 587 | python-versions = ">=3.8" 588 | groups = ["dev"] 589 | files = [ 590 | {file = "pytokens-0.1.10-py3-none-any.whl", hash = "sha256:db7b72284e480e69fb085d9f251f66b3d2df8b7166059261258ff35f50fb711b"}, 591 | {file = "pytokens-0.1.10.tar.gz", hash = "sha256:c9a4bfa0be1d26aebce03e6884ba454e842f186a59ea43a6d3b25af58223c044"}, 592 | ] 593 | 594 | [package.extras] 595 | dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] 596 | 597 | [[package]] 598 | name = "snug" 599 | version = "2.4.0" 600 | description = "Write reusable web API interactions" 601 | optional = false 602 | python-versions = ">=3.7,<4.0" 603 | groups = ["main"] 604 | files = [ 605 | {file = "snug-2.4.0-py3-none-any.whl", hash = "sha256:a5944a0ec113e7e1c8093b7ad6f534feee35647bece1b44620f2f27c578f91a8"}, 606 | {file = "snug-2.4.0.tar.gz", hash = "sha256:771822da26a2e2cd4154a6e0358c3b2fda8c269925517158d83a3dbb9b913194"}, 607 | ] 608 | 609 | [package.extras] 610 | aiohttp = ["aiohttp (>=3.4.4,<4.0.0)"] 611 | httpx = ["httpx (>=0.21.1,<0.23.0)"] 612 | requests = ["requests (>=2.20,<3.0)"] 613 | 614 | [[package]] 615 | name = "sortedcontainers" 616 | version = "2.4.0" 617 | description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" 618 | optional = false 619 | python-versions = "*" 620 | groups = ["dev"] 621 | files = [ 622 | {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, 623 | {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, 624 | ] 625 | 626 | [[package]] 627 | name = "tomli" 628 | version = "2.2.1" 629 | description = "A lil' TOML parser" 630 | optional = false 631 | python-versions = ">=3.8" 632 | groups = ["dev"] 633 | markers = "python_full_version <= \"3.11.0a6\"" 634 | files = [ 635 | {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, 636 | {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, 637 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, 638 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, 639 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, 640 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, 641 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, 642 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, 643 | {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, 644 | {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, 645 | {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, 646 | {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, 647 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, 648 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, 649 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, 650 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, 651 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, 652 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, 653 | {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, 654 | {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, 655 | {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, 656 | {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, 657 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, 658 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, 659 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, 660 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, 661 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, 662 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, 663 | {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, 664 | {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, 665 | {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, 666 | {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, 667 | ] 668 | 669 | [[package]] 670 | name = "typing-extensions" 671 | version = "4.12.2" 672 | description = "Backported and Experimental Type Hints for Python 3.8+" 673 | optional = false 674 | python-versions = ">=3.8" 675 | groups = ["dev"] 676 | files = [ 677 | {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, 678 | {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, 679 | ] 680 | 681 | [[package]] 682 | name = "zipp" 683 | version = "3.23.0" 684 | description = "Backport of pathlib-compatible object wrapper for zip files" 685 | optional = false 686 | python-versions = ">=3.9" 687 | groups = ["dev"] 688 | markers = "python_version == \"3.9\"" 689 | files = [ 690 | {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, 691 | {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, 692 | ] 693 | 694 | [package.extras] 695 | check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] 696 | cover = ["pytest-cov"] 697 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 698 | enabler = ["pytest-enabler (>=2.2)"] 699 | test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] 700 | type = ["pytest-mypy"] 701 | 702 | [metadata] 703 | lock-version = "2.1" 704 | python-versions = "^3.9" 705 | content-hash = "9a99473544c1be911ffd71c9f80a6ea7033d4d18a1c6535c8b4fb86f68ef1c00" 706 | --------------------------------------------------------------------------------