Compare commits

...

31 Commits
v3.1.5 ... main

Author SHA1 Message Date
Firas Kafri
c52cf2b045
Bump version to 3.2.3 2025-03-13 11:29:45 +03:00
Florian Zimmermann
e69e4a0399
Bugfix: call resolver function in DjangoConnectionField as documented (#1529)
* treat warnings as errors when running the tests

* silence warnings

* bugfix: let DjangoConnectionField call its resolver function

that is, the one specified using DjangoConnectionField(..., resolver=some_func)

* ignore the DeprecationWarning about typing.ByteString in graphql
2025-03-13 11:25:48 +03:00
Sergey Fursov
97deb761e9
fix typed choices, make working with different Django 5x choices options (#1539)
* fix typed choices, make working with different Django 5x choices options

* remove `graphene_django/compat.py` from ruff exclusions
2025-03-13 11:23:51 +03:00
Sergey Fursov
8d4a64a40d
add official Django 5.1 support (#1540) 2024-12-27 13:46:47 +08:00
Alexandre Detiste
269225085d
remove dead code: singledispatch has been in the standard library ... (#1534)
* remove dead code: singledispatch has been in the stard library for many years

(BTW this function does not seems to be used anywhere anymore)

* lint
2024-09-15 21:50:15 +07:00
Markus Richter
28c71c58f7 Bump to 3.2.2 2024-06-12 10:52:45 +08:00
Kien Dang
6f21dc7a94
Not require explicitly set ordering in DjangoConnectionField (#1518)
* Revert "feat!: check django model has a default ordering when used in a relay connection (#1495)"

This reverts commit 96c09ac439.

* Fix assert no warning for pytest>=8
2024-04-18 12:00:31 +08:00
Ülgen Sarıkavak
ea45de02ad
Make use of http.HTTPStatus for response status code checks (#1487) 2024-04-09 03:43:34 +03:00
dependabot[bot]
eac113e136
Bump django from 3.2.24 to 3.2.25 in /examples/cookbook (#1508)
Bumps [django](https://github.com/django/django) from 3.2.24 to 3.2.25.
- [Commits](https://github.com/django/django/compare/3.2.24...3.2.25)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-09 03:39:21 +03:00
Kien Dang
d69c90550f
Bump to 3.2.1 (#1512) 2024-04-09 03:37:32 +03:00
Pablo Alexis Domínguez Grau
3f813d4679
Fix ReadTheDocs builds (#1509)
* Add RTD config file

* Doc fixes to reference main branch instead of master
2024-03-29 12:11:56 +08:00
Alisson Patricio
45c2aa09b5
Allows field's choices to be a callable (#1497)
* Allows field's choices to be a callable

Starting in Django 5 field's choices can also be a callable

* test if field with callable choices converts into enum

---------

Co-authored-by: Kien Dang <mail@kien.ai>
2024-03-21 00:48:51 +08:00
Diogo Silva
ac09cd2967
fix: Fix broke 'get_choices' with restframework 3.15.0 (#1506) 2024-03-18 09:58:47 +08:00
dependabot[bot]
54372b41d5
Bump django from 3.1.14 to 3.2.24 in /examples/cookbook (#1498)
Bumps [django](https://github.com/django/django) from 3.1.14 to 3.2.24.
- [Commits](https://github.com/django/django/compare/3.1.14...3.2.24)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-08 10:50:13 +08:00
Thomas Leonard
96c09ac439
feat!: check django model has a default ordering when used in a relay connection (#1495)
Co-authored-by: Thomas Leonard <thomas@loftorbital.com>
2024-01-30 12:09:18 +03:00
Laurent
b85177cebf
fix: same type list (#1492)
* fix: same type list

* chore: improve test
2024-01-20 16:36:00 +08:00
Firas Kafri
4d0484f312
Bump version 2023-12-20 13:22:33 +03:00
Noxx
c416a2b0f5
Provide setting to enable/disable converting choices to enums globally (#1477)
Co-authored-by: Firas Kafri <3097061+firaskafri@users.noreply.github.com>
Co-authored-by: Kien Dang <mail@kien.ai>
2023-12-20 17:55:15 +08:00
Kien Dang
feb7252b8a
Add support for validation rules (#1475)
* Add support for validation rules

* Enable customizing validate max_errors through settings

* Add tests for validation rules

* Add examples for validation rules

* Allow setting validation_rules in class def

* Add tests for validation_rules inherited from parent class

* Make tests for validation rules stricter
2023-12-20 12:48:45 +03:00
Firas Kafri
3a64994e52
Bump version (#1486) 2023-12-20 12:44:40 +03:00
Kien Dang
db2d40ec94
Remove Django 4.1 (EOL) and add Django 5.0 to CI (#1483) 2023-12-14 11:20:54 +03:00
Kien Dang
62126dd467
Add Python 3.12 to CI (#1481) 2023-12-05 22:11:00 +03:00
danthewildcat
e735f5dbdb
Optimize views (#1439)
* Optimize execute_graphql_request

* Require operation_ast to be found by view handler

* Remove unused show_graphiql kwarg

* Old style if syntax

* Revert "Remove unused show_graphiql kwarg"

This reverts commit 33b3426092.

* Add missing schema validation step

* Pass args directly to improve clarity

* Remove duplicated operation_ast not None check

---------

Co-authored-by: Firas Kafri <3097061+firaskafri@users.noreply.github.com>
Co-authored-by: Kien Dang <mail@kien.ai>
2023-10-29 23:42:27 +08:00
Kien Dang
36cf100e8b
Use ruff format to replace black (#1473)
* Use ruff format to replace black

* Adjust ruff config to be compatible with ruff-format

https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules

* Format

* Replace black with ruff format in Makefile
2023-10-25 11:33:00 +03:00
Kien Dang
e8f36b018d
Fix test Client headers for Django 4.2 (#1465)
* Fix test Client headers for Django 4.2

* Lazy import pkg_resources

since it could be quite heavy

* Remove use of pkg_resources altogether
2023-09-18 23:23:53 +08:00
mnasiri
83d3d27f14
Fix graphiql explorer styles by sending graphiql_plugin_explorer_css_sri param to render_graphiql function of the GraphQlView (#1418) (#1460) 2023-09-14 00:26:18 +08:00
Romain Létendart
ee7560f629
Support displaying deprecated input fields in GraphiQL docs (#1458)
* Update GraphiQL docs URL in docs/settings

And deduplicate link declaration.

* Support displaying deprecated input fields in GraphiQL docs
2023-09-13 09:49:01 +03:00
lilac-supernova-2
67def2e074
Typo fixes (#1459)
* Fix Star Wars spaceship name

* Fix some typos in comments

* Typo fixes

* More typo fixes
2023-09-06 10:29:58 +03:00
mahmoudmostafa0
e49a01c189
adding optional_field in Serializermutation to enfore some fields to be optional (#1455)
* adding optional_fields to enforce fields to be optional

* adding support for all

* adding unit tests

* Update graphene_django/rest_framework/mutation.py

Co-authored-by: Kien Dang <kiend@pm.me>

* linting

* linting

* add missing import

---------

Co-authored-by: Kien Dang <kiend@pm.me>
2023-08-28 00:15:35 +03:00
Thomas Leonard
0473f1a9a3
fix: empty list is not an empty value for list filters even when a custom filtering method is provided (#1450)
Co-authored-by: Thomas Leonard <thomas@loftorbital.com>
2023-08-11 23:24:58 +08:00
Kien Dang
720db1f987
Only release on pypi after tests pass (#1452) 2023-08-11 09:51:59 +03:00
61 changed files with 1431 additions and 338 deletions

View File

@ -6,8 +6,13 @@ on:
- 'v*'
jobs:
build:
lint:
uses: ./.github/workflows/lint.yml
tests:
uses: ./.github/workflows/tests.yml
release:
runs-on: ubuntu-latest
needs: [lint, tests]
steps:
- uses: actions/checkout@v3

View File

@ -4,6 +4,7 @@ on:
push:
branches: ["main"]
pull_request:
workflow_call:
jobs:
build:

View File

@ -4,6 +4,7 @@ on:
push:
branches: ["main"]
pull_request:
workflow_call:
jobs:
build:
@ -11,13 +12,21 @@ jobs:
strategy:
max-parallel: 4
matrix:
django: ["3.2", "4.1", "4.2"]
python-version: ["3.8", "3.9", "3.10"]
include:
- django: "4.1"
python-version: "3.11"
- django: "4.2"
django: ["3.2", "4.2", "5.0", "5.1"]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
exclude:
- django: "3.2"
python-version: "3.11"
- django: "3.2"
python-version: "3.12"
- django: "5.0"
python-version: "3.8"
- django: "5.0"
python-version: "3.9"
- django: "5.1"
python-version: "3.8"
- django: "5.1"
python-version: "3.9"
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}

View File

@ -2,7 +2,7 @@ default_language_version:
python: python3.11
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
rev: v4.5.0
hooks:
- id: check-merge-conflict
- id: check-json
@ -15,12 +15,9 @@ repos:
- --autofix
- id: trailing-whitespace
exclude: README.md
- repo: https://github.com/psf/black
rev: 23.7.0
hooks:
- id: black
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.0.283
rev: v0.1.2
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix, --show-fixes]
- id: ruff-format

18
.readthedocs.yaml Normal file
View File

@ -0,0 +1,18 @@
# .readthedocs.yaml
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
version: 2
build:
os: ubuntu-22.04
tools:
python: "3.12"
# Build documentation in the "docs/" directory with Sphinx
sphinx:
configuration: docs/conf.py
# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
python:
install:
- requirements: docs/requirements.txt

View File

@ -13,6 +13,7 @@ ignore = [
"B017", # pytest.raises(Exception) should be considered evil
"B028", # warnings.warn called without an explicit stacklevel keyword argument
"B904", # check for raise statements in exception handlers that lack a from clause
"W191", # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
]
exclude = [
@ -24,10 +25,8 @@ target-version = "py38"
[per-file-ignores]
# Ignore unused imports (F401) in these files
"__init__.py" = ["F401"]
"graphene_django/compat.py" = ["F401"]
[isort]
known-first-party = ["graphene", "graphene-django"]
known-local-folder = ["cookbook"]
force-wrap-aliases = true
combine-as-imports = true

View File

@ -33,7 +33,7 @@ make tests
## Opening Pull Requests
Please fork the project and open a pull request against the master branch.
Please fork the project and open a pull request against the `main` branch.
This will trigger a series of test and lint checks.

View File

@ -14,7 +14,7 @@ tests:
.PHONY: format ## Format code
format:
black graphene_django examples setup.py
ruff format graphene_django examples setup.py
.PHONY: lint ## Lint code
lint:

View File

@ -30,7 +30,7 @@ Graphene-Django is an open-source library that provides seamless integration bet
To install Graphene-Django, run the following command:
```
```sh
pip install graphene-django
```
@ -114,11 +114,11 @@ class MyModelAPITestCase(GraphQLTestCase):
## Contributing
Contributions to Graphene-Django are always welcome! To get started, check the repository's [issue tracker](https://github.com/graphql-python/graphene-django/issues) and [contribution guidelines](https://github.com/graphql-python/graphene-django/blob/master/CONTRIBUTING.md).
Contributions to Graphene-Django are always welcome! To get started, check the repository's [issue tracker](https://github.com/graphql-python/graphene-django/issues) and [contribution guidelines](https://github.com/graphql-python/graphene-django/blob/main/CONTRIBUTING.md).
## License
Graphene-Django is released under the [MIT License](https://github.com/graphql-python/graphene-django/blob/master/LICENSE).
Graphene-Django is released under the [MIT License](https://github.com/graphql-python/graphene-django/blob/main/LICENSE).
## Resources

View File

@ -33,5 +33,6 @@ For more advanced use, check out the Relay tutorial.
authorization
debug
introspection
validation
testing
settings

View File

@ -6,7 +6,7 @@ Graphene-Django can be customised using settings. This page explains each settin
Usage
-----
Add settings to your Django project by creating a Dictonary with name ``GRAPHENE`` in the project's ``settings.py``:
Add settings to your Django project by creating a Dictionary with name ``GRAPHENE`` in the project's ``settings.py``:
.. code:: python
@ -142,6 +142,15 @@ Default: ``False``
# ]
``DJANGO_CHOICE_FIELD_ENUM_CONVERT``
--------------------------------------
When set to ``True`` Django choice fields are automatically converted into Enum types.
Can be disabled globally by setting it to ``False``.
Default: ``True``
``DJANGO_CHOICE_FIELD_ENUM_V2_NAMING``
--------------------------------------
@ -197,9 +206,6 @@ Set to ``False`` if you want to disable GraphiQL headers editor tab for some rea
This setting is passed to ``headerEditorEnabled`` GraphiQL options, for details refer to GraphiQLDocs_.
.. _GraphiQLDocs: https://github.com/graphql/graphiql/tree/main/packages/graphiql#options
Default: ``True``
.. code:: python
@ -230,8 +236,6 @@ Set to ``True`` if you want to persist GraphiQL headers after refreshing the pag
This setting is passed to ``shouldPersistHeaders`` GraphiQL options, for details refer to GraphiQLDocs_.
.. _GraphiQLDocs: https://github.com/graphql/graphiql/tree/main/packages/graphiql#options
Default: ``False``
@ -240,3 +244,48 @@ Default: ``False``
GRAPHENE = {
'GRAPHIQL_SHOULD_PERSIST_HEADERS': False,
}
``GRAPHIQL_INPUT_VALUE_DEPRECATION``
------------------------------------
Set to ``True`` if you want GraphiQL to show any deprecated fields on input object types' docs.
For example, having this schema:
.. code:: python
class MyMutationInputType(graphene.InputObjectType):
old_field = graphene.String(deprecation_reason="You should now use 'newField' instead.")
new_field = graphene.String()
class MyMutation(graphene.Mutation):
class Arguments:
input = types.MyMutationInputType()
GraphiQL will add a ``Show Deprecated Fields`` button to toggle information display on ``oldField`` and its deprecation
reason. Otherwise, you would get neither a button nor any information at all on ``oldField``.
This setting is passed to ``inputValueDeprecation`` GraphiQL options, for details refer to GraphiQLDocs_.
Default: ``False``
.. code:: python
GRAPHENE = {
'GRAPHIQL_INPUT_VALUE_DEPRECATION': False,
}
.. _GraphiQLDocs: https://graphiql-test.netlify.app/typedoc/modules/graphiql_react#graphiqlprovider-2
``MAX_VALIDATION_ERRORS``
------------------------------------
In case ``validation_rules`` are provided to ``GraphQLView``, if this is set to a non-negative ``int`` value,
``graphql.validation.validate`` will stop validation after this number of errors has been reached.
If not set or set to ``None``, the maximum number of errors will follow ``graphql.validation.validate`` default
*i.e.* 100.
Default: ``None``

View File

@ -104,7 +104,7 @@ Load some test data
Now is a good time to load up some test data. The easiest option will be
to `download the
ingredients.json <https://raw.githubusercontent.com/graphql-python/graphene-django/master/examples/cookbook/cookbook/ingredients/fixtures/ingredients.json>`__
ingredients.json <https://raw.githubusercontent.com/graphql-python/graphene-django/main/examples/cookbook/cookbook/ingredients/fixtures/ingredients.json>`__
fixture and place it in
``cookbook/ingredients/fixtures/ingredients.json``. You can then run the
following:

View File

@ -7,7 +7,7 @@ Graphene has a number of additional features that are designed to make
working with Django *really simple*.
Note: The code in this quickstart is pulled from the `cookbook example
app <https://github.com/graphql-python/graphene-django/tree/master/examples/cookbook>`__.
app <https://github.com/graphql-python/graphene-django/tree/main/examples/cookbook>`__.
A good idea is to check the following things first:
@ -87,7 +87,7 @@ Load some test data
Now is a good time to load up some test data. The easiest option will be
to `download the
ingredients.json <https://raw.githubusercontent.com/graphql-python/graphene-django/master/examples/cookbook/cookbook/ingredients/fixtures/ingredients.json>`__
ingredients.json <https://raw.githubusercontent.com/graphql-python/graphene-django/main/examples/cookbook/cookbook/ingredients/fixtures/ingredients.json>`__
fixture and place it in
``cookbook/ingredients/fixtures/ingredients.json``. You can then run the
following:

29
docs/validation.rst Normal file
View File

@ -0,0 +1,29 @@
Query Validation
================
Graphene-Django supports query validation by allowing passing a list of validation rules (subclasses of `ValidationRule <https://github.com/graphql-python/graphql-core/blob/v3.2.3/src/graphql/validation/rules/__init__.py>`_ from graphql-core) to the ``validation_rules`` option in ``GraphQLView``.
.. code:: python
from django.urls import path
from graphene.validation import DisableIntrospection
from graphene_django.views import GraphQLView
urlpatterns = [
path("graphql", GraphQLView.as_view(validation_rules=(DisableIntrospection,))),
]
or
.. code:: python
from django.urls import path
from graphene.validation import DisableIntrospection
from graphene_django.views import GraphQLView
class View(GraphQLView):
validation_rules = (DisableIntrospection,)
urlpatterns = [
path("graphql", View.as_view()),
]

View File

@ -231,7 +231,7 @@
"fields": {
"category": 3,
"name": "Newt",
"notes": "Braised and Confuesd"
"notes": "Braised and Confused"
},
"model": "ingredients.ingredient",
"pk": 5

View File

@ -1,5 +1,5 @@
graphene>=2.1,<3
graphene-django>=2.1,<3
graphql-core>=2.1,<3
django==3.1.14
django==3.2.25
django-filter>=2

View File

@ -28,3 +28,5 @@ TEMPLATES = [
GRAPHENE = {"SCHEMA": "graphene_django.tests.schema_view.schema"}
ROOT_URLCONF = "graphene_django.tests.urls"
USE_TZ = True

View File

@ -28,7 +28,7 @@ def initialize():
# Yeah, technically it's Corellian. But it flew in the service of the rebels,
# so for the purposes of this demo it's a rebel ship.
falcon = Ship(id="4", name="Millenium Falcon", faction=rebels)
falcon = Ship(id="4", name="Millennium Falcon", faction=rebels)
falcon.save()
homeOne = Ship(id="5", name="Home One", faction=rebels)

View File

@ -1,5 +1,5 @@
import graphene
from graphene import Schema, relay, resolve_only_args
from graphene import Schema, relay
from graphene_django import DjangoConnectionField, DjangoObjectType
from .data import create_ship, get_empire, get_faction, get_rebels, get_ship, get_ships
@ -62,16 +62,13 @@ class Query(graphene.ObjectType):
node = relay.Node.Field()
ships = DjangoConnectionField(Ship, description="All the ships.")
@resolve_only_args
def resolve_ships(self):
def resolve_ships(self, info):
return get_ships()
@resolve_only_args
def resolve_rebels(self):
def resolve_rebels(self, info):
return get_rebels()
@resolve_only_args
def resolve_empire(self):
def resolve_empire(self, info):
return get_empire()

View File

@ -40,7 +40,7 @@ def test_mutations():
{"node": {"id": "U2hpcDox", "name": "X-Wing"}},
{"node": {"id": "U2hpcDoy", "name": "Y-Wing"}},
{"node": {"id": "U2hpcDoz", "name": "A-Wing"}},
{"node": {"id": "U2hpcDo0", "name": "Millenium Falcon"}},
{"node": {"id": "U2hpcDo0", "name": "Millennium Falcon"}},
{"node": {"id": "U2hpcDo1", "name": "Home One"}},
{"node": {"id": "U2hpcDo5", "name": "Peter"}},
]

View File

@ -2,7 +2,7 @@ from .fields import DjangoConnectionField, DjangoListField
from .types import DjangoObjectType
from .utils import bypass_get_queryset
__version__ = "3.1.5"
__version__ = "3.2.3"
__all__ = [
"__version__",

View File

@ -1,7 +1,11 @@
import sys
from collections.abc import Callable
from pathlib import PurePath
# For backwards compatibility, we import JSONField to have it available for import via
# this compat module (https://github.com/graphql-python/graphene-django/issues/1428).
# Django's JSONField is available in Django 3.2+ (the minimum version we support)
from django.db.models import JSONField
from django.db.models import Choices, JSONField
class MissingType:
@ -19,4 +23,43 @@ try:
RangeField,
)
except ImportError:
IntegerRangeField, ArrayField, HStoreField, RangeField = (MissingType,) * 4
IntegerRangeField, HStoreField, RangeField = (MissingType,) * 3
# For unit tests we fake ArrayField using JSONFields
if any(
PurePath(sys.argv[0]).match(p)
for p in [
"**/pytest",
"**/py.test",
"**/pytest/__main__.py",
]
):
class ArrayField(JSONField):
def __init__(self, *args, **kwargs):
if len(args) > 0:
self.base_field = args[0]
super().__init__(**kwargs)
else:
ArrayField = MissingType
try:
from django.utils.choices import normalize_choices
except ImportError:
def normalize_choices(choices):
if isinstance(choices, type) and issubclass(choices, Choices):
choices = choices.choices
if isinstance(choices, Callable):
choices = choices()
# In restframework==3.15.0, choices are not passed
# as OrderedDict anymore, so it's safer to check
# for a dict
if isinstance(choices, dict):
choices = choices.items()
return choices

View File

@ -1,5 +1,4 @@
import inspect
from collections import OrderedDict
from functools import partial, singledispatch, wraps
from django.db import models
@ -37,7 +36,7 @@ except ImportError:
from graphql import assert_valid_name as assert_name
from graphql.pyutils import register_description
from .compat import ArrayField, HStoreField, RangeField
from .compat import ArrayField, HStoreField, RangeField, normalize_choices
from .fields import DjangoConnectionField, DjangoListField
from .settings import graphene_settings
from .utils.str_converters import to_const
@ -61,6 +60,24 @@ class BlankValueField(Field):
return blank_field_wrapper(resolver)
class EnumValueField(BlankValueField):
def wrap_resolve(self, parent_resolver):
resolver = super().wrap_resolve(parent_resolver)
# create custom resolver
def enum_field_wrapper(func):
@wraps(func)
def wrapped_resolver(*args, **kwargs):
return_value = func(*args, **kwargs)
if isinstance(return_value, models.Choices):
return_value = return_value.value
return return_value
return wrapped_resolver
return enum_field_wrapper(resolver)
def convert_choice_name(name):
name = to_const(force_str(name))
try:
@ -72,8 +89,7 @@ def convert_choice_name(name):
def get_choices(choices):
converted_names = []
if isinstance(choices, OrderedDict):
choices = choices.items()
choices = normalize_choices(choices)
for value, help_text in choices:
if isinstance(help_text, (tuple, list)):
yield from get_choices(help_text)
@ -133,20 +149,24 @@ def convert_choice_field_to_enum(field, name=None):
def convert_django_field_with_choices(
field, registry=None, convert_choices_to_enum=True
field, registry=None, convert_choices_to_enum=None
):
if registry is not None:
converted = registry.get_converted_field(field)
if converted:
return converted
choices = getattr(field, "choices", None)
if convert_choices_to_enum is None:
convert_choices_to_enum = bool(
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CONVERT
)
if choices and convert_choices_to_enum:
EnumCls = convert_choice_field_to_enum(field)
required = not (field.blank or field.null)
converted = EnumCls(
description=get_django_field_description(field), required=required
).mount_as(BlankValueField)
).mount_as(EnumValueField)
else:
converted = convert_django_field(field, registry)
if registry is not None:
@ -179,19 +199,13 @@ def convert_field_to_string(field, registry=None):
)
@convert_django_field.register(models.BigAutoField)
@convert_django_field.register(models.AutoField)
@convert_django_field.register(models.BigAutoField)
@convert_django_field.register(models.SmallAutoField)
def convert_field_to_id(field, registry=None):
return ID(description=get_django_field_description(field), required=not field.null)
if hasattr(models, "SmallAutoField"):
@convert_django_field.register(models.SmallAutoField)
def convert_field_small_to_id(field, registry=None):
return convert_field_to_id(field, registry)
@convert_django_field.register(models.UUIDField)
def convert_field_to_uuid(field, registry=None):
return UUID(

View File

@ -20,17 +20,20 @@ from .utils import maybe_queryset
class DjangoListField(Field):
def __init__(self, _type, *args, **kwargs):
from .types import DjangoObjectType
if isinstance(_type, NonNull):
_type = _type.of_type
# Django would never return a Set of None vvvvvvv
super().__init__(List(NonNull(_type)), *args, **kwargs)
@property
def type(self):
from .types import DjangoObjectType
assert issubclass(
self._underlying_type, DjangoObjectType
), "DjangoListField only accepts DjangoObjectType types"
), "DjangoListField only accepts DjangoObjectType types as underlying type"
return super().type
@property
def _underlying_type(self):
@ -194,7 +197,7 @@ class DjangoConnectionField(ConnectionField):
enforce_first_or_last,
root,
info,
**args
**args,
):
first = args.get("first")
last = args.get("last")
@ -244,7 +247,7 @@ class DjangoConnectionField(ConnectionField):
def wrap_resolve(self, parent_resolver):
return partial(
self.connection_resolver,
parent_resolver,
self.resolver or parent_resolver,
self.connection_type,
self.get_manager(),
self.get_queryset_resolver(),

View File

@ -36,7 +36,7 @@ class DjangoFilterConnectionField(DjangoConnectionField):
extra_filter_meta=None,
filterset_class=None,
*args,
**kwargs
**kwargs,
):
self._fields = fields
self._provided_filterset_class = filterset_class

View File

@ -1,13 +1,36 @@
from django_filters.constants import EMPTY_VALUES
from django_filters.filters import FilterMethod
from .typed_filter import TypedFilter
class ArrayFilterMethod(FilterMethod):
def __call__(self, qs, value):
if value is None:
return qs
return self.method(qs, self.f.field_name, value)
class ArrayFilter(TypedFilter):
"""
Filter made for PostgreSQL ArrayField.
"""
@TypedFilter.method.setter
def method(self, value):
"""
Override method setter so that in case a custom `method` is provided
(see documentation https://django-filter.readthedocs.io/en/stable/ref/filters.html#method),
it doesn't fall back to checking if the value is in `EMPTY_VALUES` (from the `__call__` method
of the `FilterMethod` class) and instead use our ArrayFilterMethod that consider empty lists as values.
Indeed when providing a `method` the `filter` method below is overridden and replaced by `FilterMethod(self)`
which means that the validation of the empty value is made by the `FilterMethod.__call__` method instead.
"""
TypedFilter.method.fset(self, value)
if value is not None:
self.filter = ArrayFilterMethod(self)
def filter(self, qs, value):
"""
Override the default filter class to check first whether the list is

View File

@ -1,12 +1,36 @@
from django_filters.filters import FilterMethod
from .typed_filter import TypedFilter
class ListFilterMethod(FilterMethod):
def __call__(self, qs, value):
if value is None:
return qs
return self.method(qs, self.f.field_name, value)
class ListFilter(TypedFilter):
"""
Filter that takes a list of value as input.
It is for example used for `__in` filters.
"""
@TypedFilter.method.setter
def method(self, value):
"""
Override method setter so that in case a custom `method` is provided
(see documentation https://django-filter.readthedocs.io/en/stable/ref/filters.html#method),
it doesn't fall back to checking if the value is in `EMPTY_VALUES` (from the `__call__` method
of the `FilterMethod` class) and instead use our ListFilterMethod that consider empty lists as values.
Indeed when providing a `method` the `filter` method below is overridden and replaced by `FilterMethod(self)`
which means that the validation of the empty value is made by the `FilterMethod.__call__` method instead.
"""
TypedFilter.method.fset(self, value)
if value is not None:
self.filter = ListFilterMethod(self)
def filter(self, qs, value):
"""
Override the default filter class to check first whether the list is

View File

@ -1,4 +1,4 @@
from unittest.mock import MagicMock
from functools import reduce
import pytest
from django.db import models
@ -25,15 +25,15 @@ else:
)
STORE = {"events": []}
class Event(models.Model):
name = models.CharField(max_length=50)
tags = ArrayField(models.CharField(max_length=50))
tag_ids = ArrayField(models.IntegerField())
random_field = ArrayField(models.BooleanField())
def __repr__(self):
return f"Event [{self.name}]"
@pytest.fixture
def EventFilterSet():
@ -44,10 +44,18 @@ def EventFilterSet():
"name": ["exact", "contains"],
}
# Those are actually usable with our Query fixture bellow
# Those are actually usable with our Query fixture below
tags__contains = ArrayFilter(field_name="tags", lookup_expr="contains")
tags__overlap = ArrayFilter(field_name="tags", lookup_expr="overlap")
tags = ArrayFilter(field_name="tags", lookup_expr="exact")
tags__len = ArrayFilter(
field_name="tags", lookup_expr="len", input_type=graphene.Int
)
tags__len__in = ArrayFilter(
field_name="tags",
method="tags__len__in_filter",
input_type=graphene.List(graphene.Int),
)
# Those are actually not usable and only to check type declarations
tags_ids__contains = ArrayFilter(field_name="tag_ids", lookup_expr="contains")
@ -61,6 +69,14 @@ def EventFilterSet():
)
random_field = ArrayFilter(field_name="random_field", lookup_expr="exact")
def tags__len__in_filter(self, queryset, _name, value):
if not value:
return queryset.none()
return reduce(
lambda q1, q2: q1.union(q2),
[queryset.filter(tags__len=v) for v in value],
).distinct()
return EventFilterSet
@ -83,68 +99,94 @@ def Query(EventType):
we are running unit tests in sqlite which does not have ArrayFields.
"""
events = [
Event(name="Live Show", tags=["concert", "music", "rock"]),
Event(name="Musical", tags=["movie", "music"]),
Event(name="Ballet", tags=["concert", "dance"]),
Event(name="Speech", tags=[]),
]
class Query(graphene.ObjectType):
events = DjangoFilterConnectionField(EventType)
def resolve_events(self, info, **kwargs):
events = [
Event(name="Live Show", tags=["concert", "music", "rock"]),
Event(name="Musical", tags=["movie", "music"]),
Event(name="Ballet", tags=["concert", "dance"]),
Event(name="Speech", tags=[]),
]
class FakeQuerySet(QuerySet):
def __init__(self, model=None):
self.model = Event
self.__store = list(events)
STORE["events"] = events
def all(self):
return self
m_queryset = MagicMock(spec=QuerySet)
m_queryset.model = Event
def filter_events(**kwargs):
if "tags__contains" in kwargs:
STORE["events"] = list(
filter(
lambda e: set(kwargs["tags__contains"]).issubset(
set(e.tags)
),
STORE["events"],
def filter(self, **kwargs):
queryset = FakeQuerySet()
queryset.__store = list(self.__store)
if "tags__contains" in kwargs:
queryset.__store = list(
filter(
lambda e: set(kwargs["tags__contains"]).issubset(
set(e.tags)
),
queryset.__store,
)
)
)
if "tags__overlap" in kwargs:
STORE["events"] = list(
filter(
lambda e: not set(kwargs["tags__overlap"]).isdisjoint(
set(e.tags)
),
STORE["events"],
if "tags__overlap" in kwargs:
queryset.__store = list(
filter(
lambda e: not set(kwargs["tags__overlap"]).isdisjoint(
set(e.tags)
),
queryset.__store,
)
)
)
if "tags__exact" in kwargs:
STORE["events"] = list(
filter(
lambda e: set(kwargs["tags__exact"]) == set(e.tags),
STORE["events"],
if "tags__exact" in kwargs:
queryset.__store = list(
filter(
lambda e: set(kwargs["tags__exact"]) == set(e.tags),
queryset.__store,
)
)
)
if "tags__len" in kwargs:
queryset.__store = list(
filter(
lambda e: len(e.tags) == kwargs["tags__len"],
queryset.__store,
)
)
return queryset
def mock_queryset_filter(*args, **kwargs):
filter_events(**kwargs)
return m_queryset
def union(self, *args):
queryset = FakeQuerySet()
queryset.__store = self.__store
for arg in args:
queryset.__store += arg.__store
return queryset
def mock_queryset_none(*args, **kwargs):
STORE["events"] = []
return m_queryset
def none(self):
queryset = FakeQuerySet()
queryset.__store = []
return queryset
def mock_queryset_count(*args, **kwargs):
return len(STORE["events"])
def count(self):
return len(self.__store)
m_queryset.all.return_value = m_queryset
m_queryset.filter.side_effect = mock_queryset_filter
m_queryset.none.side_effect = mock_queryset_none
m_queryset.count.side_effect = mock_queryset_count
m_queryset.__getitem__.side_effect = lambda index: STORE[
"events"
].__getitem__(index)
def distinct(self):
queryset = FakeQuerySet()
queryset.__store = []
for event in self.__store:
if event not in queryset.__store:
queryset.__store.append(event)
queryset.__store = sorted(queryset.__store, key=lambda e: e.name)
return queryset
return m_queryset
def __getitem__(self, index):
return self.__store[index]
return FakeQuerySet()
return Query
@pytest.fixture
def schema(Query):
return graphene.Schema(query=Query)

View File

@ -1,18 +1,14 @@
import pytest
from graphene import Schema
from ...compat import ArrayField, MissingType
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_contains_multiple(Query):
def test_array_field_contains_multiple(schema):
"""
Test contains filter on a array field of string.
"""
schema = Schema(query=Query)
query = """
query {
events (tags_Contains: ["concert", "music"]) {
@ -32,13 +28,11 @@ def test_array_field_contains_multiple(Query):
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_contains_one(Query):
def test_array_field_contains_one(schema):
"""
Test contains filter on a array field of string.
"""
schema = Schema(query=Query)
query = """
query {
events (tags_Contains: ["music"]) {
@ -59,13 +53,11 @@ def test_array_field_contains_one(Query):
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_contains_empty_list(Query):
def test_array_field_contains_empty_list(schema):
"""
Test contains filter on a array field of string.
"""
schema = Schema(query=Query)
query = """
query {
events (tags_Contains: []) {

View File

@ -0,0 +1,186 @@
import pytest
from ...compat import ArrayField, MissingType
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_len_filter(schema):
query = """
query {
events (tags_Len: 2) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == [
{"node": {"name": "Musical"}},
{"node": {"name": "Ballet"}},
]
query = """
query {
events (tags_Len: 0) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == [
{"node": {"name": "Speech"}},
]
query = """
query {
events (tags_Len: 10) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == []
query = """
query {
events (tags_Len: "2") {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert len(result.errors) == 1
assert result.errors[0].message == 'Int cannot represent non-integer value: "2"'
query = """
query {
events (tags_Len: True) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert len(result.errors) == 1
assert result.errors[0].message == "Int cannot represent non-integer value: True"
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_custom_filter(schema):
query = """
query {
events (tags_Len_In: 2) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == [
{"node": {"name": "Ballet"}},
{"node": {"name": "Musical"}},
]
query = """
query {
events (tags_Len_In: [0, 2]) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == [
{"node": {"name": "Ballet"}},
{"node": {"name": "Musical"}},
{"node": {"name": "Speech"}},
]
query = """
query {
events (tags_Len_In: [10]) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == []
query = """
query {
events (tags_Len_In: []) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == []
query = """
query {
events (tags_Len_In: "12") {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert len(result.errors) == 1
assert result.errors[0].message == 'Int cannot represent non-integer value: "12"'
query = """
query {
events (tags_Len_In: True) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert len(result.errors) == 1
assert result.errors[0].message == "Int cannot represent non-integer value: True"

View File

@ -1,18 +1,14 @@
import pytest
from graphene import Schema
from ...compat import ArrayField, MissingType
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_exact_no_match(Query):
def test_array_field_exact_no_match(schema):
"""
Test exact filter on a array field of string.
"""
schema = Schema(query=Query)
query = """
query {
events (tags: ["concert", "music"]) {
@ -30,13 +26,11 @@ def test_array_field_exact_no_match(Query):
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_exact_match(Query):
def test_array_field_exact_match(schema):
"""
Test exact filter on a array field of string.
"""
schema = Schema(query=Query)
query = """
query {
events (tags: ["movie", "music"]) {
@ -56,13 +50,11 @@ def test_array_field_exact_match(Query):
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_exact_empty_list(Query):
def test_array_field_exact_empty_list(schema):
"""
Test exact filter on a array field of string.
"""
schema = Schema(query=Query)
query = """
query {
events (tags: []) {
@ -82,11 +74,10 @@ def test_array_field_exact_empty_list(Query):
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_filter_schema_type(Query):
def test_array_field_filter_schema_type(schema):
"""
Check that the type in the filter is an array field like on the object type.
"""
schema = Schema(query=Query)
schema_str = str(schema)
assert (
@ -112,6 +103,8 @@ def test_array_field_filter_schema_type(Query):
"tags_Contains": "[String!]",
"tags_Overlap": "[String!]",
"tags": "[String!]",
"tags_Len": "Int",
"tags_Len_In": "[Int]",
"tagsIds_Contains": "[Int!]",
"tagsIds_Overlap": "[Int!]",
"tagsIds": "[Int!]",

View File

@ -1,18 +1,14 @@
import pytest
from graphene import Schema
from ...compat import ArrayField, MissingType
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_overlap_multiple(Query):
def test_array_field_overlap_multiple(schema):
"""
Test overlap filter on a array field of string.
"""
schema = Schema(query=Query)
query = """
query {
events (tags_Overlap: ["concert", "music"]) {
@ -34,13 +30,11 @@ def test_array_field_overlap_multiple(Query):
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_overlap_one(Query):
def test_array_field_overlap_one(schema):
"""
Test overlap filter on a array field of string.
"""
schema = Schema(query=Query)
query = """
query {
events (tags_Overlap: ["music"]) {
@ -61,13 +55,11 @@ def test_array_field_overlap_one(Query):
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_overlap_empty_list(Query):
def test_array_field_overlap_empty_list(schema):
"""
Test overlap filter on a array field of string.
"""
schema = Schema(query=Query)
query = """
query {
events (tags_Overlap: []) {

View File

@ -789,7 +789,7 @@ def test_order_by():
query = """
query NodeFilteringQuery {
allReporters(orderBy: "-firtsnaMe") {
allReporters(orderBy: "-firstname") {
edges {
node {
firstName
@ -802,7 +802,7 @@ def test_order_by():
assert result.errors
def test_order_by_is_perserved():
def test_order_by_is_preserved():
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter

View File

@ -1,4 +1,8 @@
import operator
from functools import reduce
import pytest
from django.db.models import Q
from django_filters import FilterSet
import graphene
@ -44,6 +48,10 @@ def schema():
only_first = TypedFilter(
input_type=graphene.Boolean, method="only_first_filter"
)
headline_search = ListFilter(
method="headline_search_filter",
input_type=graphene.List(graphene.String),
)
def first_n_filter(self, queryset, _name, value):
return queryset[:value]
@ -54,6 +62,13 @@ def schema():
else:
return queryset
def headline_search_filter(self, queryset, _name, value):
if not value:
return queryset.none()
return queryset.filter(
reduce(operator.or_, [Q(headline__icontains=v) for v in value])
)
class ArticleType(DjangoObjectType):
class Meta:
model = Article
@ -87,6 +102,7 @@ def test_typed_filter_schema(schema):
"lang_InStr": "[String]",
"firstN": "Int",
"onlyFirst": "Boolean",
"headlineSearch": "[String]",
}
all_articles_filters = (
@ -104,24 +120,7 @@ def test_typed_filters_work(schema):
Article.objects.create(headline="A", reporter=reporter, editor=reporter, lang="es")
Article.objects.create(headline="B", reporter=reporter, editor=reporter, lang="es")
Article.objects.create(headline="C", reporter=reporter, editor=reporter, lang="en")
query = "query { articles (lang_In: [ES]) { edges { node { headline } } } }"
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A"}},
{"node": {"headline": "B"}},
]
query = 'query { articles (lang_InStr: ["es"]) { edges { node { headline } } } }'
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A"}},
{"node": {"headline": "B"}},
]
Article.objects.create(headline="AB", reporter=reporter, editor=reporter, lang="es")
query = 'query { articles (lang_Contains: "n") { edges { node { headline } } } }'
@ -137,7 +136,7 @@ def test_typed_filters_work(schema):
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A"}},
{"node": {"headline": "B"}},
{"node": {"headline": "AB"}},
]
query = "query { articles (onlyFirst: true) { edges { node { headline } } } }"
@ -147,3 +146,86 @@ def test_typed_filters_work(schema):
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A"}},
]
def test_list_filters_work(schema):
reporter = Reporter.objects.create(first_name="John", last_name="Doe", email="")
Article.objects.create(headline="A", reporter=reporter, editor=reporter, lang="es")
Article.objects.create(headline="B", reporter=reporter, editor=reporter, lang="es")
Article.objects.create(headline="C", reporter=reporter, editor=reporter, lang="en")
Article.objects.create(headline="AB", reporter=reporter, editor=reporter, lang="es")
query = "query { articles (lang_In: [ES]) { edges { node { headline } } } }"
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A"}},
{"node": {"headline": "AB"}},
{"node": {"headline": "B"}},
]
query = 'query { articles (lang_InStr: ["es"]) { edges { node { headline } } } }'
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A"}},
{"node": {"headline": "AB"}},
{"node": {"headline": "B"}},
]
query = "query { articles (lang_InStr: []) { edges { node { headline } } } }"
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == []
query = "query { articles (lang_InStr: null) { edges { node { headline } } } }"
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A"}},
{"node": {"headline": "AB"}},
{"node": {"headline": "B"}},
{"node": {"headline": "C"}},
]
query = 'query { articles (headlineSearch: ["a", "B"]) { edges { node { headline } } } }'
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A"}},
{"node": {"headline": "AB"}},
{"node": {"headline": "B"}},
]
query = "query { articles (headlineSearch: []) { edges { node { headline } } } }"
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == []
query = "query { articles (headlineSearch: null) { edges { node { headline } } } }"
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A"}},
{"node": {"headline": "AB"}},
{"node": {"headline": "B"}},
{"node": {"headline": "C"}},
]
query = 'query { articles (headlineSearch: [""]) { edges { node { headline } } } }'
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A"}},
{"node": {"headline": "AB"}},
{"node": {"headline": "B"}},
{"node": {"headline": "C"}},
]

View File

@ -43,7 +43,7 @@ def get_filtering_args_from_filterset(filterset_class, type):
isinstance(filter_field, TypedFilter)
and filter_field.input_type is not None
):
# First check if the filter input type has been explicitely given
# First check if the filter input type has been explicitly given
field_type = filter_field.input_type
else:
if name not in filterset_class.declared_filters or isinstance(
@ -145,7 +145,7 @@ def replace_csv_filters(filterset_class):
label=filter_field.label,
method=filter_field.method,
exclude=filter_field.exclude,
**filter_field.extra
**filter_field.extra,
)
elif filter_type == "range":
filterset_class.base_filters[name] = RangeFilter(
@ -154,5 +154,5 @@ def replace_csv_filters(filterset_class):
label=filter_field.label,
method=filter_field.method,
exclude=filter_field.exclude,
**filter_field.extra
**filter_field.extra,
)

View File

@ -23,8 +23,7 @@ def fields_for_form(form, only_fields, exclude_fields):
for name, field in form.fields.items():
is_not_in_only = only_fields and name not in only_fields
is_excluded = (
name
in exclude_fields # or
name in exclude_fields # or
# name in already_created_fields
)

View File

@ -1,4 +1,4 @@
from django import forms
from django import VERSION as DJANGO_VERSION, forms
from pytest import raises
from graphene import (
@ -19,12 +19,16 @@ from graphene import (
from ..converter import convert_form_field
def assert_conversion(django_field, graphene_field, *args):
field = django_field(*args, help_text="Custom Help Text")
def assert_conversion(django_field, graphene_field, *args, **kwargs):
# Arrange
help_text = kwargs.setdefault("help_text", "Custom Help Text")
field = django_field(*args, **kwargs)
# Act
graphene_type = convert_form_field(field)
# Assert
assert isinstance(graphene_type, graphene_field)
field = graphene_type.Field()
assert field.description == "Custom Help Text"
assert field.description == help_text
return field
@ -59,7 +63,12 @@ def test_should_slug_convert_string():
def test_should_url_convert_string():
assert_conversion(forms.URLField, String)
kwargs = {}
if DJANGO_VERSION >= (5, 0):
# silence RemovedInDjango60Warning
kwargs["assume_scheme"] = "https"
assert_conversion(forms.URLField, String, **kwargs)
def test_should_choice_convert_string():
@ -75,8 +84,7 @@ def test_should_regex_convert_string():
def test_should_uuid_convert_string():
if hasattr(forms, "UUIDField"):
assert_conversion(forms.UUIDField, UUID)
assert_conversion(forms.UUIDField, UUID)
def test_should_integer_convert_int():

View File

@ -3,8 +3,8 @@ from graphene import ID
from graphene.types.inputobjecttype import InputObjectType
from graphene.utils.str_converters import to_camel_case
from ..converter import BlankValueField
from ..types import ErrorType # noqa Import ErrorType for backwards compatability
from ..converter import EnumValueField
from ..types import ErrorType # noqa Import ErrorType for backwards compatibility
from .mutation import fields_for_form
@ -57,11 +57,10 @@ class DjangoFormInputObjectType(InputObjectType):
if (
object_type
and name in object_type._meta.fields
and isinstance(object_type._meta.fields[name], BlankValueField)
and isinstance(object_type._meta.fields[name], EnumValueField)
):
# Field type BlankValueField here means that field
# with choises have been converted to enum
# (BlankValueField is using only for that task ?)
# Field type EnumValueField here means that field
# with choices have been converted to enum
setattr(cls, name, cls.get_enum_cnv_cls_instance(name, object_type))
elif (
object_type

View File

@ -19,6 +19,7 @@ class SerializerMutationOptions(MutationOptions):
model_class = None
model_operations = ["create", "update"]
serializer_class = None
optional_fields = ()
def fields_for_serializer(
@ -28,6 +29,7 @@ def fields_for_serializer(
is_input=False,
convert_choices_to_enum=True,
lookup_field=None,
optional_fields=(),
):
fields = OrderedDict()
for name, field in serializer.fields.items():
@ -48,9 +50,13 @@ def fields_for_serializer(
if is_not_in_only or is_excluded:
continue
is_optional = name in optional_fields or "__all__" in optional_fields
fields[name] = convert_serializer_field(
field, is_input=is_input, convert_choices_to_enum=convert_choices_to_enum
field,
is_input=is_input,
convert_choices_to_enum=convert_choices_to_enum,
force_optional=is_optional,
)
return fields
@ -74,7 +80,8 @@ class SerializerMutation(ClientIDMutation):
exclude_fields=(),
convert_choices_to_enum=True,
_meta=None,
**options
optional_fields=(),
**options,
):
if not serializer_class:
raise Exception("serializer_class is required for the SerializerMutation")
@ -98,6 +105,7 @@ class SerializerMutation(ClientIDMutation):
is_input=True,
convert_choices_to_enum=convert_choices_to_enum,
lookup_field=lookup_field,
optional_fields=optional_fields,
)
output_fields = fields_for_serializer(
serializer,

View File

@ -18,7 +18,9 @@ def get_graphene_type_from_serializer_field(field):
)
def convert_serializer_field(field, is_input=True, convert_choices_to_enum=True):
def convert_serializer_field(
field, is_input=True, convert_choices_to_enum=True, force_optional=False
):
"""
Converts a django rest frameworks field to a graphql field
and marks the field as required if we are creating an input type
@ -31,7 +33,10 @@ def convert_serializer_field(field, is_input=True, convert_choices_to_enum=True)
graphql_type = get_graphene_type_from_serializer_field(field)
args = []
kwargs = {"description": field.help_text, "required": is_input and field.required}
kwargs = {
"description": field.help_text,
"required": is_input and field.required and not force_optional,
}
# if it is a tuple or a list it means that we are returning
# the graphql type and the child type

View File

@ -96,8 +96,7 @@ def test_should_regex_convert_string():
def test_should_uuid_convert_string():
if hasattr(serializers, "UUIDField"):
assert_conversion(serializers.UUIDField, graphene.String)
assert_conversion(serializers.UUIDField, graphene.String)
def test_should_model_convert_field():

View File

@ -3,7 +3,7 @@ import datetime
from pytest import raises
from rest_framework import serializers
from graphene import Field, ResolveInfo
from graphene import Field, ResolveInfo, String
from graphene.types.inputobjecttype import InputObjectType
from ...types import DjangoObjectType
@ -105,6 +105,16 @@ def test_exclude_fields():
assert "created" not in MyMutation.Input._meta.fields
def test_model_serializer_optional_fields():
class MyMutation(SerializerMutation):
class Meta:
serializer_class = MyModelSerializer
optional_fields = ("cool_name",)
assert "cool_name" in MyMutation.Input._meta.fields
assert MyMutation.Input._meta.fields["cool_name"].type == String
def test_write_only_field():
class WriteOnlyFieldModelSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True)
@ -265,7 +275,7 @@ def test_perform_mutate_success():
result = MyMethodMutation.mutate_and_get_payload(
None,
mock_info(),
**{"cool_name": "Narf", "last_edited": datetime.date(2020, 1, 4)}
**{"cool_name": "Narf", "last_edited": datetime.date(2020, 1, 4)},
)
assert result.errors is None

View File

@ -30,6 +30,8 @@ DEFAULTS = {
# Max items returned in ConnectionFields / FilterConnectionFields
"RELAY_CONNECTION_MAX_LIMIT": 100,
"CAMELCASE_ERRORS": True,
# Automatically convert Choice fields of Django into Enum fields
"DJANGO_CHOICE_FIELD_ENUM_CONVERT": True,
# Set to True to enable v2 naming convention for choice field Enum's
"DJANGO_CHOICE_FIELD_ENUM_V2_NAMING": False,
"DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME": None,
@ -40,8 +42,10 @@ DEFAULTS = {
# https://github.com/graphql/graphiql/tree/main/packages/graphiql#options
"GRAPHIQL_HEADER_EDITOR_ENABLED": True,
"GRAPHIQL_SHOULD_PERSIST_HEADERS": False,
"GRAPHIQL_INPUT_VALUE_DEPRECATION": False,
"ATOMIC_MUTATIONS": False,
"TESTING_ENDPOINT": "/graphql",
"MAX_VALIDATION_ERRORS": None,
}
if settings.DEBUG:

View File

@ -122,6 +122,7 @@
onEditOperationName: onEditOperationName,
isHeadersEditorEnabled: GRAPHENE_SETTINGS.graphiqlHeaderEditorEnabled,
shouldPersistHeaders: GRAPHENE_SETTINGS.graphiqlShouldPersistHeaders,
inputValueDeprecation: GRAPHENE_SETTINGS.graphiqlInputValueDeprecation,
query: query,
};
if (parameters.variables) {

View File

@ -54,6 +54,7 @@ add "&raw" to the end of the URL within a browser.
{% endif %}
graphiqlHeaderEditorEnabled: {{ graphiql_header_editor_enabled|yesno:"true,false" }},
graphiqlShouldPersistHeaders: {{ graphiql_should_persist_headers|yesno:"true,false" }},
graphiqlInputValueDeprecation: {{ graphiql_input_value_deprecation|yesno:"true,false" }},
};
</script>
<script src="{% static 'graphene_django/graphiql.js' %}"></script>

View File

@ -1,11 +1,43 @@
import django
from django.db import models
from django.utils.translation import gettext_lazy as _
CHOICES = ((1, "this"), (2, _("that")))
def get_choices_as_class(choices_class):
if django.VERSION >= (5, 0):
return choices_class
else:
return choices_class.choices
def get_choices_as_callable(choices_class):
if django.VERSION >= (5, 0):
def choices():
return choices_class.choices
return choices
else:
return choices_class.choices
class TypedIntChoice(models.IntegerChoices):
CHOICE_THIS = 1
CHOICE_THAT = 2
class TypedStrChoice(models.TextChoices):
CHOICE_THIS = "this"
CHOICE_THAT = "that"
class Person(models.Model):
name = models.CharField(max_length=30)
parent = models.ForeignKey(
"self", on_delete=models.CASCADE, null=True, blank=True, related_name="children"
)
class Pet(models.Model):
@ -48,6 +80,21 @@ class Reporter(models.Model):
email = models.EmailField()
pets = models.ManyToManyField("self")
a_choice = models.IntegerField(choices=CHOICES, null=True, blank=True)
typed_choice = models.IntegerField(
choices=TypedIntChoice.choices,
null=True,
blank=True,
)
class_choice = models.IntegerField(
choices=get_choices_as_class(TypedIntChoice),
null=True,
blank=True,
)
callable_choice = models.IntegerField(
choices=get_choices_as_callable(TypedStrChoice),
null=True,
blank=True,
)
objects = models.Manager()
doe_objects = DoeReporterManager()
fans = models.ManyToManyField(Person)
@ -97,7 +144,7 @@ class CNNReporter(Reporter):
class APNewsReporter(Reporter):
"""
This class only inherits from Reporter for testing multi table inheritence
This class only inherits from Reporter for testing multi table inheritance
similar to what you'd see in django-polymorphic
"""

View File

@ -25,7 +25,7 @@ from ..converter import (
)
from ..registry import Registry
from ..types import DjangoObjectType
from .models import Article, Film, FilmDetails, Reporter
from .models import Article, Film, FilmDetails, Reporter, TypedIntChoice, TypedStrChoice
# from graphene.core.types.custom_scalars import DateTime, Time, JSONString
@ -53,9 +53,8 @@ def assert_conversion(django_field, graphene_field, *args, **kwargs):
def test_should_unknown_django_field_raise_exception():
with raises(Exception) as excinfo:
with raises(Exception, match="Don't know how to convert the Django field"):
convert_django_field(None)
assert "Don't know how to convert the Django field" in str(excinfo.value)
def test_should_date_time_convert_string():
@ -115,8 +114,7 @@ def test_should_big_auto_convert_id():
def test_should_small_auto_convert_id():
if hasattr(models, "SmallAutoField"):
assert_conversion(models.SmallAutoField, graphene.ID, primary_key=True)
assert_conversion(models.SmallAutoField, graphene.ID, primary_key=True)
def test_should_uuid_convert_id():
@ -166,14 +164,34 @@ def test_field_with_choices_convert_enum():
help_text="Language", choices=(("es", "Spanish"), ("en", "English"))
)
class TranslatedModel(models.Model):
class ChoicesModel(models.Model):
language = field
class Meta:
app_label = "test"
graphene_type = convert_django_field_with_choices(field).type.of_type
assert graphene_type._meta.name == "TestTranslatedModelLanguageChoices"
assert graphene_type._meta.name == "TestChoicesModelLanguageChoices"
assert graphene_type._meta.enum.__members__["ES"].value == "es"
assert graphene_type._meta.enum.__members__["ES"].description == "Spanish"
assert graphene_type._meta.enum.__members__["EN"].value == "en"
assert graphene_type._meta.enum.__members__["EN"].description == "English"
def test_field_with_callable_choices_convert_enum():
def get_choices():
return ("es", "Spanish"), ("en", "English")
field = models.CharField(help_text="Language", choices=get_choices)
class CallableChoicesModel(models.Model):
language = field
class Meta:
app_label = "test"
graphene_type = convert_django_field_with_choices(field).type.of_type
assert graphene_type._meta.name == "TestCallableChoicesModelLanguageChoices"
assert graphene_type._meta.enum.__members__["ES"].value == "es"
assert graphene_type._meta.enum.__members__["ES"].description == "Spanish"
assert graphene_type._meta.enum.__members__["EN"].value == "en"
@ -423,35 +441,102 @@ def test_choice_enum_blank_value():
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
fields = (
"first_name",
"a_choice",
)
fields = ("callable_choice",)
class Query(graphene.ObjectType):
reporter = graphene.Field(ReporterType)
def resolve_reporter(root, info):
return Reporter.objects.first()
# return a model instance with blank choice field value
return Reporter(callable_choice="")
schema = graphene.Schema(query=Query)
# Create model with empty choice option
Reporter.objects.create(
first_name="Bridget", last_name="Jones", email="bridget@example.com"
)
result = schema.execute(
"""
query {
reporter {
firstName
aChoice
callableChoice
}
}
"""
)
assert not result.errors
assert result.data == {
"reporter": {"firstName": "Bridget", "aChoice": None},
"reporter": {"callableChoice": None},
}
def test_typed_choice_value():
"""Test that typed choices fields are resolved correctly to the enum values"""
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
fields = ("typed_choice", "class_choice", "callable_choice")
class Query(graphene.ObjectType):
reporter = graphene.Field(ReporterType)
def resolve_reporter(root, info):
# assign choice values to the fields instead of their str or int values
return Reporter(
typed_choice=TypedIntChoice.CHOICE_THIS,
class_choice=TypedIntChoice.CHOICE_THAT,
callable_choice=TypedStrChoice.CHOICE_THIS,
)
class CreateReporter(graphene.Mutation):
reporter = graphene.Field(ReporterType)
def mutate(root, info, **kwargs):
return CreateReporter(
reporter=Reporter(
typed_choice=TypedIntChoice.CHOICE_THIS,
class_choice=TypedIntChoice.CHOICE_THAT,
callable_choice=TypedStrChoice.CHOICE_THIS,
),
)
class Mutation(graphene.ObjectType):
create_reporter = CreateReporter.Field()
schema = graphene.Schema(query=Query, mutation=Mutation)
reporter_fragment = """
fragment reporter on ReporterType {
typedChoice
classChoice
callableChoice
}
"""
expected_reporter = {
"typedChoice": "A_1",
"classChoice": "A_2",
"callableChoice": "THIS",
}
result = schema.execute(
reporter_fragment
+ """
query {
reporter { ...reporter }
}
"""
)
assert not result.errors
assert result.data["reporter"] == expected_reporter
result = schema.execute(
reporter_fragment
+ """
mutation {
createReporter {
reporter { ...reporter }
}
}
"""
)
assert not result.errors
assert result.data["createReporter"]["reporter"] == expected_reporter

View File

@ -12,17 +12,23 @@ from .models import (
Article as ArticleModel,
Film as FilmModel,
FilmDetails as FilmDetailsModel,
Person as PersonModel,
Reporter as ReporterModel,
)
class TestDjangoListField:
def test_only_django_object_types(self):
class TestType(ObjectType):
foo = String()
class Query(ObjectType):
something = DjangoListField(String)
with pytest.raises(AssertionError):
DjangoListField(TestType)
with pytest.raises(TypeError) as excinfo:
Schema(query=Query)
assert (
"Query fields cannot be resolved. DjangoListField only accepts DjangoObjectType types as underlying type"
in str(excinfo.value)
)
def test_only_import_paths(self):
list_field = DjangoListField("graphene_django.tests.schema.Human")
@ -262,6 +268,69 @@ class TestDjangoListField:
]
}
def test_same_type_nested_list_field(self):
class Person(DjangoObjectType):
class Meta:
model = PersonModel
fields = ("name", "parent")
children = DjangoListField(lambda: Person)
class Query(ObjectType):
persons = DjangoListField(Person)
schema = Schema(query=Query)
query = """
query {
persons {
name
children {
name
}
}
}
"""
p1 = PersonModel.objects.create(name="Tara")
PersonModel.objects.create(name="Debra")
PersonModel.objects.create(
name="Toto",
parent=p1,
)
PersonModel.objects.create(
name="Tata",
parent=p1,
)
result = schema.execute(query)
assert not result.errors
assert result.data == {
"persons": [
{
"name": "Tara",
"children": [
{"name": "Toto"},
{"name": "Tata"},
],
},
{
"name": "Debra",
"children": [],
},
{
"name": "Toto",
"children": [],
},
{
"name": "Tata",
"children": [],
},
]
}
def test_get_queryset_filter(self):
class Reporter(DjangoObjectType):
class Meta:

View File

@ -26,6 +26,7 @@ class TestShouldCallGetQuerySetOnForeignKey:
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
fields = "__all__"
@classmethod
def get_queryset(cls, queryset, info):
@ -36,6 +37,7 @@ class TestShouldCallGetQuerySetOnForeignKey:
class ArticleType(DjangoObjectType):
class Meta:
model = Article
fields = "__all__"
@classmethod
def get_queryset(cls, queryset, info):
@ -200,6 +202,7 @@ class TestShouldCallGetQuerySetOnForeignKeyNode:
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
fields = "__all__"
interfaces = (Node,)
@classmethod
@ -211,6 +214,7 @@ class TestShouldCallGetQuerySetOnForeignKeyNode:
class ArticleType(DjangoObjectType):
class Meta:
model = Article
fields = "__all__"
interfaces = (Node,)
@classmethod
@ -370,6 +374,7 @@ class TestShouldCallGetQuerySetOnOneToOne:
class FilmDetailsType(DjangoObjectType):
class Meta:
model = FilmDetails
fields = "__all__"
@classmethod
def get_queryset(cls, queryset, info):
@ -380,6 +385,7 @@ class TestShouldCallGetQuerySetOnOneToOne:
class FilmType(DjangoObjectType):
class Meta:
model = Film
fields = "__all__"
@classmethod
def get_queryset(cls, queryset, info):

View File

@ -1,5 +1,6 @@
import base64
import datetime
from unittest.mock import ANY, Mock
import pytest
from django.db import models
@ -2000,14 +2001,62 @@ def test_connection_should_succeed_if_last_higher_than_number_of_objects():
assert result.data == expected
def test_connection_should_call_resolver_function():
resolver_mock = Mock(
name="resolver",
return_value=[
Reporter(first_name="Some", last_name="One"),
Reporter(first_name="John", last_name="Doe"),
],
)
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
fields = "__all__"
interfaces = [Node]
class Query(graphene.ObjectType):
reporters = DjangoConnectionField(ReporterType, resolver=resolver_mock)
schema = graphene.Schema(query=Query)
result = schema.execute(
"""
query {
reporters {
edges {
node {
firstName
lastName
}
}
}
}
"""
)
resolver_mock.assert_called_once_with(None, ANY)
assert not result.errors
assert result.data == {
"reporters": {
"edges": [
{"node": {"firstName": "Some", "lastName": "One"}},
{"node": {"firstName": "John", "lastName": "Doe"}},
],
},
}
def test_should_query_nullable_foreign_key():
class PetType(DjangoObjectType):
class Meta:
model = Pet
fields = "__all__"
class PersonType(DjangoObjectType):
class Meta:
model = Person
fields = "__all__"
class Query(graphene.ObjectType):
pet = graphene.Field(PetType, name=graphene.String(required=True))
@ -2022,10 +2071,8 @@ def test_should_query_nullable_foreign_key():
schema = graphene.Schema(query=Query)
person = Person.objects.create(name="Jane")
[
Pet.objects.create(name="Stray dog", age=1),
Pet.objects.create(name="Jane's dog", owner=person, age=1),
]
Pet.objects.create(name="Stray dog", age=1)
Pet.objects.create(name="Jane's dog", owner=person, age=1)
query_pet = """
query getPet($name: String!) {
@ -2068,6 +2115,7 @@ def test_should_query_nullable_one_to_one_relation_with_custom_resolver():
class FilmType(DjangoObjectType):
class Meta:
model = Film
fields = "__all__"
@classmethod
def get_queryset(cls, queryset, info):
@ -2076,6 +2124,7 @@ def test_should_query_nullable_one_to_one_relation_with_custom_resolver():
class FilmDetailsType(DjangoObjectType):
class Meta:
model = FilmDetails
fields = "__all__"
@classmethod
def get_queryset(cls, queryset, info):

View File

@ -40,6 +40,9 @@ def test_should_map_fields_correctly():
"email",
"pets",
"a_choice",
"typed_choice",
"class_choice",
"callable_choice",
"fans",
"reporter_type",
]

View File

@ -1,3 +1,4 @@
import warnings
from collections import OrderedDict, defaultdict
from textwrap import dedent
from unittest.mock import patch
@ -76,6 +77,9 @@ def test_django_objecttype_map_correct_fields():
"email",
"pets",
"a_choice",
"typed_choice",
"class_choice",
"callable_choice",
"fans",
"reporter_type",
]
@ -185,6 +189,9 @@ def test_schema_representation():
email: String!
pets: [Reporter!]!
aChoice: TestsReporterAChoiceChoices
typedChoice: TestsReporterTypedChoiceChoices
classChoice: TestsReporterClassChoiceChoices
callableChoice: TestsReporterCallableChoiceChoices
reporterType: TestsReporterReporterTypeChoices
articles(offset: Int, before: String, after: String, first: Int, last: Int): ArticleConnection!
}
@ -198,6 +205,33 @@ def test_schema_representation():
A_2
}
\"""An enumeration.\"""
enum TestsReporterTypedChoiceChoices {
\"""Choice This\"""
A_1
\"""Choice That\"""
A_2
}
\"""An enumeration.\"""
enum TestsReporterClassChoiceChoices {
\"""Choice This\"""
A_1
\"""Choice That\"""
A_2
}
\"""An enumeration.\"""
enum TestsReporterCallableChoiceChoices {
\"""Choice This\"""
THIS
\"""Choice That\"""
THAT
}
\"""An enumeration.\"""
enum TestsReporterReporterTypeChoices {
\"""Regular\"""
@ -399,7 +433,7 @@ def test_django_objecttype_fields_exist_on_model():
with pytest.warns(
UserWarning,
match=r"Field name .* matches an attribute on Django model .* but it's not a model field",
) as record:
):
class Reporter2(DjangoObjectType):
class Meta:
@ -407,7 +441,8 @@ def test_django_objecttype_fields_exist_on_model():
fields = ["first_name", "some_method", "email"]
# Don't warn if selecting a custom field
with pytest.warns(None) as record:
with warnings.catch_warnings():
warnings.simplefilter("error")
class Reporter3(DjangoObjectType):
custom_field = String()
@ -416,8 +451,6 @@ def test_django_objecttype_fields_exist_on_model():
model = ReporterModel
fields = ["first_name", "custom_field", "email"]
assert len(record) == 0
@with_local_registry
def test_django_objecttype_exclude_fields_exist_on_model():
@ -445,15 +478,14 @@ def test_django_objecttype_exclude_fields_exist_on_model():
exclude = ["custom_field"]
# Don't warn on exclude fields
with pytest.warns(None) as record:
with warnings.catch_warnings():
warnings.simplefilter("error")
class Reporter4(DjangoObjectType):
class Meta:
model = ReporterModel
exclude = ["email", "first_name"]
assert len(record) == 0
@with_local_registry
def test_django_objecttype_neither_fields_nor_exclude():
@ -467,24 +499,22 @@ def test_django_objecttype_neither_fields_nor_exclude():
class Meta:
model = ReporterModel
with pytest.warns(None) as record:
with warnings.catch_warnings():
warnings.simplefilter("error")
class Reporter2(DjangoObjectType):
class Meta:
model = ReporterModel
fields = ["email"]
assert len(record) == 0
with pytest.warns(None) as record:
with warnings.catch_warnings():
warnings.simplefilter("error")
class Reporter3(DjangoObjectType):
class Meta:
model = ReporterModel
exclude = ["email"]
assert len(record) == 0
def custom_enum_name(field):
return f"CustomEnum{field.name.title()}"
@ -661,6 +691,122 @@ class TestDjangoObjectType:
}"""
)
def test_django_objecttype_convert_choices_global_false(
self, graphene_settings, PetModel
):
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CONVERT = False
class Pet(DjangoObjectType):
class Meta:
model = PetModel
fields = "__all__"
class Query(ObjectType):
pet = Field(Pet)
schema = Schema(query=Query)
assert str(schema) == dedent(
"""\
type Query {
pet: Pet
}
type Pet {
id: ID!
kind: String!
cuteness: Int!
}"""
)
def test_django_objecttype_convert_choices_true_global_false(
self, graphene_settings, PetModel
):
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CONVERT = False
class Pet(DjangoObjectType):
class Meta:
model = PetModel
fields = "__all__"
convert_choices_to_enum = True
class Query(ObjectType):
pet = Field(Pet)
schema = Schema(query=Query)
assert str(schema) == dedent(
"""\
type Query {
pet: Pet
}
type Pet {
id: ID!
kind: TestsPetModelKindChoices!
cuteness: TestsPetModelCutenessChoices!
}
\"""An enumeration.\"""
enum TestsPetModelKindChoices {
\"""Cat\"""
CAT
\"""Dog\"""
DOG
}
\"""An enumeration.\"""
enum TestsPetModelCutenessChoices {
\"""Kind of cute\"""
A_1
\"""Pretty cute\"""
A_2
\"""OMG SO CUTE!!!\"""
A_3
}"""
)
def test_django_objecttype_convert_choices_enum_list_global_false(
self, graphene_settings, PetModel
):
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CONVERT = False
class Pet(DjangoObjectType):
class Meta:
model = PetModel
convert_choices_to_enum = ["kind"]
fields = "__all__"
class Query(ObjectType):
pet = Field(Pet)
schema = Schema(query=Query)
assert str(schema) == dedent(
"""\
type Query {
pet: Pet
}
type Pet {
id: ID!
kind: TestsPetModelKindChoices!
cuteness: Int!
}
\"""An enumeration.\"""
enum TestsPetModelKindChoices {
\"""Cat\"""
CAT
\"""Dog\"""
DOG
}"""
)
@with_local_registry
def test_django_objecttype_name_connection_propagation():

View File

@ -1,4 +1,5 @@
import json
from http import HTTPStatus
from unittest.mock import patch
import pytest
@ -37,7 +38,7 @@ def jl(**kwargs):
def test_graphiql_is_enabled(client):
response = client.get(url_string(), HTTP_ACCEPT="text/html")
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response["Content-Type"].split(";")[0] == "text/html"
@ -46,7 +47,7 @@ def test_qfactor_graphiql(client):
url_string(query="{test}"),
HTTP_ACCEPT="application/json;q=0.8, text/html;q=0.9",
)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response["Content-Type"].split(";")[0] == "text/html"
@ -55,7 +56,7 @@ def test_qfactor_json(client):
url_string(query="{test}"),
HTTP_ACCEPT="text/html;q=0.8, application/json;q=0.9",
)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response["Content-Type"].split(";")[0] == "application/json"
assert response_json(response) == {"data": {"test": "Hello World"}}
@ -63,7 +64,7 @@ def test_qfactor_json(client):
def test_allows_get_with_query_param(client):
response = client.get(url_string(query="{test}"))
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response_json(response) == {"data": {"test": "Hello World"}}
@ -75,7 +76,7 @@ def test_allows_get_with_variable_values(client):
)
)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response_json(response) == {"data": {"test": "Hello Dolly"}}
@ -94,7 +95,7 @@ def test_allows_get_with_operation_name(client):
)
)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response_json(response) == {
"data": {"test": "Hello World", "shared": "Hello Everyone"}
}
@ -103,7 +104,7 @@ def test_allows_get_with_operation_name(client):
def test_reports_validation_errors(client):
response = client.get(url_string(query="{ test, unknownOne, unknownTwo }"))
assert response.status_code == 400
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response_json(response) == {
"errors": [
{
@ -128,7 +129,7 @@ def test_errors_when_missing_operation_name(client):
)
)
assert response.status_code == 400
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response_json(response) == {
"errors": [
{
@ -146,7 +147,7 @@ def test_errors_when_sending_a_mutation_via_get(client):
"""
)
)
assert response.status_code == 405
assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED
assert response_json(response) == {
"errors": [
{"message": "Can only perform a mutation operation from a POST request."}
@ -165,7 +166,7 @@ def test_errors_when_selecting_a_mutation_within_a_get(client):
)
)
assert response.status_code == 405
assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED
assert response_json(response) == {
"errors": [
{"message": "Can only perform a mutation operation from a POST request."}
@ -184,14 +185,14 @@ def test_allows_mutation_to_exist_within_a_get(client):
)
)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response_json(response) == {"data": {"test": "Hello World"}}
def test_allows_post_with_json_encoding(client):
response = client.post(url_string(), j(query="{test}"), "application/json")
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response_json(response) == {"data": {"test": "Hello World"}}
@ -200,7 +201,7 @@ def test_batch_allows_post_with_json_encoding(client):
batch_url_string(), jl(id=1, query="{test}"), "application/json"
)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response_json(response) == [
{"id": 1, "data": {"test": "Hello World"}, "status": 200}
]
@ -209,7 +210,7 @@ def test_batch_allows_post_with_json_encoding(client):
def test_batch_fails_if_is_empty(client):
response = client.post(batch_url_string(), "[]", "application/json")
assert response.status_code == 400
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response_json(response) == {
"errors": [{"message": "Received an empty list in the batch request."}]
}
@ -222,7 +223,7 @@ def test_allows_sending_a_mutation_via_post(client):
"application/json",
)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}}
@ -233,7 +234,7 @@ def test_allows_post_with_url_encoding(client):
"application/x-www-form-urlencoded",
)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response_json(response) == {"data": {"test": "Hello World"}}
@ -247,7 +248,7 @@ def test_supports_post_json_query_with_string_variables(client):
"application/json",
)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response_json(response) == {"data": {"test": "Hello Dolly"}}
@ -262,7 +263,7 @@ def test_batch_supports_post_json_query_with_string_variables(client):
"application/json",
)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response_json(response) == [
{"id": 1, "data": {"test": "Hello Dolly"}, "status": 200}
]
@ -278,7 +279,7 @@ def test_supports_post_json_query_with_json_variables(client):
"application/json",
)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response_json(response) == {"data": {"test": "Hello Dolly"}}
@ -293,7 +294,7 @@ def test_batch_supports_post_json_query_with_json_variables(client):
"application/json",
)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response_json(response) == [
{"id": 1, "data": {"test": "Hello Dolly"}, "status": 200}
]
@ -311,7 +312,7 @@ def test_supports_post_url_encoded_query_with_string_variables(client):
"application/x-www-form-urlencoded",
)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response_json(response) == {"data": {"test": "Hello Dolly"}}
@ -322,7 +323,7 @@ def test_supports_post_json_quey_with_get_variable_values(client):
"application/json",
)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response_json(response) == {"data": {"test": "Hello Dolly"}}
@ -333,7 +334,7 @@ def test_post_url_encoded_query_with_get_variable_values(client):
"application/x-www-form-urlencoded",
)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response_json(response) == {"data": {"test": "Hello Dolly"}}
@ -344,7 +345,7 @@ def test_supports_post_raw_text_query_with_get_variable_values(client):
"application/graphql",
)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response_json(response) == {"data": {"test": "Hello Dolly"}}
@ -365,7 +366,7 @@ def test_allows_post_with_operation_name(client):
"application/json",
)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response_json(response) == {
"data": {"test": "Hello World", "shared": "Hello Everyone"}
}
@ -389,7 +390,7 @@ def test_batch_allows_post_with_operation_name(client):
"application/json",
)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response_json(response) == [
{
"id": 1,
@ -413,7 +414,7 @@ def test_allows_post_with_get_operation_name(client):
"application/graphql",
)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response_json(response) == {
"data": {"test": "Hello World", "shared": "Hello Everyone"}
}
@ -430,7 +431,7 @@ def test_inherited_class_with_attributes_works(client):
# Check graphiql works
response = client.get(url_string(inherited_url), HTTP_ACCEPT="text/html")
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
@pytest.mark.urls("graphene_django.tests.urls_pretty")
@ -452,7 +453,7 @@ def test_supports_pretty_printing_by_request(client):
def test_handles_field_errors_caught_by_graphql(client):
response = client.get(url_string(query="{thrower}"))
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response_json(response) == {
"data": None,
"errors": [
@ -467,7 +468,7 @@ def test_handles_field_errors_caught_by_graphql(client):
def test_handles_syntax_errors_caught_by_graphql(client):
response = client.get(url_string(query="syntaxerror"))
assert response.status_code == 400
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response_json(response) == {
"errors": [
{
@ -481,7 +482,7 @@ def test_handles_syntax_errors_caught_by_graphql(client):
def test_handles_errors_caused_by_a_lack_of_query(client):
response = client.get(url_string())
assert response.status_code == 400
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response_json(response) == {
"errors": [{"message": "Must provide query string."}]
}
@ -490,7 +491,7 @@ def test_handles_errors_caused_by_a_lack_of_query(client):
def test_handles_not_expected_json_bodies(client):
response = client.post(url_string(), "[]", "application/json")
assert response.status_code == 400
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response_json(response) == {
"errors": [{"message": "The received data is not a valid JSON query."}]
}
@ -499,7 +500,7 @@ def test_handles_not_expected_json_bodies(client):
def test_handles_invalid_json_bodies(client):
response = client.post(url_string(), "[oh}", "application/json")
assert response.status_code == 400
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response_json(response) == {
"errors": [{"message": "POST body sent invalid JSON."}]
}
@ -514,14 +515,14 @@ def test_handles_django_request_error(client, monkeypatch):
valid_json = json.dumps({"foo": "bar"})
response = client.post(url_string(), valid_json, "application/json")
assert response.status_code == 400
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response_json(response) == {"errors": [{"message": "foo-bar"}]}
def test_handles_incomplete_json_bodies(client):
response = client.post(url_string(), '{"query":', "application/json")
assert response.status_code == 400
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response_json(response) == {
"errors": [{"message": "POST body sent invalid JSON."}]
}
@ -533,7 +534,7 @@ def test_handles_plain_post_text(client):
"query helloWho($who: String){ test(who: $who) }",
"text/plain",
)
assert response.status_code == 400
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response_json(response) == {
"errors": [{"message": "Must provide query string."}]
}
@ -545,7 +546,7 @@ def test_handles_poorly_formed_variables(client):
query="query helloWho($who: String){ test(who: $who) }", variables="who:You"
)
)
assert response.status_code == 400
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response_json(response) == {
"errors": [{"message": "Variables are invalid JSON."}]
}
@ -553,7 +554,7 @@ def test_handles_poorly_formed_variables(client):
def test_handles_unsupported_http_methods(client):
response = client.put(url_string(query="{test}"))
assert response.status_code == 405
assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED
assert response["Allow"] == "GET, POST"
assert response_json(response) == {
"errors": [{"message": "GraphQL only supports GET and POST requests."}]
@ -563,7 +564,7 @@ def test_handles_unsupported_http_methods(client):
def test_passes_request_into_context_request(client):
response = client.get(url_string(query="{request}", q="testing"))
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
assert response_json(response) == {"data": {"request": "testing"}}
@ -827,3 +828,97 @@ def test_query_errors_atomic_request(set_rollback_mock, client):
def test_query_errors_non_atomic(set_rollback_mock, client):
client.get(url_string(query="force error"))
set_rollback_mock.assert_not_called()
VALIDATION_URLS = [
"/graphql/validation/",
"/graphql/validation/alternative/",
"/graphql/validation/inherited/",
]
QUERY_WITH_TWO_INTROSPECTIONS = """
query Instrospection {
queryType: __schema {
queryType {name}
}
mutationType: __schema {
mutationType {name}
}
}
"""
N_INTROSPECTIONS = 2
INTROSPECTION_DISALLOWED_ERROR_MESSAGE = "introspection is disabled"
MAX_VALIDATION_ERRORS_EXCEEDED_MESSAGE = "too many validation errors"
@pytest.mark.urls("graphene_django.tests.urls_validation")
def test_allow_introspection(client):
response = client.post(
url_string("/graphql/", query="{__schema {queryType {name}}}")
)
assert response.status_code == HTTPStatus.OK
assert response_json(response) == {
"data": {"__schema": {"queryType": {"name": "QueryRoot"}}}
}
@pytest.mark.parametrize("url", VALIDATION_URLS)
@pytest.mark.urls("graphene_django.tests.urls_validation")
def test_validation_disallow_introspection(client, url):
response = client.post(url_string(url, query="{__schema {queryType {name}}}"))
assert response.status_code == HTTPStatus.BAD_REQUEST
json_response = response_json(response)
assert "data" not in json_response
assert "errors" in json_response
assert len(json_response["errors"]) == 1
error_message = json_response["errors"][0]["message"]
assert INTROSPECTION_DISALLOWED_ERROR_MESSAGE in error_message
@pytest.mark.parametrize("url", VALIDATION_URLS)
@pytest.mark.urls("graphene_django.tests.urls_validation")
@patch(
"graphene_django.settings.graphene_settings.MAX_VALIDATION_ERRORS", N_INTROSPECTIONS
)
def test_within_max_validation_errors(client, url):
response = client.post(url_string(url, query=QUERY_WITH_TWO_INTROSPECTIONS))
assert response.status_code == HTTPStatus.BAD_REQUEST
json_response = response_json(response)
assert "data" not in json_response
assert "errors" in json_response
assert len(json_response["errors"]) == N_INTROSPECTIONS
error_messages = [error["message"].lower() for error in json_response["errors"]]
n_introspection_error_messages = sum(
INTROSPECTION_DISALLOWED_ERROR_MESSAGE in msg for msg in error_messages
)
assert n_introspection_error_messages == N_INTROSPECTIONS
assert all(
MAX_VALIDATION_ERRORS_EXCEEDED_MESSAGE not in msg for msg in error_messages
)
@pytest.mark.parametrize("url", VALIDATION_URLS)
@pytest.mark.urls("graphene_django.tests.urls_validation")
@patch("graphene_django.settings.graphene_settings.MAX_VALIDATION_ERRORS", 1)
def test_exceeds_max_validation_errors(client, url):
response = client.post(url_string(url, query=QUERY_WITH_TWO_INTROSPECTIONS))
assert response.status_code == HTTPStatus.BAD_REQUEST
json_response = response_json(response)
assert "data" not in json_response
assert "errors" in json_response
error_messages = (error["message"].lower() for error in json_response["errors"])
assert any(MAX_VALIDATION_ERRORS_EXCEEDED_MESSAGE in msg for msg in error_messages)

View File

@ -0,0 +1,26 @@
from django.urls import path
from graphene.validation import DisableIntrospection
from ..views import GraphQLView
from .schema_view import schema
class View(GraphQLView):
schema = schema
class NoIntrospectionView(View):
validation_rules = (DisableIntrospection,)
class NoIntrospectionViewInherited(NoIntrospectionView):
pass
urlpatterns = [
path("graphql/", View.as_view()),
path("graphql/validation/", View.as_view(validation_rules=(DisableIntrospection,))),
path("graphql/validation/alternative/", NoIntrospectionView.as_view()),
path("graphql/validation/inherited/", NoIntrospectionViewInherited.as_view()),
]

View File

@ -23,7 +23,7 @@ ALL_FIELDS = "__all__"
def construct_fields(
model, registry, only_fields, exclude_fields, convert_choices_to_enum
model, registry, only_fields, exclude_fields, convert_choices_to_enum=None
):
_model_fields = get_model_fields(model)
@ -47,7 +47,7 @@ def construct_fields(
continue
_convert_choices_to_enum = convert_choices_to_enum
if not isinstance(_convert_choices_to_enum, bool):
if isinstance(_convert_choices_to_enum, list):
# then `convert_choices_to_enum` is a list of field names to convert
if name in _convert_choices_to_enum:
_convert_choices_to_enum = True
@ -102,10 +102,8 @@ def validate_fields(type_, model, fields, only_fields, exclude_fields):
if name in all_field_names:
# Field is a custom field
warnings.warn(
(
'Excluding the custom field "{field_name}" on DjangoObjectType "{type_}" has no effect. '
'Either remove the custom field or remove the field from the "exclude" list.'
).format(field_name=name, type_=type_)
f'Excluding the custom field "{name}" on DjangoObjectType "{type_}" has no effect. '
'Either remove the custom field or remove the field from the "exclude" list.'
)
else:
if not hasattr(model, name):
@ -148,7 +146,7 @@ class DjangoObjectType(ObjectType):
connection_class=None,
use_connection=None,
interfaces=(),
convert_choices_to_enum=True,
convert_choices_to_enum=None,
_meta=None,
**options,
):

View File

@ -4,6 +4,7 @@ import warnings
from django.test import Client, TestCase, TransactionTestCase
from graphene_django.settings import graphene_settings
from graphene_django.utils.utils import _DJANGO_VERSION_AT_LEAST_4_2
DEFAULT_GRAPHQL_URL = "/graphql"
@ -55,8 +56,14 @@ def graphql_query(
else:
body["variables"] = {"input": input_data}
if headers:
header_params = (
{"headers": headers} if _DJANGO_VERSION_AT_LEAST_4_2 else headers
)
resp = client.post(
graphql_url, json.dumps(body), content_type="application/json", **headers
graphql_url,
json.dumps(body),
content_type="application/json",
**header_params,
)
else:
resp = client.post(

View File

@ -1,5 +1,6 @@
import inspect
import django
from django.db import connection, models, transaction
from django.db.models.manager import Manager
from django.utils.encoding import force_str
@ -110,24 +111,7 @@ def is_valid_django_model(model):
def import_single_dispatch():
try:
from functools import singledispatch
except ImportError:
singledispatch = None
if not singledispatch:
try:
from singledispatch import singledispatch
except ImportError:
pass
if not singledispatch:
raise Exception(
"It seems your python version does not include "
"functools.singledispatch. Please install the 'singledispatch' "
"package. More information here: "
"https://pypi.python.org/pypi/singledispatch"
)
from functools import singledispatch
return singledispatch
@ -145,3 +129,8 @@ def bypass_get_queryset(resolver):
"""
resolver._bypass_get_queryset = True
return resolver
_DJANGO_VERSION_AT_LEAST_4_2 = django.VERSION[0] > 4 or (
django.VERSION[0] >= 4 and django.VERSION[1] >= 2
)

View File

@ -9,10 +9,17 @@ from django.shortcuts import render
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.generic import View
from graphql import OperationType, get_operation_ast, parse
from graphql import (
ExecutionResult,
OperationType,
execute,
get_operation_ast,
parse,
validate_schema,
)
from graphql.error import GraphQLError
from graphql.execution import ExecutionResult
from graphql.execution.middleware import MiddlewareManager
from graphql.validation import validate
from graphene import Schema
from graphene_django.constants import MUTATION_ERRORS_FLAG
@ -89,6 +96,7 @@ class GraphQLView(View):
batch = False
subscription_path = None
execution_context_class = None
validation_rules = None
def __init__(
self,
@ -100,6 +108,7 @@ class GraphQLView(View):
batch=False,
subscription_path=None,
execution_context_class=None,
validation_rules=None,
):
if not schema:
schema = graphene_settings.SCHEMA
@ -128,6 +137,8 @@ class GraphQLView(View):
), "A Schema is required to be provided to GraphQLView."
assert not all((graphiql, batch)), "Use either graphiql or batch processing"
self.validation_rules = validation_rules or self.validation_rules
# noinspection PyUnusedLocal
def get_root_value(self, request):
return self.root_value
@ -167,11 +178,13 @@ class GraphQLView(View):
subscriptions_transport_ws_sri=self.subscriptions_transport_ws_sri,
graphiql_plugin_explorer_version=self.graphiql_plugin_explorer_version,
graphiql_plugin_explorer_sri=self.graphiql_plugin_explorer_sri,
graphiql_plugin_explorer_css_sri=self.graphiql_plugin_explorer_css_sri,
# The SUBSCRIPTION_PATH setting.
subscription_path=self.subscription_path,
# GraphiQL headers tab,
graphiql_header_editor_enabled=graphene_settings.GRAPHIQL_HEADER_EDITOR_ENABLED,
graphiql_should_persist_headers=graphene_settings.GRAPHIQL_SHOULD_PERSIST_HEADERS,
graphiql_input_value_deprecation=graphene_settings.GRAPHIQL_INPUT_VALUE_DEPRECATION,
)
if self.batch:
@ -293,43 +306,61 @@ class GraphQLView(View):
return None
raise HttpError(HttpResponseBadRequest("Must provide query string."))
schema = self.schema.graphql_schema
schema_validation_errors = validate_schema(schema)
if schema_validation_errors:
return ExecutionResult(data=None, errors=schema_validation_errors)
try:
document = parse(query)
except Exception as e:
return ExecutionResult(errors=[e])
if request.method.lower() == "get":
operation_ast = get_operation_ast(document, operation_name)
if operation_ast and operation_ast.operation != OperationType.QUERY:
if show_graphiql:
return None
operation_ast = get_operation_ast(document, operation_name)
raise HttpError(
HttpResponseNotAllowed(
["POST"],
"Can only perform a {} operation from a POST request.".format(
operation_ast.operation.value
),
)
if (
request.method.lower() == "get"
and operation_ast is not None
and operation_ast.operation != OperationType.QUERY
):
if show_graphiql:
return None
raise HttpError(
HttpResponseNotAllowed(
["POST"],
"Can only perform a {} operation from a POST request.".format(
operation_ast.operation.value
),
)
try:
extra_options = {}
if self.execution_context_class:
extra_options["execution_context_class"] = self.execution_context_class
)
options = {
"source": query,
validation_errors = validate(
schema,
document,
self.validation_rules,
graphene_settings.MAX_VALIDATION_ERRORS,
)
if validation_errors:
return ExecutionResult(data=None, errors=validation_errors)
try:
execute_options = {
"root_value": self.get_root_value(request),
"context_value": self.get_context(request),
"variable_values": variables,
"operation_name": operation_name,
"context_value": self.get_context(request),
"middleware": self.get_middleware(request),
}
options.update(extra_options)
if self.execution_context_class:
execute_options[
"execution_context_class"
] = self.execution_context_class
operation_ast = get_operation_ast(document, operation_name)
if (
operation_ast
operation_ast is not None
and operation_ast.operation == OperationType.MUTATION
and (
graphene_settings.ATOMIC_MUTATIONS is True
@ -337,12 +368,12 @@ class GraphQLView(View):
)
):
with transaction.atomic():
result = self.schema.execute(**options)
result = execute(schema, document, **execute_options)
if getattr(request, MUTATION_ERRORS_FLAG, False) is True:
transaction.set_rollback(True)
return result
return self.schema.execute(**options)
return execute(schema, document, **execute_options)
except Exception as e:
return ExecutionResult(errors=[e])

View File

@ -10,3 +10,7 @@ omit = */tests/*
[tool:pytest]
DJANGO_SETTINGS_MODULE = examples.django_test_settings
addopts = --random-order
filterwarnings =
error
# we can't do anything about the DeprecationWarning about typing.ByteString in graphql
default:'typing\.ByteString' is deprecated:DeprecationWarning:graphql\.pyutils\.is_iterable

View File

@ -26,8 +26,7 @@ tests_require = [
dev_requires = [
"black==23.7.0",
"ruff==0.0.283",
"ruff==0.1.2",
"pre-commit",
] + tests_require
@ -50,6 +49,7 @@ setup(
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: PyPy",
"Framework :: Django",
"Framework :: Django :: 3.2",

11
tox.ini
View File

@ -1,8 +1,8 @@
[tox]
envlist =
py{38,39,310}-django32
py{38,39}-django{41,42}
py{310,311}-django{41,42,main}
py{38,39}-django42
py{310,311,312}-django{42,50,51,main}
pre-commit
[gh-actions]
@ -11,12 +11,14 @@ python =
3.9: py39
3.10: py310
3.11: py311
3.12: py312
[gh-actions:env]
DJANGO =
3.2: django32
4.1: django41
4.2: django42
5.0: django50
5.1: django51
main: djangomain
[testenv]
@ -29,8 +31,9 @@ deps =
-e.[test]
psycopg2-binary
django32: Django>=3.2,<4.0
django41: Django>=4.1,<4.2
django42: Django>=4.2,<4.3
django50: Django>=5.0,<5.1
django51: Django>=5.1,<5.2
djangomain: https://github.com/django/django/archive/main.zip
commands = {posargs:pytest --cov=graphene_django graphene_django examples}