From 78973964b8bb98b13ffd787237aa932cc2cec8d2 Mon Sep 17 00:00:00 2001 From: Drew Hoover Date: Tue, 21 Sep 2021 13:00:19 -0400 Subject: [PATCH 01/79] fix: update ariadne url to the new docs --- docs/quickstart.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 62d11949a..23fd4444e 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -28,7 +28,7 @@ Compare Graphene's *code-first* approach to building a GraphQL API with *schema- .. _Apollo Server: https://www.apollographql.com/docs/apollo-server/ -.. _Ariadne: https://ariadne.readthedocs.io +.. _Ariadne: https://ariadnegraphql.org/ Graphene is fully featured with integrations for the most popular web frameworks and ORMs. Graphene produces schemas that are fully compliant with the GraphQL spec and provides tools and patterns for building a Relay-Compliant API as well. From bf40e6c419c6b224ae482c470161fb76f9b3c4bb Mon Sep 17 00:00:00 2001 From: Ali McMaster Date: Mon, 14 Feb 2022 09:01:42 +0000 Subject: [PATCH 02/79] Update quickstart.rst --- docs/quickstart.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 0b6c69938..b850d58da 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -59,7 +59,7 @@ When we send a **Query** requesting only one **Field**, ``hello``, and specify a Requirements ~~~~~~~~~~~~ -- Python (2.7, 3.4, 3.5, 3.6, pypy) +- Python (3.6, 3.7, 3.8, 3.9, 3.10, pypy) - Graphene (3.0) Project setup From 0ac4d9397e394b3dfcde981b6e5ec654caed6ab6 Mon Sep 17 00:00:00 2001 From: karming Date: Tue, 16 Aug 2022 19:21:29 -0400 Subject: [PATCH 03/79] fix: use install instead of instal for consistency --- README.md | 2 +- README.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a7714e336..897ea5298 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Also, Graphene is fully compatible with the GraphQL spec, working seamlessly wit ## Installation -For instaling graphene, just run this command in your shell +To install `graphene`, just run this command in your shell ```bash pip install "graphene>=3.0" diff --git a/README.rst b/README.rst index 3fb51df20..a38b9376e 100644 --- a/README.rst +++ b/README.rst @@ -49,7 +49,7 @@ seamlessly with all GraphQL clients, such as Installation ------------ -For instaling graphene, just run this command in your shell +To install `graphene`, just run this command in your shell .. code:: bash From e6429c3c5b64c6dd60f82fd118e3d3743b058d68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?U=CC=88lgen=20Sar=C4=B1kavak?= Date: Fri, 19 Aug 2022 09:20:51 +0300 Subject: [PATCH 04/79] Update pre-commit hooks --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 87fa4872b..2ad4e02ff 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ default_language_version: repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.2.0 + rev: v4.3.0 hooks: - id: check-merge-conflict - id: check-json @@ -17,14 +17,14 @@ repos: - id: trailing-whitespace exclude: README.md - repo: https://github.com/asottile/pyupgrade - rev: v2.32.1 + rev: v2.37.3 hooks: - id: pyupgrade -- repo: https://github.com/ambv/black - rev: 22.3.0 +- repo: https://github.com/psf/black + rev: 22.6.0 hooks: - id: black - repo: https://github.com/PyCQA/flake8 - rev: 4.0.1 + rev: 5.0.4 hooks: - id: flake8 From cbf59a88ad8acfe613c9cdcf8ae869a76eb541d4 Mon Sep 17 00:00:00 2001 From: Christian Clauss Date: Sat, 27 Aug 2022 18:06:38 +0200 Subject: [PATCH 05/79] Add Python 3.11 release candidate 1 to the testing (#1450) * Add Python 3.11 release candidate 1 to the testing https://www.python.org/download/pre-releases * Update tests.yml --- .github/workflows/tests.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8a962ac60..51832084e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,22 +25,22 @@ jobs: fail-fast: false matrix: include: + - {name: '3.11', python: '3.11-dev', os: ubuntu-latest, tox: py311} - {name: '3.10', python: '3.10', os: ubuntu-latest, tox: py310} - {name: '3.9', python: '3.9', os: ubuntu-latest, tox: py39} - - { name: '3.8', python: '3.8', os: ubuntu-latest, tox: py38 } - - { name: '3.7', python: '3.7', os: ubuntu-latest, tox: py37 } - - { name: '3.6', python: '3.6', os: ubuntu-latest, tox: py36 } + - {name: '3.8', python: '3.8', os: ubuntu-latest, tox: py38} + - {name: '3.7', python: '3.7', os: ubuntu-latest, tox: py37} + - {name: '3.6', python: '3.6', os: ubuntu-latest, tox: py36} steps: - uses: actions/checkout@v3 - - uses: actions/setup-python@v3 + - uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} - name: update pip run: | - pip install -U wheel - pip install -U setuptools - python -m pip install -U pip + python -m pip install --upgrade pip + pip install --upgrade setuptools wheel - name: get pip cache dir id: pip-cache From 355601bd5cce8c2ec2dacf128e6819af38a0c381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Sat, 27 Aug 2022 19:13:48 +0300 Subject: [PATCH 06/79] Remove duplicate flake8 call in tox, it's covered by pre-commit (#1448) --- tox.ini | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/tox.ini b/tox.ini index 07ddc767f..d0be5a24a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py3{6,7,8,9,10}, flake8, mypy, pre-commit +envlist = py3{6,7,8,9,10}, mypy, pre-commit skipsdist = true [testenv] @@ -26,12 +26,4 @@ deps = commands = mypy graphene -[testenv:flake8] -basepython = python3.9 -deps = - flake8>=4,<5 -commands = - pip install --pre -e . - flake8 graphene - [pytest] From 35c281a3cd1fd83bc71b2d9c4b2160e118a7d484 Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Sun, 28 Aug 2022 17:30:26 +0200 Subject: [PATCH 07/79] Fix BigInt export (#1456) --- graphene/__init__.py | 2 ++ graphene/types/__init__.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/graphene/__init__.py b/graphene/__init__.py index bf9831b59..52ed205ad 100644 --- a/graphene/__init__.py +++ b/graphene/__init__.py @@ -13,6 +13,7 @@ UUID, Argument, Base64, + BigInt, Boolean, Context, Date, @@ -50,6 +51,7 @@ "__version__", "Argument", "Base64", + "BigInt", "Boolean", "ClientIDMutation", "Connection", diff --git a/graphene/types/__init__.py b/graphene/types/__init__.py index 2641dd539..70478a058 100644 --- a/graphene/types/__init__.py +++ b/graphene/types/__init__.py @@ -15,7 +15,7 @@ from .json import JSONString from .mutation import Mutation from .objecttype import ObjectType -from .scalars import ID, Boolean, Float, Int, Scalar, String +from .scalars import ID, BigInt, Boolean, Float, Int, Scalar, String from .schema import Schema from .structures import List, NonNull from .union import Union @@ -24,6 +24,7 @@ __all__ = [ "Argument", "Base64", + "BigInt", "Boolean", "Context", "Date", From c5ccc9502df2edd949af2f7733bfb659204d9744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Sun, 28 Aug 2022 18:33:35 +0300 Subject: [PATCH 08/79] Upgrade base Python version to 3.10 (#1449) --- .github/workflows/deploy.yml | 4 ++-- .github/workflows/lint.yml | 4 ++-- .pre-commit-config.yaml | 2 +- tox.ini | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 07c0766f8..12140d885 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -11,10 +11,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up Python 3.9 + - name: Set up Python 3.10 uses: actions/setup-python@v2 with: - python-version: 3.9 + python-version: "3.10" - name: Build wheel and source tarball run: | pip install wheel diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c9efc0cf8..d8ebd2f66 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,10 +8,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up Python 3.9 + - name: Set up Python 3.10 uses: actions/setup-python@v2 with: - python-version: 3.9 + python-version: "3.10" - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2ad4e02ff..eece56e04 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ default_language_version: - python: python3.9 + python: python3.10 repos: - repo: https://github.com/pre-commit/pre-commit-hooks diff --git a/tox.ini b/tox.ini index d0be5a24a..65fceadd8 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,7 @@ commands = py{36,37,38,39,310}: pytest --cov=graphene graphene --cov-report=term --cov-report=xml examples {posargs} [testenv:pre-commit] -basepython = python3.9 +basepython = python3.10 deps = pre-commit>=2.16,<3 setenv = @@ -20,7 +20,7 @@ commands = pre-commit run --all-files --show-diff-on-failure [testenv:mypy] -basepython = python3.9 +basepython = python3.10 deps = mypy>=0.950,<1 commands = From 45986b18e7c4b05a1143c2e5da42224666a7cf07 Mon Sep 17 00:00:00 2001 From: Christian Clauss Date: Sun, 28 Aug 2022 20:25:55 +0200 Subject: [PATCH 09/79] Upgrade GitHub Actions (#1457) --- .github/workflows/deploy.yml | 4 ++-- .github/workflows/lint.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 12140d885..6cce61d5c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,9 +10,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python 3.10 - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: "3.10" - name: Build wheel and source tarball diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d8ebd2f66..ad5bea6ad 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,9 +7,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python 3.10 - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: "3.10" - name: Install dependencies From 20219fdc1bc9ce9ff7213ab03b74607556526724 Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Tue, 6 Sep 2022 13:42:38 +0200 Subject: [PATCH 10/79] Update README.md Update --- README.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 897ea5298..0456f888a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [💬 Join the community on Slack](https://join.slack.com/t/graphenetools/shared_invite/enQtOTE2MDQ1NTg4MDM1LTA4Nzk0MGU0NGEwNzUxZGNjNDQ4ZjAwNDJjMjY0OGE1ZDgxZTg4YjM2ZTc4MjE2ZTAzZjE2ZThhZTQzZTkyMmM) -**We are looking for contributors**! Please check the [ROADMAP](https://github.com/graphql-python/graphene/blob/master/ROADMAP.md) to see how you can help ❤️ +**We are looking for contributors**! Please check the current issues to see how you can help ❤️ ## Introduction @@ -10,7 +10,7 @@ - **Easy to use:** Graphene helps you use GraphQL in Python without effort. - **Relay:** Graphene has builtin support for Relay. -- **Data agnostic:** Graphene supports any kind of data source: SQL (Django, SQLAlchemy), NoSQL, custom Python objects, etc. +- **Data agnostic:** Graphene supports any kind of data source: SQL (Django, SQLAlchemy), Mongo, custom Python objects, etc. We believe that by providing a complete API you could plug Graphene anywhere your data lives and make your data available through GraphQL. @@ -20,9 +20,10 @@ Graphene has multiple integrations with different frameworks: | integration | Package | | ----------------- | --------------------------------------------------------------------------------------- | -| Django | [graphene-django](https://github.com/graphql-python/graphene-django/) | | SQLAlchemy | [graphene-sqlalchemy](https://github.com/graphql-python/graphene-sqlalchemy/) | -| Google App Engine | [graphene-gae](https://github.com/graphql-python/graphene-gae/) | +| Mongo | [graphene-mongo](https://github.com/graphql-python/graphene-mongo/) | +| Apollo Federation | [graphene-federation](https://github.com/graphql-python/graphene-federation/) | +| Django | [graphene-django](https://github.com/graphql-python/graphene-django/) | Also, Graphene is fully compatible with the GraphQL spec, working seamlessly with all GraphQL clients, such as [Relay](https://github.com/facebook/relay), [Apollo](https://github.com/apollographql/apollo-client) and [gql](https://github.com/graphql-python/gql). @@ -31,7 +32,7 @@ Also, Graphene is fully compatible with the GraphQL spec, working seamlessly wit To install `graphene`, just run this command in your shell ```bash -pip install "graphene>=3.0" +pip install "graphene>=3.1" ``` ## Examples @@ -84,18 +85,24 @@ pip install -e ".[test]" Well-written tests and maintaining good test coverage is important to this project. While developing, run new and existing tests with: ```sh -py.test graphene/relay/tests/test_node.py # Single file -py.test graphene/relay # All tests in directory +pytest graphene/relay/tests/test_node.py # Single file +pytest graphene/relay # All tests in directory ``` Add the `-s` flag if you have introduced breakpoints into the code for debugging. Add the `-v` ("verbose") flag to get more detailed test output. For even more detailed output, use `-vv`. Check out the [pytest documentation](https://docs.pytest.org/en/latest/) for more options and test running controls. +Regularly ensure your `pre-commit` hooks are up to date and enabled: + +```sh +pre-commit install +``` + You can also run the benchmarks with: ```sh -py.test graphene --benchmark-only +pytest graphene --benchmark-only ``` Graphene supports several versions of Python. To make sure that changes do not break compatibility with any of those versions, we use `tox` to create virtualenvs for each Python version and run tests with that version. To run against all Python versions defined in the `tox.ini` config file, just run: @@ -107,10 +114,10 @@ tox If you wish to run against a specific version defined in the `tox.ini` file: ```sh -tox -e py36 +tox -e py39 ``` -Tox can only use whatever versions of Python are installed on your system. When you create a pull request, Travis will also be running the same tests and report the results, so there is no need for potential contributors to try to install every single version of Python on their own system ahead of time. We appreciate opening issues and pull requests to make graphene even more stable & useful! +Tox can only use whatever versions of Python are installed on your system. When you create a pull request, GitHub Actions pipelines will also be running the same tests and report the results, so there is no need for potential contributors to try to install every single version of Python on their own system ahead of time. We appreciate opening issues and pull requests to make graphene even more stable & useful! ### Building Documentation From 694c1db21ed73487484256e211fb56bd292eb0fb Mon Sep 17 00:00:00 2001 From: Cadu Date: Wed, 7 Sep 2022 15:32:53 -0300 Subject: [PATCH 11/79] Vendor `DataLoader` from `aiodataloader` and move `get_event_loop()` out of `__init__` function. (#1459) * Vendor DataLoader from aiodataloader and also move get_event_loop behavior from `__init__` to a property which only gets resolved when actually needed (this will solve PyTest-related to early get_event_loop() issues) * Added DataLoader's specific tests * plug `loop` parameter into `self._loop`, so that we still have the ability to pass in a custom event loop, if needed. Co-authored-by: Erik Wrede --- graphene/utils/dataloader.py | 281 +++++++++++++++ graphene/utils/tests/test_dataloader.py | 452 ++++++++++++++++++++++++ setup.cfg | 4 + setup.py | 1 - tests_asyncio/test_dataloader.py | 79 ----- 5 files changed, 737 insertions(+), 80 deletions(-) create mode 100644 graphene/utils/dataloader.py create mode 100644 graphene/utils/tests/test_dataloader.py delete mode 100644 tests_asyncio/test_dataloader.py diff --git a/graphene/utils/dataloader.py b/graphene/utils/dataloader.py new file mode 100644 index 000000000..143558aa2 --- /dev/null +++ b/graphene/utils/dataloader.py @@ -0,0 +1,281 @@ +from asyncio import ( + gather, + ensure_future, + get_event_loop, + iscoroutine, + iscoroutinefunction, +) +from collections import namedtuple +from collections.abc import Iterable +from functools import partial + +from typing import List # flake8: noqa + +Loader = namedtuple("Loader", "key,future") + + +def iscoroutinefunctionorpartial(fn): + return iscoroutinefunction(fn.func if isinstance(fn, partial) else fn) + + +class DataLoader(object): + batch = True + max_batch_size = None # type: int + cache = True + + def __init__( + self, + batch_load_fn=None, + batch=None, + max_batch_size=None, + cache=None, + get_cache_key=None, + cache_map=None, + loop=None, + ): + + self._loop = loop + + if batch_load_fn is not None: + self.batch_load_fn = batch_load_fn + + assert iscoroutinefunctionorpartial( + self.batch_load_fn + ), "batch_load_fn must be coroutine. Received: {}".format(self.batch_load_fn) + + if not callable(self.batch_load_fn): + raise TypeError( # pragma: no cover + ( + "DataLoader must be have a batch_load_fn which accepts " + "Iterable and returns Future>, but got: {}." + ).format(batch_load_fn) + ) + + if batch is not None: + self.batch = batch # pragma: no cover + + if max_batch_size is not None: + self.max_batch_size = max_batch_size + + if cache is not None: + self.cache = cache # pragma: no cover + + self.get_cache_key = get_cache_key or (lambda x: x) + + self._cache = cache_map if cache_map is not None else {} + self._queue = [] # type: List[Loader] + + @property + def loop(self): + if not self._loop: + self._loop = get_event_loop() + + return self._loop + + def load(self, key=None): + """ + Loads a key, returning a `Future` for the value represented by that key. + """ + if key is None: + raise TypeError( # pragma: no cover + ( + "The loader.load() function must be called with a value, " + "but got: {}." + ).format(key) + ) + + cache_key = self.get_cache_key(key) + + # If caching and there is a cache-hit, return cached Future. + if self.cache: + cached_result = self._cache.get(cache_key) + if cached_result: + return cached_result + + # Otherwise, produce a new Future for this value. + future = self.loop.create_future() + # If caching, cache this Future. + if self.cache: + self._cache[cache_key] = future + + self.do_resolve_reject(key, future) + return future + + def do_resolve_reject(self, key, future): + # Enqueue this Future to be dispatched. + self._queue.append(Loader(key=key, future=future)) + # Determine if a dispatch of this queue should be scheduled. + # A single dispatch should be scheduled per queue at the time when the + # queue changes from "empty" to "full". + if len(self._queue) == 1: + if self.batch: + # If batching, schedule a task to dispatch the queue. + enqueue_post_future_job(self.loop, self) + else: + # Otherwise dispatch the (queue of one) immediately. + dispatch_queue(self) # pragma: no cover + + def load_many(self, keys): + """ + Loads multiple keys, returning a list of values + + >>> a, b = await my_loader.load_many([ 'a', 'b' ]) + + This is equivalent to the more verbose: + + >>> a, b = await gather( + >>> my_loader.load('a'), + >>> my_loader.load('b') + >>> ) + """ + if not isinstance(keys, Iterable): + raise TypeError( # pragma: no cover + ( + "The loader.load_many() function must be called with Iterable " + "but got: {}." + ).format(keys) + ) + + return gather(*[self.load(key) for key in keys]) + + def clear(self, key): + """ + Clears the value at `key` from the cache, if it exists. Returns itself for + method chaining. + """ + cache_key = self.get_cache_key(key) + self._cache.pop(cache_key, None) + return self + + def clear_all(self): + """ + Clears the entire cache. To be used when some event results in unknown + invalidations across this particular `DataLoader`. Returns itself for + method chaining. + """ + self._cache.clear() + return self + + def prime(self, key, value): + """ + Adds the provied key and value to the cache. If the key already exists, no + change is made. Returns itself for method chaining. + """ + cache_key = self.get_cache_key(key) + + # Only add the key if it does not already exist. + if cache_key not in self._cache: + # Cache a rejected future if the value is an Error, in order to match + # the behavior of load(key). + future = self.loop.create_future() + if isinstance(value, Exception): + future.set_exception(value) + else: + future.set_result(value) + + self._cache[cache_key] = future + + return self + + +def enqueue_post_future_job(loop, loader): + async def dispatch(): + dispatch_queue(loader) + + loop.call_soon(ensure_future, dispatch()) + + +def get_chunks(iterable_obj, chunk_size=1): + chunk_size = max(1, chunk_size) + return ( + iterable_obj[i : i + chunk_size] + for i in range(0, len(iterable_obj), chunk_size) + ) + + +def dispatch_queue(loader): + """ + Given the current state of a Loader instance, perform a batch load + from its current queue. + """ + # Take the current loader queue, replacing it with an empty queue. + queue = loader._queue + loader._queue = [] + + # If a max_batch_size was provided and the queue is longer, then segment the + # queue into multiple batches, otherwise treat the queue as a single batch. + max_batch_size = loader.max_batch_size + + if max_batch_size and max_batch_size < len(queue): + chunks = get_chunks(queue, max_batch_size) + for chunk in chunks: + ensure_future(dispatch_queue_batch(loader, chunk)) + else: + ensure_future(dispatch_queue_batch(loader, queue)) + + +async def dispatch_queue_batch(loader, queue): + # Collect all keys to be loaded in this dispatch + keys = [loaded.key for loaded in queue] + + # Call the provided batch_load_fn for this loader with the loader queue's keys. + batch_future = loader.batch_load_fn(keys) + + # Assert the expected response from batch_load_fn + if not batch_future or not iscoroutine(batch_future): + return failed_dispatch( # pragma: no cover + loader, + queue, + TypeError( + ( + "DataLoader must be constructed with a function which accepts " + "Iterable and returns Future>, but the function did " + "not return a Coroutine: {}." + ).format(batch_future) + ), + ) + + try: + values = await batch_future + if not isinstance(values, Iterable): + raise TypeError( # pragma: no cover + ( + "DataLoader must be constructed with a function which accepts " + "Iterable and returns Future>, but the function did " + "not return a Future of a Iterable: {}." + ).format(values) + ) + + values = list(values) + if len(values) != len(keys): + raise TypeError( # pragma: no cover + ( + "DataLoader must be constructed with a function which accepts " + "Iterable and returns Future>, but the function did " + "not return a Future of a Iterable with the same length as the Iterable " + "of keys." + "\n\nKeys:\n{}" + "\n\nValues:\n{}" + ).format(keys, values) + ) + + # Step through the values, resolving or rejecting each Future in the + # loaded queue. + for loaded, value in zip(queue, values): + if isinstance(value, Exception): + loaded.future.set_exception(value) + else: + loaded.future.set_result(value) + + except Exception as e: + return failed_dispatch(loader, queue, e) + + +def failed_dispatch(loader, queue, error): + """ + Do not cache individual loads if the entire batch dispatch fails, + but still reject each request so they do not hang. + """ + for loaded in queue: + loader.clear(loaded.key) + loaded.future.set_exception(error) diff --git a/graphene/utils/tests/test_dataloader.py b/graphene/utils/tests/test_dataloader.py new file mode 100644 index 000000000..257f6b4db --- /dev/null +++ b/graphene/utils/tests/test_dataloader.py @@ -0,0 +1,452 @@ +from asyncio import gather +from collections import namedtuple +from functools import partial +from unittest.mock import Mock + +from graphene.utils.dataloader import DataLoader +from pytest import mark, raises + +from graphene import ObjectType, String, Schema, Field, List + +CHARACTERS = { + "1": {"name": "Luke Skywalker", "sibling": "3"}, + "2": {"name": "Darth Vader", "sibling": None}, + "3": {"name": "Leia Organa", "sibling": "1"}, +} + +get_character = Mock(side_effect=lambda character_id: CHARACTERS[character_id]) + + +class CharacterType(ObjectType): + name = String() + sibling = Field(lambda: CharacterType) + + async def resolve_sibling(character, info): + if character["sibling"]: + return await info.context.character_loader.load(character["sibling"]) + return None + + +class Query(ObjectType): + skywalker_family = List(CharacterType) + + async def resolve_skywalker_family(_, info): + return await info.context.character_loader.load_many(["1", "2", "3"]) + + +mock_batch_load_fn = Mock( + side_effect=lambda character_ids: [get_character(id) for id in character_ids] +) + + +class CharacterLoader(DataLoader): + async def batch_load_fn(self, character_ids): + return mock_batch_load_fn(character_ids) + + +Context = namedtuple("Context", "character_loader") + + +@mark.asyncio +async def test_basic_dataloader(): + schema = Schema(query=Query) + + character_loader = CharacterLoader() + context = Context(character_loader=character_loader) + + query = """ + { + skywalkerFamily { + name + sibling { + name + } + } + } + """ + + result = await schema.execute_async(query, context=context) + + assert not result.errors + assert result.data == { + "skywalkerFamily": [ + {"name": "Luke Skywalker", "sibling": {"name": "Leia Organa"}}, + {"name": "Darth Vader", "sibling": None}, + {"name": "Leia Organa", "sibling": {"name": "Luke Skywalker"}}, + ] + } + + assert mock_batch_load_fn.call_count == 1 + assert get_character.call_count == 3 + + +def id_loader(**options): + load_calls = [] + + async def default_resolve(x): + return x + + resolve = options.pop("resolve", default_resolve) + + async def fn(keys): + load_calls.append(keys) + return await resolve(keys) + # return keys + + identity_loader = DataLoader(fn, **options) + return identity_loader, load_calls + + +@mark.asyncio +async def test_build_a_simple_data_loader(): + async def call_fn(keys): + return keys + + identity_loader = DataLoader(call_fn) + + promise1 = identity_loader.load(1) + + value1 = await promise1 + assert value1 == 1 + + +@mark.asyncio +async def test_can_build_a_data_loader_from_a_partial(): + value_map = {1: "one"} + + async def call_fn(context, keys): + return [context.get(key) for key in keys] + + partial_fn = partial(call_fn, value_map) + identity_loader = DataLoader(partial_fn) + + promise1 = identity_loader.load(1) + + value1 = await promise1 + assert value1 == "one" + + +@mark.asyncio +async def test_supports_loading_multiple_keys_in_one_call(): + async def call_fn(keys): + return keys + + identity_loader = DataLoader(call_fn) + + promise_all = identity_loader.load_many([1, 2]) + + values = await promise_all + assert values == [1, 2] + + promise_all = identity_loader.load_many([]) + + values = await promise_all + assert values == [] + + +@mark.asyncio +async def test_batches_multiple_requests(): + identity_loader, load_calls = id_loader() + + promise1 = identity_loader.load(1) + promise2 = identity_loader.load(2) + + p = gather(promise1, promise2) + + value1, value2 = await p + + assert value1 == 1 + assert value2 == 2 + + assert load_calls == [[1, 2]] + + +@mark.asyncio +async def test_batches_multiple_requests_with_max_batch_sizes(): + identity_loader, load_calls = id_loader(max_batch_size=2) + + promise1 = identity_loader.load(1) + promise2 = identity_loader.load(2) + promise3 = identity_loader.load(3) + + p = gather(promise1, promise2, promise3) + + value1, value2, value3 = await p + + assert value1 == 1 + assert value2 == 2 + assert value3 == 3 + + assert load_calls == [[1, 2], [3]] + + +@mark.asyncio +async def test_coalesces_identical_requests(): + identity_loader, load_calls = id_loader() + + promise1 = identity_loader.load(1) + promise2 = identity_loader.load(1) + + assert promise1 == promise2 + p = gather(promise1, promise2) + + value1, value2 = await p + + assert value1 == 1 + assert value2 == 1 + + assert load_calls == [[1]] + + +@mark.asyncio +async def test_caches_repeated_requests(): + identity_loader, load_calls = id_loader() + + a, b = await gather(identity_loader.load("A"), identity_loader.load("B")) + + assert a == "A" + assert b == "B" + + assert load_calls == [["A", "B"]] + + a2, c = await gather(identity_loader.load("A"), identity_loader.load("C")) + + assert a2 == "A" + assert c == "C" + + assert load_calls == [["A", "B"], ["C"]] + + a3, b2, c2 = await gather( + identity_loader.load("A"), identity_loader.load("B"), identity_loader.load("C") + ) + + assert a3 == "A" + assert b2 == "B" + assert c2 == "C" + + assert load_calls == [["A", "B"], ["C"]] + + +@mark.asyncio +async def test_clears_single_value_in_loader(): + identity_loader, load_calls = id_loader() + + a, b = await gather(identity_loader.load("A"), identity_loader.load("B")) + + assert a == "A" + assert b == "B" + + assert load_calls == [["A", "B"]] + + identity_loader.clear("A") + + a2, b2 = await gather(identity_loader.load("A"), identity_loader.load("B")) + + assert a2 == "A" + assert b2 == "B" + + assert load_calls == [["A", "B"], ["A"]] + + +@mark.asyncio +async def test_clears_all_values_in_loader(): + identity_loader, load_calls = id_loader() + + a, b = await gather(identity_loader.load("A"), identity_loader.load("B")) + + assert a == "A" + assert b == "B" + + assert load_calls == [["A", "B"]] + + identity_loader.clear_all() + + a2, b2 = await gather(identity_loader.load("A"), identity_loader.load("B")) + + assert a2 == "A" + assert b2 == "B" + + assert load_calls == [["A", "B"], ["A", "B"]] + + +@mark.asyncio +async def test_allows_priming_the_cache(): + identity_loader, load_calls = id_loader() + + identity_loader.prime("A", "A") + + a, b = await gather(identity_loader.load("A"), identity_loader.load("B")) + + assert a == "A" + assert b == "B" + + assert load_calls == [["B"]] + + +@mark.asyncio +async def test_does_not_prime_keys_that_already_exist(): + identity_loader, load_calls = id_loader() + + identity_loader.prime("A", "X") + + a1 = await identity_loader.load("A") + b1 = await identity_loader.load("B") + + assert a1 == "X" + assert b1 == "B" + + identity_loader.prime("A", "Y") + identity_loader.prime("B", "Y") + + a2 = await identity_loader.load("A") + b2 = await identity_loader.load("B") + + assert a2 == "X" + assert b2 == "B" + + assert load_calls == [["B"]] + + +# # Represents Errors +@mark.asyncio +async def test_resolves_to_error_to_indicate_failure(): + async def resolve(keys): + mapped_keys = [ + key if key % 2 == 0 else Exception("Odd: {}".format(key)) for key in keys + ] + return mapped_keys + + even_loader, load_calls = id_loader(resolve=resolve) + + with raises(Exception) as exc_info: + await even_loader.load(1) + + assert str(exc_info.value) == "Odd: 1" + + value2 = await even_loader.load(2) + assert value2 == 2 + assert load_calls == [[1], [2]] + + +@mark.asyncio +async def test_can_represent_failures_and_successes_simultaneously(): + async def resolve(keys): + mapped_keys = [ + key if key % 2 == 0 else Exception("Odd: {}".format(key)) for key in keys + ] + return mapped_keys + + even_loader, load_calls = id_loader(resolve=resolve) + + promise1 = even_loader.load(1) + promise2 = even_loader.load(2) + + with raises(Exception) as exc_info: + await promise1 + + assert str(exc_info.value) == "Odd: 1" + value2 = await promise2 + assert value2 == 2 + assert load_calls == [[1, 2]] + + +@mark.asyncio +async def test_caches_failed_fetches(): + async def resolve(keys): + mapped_keys = [Exception("Error: {}".format(key)) for key in keys] + return mapped_keys + + error_loader, load_calls = id_loader(resolve=resolve) + + with raises(Exception) as exc_info: + await error_loader.load(1) + + assert str(exc_info.value) == "Error: 1" + + with raises(Exception) as exc_info: + await error_loader.load(1) + + assert str(exc_info.value) == "Error: 1" + + assert load_calls == [[1]] + + +@mark.asyncio +async def test_caches_failed_fetches_2(): + identity_loader, load_calls = id_loader() + + identity_loader.prime(1, Exception("Error: 1")) + + with raises(Exception) as _: + await identity_loader.load(1) + + assert load_calls == [] + + +# It is resilient to job queue ordering +@mark.asyncio +async def test_batches_loads_occuring_within_promises(): + identity_loader, load_calls = id_loader() + + async def load_b_1(): + return await load_b_2() + + async def load_b_2(): + return await identity_loader.load("B") + + values = await gather(identity_loader.load("A"), load_b_1()) + + assert values == ["A", "B"] + + assert load_calls == [["A", "B"]] + + +@mark.asyncio +async def test_catches_error_if_loader_resolver_fails(): + exc = Exception("AOH!") + + def do_resolve(x): + raise exc + + a_loader, a_load_calls = id_loader(resolve=do_resolve) + + with raises(Exception) as exc_info: + await a_loader.load("A1") + + assert exc_info.value == exc + + +@mark.asyncio +async def test_can_call_a_loader_from_a_loader(): + deep_loader, deep_load_calls = id_loader() + a_loader, a_load_calls = id_loader( + resolve=lambda keys: deep_loader.load(tuple(keys)) + ) + b_loader, b_load_calls = id_loader( + resolve=lambda keys: deep_loader.load(tuple(keys)) + ) + + a1, b1, a2, b2 = await gather( + a_loader.load("A1"), + b_loader.load("B1"), + a_loader.load("A2"), + b_loader.load("B2"), + ) + + assert a1 == "A1" + assert b1 == "B1" + assert a2 == "A2" + assert b2 == "B2" + + assert a_load_calls == [["A1", "A2"]] + assert b_load_calls == [["B1", "B2"]] + assert deep_load_calls == [[("A1", "A2"), ("B1", "B2")]] + + +@mark.asyncio +async def test_dataloader_clear_with_missing_key_works(): + async def do_resolve(x): + return x + + a_loader, a_load_calls = id_loader(resolve=do_resolve) + assert a_loader.clear("A1") == a_loader diff --git a/setup.cfg b/setup.cfg index 2037bc1be..db1ff1345 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,6 +2,10 @@ exclude = setup.py,docs/*,*/examples/*,graphene/pyutils/*,tests max-line-length = 120 +# This is a specific ignore for Black+Flake8 +# source: https://black.readthedocs.io/en/stable/guides/using_black_with_other_tools.html#id1 +extend-ignore = E203 + [coverage:run] omit = graphene/pyutils/*,*/tests/*,graphene/types/scalars.py diff --git a/setup.py b/setup.py index b87f56ccd..dce6aa6c0 100644 --- a/setup.py +++ b/setup.py @@ -53,7 +53,6 @@ def run_tests(self): "snapshottest>=0.6,<1", "coveralls>=3.3,<4", "promise>=2.3,<3", - "aiodataloader<1", "mock>=4,<5", "pytz==2022.1", "iso8601>=1,<2", diff --git a/tests_asyncio/test_dataloader.py b/tests_asyncio/test_dataloader.py deleted file mode 100644 index fb8d1630e..000000000 --- a/tests_asyncio/test_dataloader.py +++ /dev/null @@ -1,79 +0,0 @@ -from collections import namedtuple -from unittest.mock import Mock -from pytest import mark -from aiodataloader import DataLoader - -from graphene import ObjectType, String, Schema, Field, List - - -CHARACTERS = { - "1": {"name": "Luke Skywalker", "sibling": "3"}, - "2": {"name": "Darth Vader", "sibling": None}, - "3": {"name": "Leia Organa", "sibling": "1"}, -} - - -get_character = Mock(side_effect=lambda character_id: CHARACTERS[character_id]) - - -class CharacterType(ObjectType): - name = String() - sibling = Field(lambda: CharacterType) - - async def resolve_sibling(character, info): - if character["sibling"]: - return await info.context.character_loader.load(character["sibling"]) - return None - - -class Query(ObjectType): - skywalker_family = List(CharacterType) - - async def resolve_skywalker_family(_, info): - return await info.context.character_loader.load_many(["1", "2", "3"]) - - -mock_batch_load_fn = Mock( - side_effect=lambda character_ids: [get_character(id) for id in character_ids] -) - - -class CharacterLoader(DataLoader): - async def batch_load_fn(self, character_ids): - return mock_batch_load_fn(character_ids) - - -Context = namedtuple("Context", "character_loader") - - -@mark.asyncio -async def test_basic_dataloader(): - schema = Schema(query=Query) - - character_loader = CharacterLoader() - context = Context(character_loader=character_loader) - - query = """ - { - skywalkerFamily { - name - sibling { - name - } - } - } - """ - - result = await schema.execute_async(query, context=context) - - assert not result.errors - assert result.data == { - "skywalkerFamily": [ - {"name": "Luke Skywalker", "sibling": {"name": "Leia Organa"}}, - {"name": "Darth Vader", "sibling": None}, - {"name": "Leia Organa", "sibling": {"name": "Luke Skywalker"}}, - ] - } - - assert mock_batch_load_fn.call_count == 1 - assert get_character.call_count == 3 From b20bbdcdf728eb1e009edb20da62df1f13fb165f Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Thu, 8 Sep 2022 10:55:05 +0200 Subject: [PATCH 12/79] v3.1.1 --- graphene/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene/__init__.py b/graphene/__init__.py index 52ed205ad..aeb6d6d25 100644 --- a/graphene/__init__.py +++ b/graphene/__init__.py @@ -42,7 +42,7 @@ from .utils.module_loading import lazy_import from .utils.resolve_only_args import resolve_only_args -VERSION = (3, 1, 0, "final", 0) +VERSION = (3, 1, 1, "final", 0) __version__ = get_version(VERSION) From ee1ff975d71f6590eb6933d76d12054c9839774a Mon Sep 17 00:00:00 2001 From: Thomas Leonard <64223923+tcleonard@users.noreply.github.com> Date: Mon, 19 Sep 2022 10:17:31 +0200 Subject: [PATCH 13/79] feat: Add support for custom global (Issue #1276) (#1428) Co-authored-by: Thomas Leonard --- .github/workflows/tests.yml | 2 +- Makefile | 1 + graphene/__init__.py | 10 +- graphene/relay/__init__.py | 16 +- graphene/relay/id_type.py | 87 +++++ graphene/relay/node.py | 60 ++-- graphene/relay/tests/test_custom_global_id.py | 325 ++++++++++++++++++ graphene/relay/tests/test_node.py | 1 + 8 files changed, 472 insertions(+), 30 deletions(-) create mode 100644 graphene/relay/id_type.py create mode 100644 graphene/relay/tests/test_custom_global_id.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 51832084e..9df18f994 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -58,7 +58,7 @@ jobs: if: ${{ matrix.python == '3.10' }} uses: actions/upload-artifact@v3 with: - name: graphene-sqlalchemy-coverage + name: graphene-coverage path: coverage.xml if-no-files-found: error - name: Upload coverage.xml to codecov diff --git a/Makefile b/Makefile index c78e2b4fb..089477073 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,7 @@ help: install-dev: pip install -e ".[dev]" +.PHONY: test ## Run tests test: py.test graphene examples diff --git a/graphene/__init__.py b/graphene/__init__.py index aeb6d6d25..af83f059c 100644 --- a/graphene/__init__.py +++ b/graphene/__init__.py @@ -1,11 +1,15 @@ from .pyutils.version import get_version from .relay import ( + BaseGlobalIDType, ClientIDMutation, Connection, ConnectionField, + DefaultGlobalIDType, GlobalID, Node, PageInfo, + SimpleGlobalIDType, + UUIDGlobalIDType, is_node, ) from .types import ( @@ -52,6 +56,7 @@ "Argument", "Base64", "BigInt", + "BaseGlobalIDType", "Boolean", "ClientIDMutation", "Connection", @@ -60,6 +65,7 @@ "Date", "DateTime", "Decimal", + "DefaultGlobalIDType", "Dynamic", "Enum", "Field", @@ -80,10 +86,12 @@ "ResolveInfo", "Scalar", "Schema", + "SimpleGlobalIDType", "String", "Time", - "UUID", "Union", + "UUID", + "UUIDGlobalIDType", "is_node", "lazy_import", "resolve_only_args", diff --git a/graphene/relay/__init__.py b/graphene/relay/__init__.py index 7238fa72e..3b842cf56 100644 --- a/graphene/relay/__init__.py +++ b/graphene/relay/__init__.py @@ -1,13 +1,23 @@ from .node import Node, is_node, GlobalID from .mutation import ClientIDMutation from .connection import Connection, ConnectionField, PageInfo +from .id_type import ( + BaseGlobalIDType, + DefaultGlobalIDType, + SimpleGlobalIDType, + UUIDGlobalIDType, +) __all__ = [ - "Node", - "is_node", - "GlobalID", + "BaseGlobalIDType", "ClientIDMutation", "Connection", "ConnectionField", + "DefaultGlobalIDType", + "GlobalID", + "Node", "PageInfo", + "SimpleGlobalIDType", + "UUIDGlobalIDType", + "is_node", ] diff --git a/graphene/relay/id_type.py b/graphene/relay/id_type.py new file mode 100644 index 000000000..fb5c30e72 --- /dev/null +++ b/graphene/relay/id_type.py @@ -0,0 +1,87 @@ +from graphql_relay import from_global_id, to_global_id + +from ..types import ID, UUID +from ..types.base import BaseType + +from typing import Type + + +class BaseGlobalIDType: + """ + Base class that define the required attributes/method for a type. + """ + + graphene_type = ID # type: Type[BaseType] + + @classmethod + def resolve_global_id(cls, info, global_id): + # return _type, _id + raise NotImplementedError + + @classmethod + def to_global_id(cls, _type, _id): + # return _id + raise NotImplementedError + + +class DefaultGlobalIDType(BaseGlobalIDType): + """ + Default global ID type: base64 encoded version of ": ". + """ + + graphene_type = ID + + @classmethod + def resolve_global_id(cls, info, global_id): + try: + _type, _id = from_global_id(global_id) + if not _type: + raise ValueError("Invalid Global ID") + return _type, _id + except Exception as e: + raise Exception( + f'Unable to parse global ID "{global_id}". ' + 'Make sure it is a base64 encoded string in the format: "TypeName:id". ' + f"Exception message: {e}" + ) + + @classmethod + def to_global_id(cls, _type, _id): + return to_global_id(_type, _id) + + +class SimpleGlobalIDType(BaseGlobalIDType): + """ + Simple global ID type: simply the id of the object. + To be used carefully as the user is responsible for ensuring that the IDs are indeed global + (otherwise it could cause request caching issues). + """ + + graphene_type = ID + + @classmethod + def resolve_global_id(cls, info, global_id): + _type = info.return_type.graphene_type._meta.name + return _type, global_id + + @classmethod + def to_global_id(cls, _type, _id): + return _id + + +class UUIDGlobalIDType(BaseGlobalIDType): + """ + UUID global ID type. + By definition UUID are global so they are used as they are. + """ + + graphene_type = UUID + + @classmethod + def resolve_global_id(cls, info, global_id): + _type = info.return_type.graphene_type._meta.name + return _type, global_id + + @classmethod + def to_global_id(cls, _type, _id): + return _id diff --git a/graphene/relay/node.py b/graphene/relay/node.py index dabcff6ce..544382813 100644 --- a/graphene/relay/node.py +++ b/graphene/relay/node.py @@ -1,11 +1,10 @@ from functools import partial from inspect import isclass -from graphql_relay import from_global_id, to_global_id - -from ..types import ID, Field, Interface, ObjectType +from ..types import Field, Interface, ObjectType from ..types.interface import InterfaceOptions from ..types.utils import get_type +from .id_type import BaseGlobalIDType, DefaultGlobalIDType def is_node(objecttype): @@ -22,8 +21,18 @@ def is_node(objecttype): class GlobalID(Field): - def __init__(self, node=None, parent_type=None, required=True, *args, **kwargs): - super(GlobalID, self).__init__(ID, required=required, *args, **kwargs) + def __init__( + self, + node=None, + parent_type=None, + required=True, + global_id_type=DefaultGlobalIDType, + *args, + **kwargs, + ): + super(GlobalID, self).__init__( + global_id_type.graphene_type, required=required, *args, **kwargs + ) self.node = node or Node self.parent_type_name = parent_type._meta.name if parent_type else None @@ -47,12 +56,14 @@ def __init__(self, node, type_=False, **kwargs): assert issubclass(node, Node), "NodeField can only operate in Nodes" self.node_type = node self.field_type = type_ + global_id_type = node._meta.global_id_type super(NodeField, self).__init__( - # If we don's specify a type, the field type will be the node - # interface + # If we don't specify a type, the field type will be the node interface type_ or node, - id=ID(required=True, description="The ID of the object"), + id=global_id_type.graphene_type( + required=True, description="The ID of the object" + ), **kwargs, ) @@ -65,11 +76,23 @@ class Meta: abstract = True @classmethod - def __init_subclass_with_meta__(cls, **options): + def __init_subclass_with_meta__(cls, global_id_type=DefaultGlobalIDType, **options): + assert issubclass( + global_id_type, BaseGlobalIDType + ), "Custom ID type need to be implemented as a subclass of BaseGlobalIDType." _meta = InterfaceOptions(cls) - _meta.fields = {"id": GlobalID(cls, description="The ID of the object")} + _meta.global_id_type = global_id_type + _meta.fields = { + "id": GlobalID( + cls, global_id_type=global_id_type, description="The ID of the object" + ) + } super(AbstractNode, cls).__init_subclass_with_meta__(_meta=_meta, **options) + @classmethod + def resolve_global_id(cls, info, global_id): + return cls._meta.global_id_type.resolve_global_id(info, global_id) + class Node(AbstractNode): """An object with an ID""" @@ -84,16 +107,7 @@ def node_resolver(cls, only_type, root, info, id): @classmethod def get_node_from_global_id(cls, info, global_id, only_type=None): - try: - _type, _id = cls.from_global_id(global_id) - if not _type: - raise ValueError("Invalid Global ID") - except Exception as e: - raise Exception( - f'Unable to parse global ID "{global_id}". ' - 'Make sure it is a base64 encoded string in the format: "TypeName:id". ' - f"Exception message: {e}" - ) + _type, _id = cls.resolve_global_id(info, global_id) graphene_type = info.schema.get_type(_type) if graphene_type is None: @@ -116,10 +130,6 @@ def get_node_from_global_id(cls, info, global_id, only_type=None): if get_node: return get_node(info, _id) - @classmethod - def from_global_id(cls, global_id): - return from_global_id(global_id) - @classmethod def to_global_id(cls, type_, id): - return to_global_id(type_, id) + return cls._meta.global_id_type.to_global_id(type_, id) diff --git a/graphene/relay/tests/test_custom_global_id.py b/graphene/relay/tests/test_custom_global_id.py new file mode 100644 index 000000000..c1bf0fb4b --- /dev/null +++ b/graphene/relay/tests/test_custom_global_id.py @@ -0,0 +1,325 @@ +import re +from uuid import uuid4 + +from graphql import graphql_sync + +from ..id_type import BaseGlobalIDType, SimpleGlobalIDType, UUIDGlobalIDType +from ..node import Node +from ...types import Int, ObjectType, Schema, String + + +class TestUUIDGlobalID: + def setup(self): + self.user_list = [ + {"id": uuid4(), "name": "First"}, + {"id": uuid4(), "name": "Second"}, + {"id": uuid4(), "name": "Third"}, + {"id": uuid4(), "name": "Fourth"}, + ] + self.users = {user["id"]: user for user in self.user_list} + + class CustomNode(Node): + class Meta: + global_id_type = UUIDGlobalIDType + + class User(ObjectType): + class Meta: + interfaces = [CustomNode] + + name = String() + + @classmethod + def get_node(cls, _type, _id): + return self.users[_id] + + class RootQuery(ObjectType): + user = CustomNode.Field(User) + + self.schema = Schema(query=RootQuery, types=[User]) + self.graphql_schema = self.schema.graphql_schema + + def test_str_schema_correct(self): + """ + Check that the schema has the expected and custom node interface and user type and that they both use UUIDs + """ + parsed = re.findall(r"(.+) \{\n\s*([\w\W]*?)\n\}", str(self.schema)) + types = [t for t, f in parsed] + fields = [f for t, f in parsed] + custom_node_interface = "interface CustomNode" + assert custom_node_interface in types + assert ( + '"""The ID of the object"""\n id: UUID!' + == fields[types.index(custom_node_interface)] + ) + user_type = "type User implements CustomNode" + assert user_type in types + assert ( + '"""The ID of the object"""\n id: UUID!\n name: String' + == fields[types.index(user_type)] + ) + + def test_get_by_id(self): + query = """query userById($id: UUID!) { + user(id: $id) { + id + name + } + }""" + # UUID need to be converted to string for serialization + result = graphql_sync( + self.graphql_schema, + query, + variable_values={"id": str(self.user_list[0]["id"])}, + ) + assert not result.errors + assert result.data["user"]["id"] == str(self.user_list[0]["id"]) + assert result.data["user"]["name"] == self.user_list[0]["name"] + + +class TestSimpleGlobalID: + def setup(self): + self.user_list = [ + {"id": "my global primary key in clear 1", "name": "First"}, + {"id": "my global primary key in clear 2", "name": "Second"}, + {"id": "my global primary key in clear 3", "name": "Third"}, + {"id": "my global primary key in clear 4", "name": "Fourth"}, + ] + self.users = {user["id"]: user for user in self.user_list} + + class CustomNode(Node): + class Meta: + global_id_type = SimpleGlobalIDType + + class User(ObjectType): + class Meta: + interfaces = [CustomNode] + + name = String() + + @classmethod + def get_node(cls, _type, _id): + return self.users[_id] + + class RootQuery(ObjectType): + user = CustomNode.Field(User) + + self.schema = Schema(query=RootQuery, types=[User]) + self.graphql_schema = self.schema.graphql_schema + + def test_str_schema_correct(self): + """ + Check that the schema has the expected and custom node interface and user type and that they both use UUIDs + """ + parsed = re.findall(r"(.+) \{\n\s*([\w\W]*?)\n\}", str(self.schema)) + types = [t for t, f in parsed] + fields = [f for t, f in parsed] + custom_node_interface = "interface CustomNode" + assert custom_node_interface in types + assert ( + '"""The ID of the object"""\n id: ID!' + == fields[types.index(custom_node_interface)] + ) + user_type = "type User implements CustomNode" + assert user_type in types + assert ( + '"""The ID of the object"""\n id: ID!\n name: String' + == fields[types.index(user_type)] + ) + + def test_get_by_id(self): + query = """query { + user(id: "my global primary key in clear 3") { + id + name + } + }""" + result = graphql_sync(self.graphql_schema, query) + assert not result.errors + assert result.data["user"]["id"] == self.user_list[2]["id"] + assert result.data["user"]["name"] == self.user_list[2]["name"] + + +class TestCustomGlobalID: + def setup(self): + self.user_list = [ + {"id": 1, "name": "First"}, + {"id": 2, "name": "Second"}, + {"id": 3, "name": "Third"}, + {"id": 4, "name": "Fourth"}, + ] + self.users = {user["id"]: user for user in self.user_list} + + class CustomGlobalIDType(BaseGlobalIDType): + """ + Global id that is simply and integer in clear. + """ + + graphene_type = Int + + @classmethod + def resolve_global_id(cls, info, global_id): + _type = info.return_type.graphene_type._meta.name + return _type, global_id + + @classmethod + def to_global_id(cls, _type, _id): + return _id + + class CustomNode(Node): + class Meta: + global_id_type = CustomGlobalIDType + + class User(ObjectType): + class Meta: + interfaces = [CustomNode] + + name = String() + + @classmethod + def get_node(cls, _type, _id): + return self.users[_id] + + class RootQuery(ObjectType): + user = CustomNode.Field(User) + + self.schema = Schema(query=RootQuery, types=[User]) + self.graphql_schema = self.schema.graphql_schema + + def test_str_schema_correct(self): + """ + Check that the schema has the expected and custom node interface and user type and that they both use UUIDs + """ + parsed = re.findall(r"(.+) \{\n\s*([\w\W]*?)\n\}", str(self.schema)) + types = [t for t, f in parsed] + fields = [f for t, f in parsed] + custom_node_interface = "interface CustomNode" + assert custom_node_interface in types + assert ( + '"""The ID of the object"""\n id: Int!' + == fields[types.index(custom_node_interface)] + ) + user_type = "type User implements CustomNode" + assert user_type in types + assert ( + '"""The ID of the object"""\n id: Int!\n name: String' + == fields[types.index(user_type)] + ) + + def test_get_by_id(self): + query = """query { + user(id: 2) { + id + name + } + }""" + result = graphql_sync(self.graphql_schema, query) + assert not result.errors + assert result.data["user"]["id"] == self.user_list[1]["id"] + assert result.data["user"]["name"] == self.user_list[1]["name"] + + +class TestIncompleteCustomGlobalID: + def setup(self): + self.user_list = [ + {"id": 1, "name": "First"}, + {"id": 2, "name": "Second"}, + {"id": 3, "name": "Third"}, + {"id": 4, "name": "Fourth"}, + ] + self.users = {user["id"]: user for user in self.user_list} + + def test_must_define_to_global_id(self): + """ + Test that if the `to_global_id` method is not defined, we can query the object, but we can't request its ID. + """ + + class CustomGlobalIDType(BaseGlobalIDType): + graphene_type = Int + + @classmethod + def resolve_global_id(cls, info, global_id): + _type = info.return_type.graphene_type._meta.name + return _type, global_id + + class CustomNode(Node): + class Meta: + global_id_type = CustomGlobalIDType + + class User(ObjectType): + class Meta: + interfaces = [CustomNode] + + name = String() + + @classmethod + def get_node(cls, _type, _id): + return self.users[_id] + + class RootQuery(ObjectType): + user = CustomNode.Field(User) + + self.schema = Schema(query=RootQuery, types=[User]) + self.graphql_schema = self.schema.graphql_schema + + query = """query { + user(id: 2) { + name + } + }""" + result = graphql_sync(self.graphql_schema, query) + assert not result.errors + assert result.data["user"]["name"] == self.user_list[1]["name"] + + query = """query { + user(id: 2) { + id + name + } + }""" + result = graphql_sync(self.graphql_schema, query) + assert result.errors is not None + assert len(result.errors) == 1 + assert result.errors[0].path == ["user", "id"] + + def test_must_define_resolve_global_id(self): + """ + Test that if the `resolve_global_id` method is not defined, we can't query the object by ID. + """ + + class CustomGlobalIDType(BaseGlobalIDType): + graphene_type = Int + + @classmethod + def to_global_id(cls, _type, _id): + return _id + + class CustomNode(Node): + class Meta: + global_id_type = CustomGlobalIDType + + class User(ObjectType): + class Meta: + interfaces = [CustomNode] + + name = String() + + @classmethod + def get_node(cls, _type, _id): + return self.users[_id] + + class RootQuery(ObjectType): + user = CustomNode.Field(User) + + self.schema = Schema(query=RootQuery, types=[User]) + self.graphql_schema = self.schema.graphql_schema + + query = """query { + user(id: 2) { + id + name + } + }""" + result = graphql_sync(self.graphql_schema, query) + assert result.errors is not None + assert len(result.errors) == 1 + assert result.errors[0].path == ["user"] diff --git a/graphene/relay/tests/test_node.py b/graphene/relay/tests/test_node.py index 6b310fde6..e75645664 100644 --- a/graphene/relay/tests/test_node.py +++ b/graphene/relay/tests/test_node.py @@ -55,6 +55,7 @@ def test_node_good(): assert "id" in MyNode._meta.fields assert is_node(MyNode) assert not is_node(object) + assert not is_node("node") def test_node_query(): From 6969023491793a7166ff0b4b62a35898b578196c Mon Sep 17 00:00:00 2001 From: Kristian Uzhca Date: Mon, 24 Oct 2022 14:06:24 -0400 Subject: [PATCH 14/79] Add copy function for GrapheneGraphQLType (#1463) --- graphene/types/definitions.py | 5 +++++ graphene/types/tests/test_definition.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/graphene/types/definitions.py b/graphene/types/definitions.py index e5505fd3a..ac574bed5 100644 --- a/graphene/types/definitions.py +++ b/graphene/types/definitions.py @@ -20,6 +20,11 @@ def __init__(self, *args, **kwargs): self.graphene_type = kwargs.pop("graphene_type") super(GrapheneGraphQLType, self).__init__(*args, **kwargs) + def __copy__(self): + result = GrapheneGraphQLType(graphene_type=self.graphene_type) + result.__dict__.update(self.__dict__) + return result + class GrapheneInterfaceType(GrapheneGraphQLType, GraphQLInterfaceType): pass diff --git a/graphene/types/tests/test_definition.py b/graphene/types/tests/test_definition.py index 0d8a95dfa..898fac71b 100644 --- a/graphene/types/tests/test_definition.py +++ b/graphene/types/tests/test_definition.py @@ -1,4 +1,7 @@ +import copy + from ..argument import Argument +from ..definitions import GrapheneGraphQLType from ..enum import Enum from ..field import Field from ..inputfield import InputField @@ -312,3 +315,16 @@ class TestInputObject2(CommonFields, InputObjectType): pass assert TestInputObject1._meta.fields == TestInputObject2._meta.fields + + +def test_graphene_graphql_type_can_be_copied(): + class Query(ObjectType): + field = String() + + def resolve_field(self, info): + return "" + + schema = Schema(query=Query) + query_type_copy = copy.copy(schema.graphql_schema.query_type) + assert query_type_copy.__dict__ == schema.graphql_schema.query_type.__dict__ + assert isinstance(schema.graphql_schema.query_type, GrapheneGraphQLType) From ccdd35b354007c3899a7b62ffe132414ee226fec Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Thu, 27 Oct 2022 04:55:38 -0700 Subject: [PATCH 15/79] hashable Enum (#1461) --- graphene/types/enum.py | 6 +++++- graphene/types/tests/test_enum.py | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/graphene/types/enum.py b/graphene/types/enum.py index e5cc50ed2..0f68236b4 100644 --- a/graphene/types/enum.py +++ b/graphene/types/enum.py @@ -12,6 +12,10 @@ def eq_enum(self, other): return self.value is other +def hash_enum(self): + return hash(self.name) + + EnumType = type(PyEnum) @@ -22,7 +26,7 @@ class EnumOptions(BaseOptions): class EnumMeta(SubclassWithMeta_Meta): def __new__(cls, name_, bases, classdict, **options): - enum_members = dict(classdict, __eq__=eq_enum) + enum_members = dict(classdict, __eq__=eq_enum, __hash__=hash_enum) # We remove the Meta attribute from the class to not collide # with the enum values. enum_members.pop("Meta", None) diff --git a/graphene/types/tests/test_enum.py b/graphene/types/tests/test_enum.py index 679de16e4..ab1e093e8 100644 --- a/graphene/types/tests/test_enum.py +++ b/graphene/types/tests/test_enum.py @@ -518,3 +518,28 @@ class Query(ObjectType): assert result.data == {"createPaint": {"color": "RED"}} assert color_input_value == RGB.RED + + +def test_hashable_enum(): + class RGB(Enum): + """Available colors""" + + RED = 1 + GREEN = 2 + BLUE = 3 + + color_map = {RGB.RED: "a", RGB.BLUE: "b", 1: "c"} + + assert color_map[RGB.RED] == "a" + assert color_map[RGB.BLUE] == "b" + assert color_map[1] == "c" + + +def test_hashable_instance_creation_enum(): + Episode = Enum("Episode", [("NEWHOPE", 4), ("EMPIRE", 5), ("JEDI", 6)]) + + trilogy_map = {Episode.NEWHOPE: "better", Episode.EMPIRE: "best", 5: "foo"} + + assert trilogy_map[Episode.NEWHOPE] == "better" + assert trilogy_map[Episode.EMPIRE] == "best" + assert trilogy_map[5] == "foo" From b349632a826a1054c17a3aa9ae2f9689776c6e9e Mon Sep 17 00:00:00 2001 From: Rens Groothuijsen Date: Tue, 15 Nov 2022 08:48:48 +0100 Subject: [PATCH 16/79] Clarify execution order in middleware docs (#1475) --- docs/execution/middleware.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/execution/middleware.rst b/docs/execution/middleware.rst index c0a8c7929..3d086f3e9 100644 --- a/docs/execution/middleware.rst +++ b/docs/execution/middleware.rst @@ -41,6 +41,8 @@ And then execute it with: result = schema.execute('THE QUERY', middleware=[AuthorizationMiddleware()]) +If the ``middleware`` argument includes multiple middlewares, +these middlewares will be executed bottom-up, i.e. from last to first. Functional example ------------------ From a2b63d8d84c7c3aefe528fa84963a4186439406c Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Wed, 16 Nov 2022 21:23:37 +0100 Subject: [PATCH 17/79] fix: MyPy findings due to a mypy version upgrade were corrected (#1477) --- graphene/types/inputobjecttype.py | 2 +- graphene/validation/depth_limit.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/graphene/types/inputobjecttype.py b/graphene/types/inputobjecttype.py index 98f0148de..5d2785105 100644 --- a/graphene/types/inputobjecttype.py +++ b/graphene/types/inputobjecttype.py @@ -14,7 +14,7 @@ class InputObjectTypeOptions(BaseOptions): container = None # type: InputObjectTypeContainer -class InputObjectTypeContainer(dict, BaseType): +class InputObjectTypeContainer(dict, BaseType): # type: ignore class Meta: abstract = True diff --git a/graphene/validation/depth_limit.py b/graphene/validation/depth_limit.py index 5be852c7b..b4599e660 100644 --- a/graphene/validation/depth_limit.py +++ b/graphene/validation/depth_limit.py @@ -53,7 +53,7 @@ def depth_limit_validator( max_depth: int, ignore: Optional[List[IgnoreType]] = None, - callback: Callable[[Dict[str, int]], None] = None, + callback: Optional[Callable[[Dict[str, int]], None]] = None, ): class DepthLimitValidator(ValidationRule): def __init__(self, validation_context: ValidationContext): From f891a3683dbc1198d6b7dfad1835a16ca3562452 Mon Sep 17 00:00:00 2001 From: Rens Groothuijsen Date: Wed, 16 Nov 2022 21:27:34 +0100 Subject: [PATCH 18/79] docs: Disambiguate argument name in quickstart docs (#1474) --- docs/quickstart.rst | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index cd090561f..75f201c95 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -37,12 +37,12 @@ An example in Graphene Let’s build a basic GraphQL schema to say "hello" and "goodbye" in Graphene. -When we send a **Query** requesting only one **Field**, ``hello``, and specify a value for the ``name`` **Argument**... +When we send a **Query** requesting only one **Field**, ``hello``, and specify a value for the ``firstName`` **Argument**... .. code:: { - hello(name: "friend") + hello(firstName: "friend") } ...we would expect the following Response containing only the data requested (the ``goodbye`` field is not resolved). @@ -79,14 +79,15 @@ In Graphene, we can define a simple schema using the following code: from graphene import ObjectType, String, Schema class Query(ObjectType): - # this defines a Field `hello` in our Schema with a single Argument `name` - hello = String(name=String(default_value="stranger")) + # this defines a Field `hello` in our Schema with a single Argument `first_name` + # By default, the argument name will automatically be camel-based into firstName in the generated schema + hello = String(first_name=String(default_value="stranger")) goodbye = String() # our Resolver method takes the GraphQL context (root, info) as well as - # Argument (name) for the Field and returns data for the query Response - def resolve_hello(root, info, name): - return f'Hello {name}!' + # Argument (first_name) for the Field and returns data for the query Response + def resolve_hello(root, info, first_name): + return f'Hello {first_name}!' def resolve_goodbye(root, info): return 'See ya!' @@ -110,7 +111,7 @@ In the `GraphQL Schema Definition Language`_, we could describe the fields defin .. code:: type Query { - hello(name: String = "stranger"): String + hello(firstName: String = "stranger"): String goodbye: String } @@ -130,7 +131,7 @@ Then we can start querying our **Schema** by passing a GraphQL query string to ` # "Hello stranger!" # or passing the argument in the query - query_with_argument = '{ hello(name: "GraphQL") }' + query_with_argument = '{ hello(firstName: "GraphQL") }' result = schema.execute(query_with_argument) print(result.data['hello']) # "Hello GraphQL!" From 0b1bfbf65b5c47b69874612aec0328c3a724f0d7 Mon Sep 17 00:00:00 2001 From: Rens Groothuijsen Date: Wed, 16 Nov 2022 21:30:49 +0100 Subject: [PATCH 19/79] chore: Make Graphene enums iterable like Python enums (#1473) * Makes Graphene enums iterable like Python enums by implementing __iter__ --- graphene/types/enum.py | 3 +++ graphene/types/tests/test_enum.py | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/graphene/types/enum.py b/graphene/types/enum.py index 0f68236b4..58e65c69e 100644 --- a/graphene/types/enum.py +++ b/graphene/types/enum.py @@ -56,6 +56,9 @@ def __call__(cls, *args, **kwargs): # noqa: N805 return super(EnumMeta, cls).__call__(*args, **kwargs) # return cls._meta.enum(*args, **kwargs) + def __iter__(cls): + return cls._meta.enum.__iter__() + def from_enum( cls, enum, name=None, description=None, deprecation_reason=None ): # noqa: N805 diff --git a/graphene/types/tests/test_enum.py b/graphene/types/tests/test_enum.py index ab1e093e8..298cc233b 100644 --- a/graphene/types/tests/test_enum.py +++ b/graphene/types/tests/test_enum.py @@ -543,3 +543,25 @@ def test_hashable_instance_creation_enum(): assert trilogy_map[Episode.NEWHOPE] == "better" assert trilogy_map[Episode.EMPIRE] == "best" assert trilogy_map[5] == "foo" + + +def test_enum_iteration(): + class TestEnum(Enum): + FIRST = 1 + SECOND = 2 + + result = [] + expected_values = ["FIRST", "SECOND"] + for c in TestEnum: + result.append(c.name) + assert result == expected_values + + +def test_iterable_instance_creation_enum(): + TestEnum = Enum("TestEnum", [("FIRST", 1), ("SECOND", 2)]) + + result = [] + expected_values = ["FIRST", "SECOND"] + for c in TestEnum: + result.append(c.name) + assert result == expected_values From 7f6fa161948fd2c3312493309dbd590db7f95327 Mon Sep 17 00:00:00 2001 From: Mike Roberts <110839704+mike-roberts-healx@users.noreply.github.com> Date: Wed, 16 Nov 2022 20:38:15 +0000 Subject: [PATCH 20/79] feat_ (#1476) Previously, installing graphene and trying to do `from graphene.test import Client` as recommended in the docs caused an `ImportError`, as the 'promise' library is imported but only listed as a requirement in the 'test' section of the setup.py file. --- graphene/relay/tests/test_mutation_async.py | 16 ++++++++++------ graphene/test/__init__.py | 8 +++++--- setup.py | 1 - 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/graphene/relay/tests/test_mutation_async.py b/graphene/relay/tests/test_mutation_async.py index 4308a6141..bf61555de 100644 --- a/graphene/relay/tests/test_mutation_async.py +++ b/graphene/relay/tests/test_mutation_async.py @@ -3,6 +3,7 @@ from graphene.types import ID, Field, ObjectType, Schema from graphene.types.scalars import String from graphene.relay.mutation import ClientIDMutation +from graphene.test import Client class SharedFields(object): @@ -61,24 +62,27 @@ class Mutation(ObjectType): schema = Schema(query=RootQuery, mutation=Mutation) +client = Client(schema) @mark.asyncio async def test_node_query_promise(): - executed = await schema.execute_async( + executed = await client.execute_async( 'mutation a { sayPromise(input: {what:"hello", clientMutationId:"1"}) { phrase } }' ) - assert not executed.errors - assert executed.data == {"sayPromise": {"phrase": "hello"}} + assert isinstance(executed, dict) + assert "errors" not in executed + assert executed["data"] == {"sayPromise": {"phrase": "hello"}} @mark.asyncio async def test_edge_query(): - executed = await schema.execute_async( + executed = await client.execute_async( 'mutation a { other(input: {clientMutationId:"1"}) { clientMutationId, myNodeEdge { cursor node { name }} } }' ) - assert not executed.errors - assert dict(executed.data) == { + assert isinstance(executed, dict) + assert "errors" not in executed + assert executed["data"] == { "other": { "clientMutationId": "1", "myNodeEdge": {"cursor": "1", "node": {"name": "name"}}, diff --git a/graphene/test/__init__.py b/graphene/test/__init__.py index 13b05dd3b..1813d9284 100644 --- a/graphene/test/__init__.py +++ b/graphene/test/__init__.py @@ -1,4 +1,3 @@ -from promise import Promise, is_thenable from graphql.error import GraphQLError from graphene.types.schema import Schema @@ -31,7 +30,10 @@ def format_result(self, result): def execute(self, *args, **kwargs): executed = self.schema.execute(*args, **dict(self.execute_options, **kwargs)) - if is_thenable(executed): - return Promise.resolve(executed).then(self.format_result) + return self.format_result(executed) + async def execute_async(self, *args, **kwargs): + executed = await self.schema.execute_async( + *args, **dict(self.execute_options, **kwargs) + ) return self.format_result(executed) diff --git a/setup.py b/setup.py index dce6aa6c0..6c1f29c95 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,6 @@ def run_tests(self): "pytest-asyncio>=0.16,<2", "snapshottest>=0.6,<1", "coveralls>=3.3,<4", - "promise>=2.3,<3", "mock>=4,<5", "pytz==2022.1", "iso8601>=1,<2", From f09b2e5a81ea3ca108ccc972b65966ec363a4e78 Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Mon, 21 Nov 2022 15:40:05 +0100 Subject: [PATCH 21/79] housekeeping: pin ubuntu to 20.04 for python 3.6 Ubuntu:latest doesn't include py36 anymore. Keep this until we add 3.11 and drop 3.6. See: https://github.com/actions/setup-python/issues/544 https://github.com/rwth-i6/returnn/issues/1226 --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9df18f994..6635a35bd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,7 +30,7 @@ jobs: - {name: '3.9', python: '3.9', os: ubuntu-latest, tox: py39} - {name: '3.8', python: '3.8', os: ubuntu-latest, tox: py38} - {name: '3.7', python: '3.7', os: ubuntu-latest, tox: py37} - - {name: '3.6', python: '3.6', os: ubuntu-latest, tox: py36} + - {name: '3.6', python: '3.6', os: ubuntu-20.04, tox: py36} steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 From a141e848c3f3a70628272028fc63f7a4226029d2 Mon Sep 17 00:00:00 2001 From: Mike Roberts <110839704+mike-roberts-healx@users.noreply.github.com> Date: Thu, 1 Dec 2022 10:06:24 +0000 Subject: [PATCH 22/79] Do not interpret Enum members called 'description' as description properties (#1478) This is a workaround for `TypeError`s being raised when initialising schemas with Enum members named `description` or `deprecation_reason`. Fixes #1321 --- graphene/types/schema.py | 9 ++++++++- graphene/types/tests/test_enum.py | 33 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/graphene/types/schema.py b/graphene/types/schema.py index 1a33a93d3..7fa046ddb 100644 --- a/graphene/types/schema.py +++ b/graphene/types/schema.py @@ -1,3 +1,4 @@ +from enum import Enum as PyEnum import inspect from functools import partial @@ -169,10 +170,16 @@ def create_enum(graphene_type): values = {} for name, value in graphene_type._meta.enum.__members__.items(): description = getattr(value, "description", None) - deprecation_reason = getattr(value, "deprecation_reason", None) + # if the "description" attribute is an Enum, it is likely an enum member + # called description, not a description property + if isinstance(description, PyEnum): + description = None if not description and callable(graphene_type._meta.description): description = graphene_type._meta.description(value) + deprecation_reason = getattr(value, "deprecation_reason", None) + if isinstance(deprecation_reason, PyEnum): + deprecation_reason = None if not deprecation_reason and callable( graphene_type._meta.deprecation_reason ): diff --git a/graphene/types/tests/test_enum.py b/graphene/types/tests/test_enum.py index 298cc233b..9b3082df1 100644 --- a/graphene/types/tests/test_enum.py +++ b/graphene/types/tests/test_enum.py @@ -565,3 +565,36 @@ def test_iterable_instance_creation_enum(): for c in TestEnum: result.append(c.name) assert result == expected_values + + +# https://github.com/graphql-python/graphene/issues/1321 +def test_enum_description_member_not_interpreted_as_property(): + class RGB(Enum): + """Description""" + + red = "red" + green = "green" + blue = "blue" + description = "description" + deprecation_reason = "deprecation_reason" + + class Query(ObjectType): + color = RGB() + + def resolve_color(_, info): + return RGB.description + + values = RGB._meta.enum.__members__.values() + assert sorted(v.name for v in values) == [ + "blue", + "deprecation_reason", + "description", + "green", + "red", + ] + + schema = Schema(query=Query) + + results = schema.execute("query { color }") + assert not results.errors + assert results.data["color"] == RGB.description.name From 85963494052bbd19e16ac52d3c3d44341e4bfc9c Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Fri, 9 Dec 2022 10:46:24 +0100 Subject: [PATCH 23/79] release: 3.1.2 --- graphene/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene/__init__.py b/graphene/__init__.py index af83f059c..a878a4d32 100644 --- a/graphene/__init__.py +++ b/graphene/__init__.py @@ -46,7 +46,7 @@ from .utils.module_loading import lazy_import from .utils.resolve_only_args import resolve_only_args -VERSION = (3, 1, 1, "final", 0) +VERSION = (3, 1, 2, "final", 0) __version__ = get_version(VERSION) From d5dadb7b1ba5eb041cf5a0ec50f2cdb44164a507 Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Fri, 9 Dec 2022 10:53:50 +0100 Subject: [PATCH 24/79] release: 3.2.0 fixes previous release number 3.1.2 due to a pending feature release --- graphene/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene/__init__.py b/graphene/__init__.py index a878a4d32..8aebbf1d7 100644 --- a/graphene/__init__.py +++ b/graphene/__init__.py @@ -46,7 +46,7 @@ from .utils.module_loading import lazy_import from .utils.resolve_only_args import resolve_only_args -VERSION = (3, 1, 2, "final", 0) +VERSION = (3, 2, 0, "final", 0) __version__ = get_version(VERSION) From 19ea63b9c541d1b1f956455a88f40ff5c162d715 Mon Sep 17 00:00:00 2001 From: Vladyslav Hutov Date: Sat, 10 Dec 2022 11:25:07 +0000 Subject: [PATCH 25/79] fix: Input fields and Arguments can now be deprecated (#1472) Non-required InputFields and arguments now support deprecation via setting the `deprecation_reason` argument upon creation. --- graphene/types/argument.py | 17 ++++++++--- graphene/types/inputfield.py | 5 +++- graphene/types/schema.py | 2 ++ graphene/types/tests/test_argument.py | 40 +++++++++++++++++++++++-- graphene/types/tests/test_field.py | 9 +++++- graphene/types/tests/test_inputfield.py | 18 +++++++++++ 6 files changed, 83 insertions(+), 8 deletions(-) diff --git a/graphene/types/argument.py b/graphene/types/argument.py index f9dc843bf..d9283c416 100644 --- a/graphene/types/argument.py +++ b/graphene/types/argument.py @@ -31,18 +31,22 @@ class Argument(MountedType): type (class for a graphene.UnmountedType): must be a class (not an instance) of an unmounted graphene type (ex. scalar or object) which is used for the type of this argument in the GraphQL schema. - required (bool): indicates this argument as not null in the graphql schema. Same behavior + required (optional, bool): indicates this argument as not null in the graphql schema. Same behavior as graphene.NonNull. Default False. - name (str): the name of the GraphQL argument. Defaults to parameter name. - description (str): the description of the GraphQL argument in the schema. - default_value (Any): The value to be provided if the user does not set this argument in + name (optional, str): the name of the GraphQL argument. Defaults to parameter name. + description (optional, str): the description of the GraphQL argument in the schema. + default_value (optional, Any): The value to be provided if the user does not set this argument in the operation. + deprecation_reason (optional, str): Setting this value indicates that the argument is + depreciated and may provide instruction or reason on how for clients to proceed. Cannot be + set if the argument is required (see spec). """ def __init__( self, type_, default_value=Undefined, + deprecation_reason=None, description=None, name=None, required=False, @@ -51,12 +55,16 @@ def __init__( super(Argument, self).__init__(_creation_counter=_creation_counter) if required: + assert ( + deprecation_reason is None + ), f"Argument {name} is required, cannot deprecate it." type_ = NonNull(type_) self.name = name self._type = type_ self.default_value = default_value self.description = description + self.deprecation_reason = deprecation_reason @property def type(self): @@ -68,6 +76,7 @@ def __eq__(self, other): and self.type == other.type and self.default_value == other.default_value and self.description == other.description + and self.deprecation_reason == other.deprecation_reason ) diff --git a/graphene/types/inputfield.py b/graphene/types/inputfield.py index 791ca6a48..e7ededb0b 100644 --- a/graphene/types/inputfield.py +++ b/graphene/types/inputfield.py @@ -55,11 +55,14 @@ def __init__( description=None, required=False, _creation_counter=None, - **extra_args + **extra_args, ): super(InputField, self).__init__(_creation_counter=_creation_counter) self.name = name if required: + assert ( + deprecation_reason is None + ), f"InputField {name} is required, cannot deprecate it." type_ = NonNull(type_) self._type = type_ self.deprecation_reason = deprecation_reason diff --git a/graphene/types/schema.py b/graphene/types/schema.py index 7fa046ddb..bceede6ae 100644 --- a/graphene/types/schema.py +++ b/graphene/types/schema.py @@ -316,6 +316,7 @@ def create_fields_for_type(self, graphene_type, is_input_type=False): default_value=field.default_value, out_name=name, description=field.description, + deprecation_reason=field.deprecation_reason, ) else: args = {} @@ -327,6 +328,7 @@ def create_fields_for_type(self, graphene_type, is_input_type=False): out_name=arg_name, description=arg.description, default_value=arg.default_value, + deprecation_reason=arg.deprecation_reason, ) subscribe = field.wrap_subscribe( self.get_function_for_type( diff --git a/graphene/types/tests/test_argument.py b/graphene/types/tests/test_argument.py index db4d6c242..c5521b6c2 100644 --- a/graphene/types/tests/test_argument.py +++ b/graphene/types/tests/test_argument.py @@ -18,8 +18,20 @@ def test_argument(): def test_argument_comparasion(): - arg1 = Argument(String, name="Hey", description="Desc", default_value="default") - arg2 = Argument(String, name="Hey", description="Desc", default_value="default") + arg1 = Argument( + String, + name="Hey", + description="Desc", + default_value="default", + deprecation_reason="deprecated", + ) + arg2 = Argument( + String, + name="Hey", + description="Desc", + default_value="default", + deprecation_reason="deprecated", + ) assert arg1 == arg2 assert arg1 != String() @@ -40,6 +52,30 @@ def test_to_arguments(): } +def test_to_arguments_deprecated(): + args = {"unmounted_arg": String(required=False, deprecation_reason="deprecated")} + + my_args = to_arguments(args) + assert my_args == { + "unmounted_arg": Argument( + String, required=False, deprecation_reason="deprecated" + ), + } + + +def test_to_arguments_required_deprecated(): + args = { + "unmounted_arg": String( + required=True, name="arg", deprecation_reason="deprecated" + ) + } + + with raises(AssertionError) as exc_info: + to_arguments(args) + + assert str(exc_info.value) == "Argument arg is required, cannot deprecate it." + + def test_to_arguments_raises_if_field(): args = {"arg_string": Field(String)} diff --git a/graphene/types/tests/test_field.py b/graphene/types/tests/test_field.py index 669ada4f8..f0401bfae 100644 --- a/graphene/types/tests/test_field.py +++ b/graphene/types/tests/test_field.py @@ -128,13 +128,20 @@ def test_field_name_as_argument(): def test_field_source_argument_as_kw(): MyType = object() - field = Field(MyType, b=NonNull(True), c=Argument(None), a=NonNull(False)) + deprecation_reason = "deprecated" + field = Field( + MyType, + b=NonNull(True), + c=Argument(None, deprecation_reason=deprecation_reason), + a=NonNull(False), + ) assert list(field.args) == ["b", "c", "a"] assert isinstance(field.args["b"], Argument) assert isinstance(field.args["b"].type, NonNull) assert field.args["b"].type.of_type is True assert isinstance(field.args["c"], Argument) assert field.args["c"].type is None + assert field.args["c"].deprecation_reason == deprecation_reason assert isinstance(field.args["a"], Argument) assert isinstance(field.args["a"].type, NonNull) assert field.args["a"].type.of_type is False diff --git a/graphene/types/tests/test_inputfield.py b/graphene/types/tests/test_inputfield.py index bfedfb057..9b1001286 100644 --- a/graphene/types/tests/test_inputfield.py +++ b/graphene/types/tests/test_inputfield.py @@ -1,5 +1,7 @@ from functools import partial +from pytest import raises + from ..inputfield import InputField from ..structures import NonNull from .utils import MyLazyType @@ -12,6 +14,22 @@ def test_inputfield_required(): assert field.type.of_type == MyType +def test_inputfield_deprecated(): + MyType = object() + deprecation_reason = "deprecated" + field = InputField(MyType, required=False, deprecation_reason=deprecation_reason) + assert isinstance(field.type, type(MyType)) + assert field.deprecation_reason == deprecation_reason + + +def test_inputfield_required_deprecated(): + MyType = object() + with raises(AssertionError) as exc_info: + InputField(MyType, name="input", required=True, deprecation_reason="deprecated") + + assert str(exc_info.value) == "InputField input is required, cannot deprecate it." + + def test_inputfield_with_lazy_type(): MyType = object() field = InputField(lambda: MyType) From 340d5ed12f7e736ca6ca6fd82c9ec4abdc635d4a Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Sun, 11 Dec 2022 21:05:25 +0100 Subject: [PATCH 26/79] release: 3.2.1 --- graphene/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene/__init__.py b/graphene/__init__.py index 8aebbf1d7..b901506ec 100644 --- a/graphene/__init__.py +++ b/graphene/__init__.py @@ -46,7 +46,7 @@ from .utils.module_loading import lazy_import from .utils.resolve_only_args import resolve_only_args -VERSION = (3, 2, 0, "final", 0) +VERSION = (3, 2, 1, "final", 0) __version__ = get_version(VERSION) From 8eb2807ce570389b28aae8b713a0f3b1e97d96b0 Mon Sep 17 00:00:00 2001 From: Pei-Lun H Date: Fri, 23 Dec 2022 14:57:45 +0800 Subject: [PATCH 27/79] docs: Correct the module name of custom scalar example in documentation (#1486) --- docs/types/scalars.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/types/scalars.rst b/docs/types/scalars.rst index f47fffea6..2a55245d2 100644 --- a/docs/types/scalars.rst +++ b/docs/types/scalars.rst @@ -271,7 +271,7 @@ The following is an example for creating a DateTime scalar: @staticmethod def parse_literal(node, _variables=None): - if isinstance(node, ast.StringValue): + if isinstance(node, ast.StringValueNode): return datetime.datetime.strptime( node.value, "%Y-%m-%dT%H:%M:%S.%f") From 52143473efad141f6700237ecce79b22e8ff4e41 Mon Sep 17 00:00:00 2001 From: Peder Johnsen Date: Sun, 25 Dec 2022 21:59:05 +0000 Subject: [PATCH 28/79] docs: Remove prerelease notice (#1487) --- docs/index.rst | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 54f1f99c3..859057885 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,12 +1,6 @@ Graphene ======== ------------- - -The documentation below is for the ``dev`` (prerelease) version of Graphene. To view the documentation for the latest stable Graphene version go to the `v2 docs `_. - ------------- - Contents: .. toctree:: From 8b89afeff136ef29300d38375aad838d1b94a4eb Mon Sep 17 00:00:00 2001 From: QuentinN42 <32516498+QuentinN42@users.noreply.github.com> Date: Tue, 28 Feb 2023 13:21:45 +0100 Subject: [PATCH 29/79] docs: update sphinx to the latest version (#1497) --- README.rst | 3 --- docs/api/index.rst | 2 +- docs/conf.py | 3 +-- docs/execution/queryvalidation.rst | 8 ++++---- docs/index.rst | 1 - docs/requirements.txt | 4 ++-- 6 files changed, 8 insertions(+), 13 deletions(-) diff --git a/README.rst b/README.rst index a38b9376e..405a8f44a 100644 --- a/README.rst +++ b/README.rst @@ -36,9 +36,6 @@ Graphene has multiple integrations with different frameworks: | SQLAlchemy | `graphene-sqlalchemy `__ | +-------------------+-------------------------------------------------+ -| Google App Engine | `graphene-gae `__ | -+-------------------+-------------------------------------------------+ Also, Graphene is fully compatible with the GraphQL spec, working seamlessly with all GraphQL clients, such as diff --git a/docs/api/index.rst b/docs/api/index.rst index c5e3b6e1c..38b74909e 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -92,7 +92,7 @@ Execution Metadata .. autoclass:: graphene.Context -.. autoclass:: graphql.execution.base.ExecutionResult +.. autoclass:: graphql.ExecutionResult .. Relay .. ----- diff --git a/docs/conf.py b/docs/conf.py index 0166d4c26..75f515416 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -82,7 +82,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: @@ -456,5 +456,4 @@ "http://docs.graphene-python.org/projects/sqlalchemy/en/latest/", None, ), - "graphene_gae": ("http://docs.graphene-python.org/projects/gae/en/latest/", None), } diff --git a/docs/execution/queryvalidation.rst b/docs/execution/queryvalidation.rst index 9c24a2e38..02e29a350 100644 --- a/docs/execution/queryvalidation.rst +++ b/docs/execution/queryvalidation.rst @@ -1,5 +1,5 @@ Query Validation -========== +================ GraphQL uses query validators to check if Query AST is valid and can be executed. Every GraphQL server implements standard query validators. For example, there is an validator that tests if queried field exists on queried type, that makes query fail with "Cannot query field on type" error if it doesn't. @@ -8,7 +8,7 @@ To help with common use cases, graphene provides a few validation rules out of t Depth limit Validator ------------------ +--------------------- The depth limit validator helps to prevent execution of malicious queries. It takes in the following arguments. @@ -17,7 +17,7 @@ queries. It takes in the following arguments. - ``callback`` Called each time validation runs. Receives an Object which is a map of the depths for each operation. Usage -------- +----- Here is how you would implement depth-limiting on your schema. @@ -54,7 +54,7 @@ the disable introspection validation rule ensures that your schema cannot be int This is a useful security measure in production environments. Usage -------- +----- Here is how you would disable introspection for your schema. diff --git a/docs/index.rst b/docs/index.rst index 859057885..05b7fd87e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,7 +21,6 @@ Integrations * `Graphene-Django `_ (`source `_) * Flask-Graphql (`source `_) * `Graphene-SQLAlchemy `_ (`source `_) -* `Graphene-GAE `_ (`source `_) * `Graphene-Mongo `_ (`source `_) * `Starlette `_ (`source `_) * `FastAPI `_ (`source `_) diff --git a/docs/requirements.txt b/docs/requirements.txt index dcc403123..dee009c70 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ # Required library -Sphinx==1.5.3 -sphinx-autobuild==0.7.1 +Sphinx==6.1.3 +sphinx-autobuild==2021.3.14 # Docs template http://graphene-python.org/sphinx_graphene_theme.zip From 969a630541606eab947b4b842730dc02bd691349 Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Fri, 3 Mar 2023 17:35:05 +0100 Subject: [PATCH 30/79] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0456f888a..09fadbab4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# ![Graphene Logo](http://graphene-python.org/favicon.png) [Graphene](http://graphene-python.org) [![Build Status](https://travis-ci.org/graphql-python/graphene.svg?branch=master)](https://travis-ci.org/graphql-python/graphene) [![PyPI version](https://badge.fury.io/py/graphene.svg)](https://badge.fury.io/py/graphene) [![Coverage Status](https://coveralls.io/repos/graphql-python/graphene/badge.svg?branch=master&service=github)](https://coveralls.io/github/graphql-python/graphene?branch=master) +# ![Graphene Logo](http://graphene-python.org/favicon.png) [Graphene](http://graphene-python.org) [![Build Status](https://travis-ci.org/graphql-python/graphene.svg?branch=master)](https://travis-ci.org/graphql-python/graphene) [![PyPI version](https://badge.fury.io/py/graphene.svg)](https://badge.fury.io/py/graphene) [![Coverage Status](https://coveralls.io/repos/graphql-python/graphene/badge.svg?branch=master&service=github)](https://coveralls.io/github/graphql-python/graphene?branch=master) [![](https://dcbadge.vercel.app/api/server/T6Gp6NFYHe)](https://discord.gg/T6Gp6NFYHe) -[💬 Join the community on Slack](https://join.slack.com/t/graphenetools/shared_invite/enQtOTE2MDQ1NTg4MDM1LTA4Nzk0MGU0NGEwNzUxZGNjNDQ4ZjAwNDJjMjY0OGE1ZDgxZTg4YjM2ZTc4MjE2ZTAzZjE2ZThhZTQzZTkyMmM) +[💬 Join the community on Discord](https://discord.gg/T6Gp6NFYHe) **We are looking for contributors**! Please check the current issues to see how you can help ❤️ From 81e7eee5da4411778200e7d6cb85af4502b29f25 Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Fri, 3 Mar 2023 17:35:46 +0100 Subject: [PATCH 31/79] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 09fadbab4..3ba0737d0 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ![Graphene Logo](http://graphene-python.org/favicon.png) [Graphene](http://graphene-python.org) [![Build Status](https://travis-ci.org/graphql-python/graphene.svg?branch=master)](https://travis-ci.org/graphql-python/graphene) [![PyPI version](https://badge.fury.io/py/graphene.svg)](https://badge.fury.io/py/graphene) [![Coverage Status](https://coveralls.io/repos/graphql-python/graphene/badge.svg?branch=master&service=github)](https://coveralls.io/github/graphql-python/graphene?branch=master) [![](https://dcbadge.vercel.app/api/server/T6Gp6NFYHe)](https://discord.gg/T6Gp6NFYHe) +# ![Graphene Logo](http://graphene-python.org/favicon.png) [Graphene](http://graphene-python.org) [![Build Status](https://travis-ci.org/graphql-python/graphene.svg?branch=master)](https://travis-ci.org/graphql-python/graphene) [![PyPI version](https://badge.fury.io/py/graphene.svg)](https://badge.fury.io/py/graphene) [![Coverage Status](https://coveralls.io/repos/graphql-python/graphene/badge.svg?branch=master&service=github)](https://coveralls.io/github/graphql-python/graphene?branch=master) [![](https://dcbadge.vercel.app/api/server/T6Gp6NFYHe?style=flat)](https://discord.gg/T6Gp6NFYHe) [💬 Join the community on Discord](https://discord.gg/T6Gp6NFYHe) From b76e89c0c2a0e21f69cf6348ecfe8507907b52dc Mon Sep 17 00:00:00 2001 From: Roman Solomatin <36135455+Samoed@users.noreply.github.com> Date: Thu, 9 Mar 2023 14:09:15 +0500 Subject: [PATCH 32/79] docs: remove unpair bracket (#1500) --- docs/execution/dataloader.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/execution/dataloader.rst b/docs/execution/dataloader.rst index 618909512..557db2c13 100644 --- a/docs/execution/dataloader.rst +++ b/docs/execution/dataloader.rst @@ -36,10 +36,10 @@ and then call your batch function with all requested keys. user_loader = UserLoader() user1 = await user_loader.load(1) - user1_best_friend = await user_loader.load(user1.best_friend_id)) + user1_best_friend = await user_loader.load(user1.best_friend_id) user2 = await user_loader.load(2) - user2_best_friend = await user_loader.load(user2.best_friend_id)) + user2_best_friend = await user_loader.load(user2.best_friend_id) A naive application may have issued *four* round-trips to a backend for the From d33e38a391ee99ae48a1f13d26915634a79b3447 Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Mon, 13 Mar 2023 21:23:28 +0100 Subject: [PATCH 33/79] chore: make relay type fields extendable (#1499) --- graphene/relay/connection.py | 69 ++++++++------ graphene/relay/tests/test_connection.py | 115 +++++++++++++++++++++++- 2 files changed, 155 insertions(+), 29 deletions(-) diff --git a/graphene/relay/connection.py b/graphene/relay/connection.py index 1a4684e56..ea4973676 100644 --- a/graphene/relay/connection.py +++ b/graphene/relay/connection.py @@ -1,6 +1,7 @@ import re from collections.abc import Iterable from functools import partial +from typing import Type from graphql_relay import connection_from_array @@ -8,7 +9,28 @@ from ..types.field import Field from ..types.objecttype import ObjectType, ObjectTypeOptions from ..utils.thenables import maybe_thenable -from .node import is_node +from .node import is_node, AbstractNode + + +def get_edge_class( + connection_class: Type["Connection"], _node: Type[AbstractNode], base_name: str +): + edge_class = getattr(connection_class, "Edge", None) + + class EdgeBase: + node = Field(_node, description="The item at the end of the edge") + cursor = String(required=True, description="A cursor for use in pagination") + + class EdgeMeta: + description = f"A Relay edge containing a `{base_name}` and its cursor." + + edge_name = f"{base_name}Edge" + + edge_bases = [edge_class, EdgeBase] if edge_class else [EdgeBase] + if not isinstance(edge_class, ObjectType): + edge_bases = [*edge_bases, ObjectType] + + return type(edge_name, tuple(edge_bases), {"Meta": EdgeMeta}) class PageInfo(ObjectType): @@ -61,8 +83,9 @@ class Meta: abstract = True @classmethod - def __init_subclass_with_meta__(cls, node=None, name=None, **options): - _meta = ConnectionOptions(cls) + def __init_subclass_with_meta__(cls, node=None, name=None, _meta=None, **options): + if not _meta: + _meta = ConnectionOptions(cls) assert node, f"You have to provide a node in {cls.__name__}.Meta" assert isinstance(node, NonNull) or issubclass( node, (Scalar, Enum, ObjectType, Interface, Union, NonNull) @@ -72,39 +95,29 @@ def __init_subclass_with_meta__(cls, node=None, name=None, **options): if not name: name = f"{base_name}Connection" - edge_class = getattr(cls, "Edge", None) - _node = node - - class EdgeBase: - node = Field(_node, description="The item at the end of the edge") - cursor = String(required=True, description="A cursor for use in pagination") - - class EdgeMeta: - description = f"A Relay edge containing a `{base_name}` and its cursor." + options["name"] = name - edge_name = f"{base_name}Edge" - if edge_class: - edge_bases = (edge_class, EdgeBase, ObjectType) - else: - edge_bases = (EdgeBase, ObjectType) + _meta.node = node - edge = type(edge_name, edge_bases, {"Meta": EdgeMeta}) - cls.Edge = edge + if not _meta.fields: + _meta.fields = {} - options["name"] = name - _meta.node = node - _meta.fields = { - "page_info": Field( + if "page_info" not in _meta.fields: + _meta.fields["page_info"] = Field( PageInfo, name="pageInfo", required=True, description="Pagination data for this connection.", - ), - "edges": Field( - NonNull(List(edge)), + ) + + if "edges" not in _meta.fields: + edge_class = get_edge_class(cls, node, base_name) # type: ignore + cls.Edge = edge_class + _meta.fields["edges"] = Field( + NonNull(List(edge_class)), description="Contains the nodes in this connection.", - ), - } + ) + return super(Connection, cls).__init_subclass_with_meta__( _meta=_meta, **options ) diff --git a/graphene/relay/tests/test_connection.py b/graphene/relay/tests/test_connection.py index 4015f4b43..d45eea960 100644 --- a/graphene/relay/tests/test_connection.py +++ b/graphene/relay/tests/test_connection.py @@ -1,7 +1,15 @@ +import re + from pytest import raises from ...types import Argument, Field, Int, List, NonNull, ObjectType, Schema, String -from ..connection import Connection, ConnectionField, PageInfo +from ..connection import ( + Connection, + ConnectionField, + PageInfo, + ConnectionOptions, + get_edge_class, +) from ..node import Node @@ -51,6 +59,111 @@ class Meta: assert list(fields) == ["page_info", "edges", "extra"] +def test_connection_extra_abstract_fields(): + class ConnectionWithNodes(Connection): + class Meta: + abstract = True + + @classmethod + def __init_subclass_with_meta__(cls, node=None, name=None, **options): + _meta = ConnectionOptions(cls) + + _meta.fields = { + "nodes": Field( + NonNull(List(node)), + description="Contains all the nodes in this connection.", + ), + } + + return super(ConnectionWithNodes, cls).__init_subclass_with_meta__( + node=node, name=name, _meta=_meta, **options + ) + + class MyObjectConnection(ConnectionWithNodes): + class Meta: + node = MyObject + + class Edge: + other = String() + + assert MyObjectConnection._meta.name == "MyObjectConnection" + fields = MyObjectConnection._meta.fields + assert list(fields) == ["nodes", "page_info", "edges"] + edge_field = fields["edges"] + pageinfo_field = fields["page_info"] + nodes_field = fields["nodes"] + + assert isinstance(edge_field, Field) + assert isinstance(edge_field.type, NonNull) + assert isinstance(edge_field.type.of_type, List) + assert edge_field.type.of_type.of_type == MyObjectConnection.Edge + + assert isinstance(pageinfo_field, Field) + assert isinstance(pageinfo_field.type, NonNull) + assert pageinfo_field.type.of_type == PageInfo + + assert isinstance(nodes_field, Field) + assert isinstance(nodes_field.type, NonNull) + assert isinstance(nodes_field.type.of_type, List) + assert nodes_field.type.of_type.of_type == MyObject + + +def test_connection_override_fields(): + class ConnectionWithNodes(Connection): + class Meta: + abstract = True + + @classmethod + def __init_subclass_with_meta__(cls, node=None, name=None, **options): + _meta = ConnectionOptions(cls) + base_name = ( + re.sub("Connection$", "", name or cls.__name__) or node._meta.name + ) + + edge_class = get_edge_class(cls, node, base_name) + + _meta.fields = { + "page_info": Field( + NonNull( + PageInfo, + name="pageInfo", + required=True, + description="Pagination data for this connection.", + ) + ), + "edges": Field( + NonNull(List(NonNull(edge_class))), + description="Contains the nodes in this connection.", + ), + } + + return super(ConnectionWithNodes, cls).__init_subclass_with_meta__( + node=node, name=name, _meta=_meta, **options + ) + + class MyObjectConnection(ConnectionWithNodes): + class Meta: + node = MyObject + + assert MyObjectConnection._meta.name == "MyObjectConnection" + fields = MyObjectConnection._meta.fields + assert list(fields) == ["page_info", "edges"] + edge_field = fields["edges"] + pageinfo_field = fields["page_info"] + + assert isinstance(edge_field, Field) + assert isinstance(edge_field.type, NonNull) + assert isinstance(edge_field.type.of_type, List) + assert isinstance(edge_field.type.of_type.of_type, NonNull) + + assert edge_field.type.of_type.of_type.of_type.__name__ == "MyObjectEdge" + + # This page info is NonNull + assert isinstance(pageinfo_field, Field) + assert isinstance(edge_field.type, NonNull) + assert pageinfo_field.type.of_type == PageInfo + + def test_connection_name(): custom_name = "MyObjectCustomNameConnection" From 57cbef6666e2e466808cce21b8a1769ecd3fd118 Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Mon, 13 Mar 2023 21:24:16 +0100 Subject: [PATCH 34/79] release: 3.2.2 --- graphene/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene/__init__.py b/graphene/__init__.py index b901506ec..73e13a365 100644 --- a/graphene/__init__.py +++ b/graphene/__init__.py @@ -46,7 +46,7 @@ from .utils.module_loading import lazy_import from .utils.resolve_only_args import resolve_only_args -VERSION = (3, 2, 1, "final", 0) +VERSION = (3, 2, 2, "final", 0) __version__ = get_version(VERSION) From 8ede21e06381c096589c424960a6cfaca304badb Mon Sep 17 00:00:00 2001 From: Firas Kafri <3097061+firaskafri@users.noreply.github.com> Date: Thu, 25 May 2023 13:21:55 +0300 Subject: [PATCH 35/79] chore: default enum description to "An enumeration." (#1502) * Default enum description to "An enumeration." default to this string, which is used in many tests, is causing * Use the docstring descriptions of enums when they are present * Added tests * chore: add missing newline * Fix new line --------- Co-authored-by: Erik Wrede --- graphene/types/enum.py | 2 +- graphene/types/tests/test_enum.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/graphene/types/enum.py b/graphene/types/enum.py index 58e65c69e..7d68ccd48 100644 --- a/graphene/types/enum.py +++ b/graphene/types/enum.py @@ -63,7 +63,7 @@ def from_enum( cls, enum, name=None, description=None, deprecation_reason=None ): # noqa: N805 name = name or enum.__name__ - description = description or enum.__doc__ + description = description or enum.__doc__ or "An enumeration." meta_dict = { "enum": enum, "description": description, diff --git a/graphene/types/tests/test_enum.py b/graphene/types/tests/test_enum.py index 9b3082df1..e6fce66c9 100644 --- a/graphene/types/tests/test_enum.py +++ b/graphene/types/tests/test_enum.py @@ -65,6 +65,21 @@ def test_enum_from_builtin_enum(): assert RGB.BLUE +def test_enum_custom_description_in_constructor(): + description = "An enumeration, but with a custom description" + RGB = Enum( + "RGB", + "RED,GREEN,BLUE", + description=description, + ) + assert RGB._meta.description == description + + +def test_enum_from_python3_enum_uses_default_builtin_doc(): + RGB = Enum("RGB", "RED,GREEN,BLUE") + assert RGB._meta.description == "An enumeration." + + def test_enum_from_builtin_enum_accepts_lambda_description(): def custom_description(value): if not value: From 2da8e9db5cd6527ca740914ce0095e5004054dfd Mon Sep 17 00:00:00 2001 From: Cadu Date: Sun, 4 Jun 2023 18:01:05 -0300 Subject: [PATCH 36/79] feat: Enable use of Undefined in InputObjectTypes (#1506) * Changed InputObjectType's default builder-from-dict argument to be `Undefined` instead of `None`, removing ambiguity of undefined optional inputs using dot notation access syntax. * Move `set_default_input_object_type_to_undefined()` fixture into conftest.py for sharing it between multiple test files. --- graphene/types/inputobjecttype.py | 27 ++++++++++++++++- graphene/types/tests/conftest.py | 12 ++++++++ graphene/types/tests/test_inputobjecttype.py | 31 ++++++++++++++++++++ graphene/types/tests/test_type_map.py | 14 ++++++++- graphene/validation/depth_limit.py | 6 ++-- 5 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 graphene/types/tests/conftest.py diff --git a/graphene/types/inputobjecttype.py b/graphene/types/inputobjecttype.py index 5d2785105..fdf38ba05 100644 --- a/graphene/types/inputobjecttype.py +++ b/graphene/types/inputobjecttype.py @@ -14,6 +14,31 @@ class InputObjectTypeOptions(BaseOptions): container = None # type: InputObjectTypeContainer +# Currently in Graphene, we get a `None` whenever we access an (optional) field that was not set in an InputObjectType +# using the InputObjectType. dot access syntax. This is ambiguous, because in this current (Graphene +# historical) arrangement, we cannot distinguish between a field not being set and a field being set to None. +# At the same time, we shouldn't break existing code that expects a `None` when accessing a field that was not set. +_INPUT_OBJECT_TYPE_DEFAULT_VALUE = None + +# To mitigate this, we provide the function `set_input_object_type_default_value` to allow users to change the default +# value returned in non-specified fields in InputObjectType to another meaningful sentinel value (e.g. Undefined) +# if they want to. This way, we can keep code that expects a `None` working while we figure out a better solution (or +# a well-documented breaking change) for this issue. + + +def set_input_object_type_default_value(default_value): + """ + Change the sentinel value returned by non-specified fields in an InputObjectType + Useful to differentiate between a field not being set and a field being set to None by using a sentinel value + (e.g. Undefined is a good sentinel value for this purpose) + + This function should be called at the beginning of the app or in some other place where it is guaranteed to + be called before any InputObjectType is defined. + """ + global _INPUT_OBJECT_TYPE_DEFAULT_VALUE + _INPUT_OBJECT_TYPE_DEFAULT_VALUE = default_value + + class InputObjectTypeContainer(dict, BaseType): # type: ignore class Meta: abstract = True @@ -21,7 +46,7 @@ class Meta: def __init__(self, *args, **kwargs): dict.__init__(self, *args, **kwargs) for key in self._meta.fields: - setattr(self, key, self.get(key, None)) + setattr(self, key, self.get(key, _INPUT_OBJECT_TYPE_DEFAULT_VALUE)) def __init_subclass__(cls, *args, **kwargs): pass diff --git a/graphene/types/tests/conftest.py b/graphene/types/tests/conftest.py new file mode 100644 index 000000000..43f7d7268 --- /dev/null +++ b/graphene/types/tests/conftest.py @@ -0,0 +1,12 @@ +import pytest +from graphql import Undefined + +from graphene.types.inputobjecttype import set_input_object_type_default_value + + +@pytest.fixture() +def set_default_input_object_type_to_undefined(): + """This fixture is used to change the default value of optional inputs in InputObjectTypes for specific tests""" + set_input_object_type_default_value(Undefined) + yield + set_input_object_type_default_value(None) diff --git a/graphene/types/tests/test_inputobjecttype.py b/graphene/types/tests/test_inputobjecttype.py index 0fb7e3945..0d7bcf80c 100644 --- a/graphene/types/tests/test_inputobjecttype.py +++ b/graphene/types/tests/test_inputobjecttype.py @@ -1,3 +1,5 @@ +from graphql import Undefined + from ..argument import Argument from ..field import Field from ..inputfield import InputField @@ -6,6 +8,7 @@ from ..scalars import Boolean, String from ..schema import Schema from ..unmountedtype import UnmountedType +from ... import NonNull class MyType: @@ -136,3 +139,31 @@ def resolve_is_child(self, info, parent): assert not result.errors assert result.data == {"isChild": True} + + +def test_inputobjecttype_default_input_as_undefined( + set_default_input_object_type_to_undefined, +): + class TestUndefinedInput(InputObjectType): + required_field = String(required=True) + optional_field = String() + + class Query(ObjectType): + undefined_optionals_work = Field(NonNull(Boolean), input=TestUndefinedInput()) + + def resolve_undefined_optionals_work(self, info, input: TestUndefinedInput): + # Confirm that optional_field comes as Undefined + return ( + input.required_field == "required" and input.optional_field is Undefined + ) + + schema = Schema(query=Query) + result = schema.execute( + """query basequery { + undefinedOptionalsWork(input: {requiredField: "required"}) + } + """ + ) + + assert not result.errors + assert result.data == {"undefinedOptionalsWork": True} diff --git a/graphene/types/tests/test_type_map.py b/graphene/types/tests/test_type_map.py index 55b1706e0..55665b6b8 100644 --- a/graphene/types/tests/test_type_map.py +++ b/graphene/types/tests/test_type_map.py @@ -20,8 +20,8 @@ from ..interface import Interface from ..objecttype import ObjectType from ..scalars import Int, String -from ..structures import List, NonNull from ..schema import Schema +from ..structures import List, NonNull def create_type_map(types, auto_camelcase=True): @@ -227,6 +227,18 @@ def resolve_foo_bar(self, args, info): assert foo_field.description == "Field description" +def test_inputobject_undefined(set_default_input_object_type_to_undefined): + class OtherObjectType(InputObjectType): + optional_field = String() + + type_map = create_type_map([OtherObjectType]) + assert "OtherObjectType" in type_map + graphql_type = type_map["OtherObjectType"] + + container = graphql_type.out_type({}) + assert container.optional_field is Undefined + + def test_objecttype_camelcase(): class MyObjectType(ObjectType): """Description""" diff --git a/graphene/validation/depth_limit.py b/graphene/validation/depth_limit.py index b4599e660..e0f286634 100644 --- a/graphene/validation/depth_limit.py +++ b/graphene/validation/depth_limit.py @@ -30,7 +30,7 @@ except ImportError: # backwards compatibility for v3.6 from typing import Pattern -from typing import Callable, Dict, List, Optional, Union +from typing import Callable, Dict, List, Optional, Union, Tuple from graphql import GraphQLError from graphql.validation import ValidationContext, ValidationRule @@ -82,7 +82,7 @@ def __init__(self, validation_context: ValidationContext): def get_fragments( - definitions: List[DefinitionNode], + definitions: Tuple[DefinitionNode, ...], ) -> Dict[str, FragmentDefinitionNode]: fragments = {} for definition in definitions: @@ -94,7 +94,7 @@ def get_fragments( # This will actually get both queries and mutations. # We can basically treat those the same def get_queries_and_mutations( - definitions: List[DefinitionNode], + definitions: Tuple[DefinitionNode, ...], ) -> Dict[str, OperationDefinitionNode]: operations = {} From c636d984c646cf303303f4c5bdb35e5d27846436 Mon Sep 17 00:00:00 2001 From: senseysensor Date: Mon, 5 Jun 2023 00:10:05 +0300 Subject: [PATCH 37/79] fix: Corrected enum metaclass to fix pickle.dumps() (#1495) * Corrected enum metaclass to fix pickle.dumps() * considered case with colliding class names (try to distinguish by file name) * reverted simple solution back (without attempt to support duplicate Enum class names) --------- Co-authored-by: sgrekov Co-authored-by: Erik Wrede --- graphene/tests/issues/test_881.py | 27 +++++++++++++++++++++++++++ graphene/types/enum.py | 4 +++- 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 graphene/tests/issues/test_881.py diff --git a/graphene/tests/issues/test_881.py b/graphene/tests/issues/test_881.py new file mode 100644 index 000000000..f97b59176 --- /dev/null +++ b/graphene/tests/issues/test_881.py @@ -0,0 +1,27 @@ +import pickle + +from ...types.enum import Enum + + +class PickleEnum(Enum): + # is defined outside of test because pickle unable to dump class inside ot pytest function + A = "a" + B = 1 + + +def test_enums_pickling(): + a = PickleEnum.A + pickled = pickle.dumps(a) + restored = pickle.loads(pickled) + assert type(a) is type(restored) + assert a == restored + assert a.value == restored.value + assert a.name == restored.name + + b = PickleEnum.B + pickled = pickle.dumps(b) + restored = pickle.loads(pickled) + assert type(a) is type(restored) + assert b == restored + assert b.value == restored.value + assert b.name == restored.name diff --git a/graphene/types/enum.py b/graphene/types/enum.py index 7d68ccd48..d3469a15e 100644 --- a/graphene/types/enum.py +++ b/graphene/types/enum.py @@ -31,9 +31,11 @@ def __new__(cls, name_, bases, classdict, **options): # with the enum values. enum_members.pop("Meta", None) enum = PyEnum(cls.__name__, enum_members) - return SubclassWithMeta_Meta.__new__( + obj = SubclassWithMeta_Meta.__new__( cls, name_, bases, dict(classdict, __enum__=enum), **options ) + globals()[name_] = obj.__enum__ + return obj def get(cls, value): return cls._meta.enum(value) From d77d0b057137452d6d93067002fd7a2c56164e75 Mon Sep 17 00:00:00 2001 From: Jeongseok Kang Date: Mon, 5 Jun 2023 06:49:26 +0900 Subject: [PATCH 38/79] chore: Use `typing.TYPE_CHECKING` instead of MYPY (#1503) Co-authored-by: Erik Wrede --- graphene/types/inputobjecttype.py | 7 ++++--- graphene/types/interface.py | 7 ++++--- graphene/types/mutation.py | 7 ++++--- graphene/types/objecttype.py | 7 ++++--- graphene/types/union.py | 7 ++++--- 5 files changed, 20 insertions(+), 15 deletions(-) diff --git a/graphene/types/inputobjecttype.py b/graphene/types/inputobjecttype.py index fdf38ba05..257f48bef 100644 --- a/graphene/types/inputobjecttype.py +++ b/graphene/types/inputobjecttype.py @@ -1,11 +1,12 @@ +from typing import TYPE_CHECKING + from .base import BaseOptions, BaseType from .inputfield import InputField from .unmountedtype import UnmountedType from .utils import yank_fields_from_attrs -# For static type checking with Mypy -MYPY = False -if MYPY: +# For static type checking with type checker +if TYPE_CHECKING: from typing import Dict, Callable # NOQA diff --git a/graphene/types/interface.py b/graphene/types/interface.py index 6503b78b3..31bcc7f97 100644 --- a/graphene/types/interface.py +++ b/graphene/types/interface.py @@ -1,10 +1,11 @@ +from typing import TYPE_CHECKING + from .base import BaseOptions, BaseType from .field import Field from .utils import yank_fields_from_attrs -# For static type checking with Mypy -MYPY = False -if MYPY: +# For static type checking with type checker +if TYPE_CHECKING: from typing import Dict, Iterable, Type # NOQA diff --git a/graphene/types/mutation.py b/graphene/types/mutation.py index ad47c62a7..2de21b367 100644 --- a/graphene/types/mutation.py +++ b/graphene/types/mutation.py @@ -1,3 +1,5 @@ +from typing import TYPE_CHECKING + from ..utils.deprecated import warn_deprecation from ..utils.get_unbound_function import get_unbound_function from ..utils.props import props @@ -6,9 +8,8 @@ from .utils import yank_fields_from_attrs from .interface import Interface -# For static type checking with Mypy -MYPY = False -if MYPY: +# For static type checking with type checker +if TYPE_CHECKING: from .argument import Argument # NOQA from typing import Dict, Type, Callable, Iterable # NOQA diff --git a/graphene/types/objecttype.py b/graphene/types/objecttype.py index 1ff29a2e4..b3b829fe4 100644 --- a/graphene/types/objecttype.py +++ b/graphene/types/objecttype.py @@ -1,3 +1,5 @@ +from typing import TYPE_CHECKING + from .base import BaseOptions, BaseType, BaseTypeMeta from .field import Field from .interface import Interface @@ -7,9 +9,8 @@ from dataclasses import make_dataclass, field except ImportError: from ..pyutils.dataclasses import make_dataclass, field # type: ignore -# For static type checking with Mypy -MYPY = False -if MYPY: +# For static type checking with type checker +if TYPE_CHECKING: from typing import Dict, Iterable, Type # NOQA diff --git a/graphene/types/union.py b/graphene/types/union.py index f77e833ab..b7c5dc627 100644 --- a/graphene/types/union.py +++ b/graphene/types/union.py @@ -1,9 +1,10 @@ +from typing import TYPE_CHECKING + from .base import BaseOptions, BaseType from .unmountedtype import UnmountedType -# For static type checking with Mypy -MYPY = False -if MYPY: +# For static type checking with type checker +if TYPE_CHECKING: from .objecttype import ObjectType # NOQA from typing import Iterable, Type # NOQA From 03cf2e131e655402ccc0a9e2d9897c39d7f7f86a Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Tue, 6 Jun 2023 20:45:01 +0200 Subject: [PATCH 39/79] chore: remove travis ci link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3ba0737d0..7beb975c2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ![Graphene Logo](http://graphene-python.org/favicon.png) [Graphene](http://graphene-python.org) [![Build Status](https://travis-ci.org/graphql-python/graphene.svg?branch=master)](https://travis-ci.org/graphql-python/graphene) [![PyPI version](https://badge.fury.io/py/graphene.svg)](https://badge.fury.io/py/graphene) [![Coverage Status](https://coveralls.io/repos/graphql-python/graphene/badge.svg?branch=master&service=github)](https://coveralls.io/github/graphql-python/graphene?branch=master) [![](https://dcbadge.vercel.app/api/server/T6Gp6NFYHe?style=flat)](https://discord.gg/T6Gp6NFYHe) +# ![Graphene Logo](http://graphene-python.org/favicon.png) [Graphene](http://graphene-python.org) [![PyPI version](https://badge.fury.io/py/graphene.svg)](https://badge.fury.io/py/graphene) [![Coverage Status](https://coveralls.io/repos/graphql-python/graphene/badge.svg?branch=master&service=github)](https://coveralls.io/github/graphql-python/graphene?branch=master) [![](https://dcbadge.vercel.app/api/server/T6Gp6NFYHe?style=flat)](https://discord.gg/T6Gp6NFYHe) [💬 Join the community on Discord](https://discord.gg/T6Gp6NFYHe) From 99f0103e37ab5846c3d8678ff5ae17a04572266f Mon Sep 17 00:00:00 2001 From: Ransom Williams <3261168+ransomw@users.noreply.github.com> Date: Wed, 19 Jul 2023 02:00:30 -0500 Subject: [PATCH 40/79] test: print schema with InputObjectType with DateTime field with default_value (#1293) (#1513) * test [1293]: regression test print schema with InputObjectType with DateTime field with default_value * chore: clarify test title and assertion --------- Co-authored-by: Erik Wrede --- graphene/tests/issues/test_1293.py | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 graphene/tests/issues/test_1293.py diff --git a/graphene/tests/issues/test_1293.py b/graphene/tests/issues/test_1293.py new file mode 100644 index 000000000..20bcde958 --- /dev/null +++ b/graphene/tests/issues/test_1293.py @@ -0,0 +1,41 @@ +# https://github.com/graphql-python/graphene/issues/1293 + +import datetime + +import graphene +from graphql.utilities import print_schema + + +class Filters(graphene.InputObjectType): + datetime_after = graphene.DateTime( + required=False, + default_value=datetime.datetime.utcfromtimestamp(1434549820776 / 1000), + ) + datetime_before = graphene.DateTime( + required=False, + default_value=datetime.datetime.utcfromtimestamp(1444549820776 / 1000), + ) + + +class SetDatetime(graphene.Mutation): + class Arguments: + filters = Filters(required=True) + + ok = graphene.Boolean() + + def mutate(root, info, filters): + return SetDatetime(ok=True) + + +class Query(graphene.ObjectType): + goodbye = graphene.String() + + +class Mutations(graphene.ObjectType): + set_datetime = SetDatetime.Field() + + +def test_schema_printable_with_default_datetime_value(): + schema = graphene.Schema(query=Query, mutation=Mutations) + schema_str = print_schema(schema.graphql_schema) + assert schema_str, "empty schema printed" From 74db349da4fb72268b309b674361ffbd9e8885e0 Mon Sep 17 00:00:00 2001 From: Naoya Yamashita Date: Wed, 19 Jul 2023 16:01:00 +0900 Subject: [PATCH 41/79] docs: add get_human function (#1380) Co-authored-by: Erik Wrede --- docs/types/objecttypes.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/types/objecttypes.rst b/docs/types/objecttypes.rst index 3cc8d8302..f142d4a69 100644 --- a/docs/types/objecttypes.rst +++ b/docs/types/objecttypes.rst @@ -80,6 +80,10 @@ If we have a schema with Person type and one field on the root query. from graphene import ObjectType, String, Field + def get_human(name): + first_name, last_name = name.split() + return Person(first_name, last_name) + class Person(ObjectType): full_name = String() From 6b8cd2dc780c503ca7742314900f19d91c164d97 Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Wed, 19 Jul 2023 15:08:19 +0800 Subject: [PATCH 42/79] ci: drop python 3.6 (#1507) Co-authored-by: Erik Wrede --- .github/workflows/tests.yml | 3 +-- tox.ini | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6635a35bd..5162d051f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,12 +25,11 @@ jobs: fail-fast: false matrix: include: - - {name: '3.11', python: '3.11-dev', os: ubuntu-latest, tox: py311} + - {name: '3.11', python: '3.11', os: ubuntu-latest, tox: py311} - {name: '3.10', python: '3.10', os: ubuntu-latest, tox: py310} - {name: '3.9', python: '3.9', os: ubuntu-latest, tox: py39} - {name: '3.8', python: '3.8', os: ubuntu-latest, tox: py38} - {name: '3.7', python: '3.7', os: ubuntu-latest, tox: py37} - - {name: '3.6', python: '3.6', os: ubuntu-20.04, tox: py36} steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 diff --git a/tox.ini b/tox.ini index 65fceadd8..872d528c6 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py3{6,7,8,9,10}, mypy, pre-commit +envlist = py3{7,8,9,10,11}, mypy, pre-commit skipsdist = true [testenv] @@ -8,7 +8,7 @@ deps = setenv = PYTHONPATH = .:{envdir} commands = - py{36,37,38,39,310}: pytest --cov=graphene graphene --cov-report=term --cov-report=xml examples {posargs} + py{37,38,39,310,311}: pytest --cov=graphene graphene --cov-report=term --cov-report=xml examples {posargs} [testenv:pre-commit] basepython = python3.10 From ea7ccc350effbf2a6009e0f2c4840aa6181195d4 Mon Sep 17 00:00:00 2001 From: "garo (they/them)" <3411715+shrouxm@users.noreply.github.com> Date: Tue, 25 Jul 2023 23:25:57 -0700 Subject: [PATCH 43/79] feat(relay): add option for strict connection types (#1504) * types: add option for strict connection types * chore: appease linter * chore: appease linter * test: add test --------- Co-authored-by: Erik Wrede --- graphene/relay/connection.py | 18 +++++++++++++----- graphene/relay/tests/test_connection.py | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/graphene/relay/connection.py b/graphene/relay/connection.py index ea4973676..cc7d2da08 100644 --- a/graphene/relay/connection.py +++ b/graphene/relay/connection.py @@ -13,12 +13,18 @@ def get_edge_class( - connection_class: Type["Connection"], _node: Type[AbstractNode], base_name: str + connection_class: Type["Connection"], + _node: Type[AbstractNode], + base_name: str, + strict_types: bool = False, ): edge_class = getattr(connection_class, "Edge", None) class EdgeBase: - node = Field(_node, description="The item at the end of the edge") + node = Field( + NonNull(_node) if strict_types else _node, + description="The item at the end of the edge", + ) cursor = String(required=True, description="A cursor for use in pagination") class EdgeMeta: @@ -83,7 +89,9 @@ class Meta: abstract = True @classmethod - def __init_subclass_with_meta__(cls, node=None, name=None, _meta=None, **options): + def __init_subclass_with_meta__( + cls, node=None, name=None, strict_types=False, _meta=None, **options + ): if not _meta: _meta = ConnectionOptions(cls) assert node, f"You have to provide a node in {cls.__name__}.Meta" @@ -111,10 +119,10 @@ def __init_subclass_with_meta__(cls, node=None, name=None, _meta=None, **options ) if "edges" not in _meta.fields: - edge_class = get_edge_class(cls, node, base_name) # type: ignore + edge_class = get_edge_class(cls, node, base_name, strict_types) # type: ignore cls.Edge = edge_class _meta.fields["edges"] = Field( - NonNull(List(edge_class)), + NonNull(List(NonNull(edge_class) if strict_types else edge_class)), description="Contains the nodes in this connection.", ) diff --git a/graphene/relay/tests/test_connection.py b/graphene/relay/tests/test_connection.py index d45eea960..9c8b89d13 100644 --- a/graphene/relay/tests/test_connection.py +++ b/graphene/relay/tests/test_connection.py @@ -299,3 +299,20 @@ def resolve_test_connection(root, info, **args): executed = schema.execute("{ testConnection { edges { cursor } } }") assert not executed.errors assert executed.data == {"testConnection": {"edges": []}} + + +def test_connectionfield_strict_types(): + class MyObjectConnection(Connection): + class Meta: + node = MyObject + strict_types = True + + connection_field = ConnectionField(MyObjectConnection) + edges_field_type = connection_field.type._meta.fields["edges"].type + assert isinstance(edges_field_type, NonNull) + + edges_list_element_type = edges_field_type.of_type.of_type + assert isinstance(edges_list_element_type, NonNull) + + node_field = edges_list_element_type.of_type._meta.fields["node"] + assert isinstance(node_field.type, NonNull) From f5aba2c027df90e650accf136f3105eb601216c6 Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Wed, 26 Jul 2023 08:26:30 +0200 Subject: [PATCH 44/79] release: 3.3.0 --- graphene/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene/__init__.py b/graphene/__init__.py index 73e13a365..72935dec0 100644 --- a/graphene/__init__.py +++ b/graphene/__init__.py @@ -46,7 +46,7 @@ from .utils.module_loading import lazy_import from .utils.resolve_only_args import resolve_only_args -VERSION = (3, 2, 2, "final", 0) +VERSION = (3, 3, 0, "final", 0) __version__ = get_version(VERSION) From 93cb33d359bf2109d1b81eaeaf052cdb06f93f49 Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Wed, 26 Jul 2023 09:43:40 +0200 Subject: [PATCH 45/79] housekeeping: delete outdated ROADMAP.md --- ROADMAP.md | 54 ------------------------------------------------------ 1 file changed, 54 deletions(-) delete mode 100644 ROADMAP.md diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index 2cdb09d68..000000000 --- a/ROADMAP.md +++ /dev/null @@ -1,54 +0,0 @@ -# GraphQL Python Roadmap - -In order to move Graphene and the GraphQL Python ecosystem forward it's essential to be clear with the community on next steps, so we can move uniformly. - -_👋 If you have more ideas on how to move the Graphene ecosystem forward, don't hesistate to [open a PR](https://github.com/graphql-python/graphene/edit/master/ROADMAP.md)_ - - -## Now -- [ ] Continue to support v2.x with security releases -- [ ] Last major/feature release is cut and graphene-* libraries should pin to that version number - -## Next -New features will only be developed on version 3 of ecosystem libraries. - -### [Core-Next](https://github.com/graphql-python/graphql-core-next) -Targeted as v3 of [graphql-core](https://pypi.org/project/graphql-core/), Python 3 only - -### Graphene -- [ ] Integrate with the core-next API and resolve all breaking changes -- [ ] GraphQL types from type annotations - [See issue](https://github.com/graphql-python/graphene/issues/729) -- [ ] Add support for coroutines in Connection, Mutation (abstracting out Promise requirement) - [See PR](https://github.com/graphql-python/graphene/pull/824) - -### Graphene-* -- [ ] Integrate with the graphene core-next API and resolve all breaking changes - -### *-graphql -- [ ] Integrate with the graphql core-next API and resolve all breaking changes - -## Ongoing Initiatives -- [ ] Improve documentation, especially for new users to the library -- [ ] Recipes for “quick start” that people can ideally use/run - - -## Dependent Libraries -| Repo | Release Manager | CODEOWNERS | Pinned | next/master created | Labels Standardized | -| ---------------------------------------------------------------------------- | --------------- | ---------- | ---------- | ------------------- | ------------------- | -| [graphene](https://github.com/graphql-python/graphene) | ekampf | ✅ | | ✅ | | -| [graphql-core](https://github.com/graphql-python/graphql-core) | Cito | ✅ | N/A | N/A | | -| [graphql-core-next](https://github.com/graphql-python/graphql-core-next) | Cito | ✅ | N/A | N/A | | -| [graphql-server-core](https://github.com/graphql-python/graphql-server-core) | Cito | | ✅ | ✅ | | -| [gql](https://github.com/graphql-python/gql) | ekampf | | | | | -| [gql-next](https://github.com/graphql-python/gql-next) | ekampf | | N/A | N/A | | -| ...[aiohttp](https://github.com/graphql-python/aiohttp-graphql) | | | | | | -| ...[django](https://github.com/graphql-python/graphene-django) | mvanlonden | | ✅ | ✅ | | -| ...[sanic](https://github.com/graphql-python/sanic-graphql) | ekampf | | | | | -| ...[flask](https://github.com/graphql-python/flask-graphql) | | | | | | -| ...[webob](https://github.com/graphql-python/webob-graphql) | | | | | | -| ...[tornado](https://github.com/graphql-python/graphene-tornado) | ewhauser | | PR created | ✅ | | -| ...[ws](https://github.com/graphql-python/graphql-ws) | Cito/dfee | | ✅ | ✅ | | -| ...[gae](https://github.com/graphql-python/graphene-gae) | ekampf | | PR created | ✅ | | -| ...[sqlalchemy](https://github.com/graphql-python/graphene-sqlalchemy) | jnak/Nabell | ✅ | ✅ | ✅ | | -| ...[mongo](https://github.com/graphql-python/graphene-mongo) | | | ✅ | ✅ | | -| ...[relay-py](https://github.com/graphql-python/graphql-relay-py) | Cito | | | | | -| ...[wsgi](https://github.com/moritzmhmk/wsgi-graphql) | | | | | | From baaef0d21ac2067d14ee781e4aa96808bfff508b Mon Sep 17 00:00:00 2001 From: wongcht Date: Wed, 30 Aug 2023 22:41:17 +0100 Subject: [PATCH 46/79] chore: remove pytz (#1520) --- .isort.cfg | 2 +- graphene/types/tests/test_datetime.py | 3 +-- setup.py | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.isort.cfg b/.isort.cfg index 76c6f842f..02dbdee4e 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,2 +1,2 @@ [settings] -known_third_party = aniso8601,graphql,graphql_relay,promise,pytest,pytz,pyutils,setuptools,snapshottest,sphinx_graphene_theme +known_third_party = aniso8601,graphql,graphql_relay,promise,pytest,pyutils,setuptools,snapshottest,sphinx_graphene_theme diff --git a/graphene/types/tests/test_datetime.py b/graphene/types/tests/test_datetime.py index 74f88bd88..129276176 100644 --- a/graphene/types/tests/test_datetime.py +++ b/graphene/types/tests/test_datetime.py @@ -1,6 +1,5 @@ import datetime -import pytz from graphql import GraphQLError from pytest import fixture @@ -30,7 +29,7 @@ def resolve_time(self, info, _at=None): @fixture def sample_datetime(): - utc_datetime = datetime.datetime(2019, 5, 25, 5, 30, 15, 10, pytz.utc) + utc_datetime = datetime.datetime(2019, 5, 25, 5, 30, 15, 10, datetime.timezone.utc) return utc_datetime diff --git a/setup.py b/setup.py index 6c1f29c95..681ef38e8 100644 --- a/setup.py +++ b/setup.py @@ -53,7 +53,6 @@ def run_tests(self): "snapshottest>=0.6,<1", "coveralls>=3.3,<4", "mock>=4,<5", - "pytz==2022.1", "iso8601>=1,<2", ] From 5fb7b543779360c8c3ffbf9d07e5769a14a1a0ed Mon Sep 17 00:00:00 2001 From: Andrew Swait Date: Fri, 6 Oct 2023 21:15:26 +0100 Subject: [PATCH 47/79] docs: update docstring for `type` arg of `Field` (#1527) --- graphene/types/field.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/graphene/types/field.py b/graphene/types/field.py index dafb04b53..06ba84872 100644 --- a/graphene/types/field.py +++ b/graphene/types/field.py @@ -43,7 +43,8 @@ class Person(ObjectType): args: type (class for a graphene.UnmountedType): Must be a class (not an instance) of an unmounted graphene type (ex. scalar or object) which is used for the type of this - field in the GraphQL schema. + field in the GraphQL schema. You can provide a dotted module import path (string) + to the class instead of the class itself (e.g. to avoid circular import issues). args (optional, Dict[str, graphene.Argument]): Arguments that can be input to the field. Prefer to use ``**extra_args``, unless you use an argument name that clashes with one of the Field arguments presented here (see :ref:`example`). From 3cd0c30de8983f57580f5e1bae037bd12f9ec42b Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Thu, 16 May 2024 16:09:19 +0800 Subject: [PATCH 48/79] CI: bump GH actions (#1544) --- .github/workflows/deploy.yml | 4 ++-- .github/workflows/lint.yml | 4 ++-- .github/workflows/tests.yml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6cce61d5c..93ce31c54 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,9 +10,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python 3.10 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.10" - name: Build wheel and source tarball diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ad5bea6ad..9112718a5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,9 +7,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python 3.10 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.10" - name: Install dependencies diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5162d051f..a835f0242 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,8 +31,8 @@ jobs: - {name: '3.8', python: '3.8', os: ubuntu-latest, tox: py38} - {name: '3.7', python: '3.7', os: ubuntu-latest, tox: py37} steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} From 82d0a68a81562d512c05ce9d180dacb77a085801 Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Thu, 16 May 2024 16:09:37 +0800 Subject: [PATCH 49/79] remove polyfill for dataclasses (#1545) * remove polyfill for dataclasses * fix lint --- graphene/pyutils/dataclasses.py | 1222 ------------------------------- graphene/types/objecttype.py | 6 +- 2 files changed, 2 insertions(+), 1226 deletions(-) delete mode 100644 graphene/pyutils/dataclasses.py diff --git a/graphene/pyutils/dataclasses.py b/graphene/pyutils/dataclasses.py deleted file mode 100644 index 1a474526d..000000000 --- a/graphene/pyutils/dataclasses.py +++ /dev/null @@ -1,1222 +0,0 @@ -# This is a polyfill for dataclasses -# https://docs.python.org/3/library/dataclasses.html -# Original PEP proposal: PEP 557 -# https://www.python.org/dev/peps/pep-0557/ -import re -import sys -import copy -import types -import inspect -import keyword - -__all__ = [ - "dataclass", - "field", - "Field", - "FrozenInstanceError", - "InitVar", - "MISSING", - # Helper functions. - "fields", - "asdict", - "astuple", - "make_dataclass", - "replace", - "is_dataclass", -] - -# Conditions for adding methods. The boxes indicate what action the -# dataclass decorator takes. For all of these tables, when I talk -# about init=, repr=, eq=, order=, unsafe_hash=, or frozen=, I'm -# referring to the arguments to the @dataclass decorator. When -# checking if a dunder method already exists, I mean check for an -# entry in the class's __dict__. I never check to see if an attribute -# is defined in a base class. - -# Key: -# +=========+=========================================+ -# + Value | Meaning | -# +=========+=========================================+ -# | | No action: no method is added. | -# +---------+-----------------------------------------+ -# | add | Generated method is added. | -# +---------+-----------------------------------------+ -# | raise | TypeError is raised. | -# +---------+-----------------------------------------+ -# | None | Attribute is set to None. | -# +=========+=========================================+ - -# __init__ -# -# +--- init= parameter -# | -# v | | | -# | no | yes | <--- class has __init__ in __dict__? -# +=======+=======+=======+ -# | False | | | -# +-------+-------+-------+ -# | True | add | | <- the default -# +=======+=======+=======+ - -# __repr__ -# -# +--- repr= parameter -# | -# v | | | -# | no | yes | <--- class has __repr__ in __dict__? -# +=======+=======+=======+ -# | False | | | -# +-------+-------+-------+ -# | True | add | | <- the default -# +=======+=======+=======+ - - -# __setattr__ -# __delattr__ -# -# +--- frozen= parameter -# | -# v | | | -# | no | yes | <--- class has __setattr__ or __delattr__ in __dict__? -# +=======+=======+=======+ -# | False | | | <- the default -# +-------+-------+-------+ -# | True | add | raise | -# +=======+=======+=======+ -# Raise because not adding these methods would break the "frozen-ness" -# of the class. - -# __eq__ -# -# +--- eq= parameter -# | -# v | | | -# | no | yes | <--- class has __eq__ in __dict__? -# +=======+=======+=======+ -# | False | | | -# +-------+-------+-------+ -# | True | add | | <- the default -# +=======+=======+=======+ - -# __lt__ -# __le__ -# __gt__ -# __ge__ -# -# +--- order= parameter -# | -# v | | | -# | no | yes | <--- class has any comparison method in __dict__? -# +=======+=======+=======+ -# | False | | | <- the default -# +-------+-------+-------+ -# | True | add | raise | -# +=======+=======+=======+ -# Raise because to allow this case would interfere with using -# functools.total_ordering. - -# __hash__ - -# +------------------- unsafe_hash= parameter -# | +----------- eq= parameter -# | | +--- frozen= parameter -# | | | -# v v v | | | -# | no | yes | <--- class has explicitly defined __hash__ -# +=======+=======+=======+========+========+ -# | False | False | False | | | No __eq__, use the base class __hash__ -# +-------+-------+-------+--------+--------+ -# | False | False | True | | | No __eq__, use the base class __hash__ -# +-------+-------+-------+--------+--------+ -# | False | True | False | None | | <-- the default, not hashable -# +-------+-------+-------+--------+--------+ -# | False | True | True | add | | Frozen, so hashable, allows override -# +-------+-------+-------+--------+--------+ -# | True | False | False | add | raise | Has no __eq__, but hashable -# +-------+-------+-------+--------+--------+ -# | True | False | True | add | raise | Has no __eq__, but hashable -# +-------+-------+-------+--------+--------+ -# | True | True | False | add | raise | Not frozen, but hashable -# +-------+-------+-------+--------+--------+ -# | True | True | True | add | raise | Frozen, so hashable -# +=======+=======+=======+========+========+ -# For boxes that are blank, __hash__ is untouched and therefore -# inherited from the base class. If the base is object, then -# id-based hashing is used. -# -# Note that a class may already have __hash__=None if it specified an -# __eq__ method in the class body (not one that was created by -# @dataclass). -# -# See _hash_action (below) for a coded version of this table. - - -# Raised when an attempt is made to modify a frozen class. -class FrozenInstanceError(AttributeError): - pass - - -# A sentinel object for default values to signal that a default -# factory will be used. This is given a nice repr() which will appear -# in the function signature of dataclasses' constructors. -class _HAS_DEFAULT_FACTORY_CLASS: - def __repr__(self): - return "" - - -_HAS_DEFAULT_FACTORY = _HAS_DEFAULT_FACTORY_CLASS() - -# A sentinel object to detect if a parameter is supplied or not. Use -# a class to give it a better repr. -class _MISSING_TYPE: - pass - - -MISSING = _MISSING_TYPE() - -# Since most per-field metadata will be unused, create an empty -# read-only proxy that can be shared among all fields. -_EMPTY_METADATA = types.MappingProxyType({}) - -# Markers for the various kinds of fields and pseudo-fields. -class _FIELD_BASE: - def __init__(self, name): - self.name = name - - def __repr__(self): - return self.name - - -_FIELD = _FIELD_BASE("_FIELD") -_FIELD_CLASSVAR = _FIELD_BASE("_FIELD_CLASSVAR") -_FIELD_INITVAR = _FIELD_BASE("_FIELD_INITVAR") - -# The name of an attribute on the class where we store the Field -# objects. Also used to check if a class is a Data Class. -_FIELDS = "__dataclass_fields__" - -# The name of an attribute on the class that stores the parameters to -# @dataclass. -_PARAMS = "__dataclass_params__" - -# The name of the function, that if it exists, is called at the end of -# __init__. -_POST_INIT_NAME = "__post_init__" - -# String regex that string annotations for ClassVar or InitVar must match. -# Allows "identifier.identifier[" or "identifier[". -# https://bugs.python.org/issue33453 for details. -_MODULE_IDENTIFIER_RE = re.compile(r"^(?:\s*(\w+)\s*\.)?\s*(\w+)") - - -class _InitVarMeta(type): - def __getitem__(self, params): - return self - - -class InitVar(metaclass=_InitVarMeta): - pass - - -# Instances of Field are only ever created from within this module, -# and only from the field() function, although Field instances are -# exposed externally as (conceptually) read-only objects. -# -# name and type are filled in after the fact, not in __init__. -# They're not known at the time this class is instantiated, but it's -# convenient if they're available later. -# -# When cls._FIELDS is filled in with a list of Field objects, the name -# and type fields will have been populated. -class Field: - __slots__ = ( - "name", - "type", - "default", - "default_factory", - "repr", - "hash", - "init", - "compare", - "metadata", - "_field_type", # Private: not to be used by user code. - ) - - def __init__(self, default, default_factory, init, repr, hash, compare, metadata): - self.name = None - self.type = None - self.default = default - self.default_factory = default_factory - self.init = init - self.repr = repr - self.hash = hash - self.compare = compare - self.metadata = ( - _EMPTY_METADATA - if metadata is None or len(metadata) == 0 - else types.MappingProxyType(metadata) - ) - self._field_type = None - - def __repr__(self): - return ( - "Field(" - f"name={self.name!r}," - f"type={self.type!r}," - f"default={self.default!r}," - f"default_factory={self.default_factory!r}," - f"init={self.init!r}," - f"repr={self.repr!r}," - f"hash={self.hash!r}," - f"compare={self.compare!r}," - f"metadata={self.metadata!r}," - f"_field_type={self._field_type}" - ")" - ) - - # This is used to support the PEP 487 __set_name__ protocol in the - # case where we're using a field that contains a descriptor as a - # defaul value. For details on __set_name__, see - # https://www.python.org/dev/peps/pep-0487/#implementation-details. - # - # Note that in _process_class, this Field object is overwritten - # with the default value, so the end result is a descriptor that - # had __set_name__ called on it at the right time. - def __set_name__(self, owner, name): - func = getattr(type(self.default), "__set_name__", None) - if func: - # There is a __set_name__ method on the descriptor, call - # it. - func(self.default, owner, name) - - -class _DataclassParams: - __slots__ = ("init", "repr", "eq", "order", "unsafe_hash", "frozen") - - def __init__(self, init, repr, eq, order, unsafe_hash, frozen): - self.init = init - self.repr = repr - self.eq = eq - self.order = order - self.unsafe_hash = unsafe_hash - self.frozen = frozen - - def __repr__(self): - return ( - "_DataclassParams(" - f"init={self.init!r}," - f"repr={self.repr!r}," - f"eq={self.eq!r}," - f"order={self.order!r}," - f"unsafe_hash={self.unsafe_hash!r}," - f"frozen={self.frozen!r}" - ")" - ) - - -# This function is used instead of exposing Field creation directly, -# so that a type checker can be told (via overloads) that this is a -# function whose type depends on its parameters. -def field( - *, - default=MISSING, - default_factory=MISSING, - init=True, - repr=True, - hash=None, - compare=True, - metadata=None, -): - """Return an object to identify dataclass fields. - - default is the default value of the field. default_factory is a - 0-argument function called to initialize a field's value. If init - is True, the field will be a parameter to the class's __init__() - function. If repr is True, the field will be included in the - object's repr(). If hash is True, the field will be included in - the object's hash(). If compare is True, the field will be used - in comparison functions. metadata, if specified, must be a - mapping which is stored but not otherwise examined by dataclass. - - It is an error to specify both default and default_factory. - """ - - if default is not MISSING and default_factory is not MISSING: - raise ValueError("cannot specify both default and default_factory") - return Field(default, default_factory, init, repr, hash, compare, metadata) - - -def _tuple_str(obj_name, fields): - # Return a string representing each field of obj_name as a tuple - # member. So, if fields is ['x', 'y'] and obj_name is "self", - # return "(self.x,self.y)". - - # Special case for the 0-tuple. - if not fields: - return "()" - # Note the trailing comma, needed if this turns out to be a 1-tuple. - return f'({",".join([f"{obj_name}.{f.name}" for f in fields])},)' - - -def _create_fn(name, args, body, *, globals=None, locals=None, return_type=MISSING): - # Note that we mutate locals when exec() is called. Caller - # beware! The only callers are internal to this module, so no - # worries about external callers. - if locals is None: - locals = {} - return_annotation = "" - if return_type is not MISSING: - locals["_return_type"] = return_type - return_annotation = "->_return_type" - args = ",".join(args) - body = "\n".join(f" {b}" for b in body) - - # Compute the text of the entire function. - txt = f"def {name}({args}){return_annotation}:\n{body}" - - exec(txt, globals, locals) - return locals[name] - - -def _field_assign(frozen, name, value, self_name): - # If we're a frozen class, then assign to our fields in __init__ - # via object.__setattr__. Otherwise, just use a simple - # assignment. - # - # self_name is what "self" is called in this function: don't - # hard-code "self", since that might be a field name. - if frozen: - return f"object.__setattr__({self_name},{name!r},{value})" - return f"{self_name}.{name}={value}" - - -def _field_init(f, frozen, globals, self_name): - # Return the text of the line in the body of __init__ that will - # initialize this field. - - default_name = f"_dflt_{f.name}" - if f.default_factory is not MISSING: - if f.init: - # This field has a default factory. If a parameter is - # given, use it. If not, call the factory. - globals[default_name] = f.default_factory - value = ( - f"{default_name}() " - f"if {f.name} is _HAS_DEFAULT_FACTORY " - f"else {f.name}" - ) - else: - # This is a field that's not in the __init__ params, but - # has a default factory function. It needs to be - # initialized here by calling the factory function, - # because there's no other way to initialize it. - - # For a field initialized with a default=defaultvalue, the - # class dict just has the default value - # (cls.fieldname=defaultvalue). But that won't work for a - # default factory, the factory must be called in __init__ - # and we must assign that to self.fieldname. We can't - # fall back to the class dict's value, both because it's - # not set, and because it might be different per-class - # (which, after all, is why we have a factory function!). - - globals[default_name] = f.default_factory - value = f"{default_name}()" - else: - # No default factory. - if f.init: - if f.default is MISSING: - # There's no default, just do an assignment. - value = f.name - elif f.default is not MISSING: - globals[default_name] = f.default - value = f.name - else: - # This field does not need initialization. Signify that - # to the caller by returning None. - return None - # Only test this now, so that we can create variables for the - # default. However, return None to signify that we're not going - # to actually do the assignment statement for InitVars. - if f._field_type == _FIELD_INITVAR: - return None - # Now, actually generate the field assignment. - return _field_assign(frozen, f.name, value, self_name) - - -def _init_param(f): - # Return the __init__ parameter string for this field. For - # example, the equivalent of 'x:int=3' (except instead of 'int', - # reference a variable set to int, and instead of '3', reference a - # variable set to 3). - if f.default is MISSING and f.default_factory is MISSING: - # There's no default, and no default_factory, just output the - # variable name and type. - default = "" - elif f.default is not MISSING: - # There's a default, this will be the name that's used to look - # it up. - default = f"=_dflt_{f.name}" - elif f.default_factory is not MISSING: - # There's a factory function. Set a marker. - default = "=_HAS_DEFAULT_FACTORY" - return f"{f.name}:_type_{f.name}{default}" - - -def _init_fn(fields, frozen, has_post_init, self_name): - # fields contains both real fields and InitVar pseudo-fields. - - # Make sure we don't have fields without defaults following fields - # with defaults. This actually would be caught when exec-ing the - # function source code, but catching it here gives a better error - # message, and future-proofs us in case we build up the function - # using ast. - seen_default = False - for f in fields: - # Only consider fields in the __init__ call. - if f.init: - if not (f.default is MISSING and f.default_factory is MISSING): - seen_default = True - elif seen_default: - raise TypeError( - f"non-default argument {f.name!r} " "follows default argument" - ) - globals = {"MISSING": MISSING, "_HAS_DEFAULT_FACTORY": _HAS_DEFAULT_FACTORY} - - body_lines = [] - for f in fields: - line = _field_init(f, frozen, globals, self_name) - # line is None means that this field doesn't require - # initialization (it's a pseudo-field). Just skip it. - if line: - body_lines.append(line) - # Does this class have a post-init function? - if has_post_init: - params_str = ",".join(f.name for f in fields if f._field_type is _FIELD_INITVAR) - body_lines.append(f"{self_name}.{_POST_INIT_NAME}({params_str})") - # If no body lines, use 'pass'. - if not body_lines: - body_lines = ["pass"] - locals = {f"_type_{f.name}": f.type for f in fields} - return _create_fn( - "__init__", - [self_name] + [_init_param(f) for f in fields if f.init], - body_lines, - locals=locals, - globals=globals, - return_type=None, - ) - - -def _repr_fn(fields): - return _create_fn( - "__repr__", - ("self",), - [ - 'return self.__class__.__qualname__ + f"(' - + ", ".join([f"{f.name}={{self.{f.name}!r}}" for f in fields]) - + ')"' - ], - ) - - -def _frozen_get_del_attr(cls, fields): - # XXX: globals is modified on the first call to _create_fn, then - # the modified version is used in the second call. Is this okay? - globals = {"cls": cls, "FrozenInstanceError": FrozenInstanceError} - if fields: - fields_str = "(" + ",".join(repr(f.name) for f in fields) + ",)" - else: - # Special case for the zero-length tuple. - fields_str = "()" - return ( - _create_fn( - "__setattr__", - ("self", "name", "value"), - ( - f"if type(self) is cls or name in {fields_str}:", - ' raise FrozenInstanceError(f"cannot assign to field {name!r}")', - f"super(cls, self).__setattr__(name, value)", - ), - globals=globals, - ), - _create_fn( - "__delattr__", - ("self", "name"), - ( - f"if type(self) is cls or name in {fields_str}:", - ' raise FrozenInstanceError(f"cannot delete field {name!r}")', - f"super(cls, self).__delattr__(name)", - ), - globals=globals, - ), - ) - - -def _cmp_fn(name, op, self_tuple, other_tuple): - # Create a comparison function. If the fields in the object are - # named 'x' and 'y', then self_tuple is the string - # '(self.x,self.y)' and other_tuple is the string - # '(other.x,other.y)'. - - return _create_fn( - name, - ("self", "other"), - [ - "if other.__class__ is self.__class__:", - f" return {self_tuple}{op}{other_tuple}", - "return NotImplemented", - ], - ) - - -def _hash_fn(fields): - self_tuple = _tuple_str("self", fields) - return _create_fn("__hash__", ("self",), [f"return hash({self_tuple})"]) - - -def _is_classvar(a_type, typing): - # This test uses a typing internal class, but it's the best way to - # test if this is a ClassVar. - return type(a_type) is typing._ClassVar - - -def _is_initvar(a_type, dataclasses): - # The module we're checking against is the module we're - # currently in (dataclasses.py). - return a_type is dataclasses.InitVar - - -def _is_type(annotation, cls, a_module, a_type, is_type_predicate): - # Given a type annotation string, does it refer to a_type in - # a_module? For example, when checking that annotation denotes a - # ClassVar, then a_module is typing, and a_type is - # typing.ClassVar. - - # It's possible to look up a_module given a_type, but it involves - # looking in sys.modules (again!), and seems like a waste since - # the caller already knows a_module. - - # - annotation is a string type annotation - # - cls is the class that this annotation was found in - # - a_module is the module we want to match - # - a_type is the type in that module we want to match - # - is_type_predicate is a function called with (obj, a_module) - # that determines if obj is of the desired type. - - # Since this test does not do a local namespace lookup (and - # instead only a module (global) lookup), there are some things it - # gets wrong. - - # With string annotations, cv0 will be detected as a ClassVar: - # CV = ClassVar - # @dataclass - # class C0: - # cv0: CV - - # But in this example cv1 will not be detected as a ClassVar: - # @dataclass - # class C1: - # CV = ClassVar - # cv1: CV - - # In C1, the code in this function (_is_type) will look up "CV" in - # the module and not find it, so it will not consider cv1 as a - # ClassVar. This is a fairly obscure corner case, and the best - # way to fix it would be to eval() the string "CV" with the - # correct global and local namespaces. However that would involve - # a eval() penalty for every single field of every dataclass - # that's defined. It was judged not worth it. - - match = _MODULE_IDENTIFIER_RE.match(annotation) - if match: - ns = None - module_name = match.group(1) - if not module_name: - # No module name, assume the class's module did - # "from dataclasses import InitVar". - ns = sys.modules.get(cls.__module__).__dict__ - else: - # Look up module_name in the class's module. - module = sys.modules.get(cls.__module__) - if module and module.__dict__.get(module_name) is a_module: - ns = sys.modules.get(a_type.__module__).__dict__ - if ns and is_type_predicate(ns.get(match.group(2)), a_module): - return True - return False - - -def _get_field(cls, a_name, a_type): - # Return a Field object for this field name and type. ClassVars - # and InitVars are also returned, but marked as such (see - # f._field_type). - - # If the default value isn't derived from Field, then it's only a - # normal default value. Convert it to a Field(). - default = getattr(cls, a_name, MISSING) - if isinstance(default, Field): - f = default - else: - if isinstance(default, types.MemberDescriptorType): - # This is a field in __slots__, so it has no default value. - default = MISSING - f = field(default=default) - # Only at this point do we know the name and the type. Set them. - f.name = a_name - f.type = a_type - - # Assume it's a normal field until proven otherwise. We're next - # going to decide if it's a ClassVar or InitVar, everything else - # is just a normal field. - f._field_type = _FIELD - - # In addition to checking for actual types here, also check for - # string annotations. get_type_hints() won't always work for us - # (see https://github.com/python/typing/issues/508 for example), - # plus it's expensive and would require an eval for every stirng - # annotation. So, make a best effort to see if this is a ClassVar - # or InitVar using regex's and checking that the thing referenced - # is actually of the correct type. - - # For the complete discussion, see https://bugs.python.org/issue33453 - - # If typing has not been imported, then it's impossible for any - # annotation to be a ClassVar. So, only look for ClassVar if - # typing has been imported by any module (not necessarily cls's - # module). - typing = sys.modules.get("typing") - if typing: - if _is_classvar(a_type, typing) or ( - isinstance(f.type, str) - and _is_type(f.type, cls, typing, typing.ClassVar, _is_classvar) - ): - f._field_type = _FIELD_CLASSVAR - # If the type is InitVar, or if it's a matching string annotation, - # then it's an InitVar. - if f._field_type is _FIELD: - # The module we're checking against is the module we're - # currently in (dataclasses.py). - dataclasses = sys.modules[__name__] - if _is_initvar(a_type, dataclasses) or ( - isinstance(f.type, str) - and _is_type(f.type, cls, dataclasses, dataclasses.InitVar, _is_initvar) - ): - f._field_type = _FIELD_INITVAR - # Validations for individual fields. This is delayed until now, - # instead of in the Field() constructor, since only here do we - # know the field name, which allows for better error reporting. - - # Special restrictions for ClassVar and InitVar. - if f._field_type in (_FIELD_CLASSVAR, _FIELD_INITVAR): - if f.default_factory is not MISSING: - raise TypeError(f"field {f.name} cannot have a " "default factory") - # Should I check for other field settings? default_factory - # seems the most serious to check for. Maybe add others. For - # example, how about init=False (or really, - # init=)? It makes no sense for - # ClassVar and InitVar to specify init=. - # For real fields, disallow mutable defaults for known types. - if f._field_type is _FIELD and isinstance(f.default, (list, dict, set)): - raise ValueError( - f"mutable default {type(f.default)} for field " - f"{f.name} is not allowed: use default_factory" - ) - return f - - -def _set_new_attribute(cls, name, value): - # Never overwrites an existing attribute. Returns True if the - # attribute already exists. - if name in cls.__dict__: - return True - setattr(cls, name, value) - return False - - -# Decide if/how we're going to create a hash function. Key is -# (unsafe_hash, eq, frozen, does-hash-exist). Value is the action to -# take. The common case is to do nothing, so instead of providing a -# function that is a no-op, use None to signify that. - - -def _hash_set_none(cls, fields): - return None - - -def _hash_add(cls, fields): - flds = [f for f in fields if (f.compare if f.hash is None else f.hash)] - return _hash_fn(flds) - - -def _hash_exception(cls, fields): - # Raise an exception. - raise TypeError(f"Cannot overwrite attribute __hash__ " f"in class {cls.__name__}") - - -# -# +-------------------------------------- unsafe_hash? -# | +------------------------------- eq? -# | | +------------------------ frozen? -# | | | +---------------- has-explicit-hash? -# | | | | -# | | | | +------- action -# | | | | | -# v v v v v -_hash_action = { - (False, False, False, False): None, - (False, False, False, True): None, - (False, False, True, False): None, - (False, False, True, True): None, - (False, True, False, False): _hash_set_none, - (False, True, False, True): None, - (False, True, True, False): _hash_add, - (False, True, True, True): None, - (True, False, False, False): _hash_add, - (True, False, False, True): _hash_exception, - (True, False, True, False): _hash_add, - (True, False, True, True): _hash_exception, - (True, True, False, False): _hash_add, - (True, True, False, True): _hash_exception, - (True, True, True, False): _hash_add, - (True, True, True, True): _hash_exception, -} -# See https://bugs.python.org/issue32929#msg312829 for an if-statement -# version of this table. - - -def _process_class(cls, init, repr, eq, order, unsafe_hash, frozen): - # Now that dicts retain insertion order, there's no reason to use - # an ordered dict. I am leveraging that ordering here, because - # derived class fields overwrite base class fields, but the order - # is defined by the base class, which is found first. - fields = {} - - setattr(cls, _PARAMS, _DataclassParams(init, repr, eq, order, unsafe_hash, frozen)) - - # Find our base classes in reverse MRO order, and exclude - # ourselves. In reversed order so that more derived classes - # override earlier field definitions in base classes. As long as - # we're iterating over them, see if any are frozen. - any_frozen_base = False - has_dataclass_bases = False - for b in cls.__mro__[-1:0:-1]: - # Only process classes that have been processed by our - # decorator. That is, they have a _FIELDS attribute. - base_fields = getattr(b, _FIELDS, None) - if base_fields: - has_dataclass_bases = True - for f in base_fields.values(): - fields[f.name] = f - if getattr(b, _PARAMS).frozen: - any_frozen_base = True - # Annotations that are defined in this class (not in base - # classes). If __annotations__ isn't present, then this class - # adds no new annotations. We use this to compute fields that are - # added by this class. - # - # Fields are found from cls_annotations, which is guaranteed to be - # ordered. Default values are from class attributes, if a field - # has a default. If the default value is a Field(), then it - # contains additional info beyond (and possibly including) the - # actual default value. Pseudo-fields ClassVars and InitVars are - # included, despite the fact that they're not real fields. That's - # dealt with later. - cls_annotations = cls.__dict__.get("__annotations__", {}) - - # Now find fields in our class. While doing so, validate some - # things, and set the default values (as class attributes) where - # we can. - cls_fields = [ - _get_field(cls, name, type_) for name, type_ in cls_annotations.items() - ] - for f in cls_fields: - fields[f.name] = f - - # If the class attribute (which is the default value for this - # field) exists and is of type 'Field', replace it with the - # real default. This is so that normal class introspection - # sees a real default value, not a Field. - if isinstance(getattr(cls, f.name, None), Field): - if f.default is MISSING: - # If there's no default, delete the class attribute. - # This happens if we specify field(repr=False), for - # example (that is, we specified a field object, but - # no default value). Also if we're using a default - # factory. The class attribute should not be set at - # all in the post-processed class. - delattr(cls, f.name) - else: - setattr(cls, f.name, f.default) - # Do we have any Field members that don't also have annotations? - for name, value in cls.__dict__.items(): - if isinstance(value, Field) and not name in cls_annotations: - raise TypeError(f"{name!r} is a field but has no type annotation") - # Check rules that apply if we are derived from any dataclasses. - if has_dataclass_bases: - # Raise an exception if any of our bases are frozen, but we're not. - if any_frozen_base and not frozen: - raise TypeError("cannot inherit non-frozen dataclass from a " "frozen one") - # Raise an exception if we're frozen, but none of our bases are. - if not any_frozen_base and frozen: - raise TypeError("cannot inherit frozen dataclass from a " "non-frozen one") - # Remember all of the fields on our class (including bases). This - # also marks this class as being a dataclass. - setattr(cls, _FIELDS, fields) - - # Was this class defined with an explicit __hash__? Note that if - # __eq__ is defined in this class, then python will automatically - # set __hash__ to None. This is a heuristic, as it's possible - # that such a __hash__ == None was not auto-generated, but it - # close enough. - class_hash = cls.__dict__.get("__hash__", MISSING) - has_explicit_hash = not ( - class_hash is MISSING or (class_hash is None and "__eq__" in cls.__dict__) - ) - - # If we're generating ordering methods, we must be generating the - # eq methods. - if order and not eq: - raise ValueError("eq must be true if order is true") - if init: - # Does this class have a post-init function? - has_post_init = hasattr(cls, _POST_INIT_NAME) - - # Include InitVars and regular fields (so, not ClassVars). - flds = [f for f in fields.values() if f._field_type in (_FIELD, _FIELD_INITVAR)] - _set_new_attribute( - cls, - "__init__", - _init_fn( - flds, - frozen, - has_post_init, - # The name to use for the "self" - # param in __init__. Use "self" - # if possible. - "__dataclass_self__" if "self" in fields else "self", - ), - ) - # Get the fields as a list, and include only real fields. This is - # used in all of the following methods. - field_list = [f for f in fields.values() if f._field_type is _FIELD] - - if repr: - flds = [f for f in field_list if f.repr] - _set_new_attribute(cls, "__repr__", _repr_fn(flds)) - if eq: - # Create _eq__ method. There's no need for a __ne__ method, - # since python will call __eq__ and negate it. - flds = [f for f in field_list if f.compare] - self_tuple = _tuple_str("self", flds) - other_tuple = _tuple_str("other", flds) - _set_new_attribute( - cls, "__eq__", _cmp_fn("__eq__", "==", self_tuple, other_tuple) - ) - if order: - # Create and set the ordering methods. - flds = [f for f in field_list if f.compare] - self_tuple = _tuple_str("self", flds) - other_tuple = _tuple_str("other", flds) - for name, op in [ - ("__lt__", "<"), - ("__le__", "<="), - ("__gt__", ">"), - ("__ge__", ">="), - ]: - if _set_new_attribute( - cls, name, _cmp_fn(name, op, self_tuple, other_tuple) - ): - raise TypeError( - f"Cannot overwrite attribute {name} " - f"in class {cls.__name__}. Consider using " - "functools.total_ordering" - ) - if frozen: - for fn in _frozen_get_del_attr(cls, field_list): - if _set_new_attribute(cls, fn.__name__, fn): - raise TypeError( - f"Cannot overwrite attribute {fn.__name__} " - f"in class {cls.__name__}" - ) - # Decide if/how we're going to create a hash function. - hash_action = _hash_action[ - bool(unsafe_hash), bool(eq), bool(frozen), has_explicit_hash - ] - if hash_action: - # No need to call _set_new_attribute here, since by the time - # we're here the overwriting is unconditional. - cls.__hash__ = hash_action(cls, field_list) - if not getattr(cls, "__doc__"): - # Create a class doc-string. - cls.__doc__ = cls.__name__ + str(inspect.signature(cls)).replace(" -> None", "") - return cls - - -# _cls should never be specified by keyword, so start it with an -# underscore. The presence of _cls is used to detect if this -# decorator is being called with parameters or not. -def dataclass( - _cls=None, - *, - init=True, - repr=True, - eq=True, - order=False, - unsafe_hash=False, - frozen=False, -): - """Returns the same class as was passed in, with dunder methods - added based on the fields defined in the class. - - Examines PEP 526 __annotations__ to determine fields. - - If init is true, an __init__() method is added to the class. If - repr is true, a __repr__() method is added. If order is true, rich - comparison dunder methods are added. If unsafe_hash is true, a - __hash__() method function is added. If frozen is true, fields may - not be assigned to after instance creation. - """ - - def wrap(cls): - return _process_class(cls, init, repr, eq, order, unsafe_hash, frozen) - - # See if we're being called as @dataclass or @dataclass(). - if _cls is None: - # We're called with parens. - return wrap - # We're called as @dataclass without parens. - return wrap(_cls) - - -def fields(class_or_instance): - """Return a tuple describing the fields of this dataclass. - - Accepts a dataclass or an instance of one. Tuple elements are of - type Field. - """ - - # Might it be worth caching this, per class? - try: - fields = getattr(class_or_instance, _FIELDS) - except AttributeError: - raise TypeError("must be called with a dataclass type or instance") - # Exclude pseudo-fields. Note that fields is sorted by insertion - # order, so the order of the tuple is as the fields were defined. - return tuple(f for f in fields.values() if f._field_type is _FIELD) - - -def _is_dataclass_instance(obj): - """Returns True if obj is an instance of a dataclass.""" - return not isinstance(obj, type) and hasattr(obj, _FIELDS) - - -def is_dataclass(obj): - """Returns True if obj is a dataclass or an instance of a - dataclass.""" - return hasattr(obj, _FIELDS) - - -def asdict(obj, *, dict_factory=dict): - """Return the fields of a dataclass instance as a new dictionary mapping - field names to field values. - - Example usage: - - @dataclass - class C: - x: int - y: int - - c = C(1, 2) - assert asdict(c) == {'x': 1, 'y': 2} - - If given, 'dict_factory' will be used instead of built-in dict. - The function applies recursively to field values that are - dataclass instances. This will also look into built-in containers: - tuples, lists, and dicts. - """ - if not _is_dataclass_instance(obj): - raise TypeError("asdict() should be called on dataclass instances") - return _asdict_inner(obj, dict_factory) - - -def _asdict_inner(obj, dict_factory): - if _is_dataclass_instance(obj): - result = [] - for f in fields(obj): - value = _asdict_inner(getattr(obj, f.name), dict_factory) - result.append((f.name, value)) - return dict_factory(result) - elif isinstance(obj, (list, tuple)): - return type(obj)(_asdict_inner(v, dict_factory) for v in obj) - elif isinstance(obj, dict): - return type(obj)( - (_asdict_inner(k, dict_factory), _asdict_inner(v, dict_factory)) - for k, v in obj.items() - ) - else: - return copy.deepcopy(obj) - - -def astuple(obj, *, tuple_factory=tuple): - """Return the fields of a dataclass instance as a new tuple of field values. - - Example usage:: - - @dataclass - class C: - x: int - y: int - - c = C(1, 2) - assert astuple(c) == (1, 2) - - If given, 'tuple_factory' will be used instead of built-in tuple. - The function applies recursively to field values that are - dataclass instances. This will also look into built-in containers: - tuples, lists, and dicts. - """ - - if not _is_dataclass_instance(obj): - raise TypeError("astuple() should be called on dataclass instances") - return _astuple_inner(obj, tuple_factory) - - -def _astuple_inner(obj, tuple_factory): - if _is_dataclass_instance(obj): - result = [] - for f in fields(obj): - value = _astuple_inner(getattr(obj, f.name), tuple_factory) - result.append(value) - return tuple_factory(result) - elif isinstance(obj, (list, tuple)): - return type(obj)(_astuple_inner(v, tuple_factory) for v in obj) - elif isinstance(obj, dict): - return type(obj)( - (_astuple_inner(k, tuple_factory), _astuple_inner(v, tuple_factory)) - for k, v in obj.items() - ) - else: - return copy.deepcopy(obj) - - -def make_dataclass( - cls_name, - fields, - *, - bases=(), - namespace=None, - init=True, - repr=True, - eq=True, - order=False, - unsafe_hash=False, - frozen=False, -): - """Return a new dynamically created dataclass. - - The dataclass name will be 'cls_name'. 'fields' is an iterable - of either (name), (name, type) or (name, type, Field) objects. If type is - omitted, use the string 'typing.Any'. Field objects are created by - the equivalent of calling 'field(name, type [, Field-info])'. - - C = make_dataclass('C', ['x', ('y', int), ('z', int, field(init=False))], bases=(Base,)) - - is equivalent to: - - @dataclass - class C(Base): - x: 'typing.Any' - y: int - z: int = field(init=False) - - For the bases and namespace parameters, see the builtin type() function. - - The parameters init, repr, eq, order, unsafe_hash, and frozen are passed to - dataclass(). - """ - - if namespace is None: - namespace = {} - else: - # Copy namespace since we're going to mutate it. - namespace = namespace.copy() - # While we're looking through the field names, validate that they - # are identifiers, are not keywords, and not duplicates. - seen = set() - anns = {} - for item in fields: - if isinstance(item, str): - name = item - tp = "typing.Any" - elif len(item) == 2: - (name, tp) = item - elif len(item) == 3: - name, tp, spec = item - namespace[name] = spec - else: - raise TypeError(f"Invalid field: {item!r}") - if not isinstance(name, str) or not name.isidentifier(): - raise TypeError(f"Field names must be valid identifers: {name!r}") - if keyword.iskeyword(name): - raise TypeError(f"Field names must not be keywords: {name!r}") - if name in seen: - raise TypeError(f"Field name duplicated: {name!r}") - seen.add(name) - anns[name] = tp - namespace["__annotations__"] = anns - # We use `types.new_class()` instead of simply `type()` to allow dynamic creation - # of generic dataclassses. - cls = types.new_class(cls_name, bases, {}, lambda ns: ns.update(namespace)) - return dataclass( - cls, - init=init, - repr=repr, - eq=eq, - order=order, - unsafe_hash=unsafe_hash, - frozen=frozen, - ) - - -def replace(obj, **changes): - """Return a new object replacing specified fields with new values. - - This is especially useful for frozen classes. Example usage: - - @dataclass(frozen=True) - class C: - x: int - y: int - - c = C(1, 2) - c1 = replace(c, x=3) - assert c1.x == 3 and c1.y == 2 - """ - - # We're going to mutate 'changes', but that's okay because it's a - # new dict, even if called with 'replace(obj, **my_changes)'. - - if not _is_dataclass_instance(obj): - raise TypeError("replace() should be called on dataclass instances") - # It's an error to have init=False fields in 'changes'. - # If a field is not in 'changes', read its value from the provided obj. - - for f in getattr(obj, _FIELDS).values(): - if not f.init: - # Error if this field is specified in changes. - if f.name in changes: - raise ValueError( - f"field {f.name} is declared with " - "init=False, it cannot be specified with " - "replace()" - ) - continue - if f.name not in changes: - changes[f.name] = getattr(obj, f.name) - # Create the new object, which calls __init__() and - # __post_init__() (if defined), using all of the init fields we've - # added and/or left in 'changes'. If there are values supplied in - # changes that aren't fields, this will correctly raise a - # TypeError. - return obj.__class__(**changes) diff --git a/graphene/types/objecttype.py b/graphene/types/objecttype.py index b3b829fe4..d707c1c51 100644 --- a/graphene/types/objecttype.py +++ b/graphene/types/objecttype.py @@ -5,10 +5,8 @@ from .interface import Interface from .utils import yank_fields_from_attrs -try: - from dataclasses import make_dataclass, field -except ImportError: - from ..pyutils.dataclasses import make_dataclass, field # type: ignore +from dataclasses import make_dataclass, field + # For static type checking with type checker if TYPE_CHECKING: from typing import Dict, Iterable, Type # NOQA From 5db1af039f1b45ba72c530e2d966a3f0c32cb55c Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Thu, 16 May 2024 16:17:26 +0800 Subject: [PATCH 50/79] Remove Python 3.7 (#1543) * CI: add Python 3.12 * dd * remove python 3.12 --- .github/workflows/tests.yml | 1 - README.rst | 2 +- docs/quickstart.rst | 2 +- setup.py | 3 +-- tox.ini | 4 ++-- 5 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a835f0242..76a72c45d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,7 +29,6 @@ jobs: - {name: '3.10', python: '3.10', os: ubuntu-latest, tox: py310} - {name: '3.9', python: '3.9', os: ubuntu-latest, tox: py39} - {name: '3.8', python: '3.8', os: ubuntu-latest, tox: py38} - - {name: '3.7', python: '3.7', os: ubuntu-latest, tox: py37} steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/README.rst b/README.rst index 405a8f44a..6b26404f7 100644 --- a/README.rst +++ b/README.rst @@ -141,7 +141,7 @@ file: .. code:: sh - tox -e py36 + tox -e py10 Tox can only use whatever versions of Python are installed on your system. When you create a pull request, Travis will also be running the diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 75f201c95..e06b12bb6 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -59,7 +59,7 @@ When we send a **Query** requesting only one **Field**, ``hello``, and specify a Requirements ~~~~~~~~~~~~ -- Python (3.6, 3.7, 3.8, 3.9, 3.10, pypy) +- Python (3.8, 3.9, 3.10, 3.11, pypy) - Graphene (3.0) Project setup diff --git a/setup.py b/setup.py index 681ef38e8..51c3ae5ce 100644 --- a/setup.py +++ b/setup.py @@ -73,11 +73,10 @@ def run_tests(self): "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Topic :: Software Development :: Libraries", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", ], keywords="api graphql protocol rest relay graphene", packages=find_packages(exclude=["examples*"]), diff --git a/tox.ini b/tox.ini index 872d528c6..2b245f28b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py3{7,8,9,10,11}, mypy, pre-commit +envlist = py3{8,9,10,11}, mypy, pre-commit skipsdist = true [testenv] @@ -8,7 +8,7 @@ deps = setenv = PYTHONPATH = .:{envdir} commands = - py{37,38,39,310,311}: pytest --cov=graphene graphene --cov-report=term --cov-report=xml examples {posargs} + py{38,39,310,311}: pytest --cov=graphene graphene --cov-report=term --cov-report=xml examples {posargs} [testenv:pre-commit] basepython = python3.10 From 221afaf4c441a5e88e039440808d4810d5f040e7 Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Thu, 16 May 2024 16:17:46 +0800 Subject: [PATCH 51/79] bump pytest to 7 (#1546) * bump pytest * downgrade pytest-cov --- setup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 51c3ae5ce..1600297a8 100644 --- a/setup.py +++ b/setup.py @@ -45,9 +45,9 @@ def run_tests(self): tests_require = [ - "pytest>=6,<7", - "pytest-benchmark>=3.4,<4", - "pytest-cov>=3,<4", + "pytest>=7,<8", + "pytest-benchmark>=4,<5", + "pytest-cov>=4,<5", "pytest-mock>=3,<4", "pytest-asyncio>=0.16,<2", "snapshottest>=0.6,<1", From 44dcdad18277e11d8e7d53ce8c8a16aa265d69b9 Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Thu, 13 Jun 2024 22:32:50 +0800 Subject: [PATCH 52/79] CI: fix deprecation warning (#1551) --- .github/workflows/tests.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 76a72c45d..0fdc7c45f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -42,14 +42,12 @@ jobs: - name: get pip cache dir id: pip-cache - run: echo "::set-output name=dir::$(pip cache dir)" - + run: echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: cache pip dependencies uses: actions/cache@v3 with: path: ${{ steps.pip-cache.outputs.dir }} key: pip|${{ runner.os }}|${{ matrix.python }}|${{ hashFiles('setup.py') }} - - run: pip install tox - run: tox -e ${{ matrix.tox }} - name: Upload coverage.xml From 614449e65193252209378cdf5bcbb97e20ab08be Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Thu, 13 Jun 2024 22:34:16 +0800 Subject: [PATCH 53/79] Python 3.12 (#1550) * python 3.12 * update classifiers --- .github/workflows/tests.yml | 1 + docs/quickstart.rst | 2 +- setup.py | 1 + tox.ini | 4 ++-- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0fdc7c45f..0389d7ad6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,6 +25,7 @@ jobs: fail-fast: false matrix: include: + - {name: '3.12', python: '3.12', os: ubuntu-latest, tox: py312} - {name: '3.11', python: '3.11', os: ubuntu-latest, tox: py311} - {name: '3.10', python: '3.10', os: ubuntu-latest, tox: py310} - {name: '3.9', python: '3.9', os: ubuntu-latest, tox: py39} diff --git a/docs/quickstart.rst b/docs/quickstart.rst index e06b12bb6..31f515001 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -59,7 +59,7 @@ When we send a **Query** requesting only one **Field**, ``hello``, and specify a Requirements ~~~~~~~~~~~~ -- Python (3.8, 3.9, 3.10, 3.11, pypy) +- Python (3.8, 3.9, 3.10, 3.11, 3.12, pypy) - Graphene (3.0) Project setup diff --git a/setup.py b/setup.py index 1600297a8..c40ca89f8 100644 --- a/setup.py +++ b/setup.py @@ -77,6 +77,7 @@ def run_tests(self): "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ], keywords="api graphql protocol rest relay graphene", packages=find_packages(exclude=["examples*"]), diff --git a/tox.ini b/tox.ini index 2b245f28b..767f957cd 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py3{8,9,10,11}, mypy, pre-commit +envlist = py3{8,9,10,11,12}, mypy, pre-commit skipsdist = true [testenv] @@ -8,7 +8,7 @@ deps = setenv = PYTHONPATH = .:{envdir} commands = - py{38,39,310,311}: pytest --cov=graphene graphene --cov-report=term --cov-report=xml examples {posargs} + py{38,39,310,311,12}: pytest --cov=graphene graphene --cov-report=term --cov-report=xml examples {posargs} [testenv:pre-commit] basepython = python3.10 From 17d09c8dedce36e2e0ea7dff4abf73bf3b39c541 Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Thu, 13 Jun 2024 22:35:00 +0800 Subject: [PATCH 54/79] remove aniso8601, mock, iso8601 (#1548) * remove aniso8601 * remove mock, iso8601 --------- Co-authored-by: Erik Wrede --- .isort.cfg | 2 +- graphene/types/datetime.py | 7 +++---- setup.py | 3 --- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/.isort.cfg b/.isort.cfg index 02dbdee4e..42fa707df 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,2 +1,2 @@ [settings] -known_third_party = aniso8601,graphql,graphql_relay,promise,pytest,pyutils,setuptools,snapshottest,sphinx_graphene_theme +known_third_party = graphql,graphql_relay,promise,pytest,pyutils,setuptools,snapshottest,sphinx_graphene_theme diff --git a/graphene/types/datetime.py b/graphene/types/datetime.py index d4f74470b..a22c5cd38 100644 --- a/graphene/types/datetime.py +++ b/graphene/types/datetime.py @@ -2,7 +2,6 @@ import datetime -from aniso8601 import parse_date, parse_datetime, parse_time from graphql.error import GraphQLError from graphql.language import StringValueNode, print_ast @@ -39,7 +38,7 @@ def parse_value(value): if not isinstance(value, str): raise GraphQLError(f"Date cannot represent non-string value: {repr(value)}") try: - return parse_date(value) + return datetime.date.fromisoformat(value) except ValueError: raise GraphQLError(f"Date cannot represent value: {repr(value)}") @@ -74,7 +73,7 @@ def parse_value(value): f"DateTime cannot represent non-string value: {repr(value)}" ) try: - return parse_datetime(value) + return datetime.datetime.fromisoformat(value) except ValueError: raise GraphQLError(f"DateTime cannot represent value: {repr(value)}") @@ -107,6 +106,6 @@ def parse_value(cls, value): if not isinstance(value, str): raise GraphQLError(f"Time cannot represent non-string value: {repr(value)}") try: - return parse_time(value) + return datetime.time.fromisoformat(value) except ValueError: raise GraphQLError(f"Time cannot represent value: {repr(value)}") diff --git a/setup.py b/setup.py index c40ca89f8..6ae8a1d52 100644 --- a/setup.py +++ b/setup.py @@ -52,8 +52,6 @@ def run_tests(self): "pytest-asyncio>=0.16,<2", "snapshottest>=0.6,<1", "coveralls>=3.3,<4", - "mock>=4,<5", - "iso8601>=1,<2", ] dev_requires = ["black==22.3.0", "flake8>=4,<5"] + tests_require @@ -84,7 +82,6 @@ def run_tests(self): install_requires=[ "graphql-core>=3.1,<3.3", "graphql-relay>=3.1,<3.3", - "aniso8601>=8,<10", ], tests_require=tests_require, extras_require={"test": tests_require, "dev": dev_requires}, From 88c3ec539bf77975dbea97431ed1d352a5d772be Mon Sep 17 00:00:00 2001 From: tijuca Date: Thu, 13 Jun 2024 16:38:48 +0200 Subject: [PATCH 55/79] pytest: Don't use nose like syntax in graphene/relay/tests/test_custom_global_id.py (#1539) (#1540) pytest: Don't use nose like syntax The tests in test_custom_global_id.py use the old nose specific method 'setup(self)' which isn't supported anymore in Pytest 8+. The tests fail with this error message without modification. E pytest.PytestRemovedIn8Warning: Support for nose tests is deprecated and will be removed in a future release. E graphene/relay/tests/test_custom_global_id.py::TestIncompleteCustomGlobalID::test_must_define_resolve_global_id is using nose-specific method: `setup(self)` E To remove this warning, rename it to `setup_method(self)` E See docs: https://docs.pytest.org/en/stable/deprecations.html#support-for-tests-written-for-nose Co-authored-by: Erik Wrede --- graphene/relay/tests/test_custom_global_id.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/graphene/relay/tests/test_custom_global_id.py b/graphene/relay/tests/test_custom_global_id.py index c1bf0fb4b..8f7991409 100644 --- a/graphene/relay/tests/test_custom_global_id.py +++ b/graphene/relay/tests/test_custom_global_id.py @@ -9,7 +9,7 @@ class TestUUIDGlobalID: - def setup(self): + def setup_method(self): self.user_list = [ {"id": uuid4(), "name": "First"}, {"id": uuid4(), "name": "Second"}, @@ -77,7 +77,7 @@ def test_get_by_id(self): class TestSimpleGlobalID: - def setup(self): + def setup_method(self): self.user_list = [ {"id": "my global primary key in clear 1", "name": "First"}, {"id": "my global primary key in clear 2", "name": "Second"}, @@ -140,7 +140,7 @@ def test_get_by_id(self): class TestCustomGlobalID: - def setup(self): + def setup_method(self): self.user_list = [ {"id": 1, "name": "First"}, {"id": 2, "name": "Second"}, @@ -219,7 +219,7 @@ def test_get_by_id(self): class TestIncompleteCustomGlobalID: - def setup(self): + def setup_method(self): self.user_list = [ {"id": 1, "name": "First"}, {"id": 2, "name": "Second"}, From 6a668514debf091269cd6fe04d56fd53c8646b8b Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Thu, 13 Jun 2024 16:51:43 +0200 Subject: [PATCH 56/79] docs: create security.md (#1554) --- SECURITY.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..5b58ab62d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Security Policy + +## Supported Versions + +Support for security issues is currently provided for Graphene 3.0 and above. Support on earlier versions cannot be guaranteed by the maintainers of this library, but community PRs may be accepted in critical cases. +The preferred mitigation strategy is via an upgrade to Graphene 3. + +| Version | Supported | +| ------- | ------------------ | +| 3.x | :white_check_mark: | +| <3.x | :x: | + +## Reporting a Vulnerability + +Please use responsible disclosure by contacting a core maintainer via Discord or E-Mail. From 5924cc4150be4a08c6f31bbfa9fcc8a3f3ab1a4f Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Thu, 13 Jun 2024 22:52:06 +0800 Subject: [PATCH 57/79] remove Python 2 (#1547) Co-authored-by: Erik Wrede --- examples/starwars/tests/snapshots/snap_test_query.py | 2 -- .../starwars_relay/tests/snapshots/snap_test_connections.py | 2 -- examples/starwars_relay/tests/snapshots/snap_test_mutation.py | 2 -- .../tests/snapshots/snap_test_objectidentification.py | 2 -- graphene/pyutils/version.py | 2 -- graphene/types/datetime.py | 2 -- graphene/types/decimal.py | 2 -- graphene/types/generic.py | 2 -- graphene/types/json.py | 2 -- graphene/types/uuid.py | 1 - 10 files changed, 19 deletions(-) diff --git a/examples/starwars/tests/snapshots/snap_test_query.py b/examples/starwars/tests/snapshots/snap_test_query.py index b4f05bdb8..1ede86e42 100644 --- a/examples/starwars/tests/snapshots/snap_test_query.py +++ b/examples/starwars/tests/snapshots/snap_test_query.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # snapshottest: v1 - https://goo.gl/zC4yUc -from __future__ import unicode_literals - from snapshottest import Snapshot snapshots = Snapshot() diff --git a/examples/starwars_relay/tests/snapshots/snap_test_connections.py b/examples/starwars_relay/tests/snapshots/snap_test_connections.py index 57a7b7ea5..353fee597 100644 --- a/examples/starwars_relay/tests/snapshots/snap_test_connections.py +++ b/examples/starwars_relay/tests/snapshots/snap_test_connections.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # snapshottest: v1 - https://goo.gl/zC4yUc -from __future__ import unicode_literals - from snapshottest import Snapshot snapshots = Snapshot() diff --git a/examples/starwars_relay/tests/snapshots/snap_test_mutation.py b/examples/starwars_relay/tests/snapshots/snap_test_mutation.py index c35b2aeba..f0012e0a7 100644 --- a/examples/starwars_relay/tests/snapshots/snap_test_mutation.py +++ b/examples/starwars_relay/tests/snapshots/snap_test_mutation.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # snapshottest: v1 - https://goo.gl/zC4yUc -from __future__ import unicode_literals - from snapshottest import Snapshot snapshots = Snapshot() diff --git a/examples/starwars_relay/tests/snapshots/snap_test_objectidentification.py b/examples/starwars_relay/tests/snapshots/snap_test_objectidentification.py index b02a420c5..ab83e3585 100644 --- a/examples/starwars_relay/tests/snapshots/snap_test_objectidentification.py +++ b/examples/starwars_relay/tests/snapshots/snap_test_objectidentification.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # snapshottest: v1 - https://goo.gl/zC4yUc -from __future__ import unicode_literals - from snapshottest import Snapshot diff --git a/graphene/pyutils/version.py b/graphene/pyutils/version.py index 8a3be07a9..c5f893f55 100644 --- a/graphene/pyutils/version.py +++ b/graphene/pyutils/version.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import datetime import os import subprocess diff --git a/graphene/types/datetime.py b/graphene/types/datetime.py index a22c5cd38..7bfd9bd22 100644 --- a/graphene/types/datetime.py +++ b/graphene/types/datetime.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import - import datetime from graphql.error import GraphQLError diff --git a/graphene/types/decimal.py b/graphene/types/decimal.py index 0c6ccc974..69952f96d 100644 --- a/graphene/types/decimal.py +++ b/graphene/types/decimal.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import - from decimal import Decimal as _Decimal from graphql import Undefined diff --git a/graphene/types/generic.py b/graphene/types/generic.py index 2a3c8d524..1c007211f 100644 --- a/graphene/types/generic.py +++ b/graphene/types/generic.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from graphql.language.ast import ( BooleanValueNode, FloatValueNode, diff --git a/graphene/types/json.py b/graphene/types/json.py index ca55836b9..806d1be66 100644 --- a/graphene/types/json.py +++ b/graphene/types/json.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import - import json from graphql import Undefined diff --git a/graphene/types/uuid.py b/graphene/types/uuid.py index f2ba1fcb3..773e31c73 100644 --- a/graphene/types/uuid.py +++ b/graphene/types/uuid.py @@ -1,4 +1,3 @@ -from __future__ import absolute_import from uuid import UUID as _UUID from graphql.language.ast import StringValueNode From d90d65cafea98871c7c602cc6e954c7a14e21c85 Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Sat, 22 Jun 2024 12:31:14 +0200 Subject: [PATCH 58/79] chore: adjust incorrect development status --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6ae8a1d52..33f611143 100644 --- a/setup.py +++ b/setup.py @@ -68,7 +68,7 @@ def run_tests(self): author_email="me@syrusakbary.com", license="MIT", classifiers=[ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Topic :: Software Development :: Libraries", "Programming Language :: Python :: 3.8", From c335c5f529dec8fa6c56b3d629690791e5179640 Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Mon, 24 Jun 2024 00:24:34 +0800 Subject: [PATCH 59/79] fix lint error in SECURITY.md (#1556) fix lint SECURITY.md --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 5b58ab62d..79a80b799 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Supported Versions -Support for security issues is currently provided for Graphene 3.0 and above. Support on earlier versions cannot be guaranteed by the maintainers of this library, but community PRs may be accepted in critical cases. +Support for security issues is currently provided for Graphene 3.0 and above. Support on earlier versions cannot be guaranteed by the maintainers of this library, but community PRs may be accepted in critical cases. The preferred mitigation strategy is via an upgrade to Graphene 3. | Version | Supported | From 68343857868b08d4ff55153fc93fb6398196f8dc Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Fri, 28 Jun 2024 21:03:34 +0800 Subject: [PATCH 60/79] support python 3.13 (#1561) --- .github/workflows/tests.yml | 1 + setup.py | 1 + tox.ini | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0389d7ad6..4bc23724b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,6 +25,7 @@ jobs: fail-fast: false matrix: include: + - {name: '3.13', python: '3.13-dev', os: ubuntu-latest, tox: py313} - {name: '3.12', python: '3.12', os: ubuntu-latest, tox: py312} - {name: '3.11', python: '3.11', os: ubuntu-latest, tox: py311} - {name: '3.10', python: '3.10', os: ubuntu-latest, tox: py310} diff --git a/setup.py b/setup.py index 33f611143..440a7652c 100644 --- a/setup.py +++ b/setup.py @@ -76,6 +76,7 @@ def run_tests(self): "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ], keywords="api graphql protocol rest relay graphene", packages=find_packages(exclude=["examples*"]), diff --git a/tox.ini b/tox.ini index 767f957cd..fdec66d08 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py3{8,9,10,11,12}, mypy, pre-commit +envlist = py3{8,9,10,11,12,13}, mypy, pre-commit skipsdist = true [testenv] @@ -8,7 +8,7 @@ deps = setenv = PYTHONPATH = .:{envdir} commands = - py{38,39,310,311,12}: pytest --cov=graphene graphene --cov-report=term --cov-report=xml examples {posargs} + py{38,39,310,311,12,13}: pytest --cov=graphene graphene --cov-report=term --cov-report=xml examples {posargs} [testenv:pre-commit] basepython = python3.10 From 74b33ae148e8e55a62636df52510443bdbc0e010 Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Fri, 28 Jun 2024 21:03:48 +0800 Subject: [PATCH 61/79] remove README.rst, leave only README.md (#1559) remove README.rst --- README.rst | 171 ----------------------------------------------------- setup.py | 3 +- 2 files changed, 2 insertions(+), 172 deletions(-) delete mode 100644 README.rst diff --git a/README.rst b/README.rst deleted file mode 100644 index 6b26404f7..000000000 --- a/README.rst +++ /dev/null @@ -1,171 +0,0 @@ -|Graphene Logo| `Graphene `__ |Build Status| |PyPI version| |Coverage Status| -========================================================================================================= - -`💬 Join the community on -Slack `__ - -**We are looking for contributors**! Please check the -`ROADMAP `__ -to see how you can help ❤️ - -Introduction ------------- - -`Graphene `__ is an opinionated Python -library for building GraphQL schemas/types fast and easily. - -- **Easy to use:** Graphene helps you use GraphQL in Python without - effort. -- **Relay:** Graphene has builtin support for Relay. -- **Data agnostic:** Graphene supports any kind of data source: SQL - (Django, SQLAlchemy), NoSQL, custom Python objects, etc. We believe - that by providing a complete API you could plug Graphene anywhere - your data lives and make your data available through GraphQL. - -Integrations ------------- - -Graphene has multiple integrations with different frameworks: - -+-------------------+-------------------------------------------------+ -| integration | Package | -+===================+=================================================+ -| Django | `graphene-django `__ | -+-------------------+-------------------------------------------------+ -| SQLAlchemy | `graphene-sqlalchemy `__ | -+-------------------+-------------------------------------------------+ - -Also, Graphene is fully compatible with the GraphQL spec, working -seamlessly with all GraphQL clients, such as -`Relay `__, -`Apollo `__ and -`gql `__. - -Installation ------------- - -To install `graphene`, just run this command in your shell - -.. code:: bash - - pip install "graphene>=3.0" - -Examples --------- - -Here is one example for you to get started: - -.. code:: python - - import graphene - - class Query(graphene.ObjectType): - hello = graphene.String(description='A typical hello world') - - def resolve_hello(self, info): - return 'World' - - schema = graphene.Schema(query=Query) - -Then Querying ``graphene.Schema`` is as simple as: - -.. code:: python - - query = ''' - query SayHello { - hello - } - ''' - result = schema.execute(query) - -If you want to learn even more, you can also check the following -`examples `__: - -- **Basic Schema**: `Starwars example `__ -- **Relay Schema**: `Starwars Relay - example `__ - -Documentation -------------- - -Documentation and links to additional resources are available at -https://docs.graphene-python.org/en/latest/ - -Contributing ------------- - -After cloning this repo, create a -`virtualenv `__ and ensure -dependencies are installed by running: - -.. code:: sh - - virtualenv venv - source venv/bin/activate - pip install -e ".[test]" - -Well-written tests and maintaining good test coverage is important to -this project. While developing, run new and existing tests with: - -.. code:: sh - - py.test graphene/relay/tests/test_node.py # Single file - py.test graphene/relay # All tests in directory - -Add the ``-s`` flag if you have introduced breakpoints into the code for -debugging. Add the ``-v`` (“verbose”) flag to get more detailed test -output. For even more detailed output, use ``-vv``. Check out the -`pytest documentation `__ for more -options and test running controls. - -You can also run the benchmarks with: - -.. code:: sh - - py.test graphene --benchmark-only - -Graphene supports several versions of Python. To make sure that changes -do not break compatibility with any of those versions, we use ``tox`` to -create virtualenvs for each Python version and run tests with that -version. To run against all Python versions defined in the ``tox.ini`` -config file, just run: - -.. code:: sh - - tox - -If you wish to run against a specific version defined in the ``tox.ini`` -file: - -.. code:: sh - - tox -e py10 - -Tox can only use whatever versions of Python are installed on your -system. When you create a pull request, Travis will also be running the -same tests and report the results, so there is no need for potential -contributors to try to install every single version of Python on their -own system ahead of time. We appreciate opening issues and pull requests -to make graphene even more stable & useful! - -Building Documentation -~~~~~~~~~~~~~~~~~~~~~~ - -The documentation is generated using the excellent -`Sphinx `__ and a custom theme. - -An HTML version of the documentation is produced by running: - -.. code:: sh - - make docs - -.. |Graphene Logo| image:: http://graphene-python.org/favicon.png -.. |Build Status| image:: https://travis-ci.org/graphql-python/graphene.svg?branch=master - :target: https://travis-ci.org/graphql-python/graphene -.. |PyPI version| image:: https://badge.fury.io/py/graphene.svg - :target: https://badge.fury.io/py/graphene -.. |Coverage Status| image:: https://coveralls.io/repos/graphql-python/graphene/badge.svg?branch=master&service=github - :target: https://coveralls.io/github/graphql-python/graphene?branch=master diff --git a/setup.py b/setup.py index 440a7652c..12a9acac3 100644 --- a/setup.py +++ b/setup.py @@ -61,8 +61,9 @@ def run_tests(self): version=version, description="GraphQL Framework for Python", long_description=codecs.open( - "README.rst", "r", encoding="ascii", errors="replace" + "README.md", "r", encoding="ascii", errors="replace" ).read(), + long_description_content_type="text/markdown", url="https://github.com/graphql-python/graphene", author="Syrus Akbary", author_email="me@syrusakbary.com", From 1263e9b41e56e577b5cae4cd2486c2039b4be063 Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Fri, 28 Jun 2024 21:04:25 +0800 Subject: [PATCH 62/79] pytest 8 (#1549) * pytest 8 * bump coveralls, pytest-cov --------- Co-authored-by: Erik Wrede --- setup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 12a9acac3..c560c21b4 100644 --- a/setup.py +++ b/setup.py @@ -45,13 +45,13 @@ def run_tests(self): tests_require = [ - "pytest>=7,<8", + "pytest>=8,<9", "pytest-benchmark>=4,<5", - "pytest-cov>=4,<5", + "pytest-cov>=5,<6", "pytest-mock>=3,<4", "pytest-asyncio>=0.16,<2", "snapshottest>=0.6,<1", - "coveralls>=3.3,<4", + "coveralls>=4,<5", ] dev_requires = ["black==22.3.0", "flake8>=4,<5"] + tests_require From fd9ecef36ea4c9feb4c99a66af295e0da19277db Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Fri, 28 Jun 2024 21:05:04 +0800 Subject: [PATCH 63/79] CI: format check using Ruff (#1557) * CI: format check using Ruff * precommit, setup py * gitignore ruff_cache --------- Co-authored-by: Erik Wrede --- .github/workflows/lint.yml | 4 +++- .gitignore | 1 + .pre-commit-config.yaml | 7 ++++--- examples/simple_example.py | 1 - .../tests/snapshots/snap_test_objectidentification.py | 4 +--- graphene/relay/tests/test_node.py | 1 - graphene/types/enum.py | 4 +--- graphene/types/tests/test_scalars_serialization.py | 2 +- graphene/utils/dataloader.py | 1 - graphene/utils/deprecated.py | 3 --- setup.py | 2 +- 11 files changed, 12 insertions(+), 18 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9112718a5..0b3c0fc3e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,7 +15,9 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install tox + pip install ruff tox + - name: Format check using Ruff + run: ruff format --check - name: Run lint run: tox env: diff --git a/.gitignore b/.gitignore index 9148845fa..fa2a6ab37 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,4 @@ venv/ *.sqlite3 .vscode .mypy_cache +.ruff_cache diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eece56e04..70c773e90 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,10 +20,11 @@ repos: rev: v2.37.3 hooks: - id: pyupgrade -- repo: https://github.com/psf/black - rev: 22.6.0 +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.4.10 hooks: - - id: black + - id: ruff-format - repo: https://github.com/PyCQA/flake8 rev: 5.0.4 hooks: diff --git a/examples/simple_example.py b/examples/simple_example.py index 9bee8d1f4..d2685d288 100644 --- a/examples/simple_example.py +++ b/examples/simple_example.py @@ -8,7 +8,6 @@ class Patron(graphene.ObjectType): class Query(graphene.ObjectType): - patron = graphene.Field(Patron) def resolve_patron(root, info): diff --git a/examples/starwars_relay/tests/snapshots/snap_test_objectidentification.py b/examples/starwars_relay/tests/snapshots/snap_test_objectidentification.py index ab83e3585..b06fb6bf4 100644 --- a/examples/starwars_relay/tests/snapshots/snap_test_objectidentification.py +++ b/examples/starwars_relay/tests/snapshots/snap_test_objectidentification.py @@ -27,9 +27,7 @@ "data": {"node": {"id": "U2hpcDox", "name": "X-Wing"}} } -snapshots[ - "test_str_schema 1" -] = '''type Query { +snapshots["test_str_schema 1"] = '''type Query { rebels: Faction empire: Faction node( diff --git a/graphene/relay/tests/test_node.py b/graphene/relay/tests/test_node.py index e75645664..80181a1bb 100644 --- a/graphene/relay/tests/test_node.py +++ b/graphene/relay/tests/test_node.py @@ -8,7 +8,6 @@ class SharedNodeFields: - shared = String() something_else = String() diff --git a/graphene/types/enum.py b/graphene/types/enum.py index d3469a15e..bc61cd4c6 100644 --- a/graphene/types/enum.py +++ b/graphene/types/enum.py @@ -61,9 +61,7 @@ def __call__(cls, *args, **kwargs): # noqa: N805 def __iter__(cls): return cls._meta.enum.__iter__() - def from_enum( - cls, enum, name=None, description=None, deprecation_reason=None - ): # noqa: N805 + def from_enum(cls, enum, name=None, description=None, deprecation_reason=None): # noqa: N805 name = name or enum.__name__ description = description or enum.__doc__ or "An enumeration." meta_dict = { diff --git a/graphene/types/tests/test_scalars_serialization.py b/graphene/types/tests/test_scalars_serialization.py index a0028c85d..4af8a413a 100644 --- a/graphene/types/tests/test_scalars_serialization.py +++ b/graphene/types/tests/test_scalars_serialization.py @@ -39,7 +39,7 @@ def test_serializes_output_string(): assert String.serialize(-1.1) == "-1.1" assert String.serialize(True) == "true" assert String.serialize(False) == "false" - assert String.serialize("\U0001F601") == "\U0001F601" + assert String.serialize("\U0001f601") == "\U0001f601" def test_serializes_output_boolean(): diff --git a/graphene/utils/dataloader.py b/graphene/utils/dataloader.py index 143558aa2..b8f4a0cdd 100644 --- a/graphene/utils/dataloader.py +++ b/graphene/utils/dataloader.py @@ -33,7 +33,6 @@ def __init__( cache_map=None, loop=None, ): - self._loop = loop if batch_load_fn is not None: diff --git a/graphene/utils/deprecated.py b/graphene/utils/deprecated.py index 71a5bb404..42c358fb3 100644 --- a/graphene/utils/deprecated.py +++ b/graphene/utils/deprecated.py @@ -17,7 +17,6 @@ def deprecated(reason): """ if isinstance(reason, string_types): - # The @deprecated is used with a 'reason'. # # .. code-block:: python @@ -27,7 +26,6 @@ def deprecated(reason): # pass def decorator(func1): - if inspect.isclass(func1): fmt1 = f"Call to deprecated class {func1.__name__} ({reason})." else: @@ -43,7 +41,6 @@ def new_func1(*args, **kwargs): return decorator elif inspect.isclass(reason) or inspect.isfunction(reason): - # The @deprecated is used without any 'reason'. # # .. code-block:: python diff --git a/setup.py b/setup.py index c560c21b4..72377c7e1 100644 --- a/setup.py +++ b/setup.py @@ -54,7 +54,7 @@ def run_tests(self): "coveralls>=4,<5", ] -dev_requires = ["black==22.3.0", "flake8>=4,<5"] + tests_require +dev_requires = ["ruff==0.4.10", "flake8>=4,<5"] + tests_require setup( name="graphene", From d53a102b085748efa85477d29caaedfccbb1bc43 Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Mon, 1 Jul 2024 23:03:13 +0800 Subject: [PATCH 64/79] Lint using Ruff (#1563) * lint using Ruff * remove isort config, flake8 comments --- .isort.cfg | 2 -- .pre-commit-config.yaml | 7 ++----- bin/autolinter | 7 ------- docs/conf.py | 3 +-- graphene/pyutils/version.py | 2 +- graphene/relay/id_type.py | 2 +- graphene/types/__init__.py | 1 - graphene/types/base.py | 12 ++++++------ graphene/types/scalars.py | 3 +-- graphene/utils/dataloader.py | 4 ++-- setup.cfg | 11 ----------- setup.py | 2 +- 12 files changed, 15 insertions(+), 41 deletions(-) delete mode 100644 .isort.cfg delete mode 100755 bin/autolinter diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index 42fa707df..000000000 --- a/.isort.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[settings] -known_third_party = graphql,graphql_relay,promise,pytest,pyutils,setuptools,snapshottest,sphinx_graphene_theme diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 70c773e90..5b889e021 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,10 +22,7 @@ repos: - id: pyupgrade - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.4.10 + rev: v0.5.0 hooks: + - id: ruff - id: ruff-format -- repo: https://github.com/PyCQA/flake8 - rev: 5.0.4 - hooks: - - id: flake8 diff --git a/bin/autolinter b/bin/autolinter deleted file mode 100755 index 0fc3ccaee..000000000 --- a/bin/autolinter +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -# Install the required scripts with -# pip install autoflake autopep8 isort -autoflake ./examples/ ./graphene/ -r --remove-unused-variables --remove-all-unused-imports --in-place -autopep8 ./examples/ ./graphene/ -r --in-place --experimental --aggressive --max-line-length 120 -isort -rc ./examples/ ./graphene/ diff --git a/docs/conf.py b/docs/conf.py index 75f515416..873531ae6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,5 @@ import os +import sys import sphinx_graphene_theme @@ -22,8 +23,6 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # -import os -import sys sys.path.insert(0, os.path.abspath("..")) diff --git a/graphene/pyutils/version.py b/graphene/pyutils/version.py index c5f893f55..1a3453e97 100644 --- a/graphene/pyutils/version.py +++ b/graphene/pyutils/version.py @@ -71,6 +71,6 @@ def get_git_changeset(): ) timestamp = git_log.communicate()[0] timestamp = datetime.datetime.utcfromtimestamp(int(timestamp)) - except: + except Exception: return None return timestamp.strftime("%Y%m%d%H%M%S") diff --git a/graphene/relay/id_type.py b/graphene/relay/id_type.py index fb5c30e72..8369a7517 100644 --- a/graphene/relay/id_type.py +++ b/graphene/relay/id_type.py @@ -11,7 +11,7 @@ class BaseGlobalIDType: Base class that define the required attributes/method for a type. """ - graphene_type = ID # type: Type[BaseType] + graphene_type: Type[BaseType] = ID @classmethod def resolve_global_id(cls, info, global_id): diff --git a/graphene/types/__init__.py b/graphene/types/__init__.py index 70478a058..e23837d27 100644 --- a/graphene/types/__init__.py +++ b/graphene/types/__init__.py @@ -1,4 +1,3 @@ -# flake8: noqa from graphql import GraphQLResolveInfo as ResolveInfo from .argument import Argument diff --git a/graphene/types/base.py b/graphene/types/base.py index 84cb377a2..063f0cd95 100644 --- a/graphene/types/base.py +++ b/graphene/types/base.py @@ -1,17 +1,17 @@ -from typing import Type +from typing import Type, Optional from ..utils.subclass_with_meta import SubclassWithMeta, SubclassWithMeta_Meta from ..utils.trim_docstring import trim_docstring class BaseOptions: - name = None # type: str - description = None # type: str + name: Optional[str] = None + description: Optional[str] = None - _frozen = False # type: bool + _frozen: bool = False - def __init__(self, class_type): - self.class_type = class_type # type: Type + def __init__(self, class_type: Type): + self.class_type: Type = class_type def freeze(self): self._frozen = True diff --git a/graphene/types/scalars.py b/graphene/types/scalars.py index a468bb3e6..8546bc85c 100644 --- a/graphene/types/scalars.py +++ b/graphene/types/scalars.py @@ -121,8 +121,7 @@ class Float(Scalar): """ @staticmethod - def coerce_float(value): - # type: (Any) -> float + def coerce_float(value: Any) -> float: try: return float(value) except ValueError: diff --git a/graphene/utils/dataloader.py b/graphene/utils/dataloader.py index b8f4a0cdd..c75b9ee9e 100644 --- a/graphene/utils/dataloader.py +++ b/graphene/utils/dataloader.py @@ -9,7 +9,7 @@ from collections.abc import Iterable from functools import partial -from typing import List # flake8: noqa +from typing import List Loader = namedtuple("Loader", "key,future") @@ -62,7 +62,7 @@ def __init__( self.get_cache_key = get_cache_key or (lambda x: x) self._cache = cache_map if cache_map is not None else {} - self._queue = [] # type: List[Loader] + self._queue: List[Loader] = [] @property def loop(self): diff --git a/setup.cfg b/setup.cfg index db1ff1345..0608b0166 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,16 +1,5 @@ -[flake8] -exclude = setup.py,docs/*,*/examples/*,graphene/pyutils/*,tests -max-line-length = 120 - -# This is a specific ignore for Black+Flake8 -# source: https://black.readthedocs.io/en/stable/guides/using_black_with_other_tools.html#id1 -extend-ignore = E203 - [coverage:run] omit = graphene/pyutils/*,*/tests/*,graphene/types/scalars.py -[isort] -known_first_party=graphene - [bdist_wheel] universal=1 diff --git a/setup.py b/setup.py index 72377c7e1..5f949a7d2 100644 --- a/setup.py +++ b/setup.py @@ -54,7 +54,7 @@ def run_tests(self): "coveralls>=4,<5", ] -dev_requires = ["ruff==0.4.10", "flake8>=4,<5"] + tests_require +dev_requires = ["ruff==0.5.0"] + tests_require setup( name="graphene", From dc3b2e49c116bd6ed54a977291b0f04b51fc9306 Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Mon, 1 Jul 2024 23:03:49 +0800 Subject: [PATCH 65/79] CI: fix tests on Python 3.13 (#1562) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5f949a7d2..6c589dcba 100644 --- a/setup.py +++ b/setup.py @@ -51,7 +51,7 @@ def run_tests(self): "pytest-mock>=3,<4", "pytest-asyncio>=0.16,<2", "snapshottest>=0.6,<1", - "coveralls>=4,<5", + "coveralls>=3.3,<5", ] dev_requires = ["ruff==0.5.0"] + tests_require From 48678afba44fc6e43334133f92ea089613a29d93 Mon Sep 17 00:00:00 2001 From: Florian Zimmermann Date: Thu, 8 Aug 2024 11:49:26 +0200 Subject: [PATCH 66/79] fix: run the tests in python 3.12 and 3.13 and remove `snapshottest` dependency (#1572) * actually run the tests in python 3.12 and 3.13 * remove snapshottest from the example tests so that the tests pass in 3.12 and 3.13 again * remove the section about snapshot testing from the testing docs because the snapshottest package doesn't work on Python 3.12 and above * fix assertion for badly formed JSON input on Python 3.13 * fix deprecation warning about datetime.utcfromtimestamp() --- docs/testing/index.rst | 40 ---- examples/starwars/tests/snapshots/__init__.py | 0 .../tests/snapshots/snap_test_query.py | 98 ---------- examples/starwars/tests/test_query.py | 179 ++++++++++++------ .../tests/snapshots/__init__.py | 0 .../tests/snapshots/snap_test_connections.py | 24 --- .../tests/snapshots/snap_test_mutation.py | 26 --- .../snap_test_objectidentification.py | 114 ----------- .../starwars_relay/tests/test_connections.py | 58 ++++-- .../starwars_relay/tests/test_mutation.py | 55 ++++-- .../tests/test_objectidentification.py | 145 +++++++++++--- graphene/tests/issues/test_1293.py | 6 +- graphene/types/tests/test_json.py | 39 ++-- setup.py | 1 - tox.ini | 4 +- 15 files changed, 344 insertions(+), 445 deletions(-) delete mode 100644 examples/starwars/tests/snapshots/__init__.py delete mode 100644 examples/starwars/tests/snapshots/snap_test_query.py delete mode 100644 examples/starwars_relay/tests/snapshots/__init__.py delete mode 100644 examples/starwars_relay/tests/snapshots/snap_test_connections.py delete mode 100644 examples/starwars_relay/tests/snapshots/snap_test_mutation.py delete mode 100644 examples/starwars_relay/tests/snapshots/snap_test_objectidentification.py diff --git a/docs/testing/index.rst b/docs/testing/index.rst index 877879f6f..db858b17b 100644 --- a/docs/testing/index.rst +++ b/docs/testing/index.rst @@ -69,43 +69,3 @@ You can also add extra keyword arguments to the ``execute`` method, such as 'hey': 'hello Peter!' } } - - -Snapshot testing -~~~~~~~~~~~~~~~~ - -As our APIs evolve, we need to know when our changes introduce any breaking changes that might break -some of the clients of our GraphQL app. - -However, writing tests and replicating the same response we expect from our GraphQL application can be a -tedious and repetitive task, and sometimes it's easier to skip this process. - -Because of that, we recommend the usage of `SnapshotTest `_. - -SnapshotTest lets us write all these tests in a breeze, as it automatically creates the ``snapshots`` for us -the first time the test are executed. - - -Here is a simple example on how our tests will look if we use ``pytest``: - -.. code:: python - - def test_hey(snapshot): - client = Client(my_schema) - # This will create a snapshot dir and a snapshot file - # the first time the test is executed, with the response - # of the execution. - snapshot.assert_match(client.execute('''{ hey }''')) - - -If we are using ``unittest``: - -.. code:: python - - from snapshottest import TestCase - - class APITestCase(TestCase): - def test_api_me(self): - """Testing the API for /me""" - client = Client(my_schema) - self.assertMatchSnapshot(client.execute('''{ hey }''')) diff --git a/examples/starwars/tests/snapshots/__init__.py b/examples/starwars/tests/snapshots/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/starwars/tests/snapshots/snap_test_query.py b/examples/starwars/tests/snapshots/snap_test_query.py deleted file mode 100644 index 1ede86e42..000000000 --- a/examples/starwars/tests/snapshots/snap_test_query.py +++ /dev/null @@ -1,98 +0,0 @@ -# -*- coding: utf-8 -*- -# snapshottest: v1 - https://goo.gl/zC4yUc -from snapshottest import Snapshot - -snapshots = Snapshot() - -snapshots["test_hero_name_query 1"] = {"data": {"hero": {"name": "R2-D2"}}} - -snapshots["test_hero_name_and_friends_query 1"] = { - "data": { - "hero": { - "id": "2001", - "name": "R2-D2", - "friends": [ - {"name": "Luke Skywalker"}, - {"name": "Han Solo"}, - {"name": "Leia Organa"}, - ], - } - } -} - -snapshots["test_nested_query 1"] = { - "data": { - "hero": { - "name": "R2-D2", - "friends": [ - { - "name": "Luke Skywalker", - "appearsIn": ["NEWHOPE", "EMPIRE", "JEDI"], - "friends": [ - {"name": "Han Solo"}, - {"name": "Leia Organa"}, - {"name": "C-3PO"}, - {"name": "R2-D2"}, - ], - }, - { - "name": "Han Solo", - "appearsIn": ["NEWHOPE", "EMPIRE", "JEDI"], - "friends": [ - {"name": "Luke Skywalker"}, - {"name": "Leia Organa"}, - {"name": "R2-D2"}, - ], - }, - { - "name": "Leia Organa", - "appearsIn": ["NEWHOPE", "EMPIRE", "JEDI"], - "friends": [ - {"name": "Luke Skywalker"}, - {"name": "Han Solo"}, - {"name": "C-3PO"}, - {"name": "R2-D2"}, - ], - }, - ], - } - } -} - -snapshots["test_fetch_luke_query 1"] = {"data": {"human": {"name": "Luke Skywalker"}}} - -snapshots["test_fetch_some_id_query 1"] = { - "data": {"human": {"name": "Luke Skywalker"}} -} - -snapshots["test_fetch_some_id_query2 1"] = {"data": {"human": {"name": "Han Solo"}}} - -snapshots["test_invalid_id_query 1"] = {"data": {"human": None}} - -snapshots["test_fetch_luke_aliased 1"] = {"data": {"luke": {"name": "Luke Skywalker"}}} - -snapshots["test_fetch_luke_and_leia_aliased 1"] = { - "data": {"luke": {"name": "Luke Skywalker"}, "leia": {"name": "Leia Organa"}} -} - -snapshots["test_duplicate_fields 1"] = { - "data": { - "luke": {"name": "Luke Skywalker", "homePlanet": "Tatooine"}, - "leia": {"name": "Leia Organa", "homePlanet": "Alderaan"}, - } -} - -snapshots["test_use_fragment 1"] = { - "data": { - "luke": {"name": "Luke Skywalker", "homePlanet": "Tatooine"}, - "leia": {"name": "Leia Organa", "homePlanet": "Alderaan"}, - } -} - -snapshots["test_check_type_of_r2 1"] = { - "data": {"hero": {"__typename": "Droid", "name": "R2-D2"}} -} - -snapshots["test_check_type_of_luke 1"] = { - "data": {"hero": {"__typename": "Human", "name": "Luke Skywalker"}} -} diff --git a/examples/starwars/tests/test_query.py b/examples/starwars/tests/test_query.py index 88934b0ed..98e92bccb 100644 --- a/examples/starwars/tests/test_query.py +++ b/examples/starwars/tests/test_query.py @@ -8,19 +8,19 @@ client = Client(schema) -def test_hero_name_query(snapshot): - query = """ +def test_hero_name_query(): + result = client.execute(""" query HeroNameQuery { hero { name } } - """ - snapshot.assert_match(client.execute(query)) + """) + assert result == {"data": {"hero": {"name": "R2-D2"}}} -def test_hero_name_and_friends_query(snapshot): - query = """ +def test_hero_name_and_friends_query(): + result = client.execute(""" query HeroNameAndFriendsQuery { hero { id @@ -30,12 +30,24 @@ def test_hero_name_and_friends_query(snapshot): } } } - """ - snapshot.assert_match(client.execute(query)) + """) + assert result == { + "data": { + "hero": { + "id": "2001", + "name": "R2-D2", + "friends": [ + {"name": "Luke Skywalker"}, + {"name": "Han Solo"}, + {"name": "Leia Organa"}, + ], + } + } + } -def test_nested_query(snapshot): - query = """ +def test_nested_query(): + result = client.execute(""" query NestedQuery { hero { name @@ -48,70 +60,113 @@ def test_nested_query(snapshot): } } } - """ - snapshot.assert_match(client.execute(query)) + """) + assert result == { + "data": { + "hero": { + "name": "R2-D2", + "friends": [ + { + "name": "Luke Skywalker", + "appearsIn": ["NEWHOPE", "EMPIRE", "JEDI"], + "friends": [ + {"name": "Han Solo"}, + {"name": "Leia Organa"}, + {"name": "C-3PO"}, + {"name": "R2-D2"}, + ], + }, + { + "name": "Han Solo", + "appearsIn": ["NEWHOPE", "EMPIRE", "JEDI"], + "friends": [ + {"name": "Luke Skywalker"}, + {"name": "Leia Organa"}, + {"name": "R2-D2"}, + ], + }, + { + "name": "Leia Organa", + "appearsIn": ["NEWHOPE", "EMPIRE", "JEDI"], + "friends": [ + {"name": "Luke Skywalker"}, + {"name": "Han Solo"}, + {"name": "C-3PO"}, + {"name": "R2-D2"}, + ], + }, + ], + } + } + } -def test_fetch_luke_query(snapshot): - query = """ +def test_fetch_luke_query(): + result = client.execute(""" query FetchLukeQuery { human(id: "1000") { name } } - """ - snapshot.assert_match(client.execute(query)) + """) + assert result == {"data": {"human": {"name": "Luke Skywalker"}}} -def test_fetch_some_id_query(snapshot): - query = """ +def test_fetch_some_id_query(): + result = client.execute( + """ query FetchSomeIDQuery($someId: String!) { human(id: $someId) { name } } - """ - params = {"someId": "1000"} - snapshot.assert_match(client.execute(query, variables=params)) + """, + variables={"someId": "1000"}, + ) + assert result == {"data": {"human": {"name": "Luke Skywalker"}}} -def test_fetch_some_id_query2(snapshot): - query = """ +def test_fetch_some_id_query2(): + result = client.execute( + """ query FetchSomeIDQuery($someId: String!) { human(id: $someId) { name } } - """ - params = {"someId": "1002"} - snapshot.assert_match(client.execute(query, variables=params)) + """, + variables={"someId": "1002"}, + ) + assert result == {"data": {"human": {"name": "Han Solo"}}} -def test_invalid_id_query(snapshot): - query = """ +def test_invalid_id_query(): + result = client.execute( + """ query humanQuery($id: String!) { human(id: $id) { name } } - """ - params = {"id": "not a valid id"} - snapshot.assert_match(client.execute(query, variables=params)) + """, + variables={"id": "not a valid id"}, + ) + assert result == {"data": {"human": None}} -def test_fetch_luke_aliased(snapshot): - query = """ +def test_fetch_luke_aliased(): + result = client.execute(""" query FetchLukeAliased { luke: human(id: "1000") { name } } - """ - snapshot.assert_match(client.execute(query)) + """) + assert result == {"data": {"luke": {"name": "Luke Skywalker"}}} -def test_fetch_luke_and_leia_aliased(snapshot): - query = """ +def test_fetch_luke_and_leia_aliased(): + result = client.execute(""" query FetchLukeAndLeiaAliased { luke: human(id: "1000") { name @@ -120,12 +175,14 @@ def test_fetch_luke_and_leia_aliased(snapshot): name } } - """ - snapshot.assert_match(client.execute(query)) + """) + assert result == { + "data": {"luke": {"name": "Luke Skywalker"}, "leia": {"name": "Leia Organa"}} + } -def test_duplicate_fields(snapshot): - query = """ +def test_duplicate_fields(): + result = client.execute(""" query DuplicateFields { luke: human(id: "1000") { name @@ -136,12 +193,17 @@ def test_duplicate_fields(snapshot): homePlanet } } - """ - snapshot.assert_match(client.execute(query)) + """) + assert result == { + "data": { + "luke": {"name": "Luke Skywalker", "homePlanet": "Tatooine"}, + "leia": {"name": "Leia Organa", "homePlanet": "Alderaan"}, + } + } -def test_use_fragment(snapshot): - query = """ +def test_use_fragment(): + result = client.execute(""" query UseFragment { luke: human(id: "1000") { ...HumanFragment @@ -154,29 +216,36 @@ def test_use_fragment(snapshot): name homePlanet } - """ - snapshot.assert_match(client.execute(query)) + """) + assert result == { + "data": { + "luke": {"name": "Luke Skywalker", "homePlanet": "Tatooine"}, + "leia": {"name": "Leia Organa", "homePlanet": "Alderaan"}, + } + } -def test_check_type_of_r2(snapshot): - query = """ +def test_check_type_of_r2(): + result = client.execute(""" query CheckTypeOfR2 { hero { __typename name } } - """ - snapshot.assert_match(client.execute(query)) + """) + assert result == {"data": {"hero": {"__typename": "Droid", "name": "R2-D2"}}} -def test_check_type_of_luke(snapshot): - query = """ +def test_check_type_of_luke(): + result = client.execute(""" query CheckTypeOfLuke { hero(episode: EMPIRE) { __typename name } } - """ - snapshot.assert_match(client.execute(query)) + """) + assert result == { + "data": {"hero": {"__typename": "Human", "name": "Luke Skywalker"}} + } diff --git a/examples/starwars_relay/tests/snapshots/__init__.py b/examples/starwars_relay/tests/snapshots/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/starwars_relay/tests/snapshots/snap_test_connections.py b/examples/starwars_relay/tests/snapshots/snap_test_connections.py deleted file mode 100644 index 353fee597..000000000 --- a/examples/starwars_relay/tests/snapshots/snap_test_connections.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -# snapshottest: v1 - https://goo.gl/zC4yUc -from snapshottest import Snapshot - -snapshots = Snapshot() - -snapshots["test_correct_fetch_first_ship_rebels 1"] = { - "data": { - "rebels": { - "name": "Alliance to Restore the Republic", - "ships": { - "pageInfo": { - "startCursor": "YXJyYXljb25uZWN0aW9uOjA=", - "endCursor": "YXJyYXljb25uZWN0aW9uOjA=", - "hasNextPage": True, - "hasPreviousPage": False, - }, - "edges": [ - {"cursor": "YXJyYXljb25uZWN0aW9uOjA=", "node": {"name": "X-Wing"}} - ], - }, - } - } -} diff --git a/examples/starwars_relay/tests/snapshots/snap_test_mutation.py b/examples/starwars_relay/tests/snapshots/snap_test_mutation.py deleted file mode 100644 index f0012e0a7..000000000 --- a/examples/starwars_relay/tests/snapshots/snap_test_mutation.py +++ /dev/null @@ -1,26 +0,0 @@ -# -*- coding: utf-8 -*- -# snapshottest: v1 - https://goo.gl/zC4yUc -from snapshottest import Snapshot - -snapshots = Snapshot() - -snapshots["test_mutations 1"] = { - "data": { - "introduceShip": { - "ship": {"id": "U2hpcDo5", "name": "Peter"}, - "faction": { - "name": "Alliance to Restore the Republic", - "ships": { - "edges": [ - {"node": {"id": "U2hpcDox", "name": "X-Wing"}}, - {"node": {"id": "U2hpcDoy", "name": "Y-Wing"}}, - {"node": {"id": "U2hpcDoz", "name": "A-Wing"}}, - {"node": {"id": "U2hpcDo0", "name": "Millennium Falcon"}}, - {"node": {"id": "U2hpcDo1", "name": "Home One"}}, - {"node": {"id": "U2hpcDo5", "name": "Peter"}}, - ] - }, - }, - } - } -} diff --git a/examples/starwars_relay/tests/snapshots/snap_test_objectidentification.py b/examples/starwars_relay/tests/snapshots/snap_test_objectidentification.py deleted file mode 100644 index b06fb6bf4..000000000 --- a/examples/starwars_relay/tests/snapshots/snap_test_objectidentification.py +++ /dev/null @@ -1,114 +0,0 @@ -# -*- coding: utf-8 -*- -# snapshottest: v1 - https://goo.gl/zC4yUc -from snapshottest import Snapshot - - -snapshots = Snapshot() - -snapshots["test_correctly_fetches_id_name_rebels 1"] = { - "data": { - "rebels": {"id": "RmFjdGlvbjox", "name": "Alliance to Restore the Republic"} - } -} - -snapshots["test_correctly_refetches_rebels 1"] = { - "data": {"node": {"id": "RmFjdGlvbjox", "name": "Alliance to Restore the Republic"}} -} - -snapshots["test_correctly_fetches_id_name_empire 1"] = { - "data": {"empire": {"id": "RmFjdGlvbjoy", "name": "Galactic Empire"}} -} - -snapshots["test_correctly_refetches_empire 1"] = { - "data": {"node": {"id": "RmFjdGlvbjoy", "name": "Galactic Empire"}} -} - -snapshots["test_correctly_refetches_xwing 1"] = { - "data": {"node": {"id": "U2hpcDox", "name": "X-Wing"}} -} - -snapshots["test_str_schema 1"] = '''type Query { - rebels: Faction - empire: Faction - node( - """The ID of the object""" - id: ID! - ): Node -} - -"""A faction in the Star Wars saga""" -type Faction implements Node { - """The ID of the object""" - id: ID! - - """The name of the faction.""" - name: String - - """The ships used by the faction.""" - ships(before: String, after: String, first: Int, last: Int): ShipConnection -} - -"""An object with an ID""" -interface Node { - """The ID of the object""" - id: ID! -} - -type ShipConnection { - """Pagination data for this connection.""" - pageInfo: PageInfo! - - """Contains the nodes in this connection.""" - edges: [ShipEdge]! -} - -""" -The Relay compliant `PageInfo` type, containing data necessary to paginate this connection. -""" -type PageInfo { - """When paginating forwards, are there more items?""" - hasNextPage: Boolean! - - """When paginating backwards, are there more items?""" - hasPreviousPage: Boolean! - - """When paginating backwards, the cursor to continue.""" - startCursor: String - - """When paginating forwards, the cursor to continue.""" - endCursor: String -} - -"""A Relay edge containing a `Ship` and its cursor.""" -type ShipEdge { - """The item at the end of the edge""" - node: Ship - - """A cursor for use in pagination""" - cursor: String! -} - -"""A ship in the Star Wars saga""" -type Ship implements Node { - """The ID of the object""" - id: ID! - - """The name of the ship.""" - name: String -} - -type Mutation { - introduceShip(input: IntroduceShipInput!): IntroduceShipPayload -} - -type IntroduceShipPayload { - ship: Ship - faction: Faction - clientMutationId: String -} - -input IntroduceShipInput { - shipName: String! - factionId: String! - clientMutationId: String -}''' diff --git a/examples/starwars_relay/tests/test_connections.py b/examples/starwars_relay/tests/test_connections.py index 697796d13..a816fa201 100644 --- a/examples/starwars_relay/tests/test_connections.py +++ b/examples/starwars_relay/tests/test_connections.py @@ -8,26 +8,46 @@ client = Client(schema) -def test_correct_fetch_first_ship_rebels(snapshot): - query = """ - query RebelsShipsQuery { - rebels { - name, - ships(first: 1) { - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - edges { - cursor - node { - name +def test_correct_fetch_first_ship_rebels(): + result = client.execute(""" + query RebelsShipsQuery { + rebels { + name, + ships(first: 1) { + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + edges { + cursor + node { + name + } + } } } } - } + """) + assert result == { + "data": { + "rebels": { + "name": "Alliance to Restore the Republic", + "ships": { + "pageInfo": { + "startCursor": "YXJyYXljb25uZWN0aW9uOjA=", + "endCursor": "YXJyYXljb25uZWN0aW9uOjA=", + "hasNextPage": True, + "hasPreviousPage": False, + }, + "edges": [ + { + "cursor": "YXJyYXljb25uZWN0aW9uOjA=", + "node": {"name": "X-Wing"}, + } + ], + }, + } + } } - """ - snapshot.assert_match(client.execute(query)) diff --git a/examples/starwars_relay/tests/test_mutation.py b/examples/starwars_relay/tests/test_mutation.py index e3ba7fe6d..6ff02c901 100644 --- a/examples/starwars_relay/tests/test_mutation.py +++ b/examples/starwars_relay/tests/test_mutation.py @@ -8,26 +8,45 @@ client = Client(schema) -def test_mutations(snapshot): - query = """ - mutation MyMutation { - introduceShip(input:{clientMutationId:"abc", shipName: "Peter", factionId: "1"}) { - ship { - id - name - } - faction { - name - ships { - edges { - node { - id - name +def test_mutations(): + result = client.execute(""" + mutation MyMutation { + introduceShip(input:{clientMutationId:"abc", shipName: "Peter", factionId: "1"}) { + ship { + id + name + } + faction { + name + ships { + edges { + node { + id + name + } + } } } } } - } + """) + assert result == { + "data": { + "introduceShip": { + "ship": {"id": "U2hpcDo5", "name": "Peter"}, + "faction": { + "name": "Alliance to Restore the Republic", + "ships": { + "edges": [ + {"node": {"id": "U2hpcDox", "name": "X-Wing"}}, + {"node": {"id": "U2hpcDoy", "name": "Y-Wing"}}, + {"node": {"id": "U2hpcDoz", "name": "A-Wing"}}, + {"node": {"id": "U2hpcDo0", "name": "Millennium Falcon"}}, + {"node": {"id": "U2hpcDo1", "name": "Home One"}}, + {"node": {"id": "U2hpcDo5", "name": "Peter"}}, + ] + }, + }, + } + } } - """ - snapshot.assert_match(client.execute(query)) diff --git a/examples/starwars_relay/tests/test_objectidentification.py b/examples/starwars_relay/tests/test_objectidentification.py index c024f432a..997efba0f 100644 --- a/examples/starwars_relay/tests/test_objectidentification.py +++ b/examples/starwars_relay/tests/test_objectidentification.py @@ -1,3 +1,5 @@ +import textwrap + from graphene.test import Client from ..data import setup @@ -8,24 +10,115 @@ client = Client(schema) -def test_str_schema(snapshot): - snapshot.assert_match(str(schema).strip()) +def test_str_schema(): + assert str(schema).strip() == textwrap.dedent( + '''\ + type Query { + rebels: Faction + empire: Faction + node( + """The ID of the object""" + id: ID! + ): Node + } + + """A faction in the Star Wars saga""" + type Faction implements Node { + """The ID of the object""" + id: ID! + + """The name of the faction.""" + name: String + + """The ships used by the faction.""" + ships(before: String, after: String, first: Int, last: Int): ShipConnection + } + + """An object with an ID""" + interface Node { + """The ID of the object""" + id: ID! + } + type ShipConnection { + """Pagination data for this connection.""" + pageInfo: PageInfo! + + """Contains the nodes in this connection.""" + edges: [ShipEdge]! + } + + """ + The Relay compliant `PageInfo` type, containing data necessary to paginate this connection. + """ + type PageInfo { + """When paginating forwards, are there more items?""" + hasNextPage: Boolean! + + """When paginating backwards, are there more items?""" + hasPreviousPage: Boolean! + + """When paginating backwards, the cursor to continue.""" + startCursor: String + + """When paginating forwards, the cursor to continue.""" + endCursor: String + } -def test_correctly_fetches_id_name_rebels(snapshot): - query = """ + """A Relay edge containing a `Ship` and its cursor.""" + type ShipEdge { + """The item at the end of the edge""" + node: Ship + + """A cursor for use in pagination""" + cursor: String! + } + + """A ship in the Star Wars saga""" + type Ship implements Node { + """The ID of the object""" + id: ID! + + """The name of the ship.""" + name: String + } + + type Mutation { + introduceShip(input: IntroduceShipInput!): IntroduceShipPayload + } + + type IntroduceShipPayload { + ship: Ship + faction: Faction + clientMutationId: String + } + + input IntroduceShipInput { + shipName: String! + factionId: String! + clientMutationId: String + }''' + ) + + +def test_correctly_fetches_id_name_rebels(): + result = client.execute(""" query RebelsQuery { rebels { id name } } - """ - snapshot.assert_match(client.execute(query)) + """) + assert result == { + "data": { + "rebels": {"id": "RmFjdGlvbjox", "name": "Alliance to Restore the Republic"} + } + } -def test_correctly_refetches_rebels(snapshot): - query = """ +def test_correctly_refetches_rebels(): + result = client.execute(""" query RebelsRefetchQuery { node(id: "RmFjdGlvbjox") { id @@ -34,24 +127,30 @@ def test_correctly_refetches_rebels(snapshot): } } } - """ - snapshot.assert_match(client.execute(query)) + """) + assert result == { + "data": { + "node": {"id": "RmFjdGlvbjox", "name": "Alliance to Restore the Republic"} + } + } -def test_correctly_fetches_id_name_empire(snapshot): - query = """ +def test_correctly_fetches_id_name_empire(): + result = client.execute(""" query EmpireQuery { empire { id name } } - """ - snapshot.assert_match(client.execute(query)) + """) + assert result == { + "data": {"empire": {"id": "RmFjdGlvbjoy", "name": "Galactic Empire"}} + } -def test_correctly_refetches_empire(snapshot): - query = """ +def test_correctly_refetches_empire(): + result = client.execute(""" query EmpireRefetchQuery { node(id: "RmFjdGlvbjoy") { id @@ -60,12 +159,14 @@ def test_correctly_refetches_empire(snapshot): } } } - """ - snapshot.assert_match(client.execute(query)) + """) + assert result == { + "data": {"node": {"id": "RmFjdGlvbjoy", "name": "Galactic Empire"}} + } -def test_correctly_refetches_xwing(snapshot): - query = """ +def test_correctly_refetches_xwing(): + result = client.execute(""" query XWingRefetchQuery { node(id: "U2hpcDox") { id @@ -74,5 +175,5 @@ def test_correctly_refetches_xwing(snapshot): } } } - """ - snapshot.assert_match(client.execute(query)) + """) + assert result == {"data": {"node": {"id": "U2hpcDox", "name": "X-Wing"}}} diff --git a/graphene/tests/issues/test_1293.py b/graphene/tests/issues/test_1293.py index 20bcde958..40bdbe9cc 100644 --- a/graphene/tests/issues/test_1293.py +++ b/graphene/tests/issues/test_1293.py @@ -1,6 +1,6 @@ # https://github.com/graphql-python/graphene/issues/1293 -import datetime +from datetime import datetime, timezone import graphene from graphql.utilities import print_schema @@ -9,11 +9,11 @@ class Filters(graphene.InputObjectType): datetime_after = graphene.DateTime( required=False, - default_value=datetime.datetime.utcfromtimestamp(1434549820776 / 1000), + default_value=datetime.fromtimestamp(1434549820.776, timezone.utc), ) datetime_before = graphene.DateTime( required=False, - default_value=datetime.datetime.utcfromtimestamp(1444549820776 / 1000), + default_value=datetime.fromtimestamp(1444549820.776, timezone.utc), ) diff --git a/graphene/types/tests/test_json.py b/graphene/types/tests/test_json.py index bb754b3a0..0d4ae7a6e 100644 --- a/graphene/types/tests/test_json.py +++ b/graphene/types/tests/test_json.py @@ -51,35 +51,30 @@ def test_jsonstring_invalid_query(): Test that if an invalid type is provided we get an error """ result = schema.execute("{ json(input: 1) }") - assert result.errors - assert len(result.errors) == 1 - assert result.errors[0].message == "Expected value of type 'JSONString', found 1." + assert result.errors == [ + {"message": "Expected value of type 'JSONString', found 1."}, + ] result = schema.execute("{ json(input: {}) }") - assert result.errors - assert len(result.errors) == 1 - assert result.errors[0].message == "Expected value of type 'JSONString', found {}." + assert result.errors == [ + {"message": "Expected value of type 'JSONString', found {}."}, + ] result = schema.execute('{ json(input: "a") }') - assert result.errors - assert len(result.errors) == 1 - assert result.errors[0].message == ( - "Expected value of type 'JSONString', found \"a\"; " - "Badly formed JSONString: Expecting value: line 1 column 1 (char 0)" - ) + assert result.errors == [ + { + "message": "Expected value of type 'JSONString', found \"a\"; " + "Badly formed JSONString: Expecting value: line 1 column 1 (char 0)", + }, + ] result = schema.execute("""{ json(input: "{\\'key\\': 0}") }""") - assert result.errors - assert len(result.errors) == 1 - assert ( - result.errors[0].message - == "Syntax Error: Invalid character escape sequence: '\\''." - ) + assert result.errors == [ + {"message": "Syntax Error: Invalid character escape sequence: '\\''."}, + ] result = schema.execute("""{ json(input: "{\\"key\\": 0,}") }""") - assert result.errors assert len(result.errors) == 1 - assert result.errors[0].message == ( - 'Expected value of type \'JSONString\', found "{\\"key\\": 0,}"; ' - "Badly formed JSONString: Expecting property name enclosed in double quotes: line 1 column 11 (char 10)" + assert result.errors[0].message.startswith( + 'Expected value of type \'JSONString\', found "{\\"key\\": 0,}"; Badly formed JSONString:' ) diff --git a/setup.py b/setup.py index 6c589dcba..c41a368a2 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,6 @@ def run_tests(self): "pytest-cov>=5,<6", "pytest-mock>=3,<4", "pytest-asyncio>=0.16,<2", - "snapshottest>=0.6,<1", "coveralls>=3.3,<5", ] diff --git a/tox.ini b/tox.ini index fdec66d08..00c10174f 100644 --- a/tox.ini +++ b/tox.ini @@ -5,10 +5,8 @@ skipsdist = true [testenv] deps = .[test] -setenv = - PYTHONPATH = .:{envdir} commands = - py{38,39,310,311,12,13}: pytest --cov=graphene graphene --cov-report=term --cov-report=xml examples {posargs} + pytest --cov=graphene graphene --cov-report=term --cov-report=xml examples {posargs} [testenv:pre-commit] basepython = python3.10 From f95e9221bb54d4115369dcae2e454cd2c3b7e29d Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Sun, 29 Sep 2024 13:31:24 +0200 Subject: [PATCH 67/79] refactor: replace @deprecated decorator with upcoming native support (via typing-extensions), bump mypy (#1578) * refactor: replace @deprecated decorator with upcoming native support (via typing-extensions) * chore: fix tests * chore: ruff fmt --- graphene/utils/deprecated.py | 68 +----------------- graphene/utils/resolve_only_args.py | 3 +- graphene/utils/tests/test_deprecated.py | 70 +------------------ .../utils/tests/test_resolve_only_args.py | 1 - setup.py | 1 + tox.ini | 2 +- 6 files changed, 8 insertions(+), 137 deletions(-) diff --git a/graphene/utils/deprecated.py b/graphene/utils/deprecated.py index 42c358fb3..7138bf56c 100644 --- a/graphene/utils/deprecated.py +++ b/graphene/utils/deprecated.py @@ -1,67 +1,5 @@ -import functools -import inspect -import warnings +from warnings import warn -string_types = (type(b""), type("")) - -def warn_deprecation(text): - warnings.warn(text, category=DeprecationWarning, stacklevel=2) - - -def deprecated(reason): - """ - This is a decorator which can be used to mark functions - as deprecated. It will result in a warning being emitted - when the function is used. - """ - - if isinstance(reason, string_types): - # The @deprecated is used with a 'reason'. - # - # .. code-block:: python - # - # @deprecated("please, use another function") - # def old_function(x, y): - # pass - - def decorator(func1): - if inspect.isclass(func1): - fmt1 = f"Call to deprecated class {func1.__name__} ({reason})." - else: - fmt1 = f"Call to deprecated function {func1.__name__} ({reason})." - - @functools.wraps(func1) - def new_func1(*args, **kwargs): - warn_deprecation(fmt1) - return func1(*args, **kwargs) - - return new_func1 - - return decorator - - elif inspect.isclass(reason) or inspect.isfunction(reason): - # The @deprecated is used without any 'reason'. - # - # .. code-block:: python - # - # @deprecated - # def old_function(x, y): - # pass - - func2 = reason - - if inspect.isclass(func2): - fmt2 = f"Call to deprecated class {func2.__name__}." - else: - fmt2 = f"Call to deprecated function {func2.__name__}." - - @functools.wraps(func2) - def new_func2(*args, **kwargs): - warn_deprecation(fmt2) - return func2(*args, **kwargs) - - return new_func2 - - else: - raise TypeError(repr(type(reason))) +def warn_deprecation(text: str): + warn(text, category=DeprecationWarning, stacklevel=2) diff --git a/graphene/utils/resolve_only_args.py b/graphene/utils/resolve_only_args.py index 5efff2edc..088e62cab 100644 --- a/graphene/utils/resolve_only_args.py +++ b/graphene/utils/resolve_only_args.py @@ -1,6 +1,5 @@ from functools import wraps - -from .deprecated import deprecated +from typing_extensions import deprecated @deprecated("This function is deprecated") diff --git a/graphene/utils/tests/test_deprecated.py b/graphene/utils/tests/test_deprecated.py index 8a14434b6..3fe90ded7 100644 --- a/graphene/utils/tests/test_deprecated.py +++ b/graphene/utils/tests/test_deprecated.py @@ -1,75 +1,9 @@ -from pytest import raises - from .. import deprecated -from ..deprecated import deprecated as deprecated_decorator from ..deprecated import warn_deprecation def test_warn_deprecation(mocker): - mocker.patch.object(deprecated.warnings, "warn") + mocker.patch.object(deprecated, "warn") warn_deprecation("OH!") - deprecated.warnings.warn.assert_called_with( - "OH!", stacklevel=2, category=DeprecationWarning - ) - - -def test_deprecated_decorator(mocker): - mocker.patch.object(deprecated, "warn_deprecation") - - @deprecated_decorator - def my_func(): - return True - - result = my_func() - assert result - deprecated.warn_deprecation.assert_called_with( - "Call to deprecated function my_func." - ) - - -def test_deprecated_class(mocker): - mocker.patch.object(deprecated, "warn_deprecation") - - @deprecated_decorator - class X: - pass - - result = X() - assert result - deprecated.warn_deprecation.assert_called_with("Call to deprecated class X.") - - -def test_deprecated_decorator_text(mocker): - mocker.patch.object(deprecated, "warn_deprecation") - - @deprecated_decorator("Deprecation text") - def my_func(): - return True - - result = my_func() - assert result - deprecated.warn_deprecation.assert_called_with( - "Call to deprecated function my_func (Deprecation text)." - ) - - -def test_deprecated_class_text(mocker): - mocker.patch.object(deprecated, "warn_deprecation") - - @deprecated_decorator("Deprecation text") - class X: - pass - - result = X() - assert result - deprecated.warn_deprecation.assert_called_with( - "Call to deprecated class X (Deprecation text)." - ) - - -def test_deprecated_other_object(mocker): - mocker.patch.object(deprecated, "warn_deprecation") - - with raises(TypeError): - deprecated_decorator({}) + deprecated.warn.assert_called_with("OH!", stacklevel=2, category=DeprecationWarning) diff --git a/graphene/utils/tests/test_resolve_only_args.py b/graphene/utils/tests/test_resolve_only_args.py index 4e44be9f6..9b80e6887 100644 --- a/graphene/utils/tests/test_resolve_only_args.py +++ b/graphene/utils/tests/test_resolve_only_args.py @@ -9,6 +9,5 @@ def resolver(root, **args): return root, args wrapped_resolver = resolve_only_args(resolver) - assert deprecated.warn_deprecation.called result = wrapped_resolver(1, 2, a=3) assert result == (1, {"a": 3}) diff --git a/setup.py b/setup.py index c41a368a2..33ceba50b 100644 --- a/setup.py +++ b/setup.py @@ -83,6 +83,7 @@ def run_tests(self): install_requires=[ "graphql-core>=3.1,<3.3", "graphql-relay>=3.1,<3.3", + "typing-extensions>=4.7.1,<5", ], tests_require=tests_require, extras_require={"test": tests_require, "dev": dev_requires}, diff --git a/tox.ini b/tox.ini index 00c10174f..dc42a6539 100644 --- a/tox.ini +++ b/tox.ini @@ -20,7 +20,7 @@ commands = [testenv:mypy] basepython = python3.10 deps = - mypy>=0.950,<1 + mypy>=1.10,<2 commands = mypy graphene From 5b3ed2c2ba7eb91b045ef4c77aa634e6b73ef0f1 Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Sun, 29 Sep 2024 19:32:26 +0800 Subject: [PATCH 68/79] bump pre-commit to 3.7 (#1568) --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index dc42a6539..52a7e5527 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,7 @@ commands = [testenv:pre-commit] basepython = python3.10 deps = - pre-commit>=2.16,<3 + pre-commit>=3.7,<4 setenv = LC_CTYPE=en_US.UTF-8 commands = From 431826814d819406b12b1563c9673450eea78f01 Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Sun, 29 Sep 2024 19:33:10 +0800 Subject: [PATCH 69/79] lint: use ruff pre commit hook (#1566) * lint: use ruff pre commit hook * dont install ruff --------- Co-authored-by: Erik Wrede --- .github/workflows/lint.yml | 4 +--- .pre-commit-config.yaml | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0b3c0fc3e..9112718a5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,9 +15,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install ruff tox - - name: Format check using Ruff - run: ruff format --check + pip install tox - name: Run lint run: tox env: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5b889e021..d7efe9963 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,3 +26,4 @@ repos: hooks: - id: ruff - id: ruff-format + args: [ --check ] From f2e68141fd0bd069b7b1e134a094486af9d5441a Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Sun, 29 Sep 2024 19:40:19 +0800 Subject: [PATCH 70/79] CI: build package (#1564) --- .github/workflows/build.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/build.yaml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 000000000..61aa7d002 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,21 @@ +name: 📦 Build + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + - name: Building package + run: python3 -m build + - name: Check package with Twine + run: twine check dist/* From 821451fddcf6848b441354368fd3e339a87a0c85 Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Sun, 29 Sep 2024 21:23:21 +0800 Subject: [PATCH 71/79] CI: bump upload-artifact and codecov actions (#1567) CI: bump actions/upload-artifact and codecov/codecov-action actions --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4bc23724b..7d9a9f88a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -54,11 +54,11 @@ jobs: - run: tox -e ${{ matrix.tox }} - name: Upload coverage.xml if: ${{ matrix.python == '3.10' }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: graphene-coverage path: coverage.xml if-no-files-found: error - name: Upload coverage.xml to codecov if: ${{ matrix.python == '3.10' }} - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 From 73df50e3dc656e2def238ec12088d00915cdfba8 Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Fri, 18 Oct 2024 13:40:31 +0200 Subject: [PATCH 72/79] housekeeping: switch 3.13 to non-dev --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7d9a9f88a..5691ac226 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,7 +25,7 @@ jobs: fail-fast: false matrix: include: - - {name: '3.13', python: '3.13-dev', os: ubuntu-latest, tox: py313} + - {name: '3.13', python: '3.13', os: ubuntu-latest, tox: py313} - {name: '3.12', python: '3.12', os: ubuntu-latest, tox: py312} - {name: '3.11', python: '3.11', os: ubuntu-latest, tox: py311} - {name: '3.10', python: '3.10', os: ubuntu-latest, tox: py310} From dca31dc61d5262444293cd7f59222341b6060b6e Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Fri, 18 Oct 2024 13:43:07 +0200 Subject: [PATCH 73/79] release: 3.4.0 --- graphene/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene/__init__.py b/graphene/__init__.py index 72935dec0..33e16b1b8 100644 --- a/graphene/__init__.py +++ b/graphene/__init__.py @@ -46,7 +46,7 @@ from .utils.module_loading import lazy_import from .utils.resolve_only_args import resolve_only_args -VERSION = (3, 3, 0, "final", 0) +VERSION = (3, 4, 0, "final", 0) __version__ = get_version(VERSION) From cf97cbb1de9e5624474008f2d774e2d7514a411a Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Sun, 27 Oct 2024 21:14:55 +0100 Subject: [PATCH 74/79] fix: use dateutil-parse for < 3.11 support (#1581) * fix: use dateutil-parse for < 3.11 support * chore: lint * chore: lint * fix mypy deps * fix mypy deps * chore: lint * chore: fix test --- graphene/types/datetime.py | 4 +++- graphene/types/tests/test_datetime.py | 12 ++++++++++++ setup.py | 7 ++++++- tox.ini | 2 +- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/graphene/types/datetime.py b/graphene/types/datetime.py index 7bfd9bd22..d8388f8de 100644 --- a/graphene/types/datetime.py +++ b/graphene/types/datetime.py @@ -1,5 +1,7 @@ import datetime +from dateutil.parser import isoparse + from graphql.error import GraphQLError from graphql.language import StringValueNode, print_ast @@ -71,7 +73,7 @@ def parse_value(value): f"DateTime cannot represent non-string value: {repr(value)}" ) try: - return datetime.datetime.fromisoformat(value) + return isoparse(value) except ValueError: raise GraphQLError(f"DateTime cannot represent value: {repr(value)}") diff --git a/graphene/types/tests/test_datetime.py b/graphene/types/tests/test_datetime.py index 129276176..bc992af5c 100644 --- a/graphene/types/tests/test_datetime.py +++ b/graphene/types/tests/test_datetime.py @@ -227,6 +227,18 @@ def test_time_query_variable(sample_time): assert result.data == {"time": isoformat} +def test_support_isoformat(): + isoformat = "2011-11-04T00:05:23Z" + + # test time variable provided as Python time + result = schema.execute( + """query DateTime($time: DateTime){ datetime(in: $time) }""", + variables={"time": isoformat}, + ) + assert not result.errors + assert result.data == {"datetime": "2011-11-04T00:05:23+00:00"} + + def test_bad_variables(sample_date, sample_datetime, sample_time): def _test_bad_variables(type_, input_): result = schema.execute( diff --git a/setup.py b/setup.py index 33ceba50b..61c99ea45 100644 --- a/setup.py +++ b/setup.py @@ -53,7 +53,11 @@ def run_tests(self): "coveralls>=3.3,<5", ] -dev_requires = ["ruff==0.5.0"] + tests_require +dev_requires = [ + "ruff==0.5.0", + "types-python-dateutil>=2.8.1,<3", + "mypy>=1.10,<2", +] + tests_require setup( name="graphene", @@ -83,6 +87,7 @@ def run_tests(self): install_requires=[ "graphql-core>=3.1,<3.3", "graphql-relay>=3.1,<3.3", + "python-dateutil>=2.7.0,<3", "typing-extensions>=4.7.1,<5", ], tests_require=tests_require, diff --git a/tox.ini b/tox.ini index 52a7e5527..a4a6b37ed 100644 --- a/tox.ini +++ b/tox.ini @@ -20,7 +20,7 @@ commands = [testenv:mypy] basepython = python3.10 deps = - mypy>=1.10,<2 + .[dev] commands = mypy graphene From ccae7364e572dbb1f08c980d79fae0134bd0263a Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Sun, 27 Oct 2024 21:16:40 +0100 Subject: [PATCH 75/79] release: 3.4.1 --- graphene/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene/__init__.py b/graphene/__init__.py index 33e16b1b8..2168308d4 100644 --- a/graphene/__init__.py +++ b/graphene/__init__.py @@ -46,7 +46,7 @@ from .utils.module_loading import lazy_import from .utils.resolve_only_args import resolve_only_args -VERSION = (3, 4, 0, "final", 0) +VERSION = (3, 4, 1, "final", 0) __version__ = get_version(VERSION) From 3ed7bf6362f59b14dcf5b22bb63d920a926ba181 Mon Sep 17 00:00:00 2001 From: Muhammed Al-Dulaimi Date: Sat, 9 Nov 2024 20:17:42 +0300 Subject: [PATCH 76/79] chore: Make Union meta overridable (#1583) This PR makes the Union Options configurable, similar to how it works with ObjectTypes --------- Co-authored-by: Erik Wrede --- graphene/types/union.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/graphene/types/union.py b/graphene/types/union.py index b7c5dc627..3d10418e5 100644 --- a/graphene/types/union.py +++ b/graphene/types/union.py @@ -51,12 +51,14 @@ class Query(ObjectType): """ @classmethod - def __init_subclass_with_meta__(cls, types=None, **options): + def __init_subclass_with_meta__(cls, types=None, _meta=None, **options): assert ( isinstance(types, (list, tuple)) and len(types) > 0 ), f"Must provide types for Union {cls.__name__}." - _meta = UnionOptions(cls) + if not _meta: + _meta = UnionOptions(cls) + _meta.types = types super(Union, cls).__init_subclass_with_meta__(_meta=_meta, **options) From b3db1c0cb293f5b37d908fd146c7ed6d101011db Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Sat, 9 Nov 2024 18:18:36 +0100 Subject: [PATCH 77/79] release: 3.4.2 --- graphene/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene/__init__.py b/graphene/__init__.py index 2168308d4..c9cb45be9 100644 --- a/graphene/__init__.py +++ b/graphene/__init__.py @@ -46,7 +46,7 @@ from .utils.module_loading import lazy_import from .utils.resolve_only_args import resolve_only_args -VERSION = (3, 4, 1, "final", 0) +VERSION = (3, 4, 2, "final", 0) __version__ = get_version(VERSION) From 4a274b8424f3ee2c25585869940b12d323fcb681 Mon Sep 17 00:00:00 2001 From: Philipp Hagemeister Date: Sat, 9 Nov 2024 21:42:51 +0100 Subject: [PATCH 78/79] fix: raise proper error when UUID parsing fails (#1582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Do not raise AttributeError when parsing non-string UUIDs When a user sends a dictionary or other object as a UUID variable like `{[123]}`, previously graphene crashed with an `AttributeError`, like this: ``` (…) File "…/lib/python3.12/site-packages/graphql/utils/is_valid_value.py", line 78, in is_valid_value parse_result = type.parse_value(value) ^^^^^^^^^^^^^^^^^^^^^^^ File "…/lib/python3.12/site-packages/graphene/types/uuid.py", line 33, in parse_value return _UUID(value) ^^^^^^^^^^^^ File "/usr/lib/python3.12/uuid.py", line 175, in __init__ hex = hex.replace('urn:', '').replace('uuid:', '') ^^^^^^^^^^^ AttributeError: 'dict' object has no attribute 'replace' ``` But an `AttributeError` makes it seem like this is the server's fault, when it's obviously the client's. Report a proper GraphQLError. * fix: adjust exception message structure --------- Co-authored-by: Erik Wrede --- graphene/types/tests/test_uuid.py | 15 +++++++++++++++ graphene/types/uuid.py | 8 +++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/graphene/types/tests/test_uuid.py b/graphene/types/tests/test_uuid.py index d34f16642..d031387b8 100644 --- a/graphene/types/tests/test_uuid.py +++ b/graphene/types/tests/test_uuid.py @@ -36,6 +36,21 @@ def test_uuidstring_query_variable(): assert result.data == {"uuid": uuid_value} +def test_uuidstring_invalid_argument(): + uuid_value = {"not": "a string"} + + result = schema.execute( + """query Test($uuid: UUID){ uuid(input: $uuid) }""", + variables={"uuid": uuid_value}, + ) + assert result.errors + assert len(result.errors) == 1 + assert ( + result.errors[0].message + == "Variable '$uuid' got invalid value {'not': 'a string'}; UUID cannot represent value: {'not': 'a string'}" + ) + + def test_uuidstring_optional_uuid_input(): """ Test that we can provide a null value to an optional input diff --git a/graphene/types/uuid.py b/graphene/types/uuid.py index 773e31c73..5f10a22e2 100644 --- a/graphene/types/uuid.py +++ b/graphene/types/uuid.py @@ -1,5 +1,6 @@ from uuid import UUID as _UUID +from graphql.error import GraphQLError from graphql.language.ast import StringValueNode from graphql import Undefined @@ -28,4 +29,9 @@ def parse_literal(node, _variables=None): @staticmethod def parse_value(value): - return _UUID(value) + if isinstance(value, _UUID): + return value + try: + return _UUID(value) + except (ValueError, AttributeError): + raise GraphQLError(f"UUID cannot represent value: {repr(value)}") From 82903263080b3b7f22c2ad84319584d7a3b1a1f6 Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Sat, 9 Nov 2024 21:43:17 +0100 Subject: [PATCH 79/79] release: 3.4.3 --- graphene/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene/__init__.py b/graphene/__init__.py index c9cb45be9..bdc312eda 100644 --- a/graphene/__init__.py +++ b/graphene/__init__.py @@ -46,7 +46,7 @@ from .utils.module_loading import lazy_import from .utils.resolve_only_args import resolve_only_args -VERSION = (3, 4, 2, "final", 0) +VERSION = (3, 4, 3, "final", 0) __version__ = get_version(VERSION)