Merge branch 'main' into support-async

This commit is contained in:
Firas Kafri 2023-08-10 09:33:42 +03:00 committed by GitHub
commit 2ae927f06a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
81 changed files with 1844 additions and 354 deletions

View File

@ -20,7 +20,7 @@ jobs:
pip install wheel
python setup.py sdist bdist_wheel
- name: Publish a Python distribution to PyPI
uses: pypa/gh-action-pypi-publish@v1.8.6
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.pypi_password }}

View File

@ -1,6 +1,9 @@
name: Lint
on: [push, pull_request]
on:
push:
branches: ["main"]
pull_request:
jobs:
build:

View File

@ -1,6 +1,9 @@
name: Tests
on: [push, pull_request]
on:
push:
branches: ["main"]
pull_request:
jobs:
build:
@ -8,13 +11,13 @@ jobs:
strategy:
max-parallel: 4
matrix:
django: ["3.2", "4.0", "4.1"]
django: ["3.2", "4.1", "4.2"]
python-version: ["3.8", "3.9", "3.10"]
include:
- django: "3.2"
python-version: "3.7"
- django: "4.1"
python-version: "3.11"
- django: "4.2"
python-version: "3.11"
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
@ -29,4 +32,3 @@ jobs:
run: tox
env:
DJANGO: ${{ matrix.django }}
TOXENV: ${{ matrix.toxenv }}

8
.gitignore vendored
View File

@ -11,6 +11,9 @@ __pycache__/
# Distribution / packaging
.Python
env/
.env/
venv/
.venv/
build/
develop-eggs/
dist/
@ -80,3 +83,8 @@ Session.vim
tags
.tox/
.pytest_cache/
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
.python-version

View File

@ -15,16 +15,12 @@ repos:
- --autofix
- id: trailing-whitespace
exclude: README.md
- repo: https://github.com/asottile/pyupgrade
rev: v3.3.2
hooks:
- id: pyupgrade
args: [--py37-plus]
- repo: https://github.com/psf/black
rev: 23.3.0
rev: 23.7.0
hooks:
- id: black
- repo: https://github.com/PyCQA/flake8
rev: 6.0.0
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.0.283
hooks:
- id: flake8
- id: ruff
args: [--fix, --exit-non-zero-on-fix, --show-fixes]

33
.ruff.toml Normal file
View File

@ -0,0 +1,33 @@
select = [
"E", # pycodestyle
"W", # pycodestyle
"F", # pyflake
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
]
ignore = [
"E501", # line-too-long
"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
]
exclude = [
"**/docs",
]
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

@ -10,7 +10,7 @@ dev-setup:
.PHONY: tests ## Run unit tests
tests:
py.test graphene_django --cov=graphene_django -vv
PYTHONPATH=. pytest graphene_django --cov=graphene_django -vv
.PHONY: format ## Format code
format:
@ -18,7 +18,7 @@ format:
.PHONY: lint ## Lint code
lint:
flake8 graphene_django examples
ruff graphene_django examples
.PHONY: docs ## Generate docs
docs: dev-setup

View File

@ -144,6 +144,21 @@ If you are using ``DjangoObjectType`` you can define a custom `get_queryset`.
return queryset.filter(published=True)
return queryset
.. warning::
Defining a custom ``get_queryset`` gives the guaranteed it will be called
when resolving the ``DjangoObjectType``, even through related objects.
Note that because of this, benefits from using ``select_related``
in objects that define a relation to this ``DjangoObjectType`` will be canceled out.
In the case of ``prefetch_related``, the benefits of the optimization will be lost only
if the custom ``get_queryset`` modifies the queryset. For more information about this, refers
to Django documentation about ``prefetch_related``: https://docs.djangoproject.com/en/4.2/ref/models/querysets/#prefetch-related.
If you want to explicitly disable the execution of the custom ``get_queryset`` when resolving,
you can decorate the resolver with `@graphene_django.bypass_get_queryset`. Note that this
can lead to authorization leaks if you are performing authorization checks in the custom
``get_queryset``.
Filtering ID-based Node Access
------------------------------
@ -197,8 +212,8 @@ For Django 2.2 and above:
.. code:: python
urlpatterns = [
# some other urls
path('graphql/', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
# some other urls
path('graphql/', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
]
.. _LoginRequiredMixin: https://docs.djangoproject.com/en/dev/topics/auth/default/#the-loginrequired-mixin

View File

@ -78,7 +78,7 @@ release = "1.0.dev"
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = None
# language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
@ -445,4 +445,7 @@ epub_exclude_files = ["search.html"]
# Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {"https://docs.python.org/": None}
intersphinx_mapping = {
# "https://docs.python.org/": None,
"python": ("https://docs.python.org/", None),
}

View File

@ -57,9 +57,9 @@ specify the parameters in your settings.py:
.. code:: python
GRAPHENE = {
'SCHEMA': 'tutorial.quickstart.schema',
'SCHEMA_OUTPUT': 'data/schema.json', # defaults to schema.json,
'SCHEMA_INDENT': 2, # Defaults to None (displays all data on a single line)
'SCHEMA': 'tutorial.quickstart.schema',
'SCHEMA_OUTPUT': 'data/schema.json', # defaults to schema.json,
'SCHEMA_INDENT': 2, # Defaults to None (displays all data on a single line)
}

View File

@ -125,6 +125,55 @@ to change how the form is saved or to return a different Graphene object type.
If the form is *not* valid then a list of errors will be returned. These errors have two fields: ``field``, a string
containing the name of the invalid form field, and ``messages``, a list of strings with the validation messages.
DjangoFormInputObjectType
~~~~~~~~~~~~~~~~~~~~~~~~~
``DjangoFormInputObjectType`` is used in mutations to create input fields by **using django form** to retrieve input data structure from it. This can be helpful in situations where you need to pass data to several django forms in one mutation.
.. code:: python
from graphene_django.forms.types import DjangoFormInputObjectType
class PetFormInput(DjangoFormInputObjectType):
# any other fields can be placed here as well as
# other djangoforminputobjects and intputobjects
class Meta:
form_class = PetForm
object_type = PetType
class QuestionFormInput(DjangoFormInputObjectType)
class Meta:
form_class = QuestionForm
object_type = QuestionType
class SeveralFormsInputData(graphene.InputObjectType):
pet = PetFormInput(required=True)
question = QuestionFormInput(required=True)
class SomeSophisticatedMutation(graphene.Mutation):
class Arguments:
data = SeveralFormsInputData(required=True)
@staticmethod
def mutate(_root, _info, data):
pet_form_inst = PetForm(data=data.pet)
question_form_inst = QuestionForm(data=data.question)
if pet_form_inst.is_valid():
pet_model_instance = pet_form_inst.save(commit=False)
if question_form_inst.is_valid():
question_model_instance = question_form_inst.save(commit=False)
# ...
Additional to **InputObjectType** ``Meta`` class attributes:
* ``form_class`` is required and should be equal to django form class.
* ``object_type`` is not required and used to enable convertion of enum values back to original if model object type ``convert_choices_to_enum`` ``Meta`` class attribute is not set to ``False``. Any data field, which have choices in django, with value ``A_1`` (for example) from client will be automatically converted to ``1`` in mutation data.
* ``add_id_field_name`` is used to specify `id` field name (not required, by default equal to ``id``)
* ``add_id_field_type`` is used to specify `id` field type (not required, default is ``graphene.ID``)
Django REST Framework
---------------------

View File

@ -1,4 +1,5 @@
Sphinx==1.5.3
sphinx-autobuild==0.7.1
Sphinx==7.0.0
sphinx-autobuild==2021.3.14
pygments-graphql-lexer==0.1.0
# Docs template
http://graphene-python.org/sphinx_graphene_theme.zip

View File

@ -224,7 +224,7 @@ Default: ``/graphql``
``GRAPHIQL_SHOULD_PERSIST_HEADERS``
---------------------
-----------------------------------
Set to ``True`` if you want to persist GraphiQL headers after refreshing the page.

View File

@ -12,7 +12,7 @@ app <https://github.com/graphql-python/graphene-django/tree/master/examples/cook
A good idea is to check the following things first:
* `Graphene Relay documentation <http://docs.graphene-python.org/en/latest/relay/>`__
* `GraphQL Relay Specification <https://facebook.github.io/relay/docs/en/graphql-server-specification.html>`__
* `GraphQL Relay Specification <https://relay.dev/docs/guides/graphql-server-specification/>`__
Setup the Django project
------------------------

View File

@ -62,3 +62,12 @@ Now head on over to
and run some queries!
(See the [Graphene-Django Tutorial](http://docs.graphene-python.org/projects/django/en/latest/tutorial-plain/#testing-our-graphql-schema)
for some example queries)
Testing local graphene-django changes
-------------------------------------
In `requirements.txt`, replace the entire `graphene-django=...` line with the following (so that we install the local version instead of the one from PyPI):
```
../../ # graphene-django
```

View File

@ -1,8 +1,8 @@
import graphene
from graphene_django.debug import DjangoDebug
import cookbook.ingredients.schema
import cookbook.recipes.schema
import graphene
from graphene_django.debug import DjangoDebug
class Query(

View File

@ -5,10 +5,10 @@ Django settings for cookbook project.
Generated by 'django-admin startproject' using Django 1.9.
For more information on this file, see
https://docs.djangoproject.com/en/1.9/topics/settings/
https://docs.djangoproject.com/en/3.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.9/ref/settings/
https://docs.djangoproject.com/en/3.2/ref/settings/
"""
import os
@ -18,7 +18,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "_$=$%eqxk$8ss4n7mtgarw^5$8^d5+c83!vwatr@i_81myb=e4"
@ -81,7 +81,7 @@ WSGI_APPLICATION = "cookbook.wsgi.application"
# Database
# https://docs.djangoproject.com/en/1.9/ref/settings/#databases
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
DATABASES = {
"default": {
@ -90,9 +90,11 @@ DATABASES = {
}
}
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Password validation
# https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
@ -105,7 +107,7 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization
# https://docs.djangoproject.com/en/1.9/topics/i18n/
# https://docs.djangoproject.com/en/3.2/topics/i18n/
LANGUAGE_CODE = "en-us"
@ -119,6 +121,6 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.9/howto/static-files/
# https://docs.djangoproject.com/en/3.2/howto/static-files/
STATIC_URL = "/static/"

View File

@ -1,9 +1,8 @@
from django.urls import path
from django.contrib import admin
from django.urls import path
from graphene_django.views import GraphQLView
urlpatterns = [
path("admin/", admin.site.urls),
path("graphql/", GraphQLView.as_view(graphiql=True)),

View File

@ -1,4 +1,3 @@
graphene>=2.1,<3
graphene-django>=2.1,<3
graphql-core>=2.1,<3
django==3.1.14
django~=3.2
graphene
graphene-django>=3.1

View File

@ -1,8 +1,9 @@
from cookbook.ingredients.models import Category, Ingredient
from graphene import Node
from graphene_django.filter import DjangoFilterConnectionField
from graphene_django.types import DjangoObjectType
from cookbook.ingredients.models import Category, Ingredient
# Graphene will automatically map the Category model's fields onto the CategoryNode.
# This is configured in the CategoryNode's Meta class (as you can see below)

View File

@ -6,7 +6,9 @@ from cookbook.ingredients.models import Ingredient
class Recipe(models.Model):
title = models.CharField(max_length=100)
instructions = models.TextField()
__unicode__ = lambda self: self.title
def __unicode__(self):
return self.title
class RecipeIngredient(models.Model):

View File

@ -7,6 +7,8 @@ from graphene import Node, String, Field
from graphene_django.filter import DjangoFilterConnectionField
from graphene_django.types import DjangoObjectType
from cookbook.recipes.models import Recipe, RecipeIngredient
class RecipeNode(DjangoObjectType):
async_field = String()

View File

@ -1,8 +1,8 @@
import graphene
from graphene_django.debug import DjangoDebug
import cookbook.ingredients.schema
import cookbook.recipes.schema
import graphene
from graphene_django.debug import DjangoDebug
class Query(

View File

@ -2,6 +2,7 @@ from django.urls import re_path
from django.contrib import admin
from django.views.decorators.csrf import csrf_exempt
from graphene_django.views import AsyncGraphQLView
from graphene_django.views import GraphQLView
urlpatterns = [
re_path(r"^admin/", admin.site.urls),

View File

@ -1,5 +1,5 @@
import sys
import os
import sys
ROOT_PATH = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, ROOT_PATH + "/examples/")

View File

@ -3,9 +3,11 @@ from graphene import Schema, relay, resolve_only_args
from graphene_django import DjangoConnectionField, DjangoObjectType
from .data import create_ship, get_empire, get_faction, get_rebels, get_ship, get_ships
from .models import Character as CharacterModel
from .models import Faction as FactionModel
from .models import Ship as ShipModel
from .models import (
Character as CharacterModel,
Faction as FactionModel,
Ship as ShipModel,
)
class Ship(DjangoObjectType):

View File

@ -1,11 +1,13 @@
from .fields import DjangoConnectionField, DjangoListField
from .types import DjangoObjectType
from .utils import bypass_get_queryset
__version__ = "3.0.2"
__version__ = "3.1.5"
__all__ = [
"__version__",
"DjangoObjectType",
"DjangoListField",
"DjangoConnectionField",
"bypass_get_queryset",
]

View File

@ -1,3 +1,9 @@
# 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
class MissingType:
def __init__(self, *args, **kwargs):
pass
@ -7,19 +13,10 @@ try:
# Postgres fields are only available in Django with psycopg2 installed
# and we cannot have psycopg2 on PyPy
from django.contrib.postgres.fields import (
IntegerRangeField,
ArrayField,
HStoreField,
JSONField as PGJSONField,
IntegerRangeField,
RangeField,
)
except ImportError:
IntegerRangeField, ArrayField, HStoreField, PGJSONField, RangeField = (
MissingType,
) * 5
try:
# JSONField is only available from Django 3.1
from django.db.models import JSONField
except ImportError:
JSONField = MissingType
IntegerRangeField, ArrayField, HStoreField, RangeField = (MissingType,) * 4

View File

@ -1,10 +1,12 @@
import inspect
from collections import OrderedDict
from functools import singledispatch, wraps
from functools import partial, singledispatch, wraps
from django.db import models
from django.utils.encoding import force_str
from django.utils.functional import Promise
from django.utils.module_loading import import_string
from graphql import GraphQLError
from graphene import (
ID,
@ -12,6 +14,7 @@ from graphene import (
Boolean,
Date,
DateTime,
Decimal,
Dynamic,
Enum,
Field,
@ -21,12 +24,11 @@ from graphene import (
NonNull,
String,
Time,
Decimal,
)
from graphene.types.json import JSONString
from graphene.types.resolver import get_default_resolver
from graphene.types.scalars import BigInt
from graphene.utils.str_converters import to_camel_case
from graphql import GraphQLError
try:
from graphql import assert_name
@ -35,8 +37,8 @@ except ImportError:
from graphql import assert_valid_name as assert_name
from graphql.pyutils import register_description
from .compat import ArrayField, HStoreField, JSONField, PGJSONField, RangeField
from .fields import DjangoListField, DjangoConnectionField
from .compat import ArrayField, HStoreField, RangeField
from .fields import DjangoConnectionField, DjangoListField
from .settings import graphene_settings
from .utils.str_converters import to_const
@ -159,9 +161,7 @@ def get_django_field_description(field):
@singledispatch
def convert_django_field(field, registry=None):
raise Exception(
"Don't know how to convert the Django field {} ({})".format(
field, field.__class__
)
f"Don't know how to convert the Django field {field} ({field.__class__})"
)
@ -258,6 +258,10 @@ def convert_time_to_string(field, registry=None):
@convert_django_field.register(models.OneToOneRel)
def convert_onetoone_field_to_djangomodel(field, registry=None):
from graphene.utils.str_converters import to_snake_case
from .types import DjangoObjectType
model = field.related_model
def dynamic_type():
@ -265,7 +269,55 @@ def convert_onetoone_field_to_djangomodel(field, registry=None):
if not _type:
return
return Field(_type, required=not field.null)
class CustomField(Field):
def wrap_resolve(self, parent_resolver):
"""
Implements a custom resolver which goes through the `get_node` method to ensure that
it goes through the `get_queryset` method of the DjangoObjectType.
"""
resolver = super().wrap_resolve(parent_resolver)
# If `get_queryset` was not overridden in the DjangoObjectType
# or if we explicitly bypass the `get_queryset` method,
# we can just return the default resolver.
if (
_type.get_queryset.__func__
is DjangoObjectType.get_queryset.__func__
or getattr(resolver, "_bypass_get_queryset", False)
):
return resolver
def custom_resolver(root, info, **args):
# Note: this function is used to resolve 1:1 relation fields
is_resolver_awaitable = inspect.iscoroutinefunction(resolver)
if is_resolver_awaitable:
fk_obj = resolver(root, info, **args)
# In case the resolver is a custom awaitable resolver that overwrites
# the default Django resolver
return fk_obj
field_name = to_snake_case(info.field_name)
reversed_field_name = root.__class__._meta.get_field(
field_name
).remote_field.name
try:
return _type.get_queryset(
_type._meta.model.objects.filter(
**{reversed_field_name: root.pk}
),
info,
).get()
except _type._meta.model.DoesNotExist:
return None
return custom_resolver
return CustomField(
_type,
required=not field.null,
)
return Dynamic(dynamic_type)
@ -313,6 +365,10 @@ def convert_field_to_list_or_connection(field, registry=None):
@convert_django_field.register(models.OneToOneField)
@convert_django_field.register(models.ForeignKey)
def convert_field_to_djangomodel(field, registry=None):
from graphene.utils.str_converters import to_snake_case
from .types import DjangoObjectType
model = field.related_model
def dynamic_type():
@ -320,7 +376,79 @@ def convert_field_to_djangomodel(field, registry=None):
if not _type:
return
return Field(
class CustomField(Field):
def wrap_resolve(self, parent_resolver):
"""
Implements a custom resolver which goes through the `get_node` method to ensure that
it goes through the `get_queryset` method of the DjangoObjectType.
"""
resolver = super().wrap_resolve(parent_resolver)
# If `get_queryset` was not overridden in the DjangoObjectType
# or if we explicitly bypass the `get_queryset` method,
# we can just return the default resolver.
if (
_type.get_queryset.__func__
is DjangoObjectType.get_queryset.__func__
or getattr(resolver, "_bypass_get_queryset", False)
):
return resolver
def custom_resolver(root, info, **args):
# Note: this function is used to resolve FK or 1:1 fields
# it does not differentiate between custom-resolved fields
# and default resolved fields.
# because this is a django foreign key or one-to-one field, the primary-key for
# this node can be accessed from the root node.
# ex: article.reporter_id
# get the name of the id field from the root's model
field_name = to_snake_case(info.field_name)
db_field_key = root.__class__._meta.get_field(field_name).attname
if hasattr(root, db_field_key):
# get the object's primary-key from root
object_pk = getattr(root, db_field_key)
else:
return None
is_resolver_awaitable = inspect.iscoroutinefunction(resolver)
if is_resolver_awaitable:
fk_obj = resolver(root, info, **args)
# In case the resolver is a custom awaitable resolver that overwrites
# the default Django resolver
return fk_obj
instance_from_get_node = _type.get_node(info, object_pk)
if instance_from_get_node is None:
# no instance to return
return
elif (
isinstance(resolver, partial)
and resolver.func is get_default_resolver()
):
return instance_from_get_node
elif resolver is not get_default_resolver():
# Default resolver is overridden
# For optimization, add the instance to the resolver
setattr(root, field_name, instance_from_get_node)
# Explanation:
# previously, _type.get_node` is called which results in at least one hit to the database.
# But, if we did not pass the instance to the root, calling the resolver will result in
# another call to get the instance which results in at least two database queries in total
# to resolve this node only.
# That's why the value of the object is set in the root so when the object is accessed
# in the resolver (root.field_name) it does not access the database unless queried explicitly.
fk_obj = resolver(root, info, **args)
return fk_obj
else:
return instance_from_get_node
return custom_resolver
return CustomField(
_type,
description=get_django_field_description(field),
required=not field.null,
@ -346,9 +474,8 @@ def convert_postgres_array_to_list(field, registry=None):
@convert_django_field.register(HStoreField)
@convert_django_field.register(PGJSONField)
@convert_django_field.register(JSONField)
def convert_pg_and_json_field_to_string(field, registry=None):
@convert_django_field.register(models.JSONField)
def convert_json_field_to_string(field, registry=None):
return JSONString(
description=get_django_field_description(field), required=not field.null
)

View File

@ -1,5 +1,6 @@
import graphene
import pytest
import graphene
from graphene.relay import Node
from graphene_django import DjangoConnectionField, DjangoObjectType

View File

@ -1,7 +1,7 @@
from graphene import List, ObjectType
from .sql.types import DjangoDebugSQL
from .exception.types import DjangoDebugException
from .sql.types import DjangoDebugSQL
class DjangoDebug(ObjectType):

View File

@ -2,7 +2,6 @@ import inspect
from functools import partial
from django.db.models.query import QuerySet
from graphql_relay import (
connection_from_array_slice,
cursor_to_offset,
@ -11,6 +10,7 @@ from graphql_relay import (
)
from asgiref.sync import sync_to_async
from promise import Promise
from graphene import Int, NonNull
from graphene.relay import ConnectionField

View File

@ -1,4 +1,5 @@
import warnings
from ..utils import DJANGO_FILTER_INSTALLED
if not DJANGO_FILTER_INSTALLED:

View File

@ -3,8 +3,8 @@ from functools import partial
from django.core.exceptions import ValidationError
from graphene.types.enum import EnumType
from graphene.types.argument import to_arguments
from graphene.types.enum import EnumType
from graphene.utils.str_converters import to_snake_case
from asgiref.sync import sync_to_async
@ -60,7 +60,7 @@ class DjangoFilterConnectionField(DjangoConnectionField):
def filterset_class(self):
if not self._filterset_class:
fields = self._fields or self.node_type._meta.filter_fields
meta = dict(model=self.model, fields=fields)
meta = {"model": self.model, "fields": fields}
if self._extra_filter_meta:
meta.update(self._extra_filter_meta)

View File

@ -1,4 +1,5 @@
import warnings
from ...utils import DJANGO_FILTER_INSTALLED
if not DJANGO_FILTER_INSTALLED:

View File

@ -1,5 +1,4 @@
from django_filters import Filter, MultipleChoiceFilter
from graphql_relay.node.node import from_global_id
from ...forms import GlobalIDFormField, GlobalIDMultipleChoiceField

View File

@ -1,12 +1,14 @@
import itertools
from django.db import models
from django_filters.filterset import BaseFilterSet, FilterSet
from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS
from django_filters.filterset import (
FILTER_FOR_DBFIELD_DEFAULTS,
BaseFilterSet,
FilterSet,
)
from .filters import GlobalIDFilter, GlobalIDMultipleChoiceFilter
GRAPHENE_FILTER_SET_OVERRIDES = {
models.AutoField: {"filter_class": GlobalIDFilter},
models.OneToOneField: {"filter_class": GlobalIDFilter},

View File

@ -1,15 +1,15 @@
from unittest.mock import MagicMock
import pytest
import pytest
from django.db import models
from django.db.models.query import QuerySet
from django_filters import filters
from django_filters import FilterSet
import graphene
from graphene.relay import Node
from graphene_django import DjangoObjectType
from graphene_django.filter import ArrayFilter
from graphene_django.utils import DJANGO_FILTER_INSTALLED
from graphene_django.filter import ArrayFilter, ListFilter
from ...compat import ArrayField

View File

@ -2,8 +2,7 @@ import pytest
import graphene
from graphene.relay import Node
from graphene_django import DjangoObjectType, DjangoConnectionField
from graphene_django import DjangoConnectionField, DjangoObjectType
from graphene_django.tests.models import Article, Reporter
from graphene_django.utils import DJANGO_FILTER_INSTALLED

View File

@ -19,8 +19,8 @@ if DJANGO_FILTER_INSTALLED:
from django_filters import FilterSet, NumberFilter, OrderingFilter
from graphene_django.filter import (
GlobalIDFilter,
DjangoFilterConnectionField,
GlobalIDFilter,
GlobalIDMultipleChoiceFilter,
)
from graphene_django.filter.tests.filters import (
@ -222,7 +222,7 @@ def test_filter_filterset_information_on_meta_related():
reporter = Field(ReporterFilterNode)
article = Field(ArticleFilterNode)
schema = Schema(query=Query)
Schema(query=Query)
articles_field = ReporterFilterNode._meta.fields["articles"].get_type()
assert_arguments(articles_field, "headline", "reporter")
assert_not_orderable(articles_field)
@ -294,7 +294,7 @@ def test_filter_filterset_class_information_on_meta_related():
reporter = Field(ReporterFilterNode)
article = Field(ArticleFilterNode)
schema = Schema(query=Query)
Schema(query=Query)
articles_field = ReporterFilterNode._meta.fields["articles"].get_type()
assert_arguments(articles_field, "headline", "reporter")
assert_not_orderable(articles_field)
@ -1186,7 +1186,7 @@ def test_filter_filterset_based_on_mixin():
first_name="Adam", last_name="Doe", email="adam@doe.com"
)
article_2 = Article.objects.create(
Article.objects.create(
headline="Good Bye",
reporter=reporter_2,
editor=reporter_2,

View File

@ -1,14 +1,16 @@
from datetime import datetime
import pytest
from django_filters import (
FilterSet,
rest_framework as filters,
)
from django_filters import FilterSet
from django_filters import rest_framework as filters
from graphene import ObjectType, Schema
from graphene.relay import Node
from graphene_django import DjangoObjectType
from graphene_django.tests.models import Pet, Person, Reporter, Article, Film
from graphene_django.filter.tests.filters import ArticleFilter
from graphene_django.tests.models import Article, Film, Person, Pet, Reporter
from graphene_django.utils import DJANGO_FILTER_INSTALLED
pytestmark = []
@ -348,9 +350,9 @@ def test_fk_id_in_filter(query):
schema = Schema(query=query)
query = """
query = f"""
query {{
articles (reporter_In: [{}, {}]) {{
articles (reporter_In: [{john_doe.id}, {jean_bon.id}]) {{
edges {{
node {{
headline
@ -361,10 +363,7 @@ def test_fk_id_in_filter(query):
}}
}}
}}
""".format(
john_doe.id,
jean_bon.id,
)
"""
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [

View File

@ -1,8 +1,7 @@
import json
import pytest
from django_filters import FilterSet
from django_filters import rest_framework as filters
from graphene import ObjectType, Schema
from graphene.relay import Node
from graphene_django import DjangoObjectType

View File

@ -1,10 +1,8 @@
import pytest
from django_filters import FilterSet
import graphene
from graphene.relay import Node
from graphene_django import DjangoObjectType
from graphene_django.tests.models import Article, Reporter
from graphene_django.utils import DJANGO_FILTER_INSTALLED
@ -14,8 +12,8 @@ pytestmark = []
if DJANGO_FILTER_INSTALLED:
from graphene_django.filter import (
DjangoFilterConnectionField,
TypedFilter,
ListFilter,
TypedFilter,
)
else:
pytestmark.append(

View File

@ -1,10 +1,11 @@
import graphene
from django import forms
from django_filters.utils import get_model_field, get_field_parts
from django_filters.filters import Filter, BaseCSVFilter
from .filters import ArrayFilter, ListFilter, RangeFilter, TypedFilter
from .filterset import custom_filterset_factory, setup_filterset
from django_filters.utils import get_model_field
import graphene
from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField
from .filters import ListFilter, RangeFilter, TypedFilter
from .filterset import custom_filterset_factory, setup_filterset
def get_field_type(registry, model, field_name):
@ -50,7 +51,7 @@ def get_filtering_args_from_filterset(filterset_class, type):
):
# Get the filter field for filters that are no explicitly declared.
if filter_type == "isnull":
field = graphene.Boolean(required=required)
field_type = graphene.Boolean
else:
model_field = get_model_field(model, filter_field.field_name)

View File

@ -5,15 +5,15 @@ from django.core.exceptions import ImproperlyConfigured
from graphene import (
ID,
UUID,
Boolean,
Date,
DateTime,
Decimal,
Float,
Int,
List,
String,
UUID,
Date,
DateTime,
Time,
)
@ -27,8 +27,8 @@ def get_form_field_description(field):
@singledispatch
def convert_form_field(field):
raise ImproperlyConfigured(
"Don't know how to convert the Django form field %s (%s) "
"to Graphene type" % (field, field.__class__)
f"Don't know how to convert the Django form field {field} ({field.__class__}) "
"to Graphene type"
)

View File

@ -3,7 +3,6 @@ import binascii
from django.core.exceptions import ValidationError
from django.forms import CharField, Field, MultipleChoiceField
from django.utils.translation import gettext_lazy as _
from graphql_relay import from_global_id

View File

@ -1,19 +1,18 @@
from django import forms
from pytest import raises
import graphene
from graphene import (
String,
Int,
Boolean,
Decimal,
Float,
ID,
UUID,
Boolean,
Date,
DateTime,
Decimal,
Float,
Int,
List,
NonNull,
DateTime,
Date,
String,
Time,
)

View File

@ -0,0 +1,333 @@
from django import forms
from pytest import raises
import graphene
from graphene_django import DjangoObjectType
from ...tests.models import CHOICES, Film, Reporter
from ..types import DjangoFormInputObjectType
# Reporter a_choice CHOICES = ((1, "this"), (2, _("that")))
THIS = CHOICES[0][0]
THIS_ON_CLIENT_CONVERTED = "A_1"
# Film genre choices=[("do", "Documentary"), ("ac", "Action"), ("ot", "Other")],
DOCUMENTARY = "do"
DOCUMENTARY_ON_CLIENT_CONVERTED = "DO"
class FilmForm(forms.ModelForm):
class Meta:
model = Film
exclude = ()
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
fields = "__all__"
class ReporterForm(forms.ModelForm):
class Meta:
model = Reporter
exclude = ("pets", "email", "fans")
class MyForm(forms.Form):
text_field = forms.CharField()
int_field = forms.IntegerField()
def test_needs_form_class():
with raises(Exception) as exc:
class MyInputType(DjangoFormInputObjectType):
pass
assert exc.value.args[0] == "form_class is required for DjangoFormInputObjectType"
def test_type_from_modelform_has_input_fields():
class ReporterInputType(DjangoFormInputObjectType):
class Meta:
form_class = ReporterForm
only_fields = ("first_name", "last_name", "a_choice")
fields = ["first_name", "last_name", "a_choice", "id"]
assert all(f in ReporterInputType._meta.fields for f in fields)
def test_type_from_form_has_input_fields():
class MyFormInputType(DjangoFormInputObjectType):
class Meta:
form_class = MyForm
fields = ["text_field", "int_field", "id"]
assert all(f in MyFormInputType._meta.fields for f in fields)
def test_type_custom_id_field():
class MyFormInputType(DjangoFormInputObjectType):
class Meta:
form_class = MyForm
add_id_field_name = "pk"
fields = ["text_field", "int_field", "pk"]
assert all(f in MyFormInputType._meta.fields for f in fields)
assert MyFormInputType._meta.fields["pk"].type is graphene.ID
def test_type_custom_id_field_type():
class MyFormInputType(DjangoFormInputObjectType):
class Meta:
form_class = MyForm
add_id_field_name = "pk"
add_id_field_type = graphene.String(required=False)
fields = ["text_field", "int_field", "pk"]
assert all(f in MyFormInputType._meta.fields for f in fields)
assert MyFormInputType._meta.fields["pk"].type is graphene.String
class MockQuery(graphene.ObjectType):
a = graphene.String()
def test_mutation_with_form_djangoforminputtype():
class MyFormInputType(DjangoFormInputObjectType):
class Meta:
form_class = MyForm
class MyFormMutation(graphene.Mutation):
class Arguments:
form_data = MyFormInputType(required=True)
result = graphene.Boolean()
def mutate(_root, _info, form_data):
form = MyForm(data=form_data)
if form.is_valid():
result = form.cleaned_data == {
"text_field": "text",
"int_field": 777,
}
return MyFormMutation(result=result)
return MyFormMutation(result=False)
class Mutation(graphene.ObjectType):
myForm_mutation = MyFormMutation.Field()
schema = graphene.Schema(query=MockQuery, mutation=Mutation)
result = schema.execute(
""" mutation MyFormMutation($formData: MyFormInputType!) {
myFormMutation(formData: $formData) {
result
}
}
""",
variable_values={"formData": {"textField": "text", "intField": 777}},
)
assert result.errors is None
assert result.data == {"myFormMutation": {"result": True}}
def test_mutation_with_modelform_djangoforminputtype():
class ReporterInputType(DjangoFormInputObjectType):
class Meta:
form_class = ReporterForm
object_type = ReporterType
only_fields = ("first_name", "last_name", "a_choice")
class ReporterMutation(graphene.Mutation):
class Arguments:
reporter_data = ReporterInputType(required=True)
result = graphene.Field(ReporterType)
def mutate(_root, _info, reporter_data):
reporter = Reporter.objects.get(pk=reporter_data.id)
form = ReporterForm(data=reporter_data, instance=reporter)
if form.is_valid():
reporter = form.save()
return ReporterMutation(result=reporter)
return ReporterMutation(result=None)
class Mutation(graphene.ObjectType):
report_mutation = ReporterMutation.Field()
schema = graphene.Schema(query=MockQuery, mutation=Mutation)
reporter = Reporter.objects.create(
first_name="Bob", last_name="Roberts", a_choice=THIS
)
result = schema.execute(
""" mutation ReportMutation($reporterData: ReporterInputType!) {
reportMutation(reporterData: $reporterData) {
result {
id,
firstName,
lastName,
aChoice
}
}
}
""",
variable_values={
"reporterData": {
"id": reporter.pk,
"firstName": "Dave",
"lastName": "Smith",
"aChoice": THIS_ON_CLIENT_CONVERTED,
}
},
)
assert result.errors is None
assert result.data["reportMutation"]["result"] == {
"id": "1",
"firstName": "Dave",
"lastName": "Smith",
"aChoice": THIS_ON_CLIENT_CONVERTED,
}
assert Reporter.objects.count() == 1
reporter.refresh_from_db()
assert reporter.first_name == "Dave"
def reporter_enum_convert_mutation_result(
ReporterInputType, choice_val_on_client=THIS_ON_CLIENT_CONVERTED
):
class ReporterMutation(graphene.Mutation):
class Arguments:
reporter = ReporterInputType(required=True)
result_str = graphene.String()
result_int = graphene.Int()
def mutate(_root, _info, reporter):
if isinstance(reporter.a_choice, int) or reporter.a_choice.isdigit():
return ReporterMutation(result_int=reporter.a_choice, result_str=None)
return ReporterMutation(result_int=None, result_str=reporter.a_choice)
class Mutation(graphene.ObjectType):
report_mutation = ReporterMutation.Field()
schema = graphene.Schema(query=MockQuery, mutation=Mutation)
return schema.execute(
""" mutation ReportMutation($reporter: ReporterInputType!) {
reportMutation(reporter: $reporter) {
resultStr,
resultInt
}
}
""",
variable_values={"reporter": {"aChoice": choice_val_on_client}},
)
def test_enum_not_converted():
class ReporterInputType(DjangoFormInputObjectType):
class Meta:
form_class = ReporterForm
only_fields = ("a_choice",)
result = reporter_enum_convert_mutation_result(ReporterInputType)
assert result.errors is None
assert result.data["reportMutation"]["resultStr"] == THIS_ON_CLIENT_CONVERTED
assert result.data["reportMutation"]["resultInt"] is None
assert ReporterInputType._meta.fields["a_choice"].type is graphene.String
def test_enum_is_converted_to_original():
class ReporterInputType(DjangoFormInputObjectType):
class Meta:
form_class = ReporterForm
object_type = ReporterType
only_fields = ("a_choice",)
result = reporter_enum_convert_mutation_result(ReporterInputType)
assert result.errors is None
assert result.data["reportMutation"]["resultInt"] == THIS
assert result.data["reportMutation"]["resultStr"] is None
assert (
ReporterInputType._meta.fields["a_choice"].type.__name__
== "AChoiceEnumBackConvString"
)
def test_convert_choices_to_enum_is_false_and_field_type_as_in_model():
class ReporterTypeNotConvertChoices(DjangoObjectType):
class Meta:
model = Reporter
convert_choices_to_enum = False
fields = "__all__"
class ReporterInputType(DjangoFormInputObjectType):
class Meta:
form_class = ReporterForm
object_type = ReporterTypeNotConvertChoices
only_fields = ("a_choice",)
result = reporter_enum_convert_mutation_result(ReporterInputType, THIS)
assert result.errors is None
assert result.data["reportMutation"]["resultInt"] == THIS
assert result.data["reportMutation"]["resultStr"] is None
assert ReporterInputType._meta.fields["a_choice"].type is graphene.Int
def enum_convert_mutation_result_film(FilmInputType):
class FilmMutation(graphene.Mutation):
class Arguments:
film = FilmInputType(required=True)
result = graphene.String()
def mutate(_root, _info, film):
return FilmMutation(result=film.genre)
class Mutation(graphene.ObjectType):
film_mutation = FilmMutation.Field()
schema = graphene.Schema(query=MockQuery, mutation=Mutation)
return schema.execute(
""" mutation FilmMutation($film: FilmInputType!) {
filmMutation(film: $film) {
result
}
}
""",
variable_values={"film": {"genre": DOCUMENTARY_ON_CLIENT_CONVERTED}},
)
def test_enum_not_converted_required_non_number():
class FilmInputType(DjangoFormInputObjectType):
class Meta:
form_class = FilmForm
only_fields = ("genre",)
result = enum_convert_mutation_result_film(FilmInputType)
assert result.errors is None
assert result.data["filmMutation"]["result"] == DOCUMENTARY_ON_CLIENT_CONVERTED
def test_enum_is_converted_to_original_required_non_number():
class FilmType(DjangoObjectType):
class Meta:
model = Film
fields = "__all__"
class FilmInputType(DjangoFormInputObjectType):
class Meta:
form_class = FilmForm
object_type = FilmType
only_fields = ("genre",)
result = enum_convert_mutation_result_film(FilmInputType)
assert result.errors is None
assert result.data["filmMutation"]["result"] == DOCUMENTARY

View File

@ -1,4 +1,3 @@
import pytest
from django import forms
from django.core.exceptions import ValidationError
from pytest import raises
@ -280,7 +279,7 @@ def test_model_form_mutation_mutate_invalid_form():
result = PetMutation.mutate_and_get_payload(None, None)
# A pet was not created
Pet.objects.count() == 0
assert Pet.objects.count() == 0
fields_w_error = [e.field for e in result.errors]
assert len(result.errors) == 2

View File

@ -1 +1,117 @@
import graphene
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 .mutation import fields_for_form
class DjangoFormInputObjectType(InputObjectType):
@classmethod
def __init_subclass_with_meta__(
cls,
container=None,
_meta=None,
only_fields=(),
exclude_fields=(),
form_class=None,
object_type=None,
add_id_field_name=None,
add_id_field_type=None,
**options,
):
"""Retrieve fields from django form (Meta.form_class). Received
fields are set to cls (they will be converted to input fields
by InputObjectType). Type of fields with choices (converted
to enum) is set to custom scalar type (using Meta.object_type)
to dynamically convert enum values back.
class MyDjangoFormInput(DjangoFormInputObjectType):
# any other fields can be placed here and other inputobjectforms as well
class Meta:
form_class = MyDjangoModelForm
object_type = MyModelType
class SomeMutation(graphene.Mutation):
class Arguments:
data = MyDjangoFormInput(required=True)
@staticmethod
def mutate(_root, _info, data):
form_inst = MyDjangoModelForm(data=data)
if form_inst.is_valid():
django_model_instance = form_inst.save(commit=False)
# ... etc ...
"""
if not form_class:
raise Exception("form_class is required for DjangoFormInputObjectType")
form = form_class()
form_fields = fields_for_form(form, only_fields, exclude_fields)
for name, field in form_fields.items():
if (
object_type
and name in object_type._meta.fields
and isinstance(object_type._meta.fields[name], BlankValueField)
):
# Field type BlankValueField here means that field
# with choises have been converted to enum
# (BlankValueField is using only for that task ?)
setattr(cls, name, cls.get_enum_cnv_cls_instance(name, object_type))
elif (
object_type
and name in object_type._meta.fields
and object_type._meta.convert_choices_to_enum is False
and form.fields[name].__class__.__name__ == "TypedChoiceField"
):
# FIXME
# in case if convert_choices_to_enum is False
# form field class is converted to String but original
# model field type is needed here... (.converter.py bug?)
# This is temp workaround to get field type from ObjectType field
# TEST: test_enum_not_converted_and_field_type_as_in_model
setattr(cls, name, object_type._meta.fields[name].type())
else:
# set input field according to django form field
setattr(cls, name, field)
# explicitly adding id field (absent in django form fields)
# with name and type from Meta or 'id' with graphene.ID by default
if add_id_field_name:
setattr(cls, add_id_field_name, add_id_field_type or ID(required=False))
elif "id" not in exclude_fields:
cls.id = ID(required=False)
super().__init_subclass_with_meta__(container=container, _meta=_meta, **options)
@staticmethod
def get_enum_cnv_cls_instance(field_name, object_type):
"""Saves args in context to convert enum values in
Dynamically created Scalar derived class
"""
@staticmethod
def parse_value(value):
# field_name & object_type have been saved in context (closure)
field = object_type._meta.fields[field_name]
if isinstance(field.type, graphene.NonNull):
val_before_convert = field.type._of_type[value].value
else:
val_before_convert = field.type[value].value
return graphene.String.parse_value(val_before_convert)
cls_doc = "String scalar to convert choice value back from enum to original"
scalar_type = type(
(
f"{field_name[0].upper()}{to_camel_case(field_name[1:])}"
"EnumBackConvString"
),
(graphene.String,),
{"parse_value": parse_value, "__doc__": cls_doc},
)
return scalar_type()

View File

@ -1,12 +1,12 @@
import os
import functools
import importlib
import json
import functools
import os
from django.core.management.base import BaseCommand, CommandError
from django.utils import autoreload
from graphql import print_schema
from graphene_django.settings import graphene_settings
@ -83,7 +83,7 @@ class Command(CommandArguments):
def handle(self, *args, **options):
options_schema = options.get("schema")
if options_schema and type(options_schema) is str:
if options_schema and isinstance(options_schema, str):
module_str, schema_name = options_schema.rsplit(".", 1)
mod = importlib.import_module(module_str)
schema = getattr(mod, schema_name)

View File

@ -8,9 +8,7 @@ class Registry:
assert issubclass(
cls, DjangoObjectType
), 'Only DjangoObjectTypes can be registered, received "{}"'.format(
cls.__name__
)
), f'Only DjangoObjectTypes can be registered, received "{cls.__name__}"'
assert cls._meta.registry == self, "Registry for a Model have to match."
# assert self.get_type_for_model(cls._meta.model) == cls, (
# 'Multiple DjangoObjectTypes registered for "{}"'.format(cls._meta.model)

View File

@ -14,3 +14,14 @@ class MyFakeModelWithPassword(models.Model):
class MyFakeModelWithDate(models.Model):
cool_name = models.CharField(max_length=50)
last_edited = models.DateField()
class MyFakeModelWithChoiceField(models.Model):
class ChoiceType(models.Choices):
ASDF = "asdf"
HI = "hi"
choice_type = models.CharField(
max_length=4,
default=ChoiceType.HI.name,
)

View File

@ -1,4 +1,5 @@
from collections import OrderedDict
from enum import Enum
from django.shortcuts import get_object_or_404
from rest_framework import serializers
@ -41,6 +42,9 @@ def fields_for_serializer(
field.read_only
and is_input
and lookup_field != name, # don't show read_only fields in Input
isinstance(
field, serializers.HiddenField
), # don't show hidden fields in Input
]
)
@ -123,8 +127,10 @@ class SerializerMutation(ClientIDMutation):
def get_serializer_kwargs(cls, root, info, **input):
lookup_field = cls._meta.lookup_field
model_class = cls._meta.model_class
if model_class:
for input_dict_key, maybe_enum in input.items():
if isinstance(maybe_enum, Enum):
input[input_dict_key] = maybe_enum.value
if "update" in cls._meta.model_operations and lookup_field in input:
instance = get_object_or_404(
model_class, **{lookup_field: input[lookup_field]}

View File

@ -5,16 +5,16 @@ from rest_framework import serializers
import graphene
from ..registry import get_global_registry
from ..converter import convert_choices_to_named_enum_with_descriptions
from ..registry import get_global_registry
from .types import DictType
@singledispatch
def get_graphene_type_from_serializer_field(field):
raise ImproperlyConfigured(
"Don't know how to convert the serializer field %s (%s) "
"to Graphene type" % (field, field.__class__)
f"Don't know how to convert the serializer field {field} ({field.__class__}) "
"to Graphene type"
)

View File

@ -1,11 +1,11 @@
import copy
import graphene
from django.db import models
from graphene import InputObjectType
from pytest import raises
from rest_framework import serializers
import graphene
from ..serializer_converter import convert_serializer_field
from ..types import DictType

View File

@ -7,7 +7,12 @@ from graphene import Field, ResolveInfo
from graphene.types.inputobjecttype import InputObjectType
from ...types import DjangoObjectType
from ..models import MyFakeModel, MyFakeModelWithDate, MyFakeModelWithPassword
from ..models import (
MyFakeModel,
MyFakeModelWithChoiceField,
MyFakeModelWithDate,
MyFakeModelWithPassword,
)
from ..mutation import SerializerMutation
@ -164,6 +169,21 @@ def test_read_only_fields():
), "'cool_name' is read_only field and shouldn't be on arguments"
def test_hidden_fields():
class SerializerWithHiddenField(serializers.Serializer):
cool_name = serializers.CharField()
user = serializers.HiddenField(default=serializers.CurrentUserDefault())
class MyMutation(SerializerMutation):
class Meta:
serializer_class = SerializerWithHiddenField
assert "cool_name" in MyMutation.Input._meta.fields
assert (
"user" not in MyMutation.Input._meta.fields
), "'user' is hidden field and shouldn't be on arguments"
def test_nested_model():
class MyFakeModelGrapheneType(DjangoObjectType):
class Meta:
@ -230,7 +250,7 @@ def test_model_invalid_update_mutate_and_get_payload_success():
model_operations = ["update"]
with raises(Exception) as exc:
result = InvalidModelMutation.mutate_and_get_payload(
InvalidModelMutation.mutate_and_get_payload(
None, mock_info(), **{"cool_name": "Narf"}
)
@ -253,6 +273,39 @@ def test_perform_mutate_success():
assert result.days_since_last_edit == 4
def test_perform_mutate_success_with_enum_choice_field():
class ListViewChoiceFieldSerializer(serializers.ModelSerializer):
choice_type = serializers.ChoiceField(
choices=[(x.name, x.value) for x in MyFakeModelWithChoiceField.ChoiceType],
required=False,
)
class Meta:
model = MyFakeModelWithChoiceField
fields = "__all__"
class SomeCreateSerializerMutation(SerializerMutation):
class Meta:
serializer_class = ListViewChoiceFieldSerializer
choice_type = {
"choice_type": SomeCreateSerializerMutation.Input.choice_type.type.get("ASDF")
}
name = MyFakeModelWithChoiceField.ChoiceType.ASDF.name
result = SomeCreateSerializerMutation.mutate_and_get_payload(
None, mock_info(), **choice_type
)
assert result.errors is None
assert result.choice_type == name
kwargs = SomeCreateSerializerMutation.get_serializer_kwargs(
None, mock_info(), **choice_type
)
assert kwargs["data"]["choice_type"] == name
assert 1 == MyFakeModelWithChoiceField.objects.count()
item = MyFakeModelWithChoiceField.objects.first()
assert item.choice_type == name
def test_mutate_and_get_payload_error():
class MyMutation(SerializerMutation):
class Meta:

View File

@ -12,11 +12,10 @@ Graphene settings, checking for user settings first, then falling
back to the defaults.
"""
from django.conf import settings
from django.test.signals import setting_changed
import importlib # Available in Python 3.1+
from django.conf import settings
from django.test.signals import setting_changed
# Copied shamelessly from Django REST Framework

View File

@ -21,6 +21,10 @@ add "&raw" to the end of the URL within a browser.
integrity="{{graphiql_css_sri}}"
rel="stylesheet"
crossorigin="anonymous" />
<link href="https://cdn.jsdelivr.net/npm/@graphiql/plugin-explorer@{{graphiql_plugin_explorer_version}}/dist/style.css"
integrity="{{graphiql_plugin_explorer_css_sri}}"
rel="stylesheet"
crossorigin="anonymous" />
<script src="https://cdn.jsdelivr.net/npm/whatwg-fetch@{{whatwg_fetch_version}}/dist/fetch.umd.js"
integrity="{{whatwg_fetch_sri}}"
crossorigin="anonymous"></script>

View File

@ -1,21 +1,14 @@
# https://github.com/graphql-python/graphene-django/issues/520
import datetime
from django import forms
from rest_framework import serializers
import graphene
from graphene import Field, ResolveInfo
from graphene.types.inputobjecttype import InputObjectType
from pytest import raises
from pytest import mark
from rest_framework import serializers
from ...types import DjangoObjectType
from ...forms.mutation import DjangoFormMutation
from ...rest_framework.models import MyFakeModel
from ...rest_framework.mutation import SerializerMutation
from ...forms.mutation import DjangoFormMutation
class MyModelSerializer(serializers.ModelSerializer):

View File

@ -19,7 +19,11 @@ class Pet(models.Model):
class FilmDetails(models.Model):
location = models.CharField(max_length=30)
film = models.OneToOneField(
"Film", on_delete=models.CASCADE, related_name="details"
"Film",
on_delete=models.CASCADE,
related_name="details",
null=True,
blank=True,
)
@ -43,9 +47,10 @@ class Reporter(models.Model):
last_name = models.CharField(max_length=30)
email = models.EmailField()
pets = models.ManyToManyField("self")
a_choice = models.CharField(max_length=30, choices=CHOICES, blank=True)
a_choice = models.IntegerField(choices=CHOICES, null=True, blank=True)
objects = models.Manager()
doe_objects = DoeReporterManager()
fans = models.ManyToManyField(Person)
reporter_type = models.IntegerField(
"Reporter Type",
@ -90,6 +95,16 @@ class CNNReporter(Reporter):
objects = CNNReporterManager()
class APNewsReporter(Reporter):
"""
This class only inherits from Reporter for testing multi table inheritence
similar to what you'd see in django-polymorphic
"""
alias = models.CharField(max_length=30)
objects = models.Manager()
class Article(models.Model):
headline = models.CharField(max_length=100)
pub_date = models.DateField(auto_now_add=True)

View File

@ -1,5 +1,4 @@
from graphene import Field
from graphene_django.forms.mutation import DjangoFormMutation, DjangoModelFormMutation
from .forms import PetForm

View File

@ -1,8 +1,8 @@
from io import StringIO
from textwrap import dedent
from unittest.mock import mock_open, patch
from django.core import management
from io import StringIO
from unittest.mock import mock_open, patch
from graphene import ObjectType, Schema, String
@ -46,7 +46,7 @@ def test_generate_graphql_file_on_call_graphql_schema():
open_mock.assert_called_once()
handle = open_mock()
assert handle.write.called_once()
handle.write.assert_called_once()
schema_output = handle.write.call_args[0][0]
assert schema_output == dedent(

View File

@ -15,8 +15,6 @@ from graphene.types.scalars import BigInt
from ..compat import (
ArrayField,
HStoreField,
JSONField,
PGJSONField,
MissingType,
RangeField,
)
@ -33,10 +31,10 @@ from .models import Article, Film, FilmDetails, Reporter
def assert_conversion(django_field, graphene_field, *args, **kwargs):
_kwargs = kwargs.copy()
_kwargs = {**kwargs, "help_text": "Custom Help Text"}
if "null" not in kwargs:
_kwargs["null"] = True
field = django_field(help_text="Custom Help Text", *args, **_kwargs)
field = django_field(*args, **_kwargs)
graphene_type = convert_django_field(field)
assert isinstance(graphene_type, graphene_field)
field = graphene_type.Field()
@ -372,16 +370,6 @@ def test_should_postgres_hstore_convert_string():
assert_conversion(HStoreField, JSONString)
@pytest.mark.skipif(PGJSONField is MissingType, reason="PGJSONField should exist")
def test_should_postgres_json_convert_string():
assert_conversion(PGJSONField, JSONString)
@pytest.mark.skipif(JSONField is MissingType, reason="JSONField should exist")
def test_should_json_convert_string():
assert_conversion(JSONField, JSONString)
@pytest.mark.skipif(RangeField is MissingType, reason="RangeField should exist")
def test_should_postgres_range_convert_list():
from django.contrib.postgres.fields import IntegerRangeField

View File

@ -2,8 +2,8 @@ import datetime
import re
from django.db.models import Count, Prefetch
from asgiref.sync import sync_to_async, async_to_sync
import pytest
from django.db.models import Count, Prefetch
from graphene import List, NonNull, ObjectType, Schema, String
@ -24,7 +24,7 @@ class TestDjangoListField:
foo = String()
with pytest.raises(AssertionError):
list_field = DjangoListField(TestType)
DjangoListField(TestType)
def test_only_import_paths(self):
list_field = DjangoListField("graphene_django.tests.schema.Human")

View File

@ -3,7 +3,6 @@ from pytest import raises
from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField
# 'TXlUeXBlOmFiYw==' -> 'MyType', 'abc'

View File

@ -1,14 +1,11 @@
import pytest
from graphql_relay import to_global_id
import graphene
from graphene.relay import Node
from graphql_relay import to_global_id
from ..fields import DjangoConnectionField
from ..types import DjangoObjectType
from .models import Article, Reporter
from .models import Article, Film, FilmDetails, Reporter
class TestShouldCallGetQuerySetOnForeignKey:
@ -127,6 +124,69 @@ class TestShouldCallGetQuerySetOnForeignKey:
assert not result.errors
assert result.data == {"reporter": {"firstName": "Jane"}}
def test_get_queryset_called_on_foreignkey(self):
# If a user tries to access a reporter through an article they should get our authorization error
query = """
query getArticle($id: ID!) {
article(id: $id) {
headline
reporter {
firstName
}
}
}
"""
result = self.schema.execute(query, variables={"id": self.articles[0].id})
assert len(result.errors) == 1
assert result.errors[0].message == "Not authorized to access reporters."
# An admin user should be able to get reporters through an article
query = """
query getArticle($id: ID!) {
article(id: $id) {
headline
reporter {
firstName
}
}
}
"""
result = self.schema.execute(
query,
variables={"id": self.articles[0].id},
context_value={"admin": True},
)
assert not result.errors
assert result.data["article"] == {
"headline": "A fantastic article",
"reporter": {"firstName": "Jane"},
}
# An admin user should not be able to access draft article through a reporter
query = """
query getReporter($id: ID!) {
reporter(id: $id) {
firstName
articles {
headline
}
}
}
"""
result = self.schema.execute(
query,
variables={"id": self.reporter.id},
context_value={"admin": True},
)
assert not result.errors
assert result.data["reporter"] == {
"firstName": "Jane",
"articles": [{"headline": "A fantastic article"}],
}
class TestShouldCallGetQuerySetOnForeignKeyNode:
"""
@ -233,3 +293,272 @@ class TestShouldCallGetQuerySetOnForeignKeyNode:
)
assert not result.errors
assert result.data == {"reporter": {"firstName": "Jane"}}
def test_get_queryset_called_on_foreignkey(self):
# If a user tries to access a reporter through an article they should get our authorization error
query = """
query getArticle($id: ID!) {
article(id: $id) {
headline
reporter {
firstName
}
}
}
"""
result = self.schema.execute(
query, variables={"id": to_global_id("ArticleType", self.articles[0].id)}
)
assert len(result.errors) == 1
assert result.errors[0].message == "Not authorized to access reporters."
# An admin user should be able to get reporters through an article
query = """
query getArticle($id: ID!) {
article(id: $id) {
headline
reporter {
firstName
}
}
}
"""
result = self.schema.execute(
query,
variables={"id": to_global_id("ArticleType", self.articles[0].id)},
context_value={"admin": True},
)
assert not result.errors
assert result.data["article"] == {
"headline": "A fantastic article",
"reporter": {"firstName": "Jane"},
}
# An admin user should not be able to access draft article through a reporter
query = """
query getReporter($id: ID!) {
reporter(id: $id) {
firstName
articles {
edges {
node {
headline
}
}
}
}
}
"""
result = self.schema.execute(
query,
variables={"id": to_global_id("ReporterType", self.reporter.id)},
context_value={"admin": True},
)
assert not result.errors
assert result.data["reporter"] == {
"firstName": "Jane",
"articles": {"edges": [{"node": {"headline": "A fantastic article"}}]},
}
class TestShouldCallGetQuerySetOnOneToOne:
@pytest.fixture(autouse=True)
def setup_schema(self):
class FilmDetailsType(DjangoObjectType):
class Meta:
model = FilmDetails
@classmethod
def get_queryset(cls, queryset, info):
if info.context and info.context.get("permission_get_film_details"):
return queryset
raise Exception("Not authorized to access film details.")
class FilmType(DjangoObjectType):
class Meta:
model = Film
@classmethod
def get_queryset(cls, queryset, info):
if info.context and info.context.get("permission_get_film"):
return queryset
raise Exception("Not authorized to access film.")
class Query(graphene.ObjectType):
film_details = graphene.Field(
FilmDetailsType, id=graphene.ID(required=True)
)
film = graphene.Field(FilmType, id=graphene.ID(required=True))
def resolve_film_details(self, info, id):
return (
FilmDetailsType.get_queryset(FilmDetails.objects, info)
.filter(id=id)
.last()
)
def resolve_film(self, info, id):
return FilmType.get_queryset(Film.objects, info).filter(id=id).last()
self.schema = graphene.Schema(query=Query)
self.films = [
Film.objects.create(
genre="do",
),
Film.objects.create(
genre="ac",
),
]
self.film_details = [
FilmDetails.objects.create(
film=self.films[0],
),
FilmDetails.objects.create(
film=self.films[1],
),
]
def test_get_queryset_called_on_field(self):
# A user tries to access a film
query = """
query getFilm($id: ID!) {
film(id: $id) {
genre
}
}
"""
# With `permission_get_film`
result = self.schema.execute(
query,
variables={"id": self.films[0].id},
context_value={"permission_get_film": True},
)
assert not result.errors
assert result.data["film"] == {
"genre": "DO",
}
# Without `permission_get_film`
result = self.schema.execute(
query,
variables={"id": self.films[1].id},
context_value={"permission_get_film": False},
)
assert len(result.errors) == 1
assert result.errors[0].message == "Not authorized to access film."
# A user tries to access a film details
query = """
query getFilmDetails($id: ID!) {
filmDetails(id: $id) {
location
}
}
"""
# With `permission_get_film`
result = self.schema.execute(
query,
variables={"id": self.film_details[0].id},
context_value={"permission_get_film_details": True},
)
assert not result.errors
assert result.data == {"filmDetails": {"location": ""}}
# Without `permission_get_film`
result = self.schema.execute(
query,
variables={"id": self.film_details[0].id},
context_value={"permission_get_film_details": False},
)
assert len(result.errors) == 1
assert result.errors[0].message == "Not authorized to access film details."
def test_get_queryset_called_on_foreignkey(self, django_assert_num_queries):
# A user tries to access a film details through a film
query = """
query getFilm($id: ID!) {
film(id: $id) {
genre
details {
location
}
}
}
"""
# With `permission_get_film_details`
with django_assert_num_queries(2):
result = self.schema.execute(
query,
variables={"id": self.films[0].id},
context_value={
"permission_get_film": True,
"permission_get_film_details": True,
},
)
assert not result.errors
assert result.data["film"] == {
"genre": "DO",
"details": {"location": ""},
}
# Without `permission_get_film_details`
with django_assert_num_queries(1):
result = self.schema.execute(
query,
variables={"id": self.films[0].id},
context_value={
"permission_get_film": True,
"permission_get_film_details": False,
},
)
assert len(result.errors) == 1
assert result.errors[0].message == "Not authorized to access film details."
# A user tries to access a film through a film details
query = """
query getFilmDetails($id: ID!) {
filmDetails(id: $id) {
location
film {
genre
}
}
}
"""
# With `permission_get_film`
with django_assert_num_queries(2):
result = self.schema.execute(
query,
variables={"id": self.film_details[0].id},
context_value={
"permission_get_film": True,
"permission_get_film_details": True,
},
)
assert not result.errors
assert result.data["filmDetails"] == {
"location": "",
"film": {"genre": "DO"},
}
# Without `permission_get_film`
with django_assert_num_queries(1):
result = self.schema.execute(
query,
variables={"id": self.film_details[1].id},
context_value={
"permission_get_film": False,
"permission_get_film_details": True,
},
)
assert len(result.errors) == 1
assert result.errors[0].message == "Not authorized to access film."

View File

@ -1,5 +1,5 @@
import datetime
import base64
import datetime
import pytest
from django.db import models
@ -16,9 +16,17 @@ from ..compat import IntegerRangeField, MissingType
from ..fields import DjangoConnectionField
from ..types import DjangoObjectType
from ..utils import DJANGO_FILTER_INSTALLED
from .models import Article, CNNReporter, Film, FilmDetails, Person, Pet, Reporter
from .async_test_helper import assert_async_result_equal
from .models import (
APNewsReporter,
Article,
CNNReporter,
Film,
FilmDetails,
Person,
Pet,
Reporter,
)
def test_should_query_only_fields():
with raises(Exception):
@ -123,15 +131,14 @@ def test_should_query_well():
@pytest.mark.skipif(IntegerRangeField is MissingType, reason="RangeField should exist")
def test_should_query_postgres_fields():
from django.contrib.postgres.fields import (
IntegerRangeField,
ArrayField,
JSONField,
HStoreField,
IntegerRangeField,
)
class Event(models.Model):
ages = IntegerRangeField(help_text="The age ranges")
data = JSONField(help_text="Data")
data = models.JSONField(help_text="Data")
store = HStoreField()
tags = ArrayField(models.CharField(max_length=50))
@ -357,7 +364,7 @@ def test_should_query_connectionfields():
def test_should_keep_annotations():
from django.db.models import Count, Avg
from django.db.models import Avg, Count
class ReporterType(DjangoObjectType):
class Meta:
@ -521,7 +528,7 @@ def test_should_query_node_filtering_with_distinct_queryset():
).distinct()
f = Film.objects.create()
fd = FilmDetails.objects.create(location="Berlin", film=f)
FilmDetails.objects.create(location="Berlin", film=f)
schema = graphene.Schema(query=Query)
query = """
@ -646,7 +653,7 @@ def test_should_enforce_first_or_last(graphene_settings):
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
r = Reporter.objects.create(
Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
@ -689,7 +696,7 @@ def test_should_error_if_first_is_greater_than_max(graphene_settings):
assert Query.all_reporters.max_limit == 100
r = Reporter.objects.create(
Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
@ -732,7 +739,7 @@ def test_should_error_if_last_is_greater_than_max(graphene_settings):
assert Query.all_reporters.max_limit == 100
r = Reporter.objects.create(
Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
@ -798,7 +805,7 @@ def test_should_query_promise_connectionfields():
def test_should_query_connectionfields_with_last():
r = Reporter.objects.create(
Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
@ -836,11 +843,11 @@ def test_should_query_connectionfields_with_last():
def test_should_query_connectionfields_with_manager():
r = Reporter.objects.create(
Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
r = Reporter.objects.create(
Reporter.objects.create(
first_name="John", last_name="NotDoe", email="johndoe@example.com", a_choice=1
)
@ -1186,11 +1193,306 @@ def test_proxy_model_support():
assert_async_result_equal(schema, query, result)
def test_should_resolve_get_queryset_connectionfields():
reporter_1 = Reporter.objects.create(
def test_model_inheritance_support_reverse_relationships():
"""
This test asserts that we can query reverse relationships for all Reporters and proxied Reporters and multi table Reporters.
"""
class FilmType(DjangoObjectType):
class Meta:
model = Film
fields = "__all__"
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
use_connection = True
fields = "__all__"
class CNNReporterType(DjangoObjectType):
class Meta:
model = CNNReporter
interfaces = (Node,)
use_connection = True
fields = "__all__"
class APNewsReporterType(DjangoObjectType):
class Meta:
model = APNewsReporter
interfaces = (Node,)
use_connection = True
fields = "__all__"
film = Film.objects.create(genre="do")
reporter = Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
reporter_2 = CNNReporter.objects.create(
cnn_reporter = CNNReporter.objects.create(
first_name="Some",
last_name="Guy",
email="someguy@cnn.com",
a_choice=1,
reporter_type=2, # set this guy to be CNN
)
ap_news_reporter = APNewsReporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
film.reporters.add(cnn_reporter, ap_news_reporter)
film.save()
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
cnn_reporters = DjangoConnectionField(CNNReporterType)
ap_news_reporters = DjangoConnectionField(APNewsReporterType)
schema = graphene.Schema(query=Query)
query = """
query ProxyModelQuery {
allReporters {
edges {
node {
id
films {
id
}
}
}
}
cnnReporters {
edges {
node {
id
films {
id
}
}
}
}
apNewsReporters {
edges {
node {
id
films {
id
}
}
}
}
}
"""
expected = {
"allReporters": {
"edges": [
{
"node": {
"id": to_global_id("ReporterType", reporter.id),
"films": [],
},
},
{
"node": {
"id": to_global_id("ReporterType", cnn_reporter.id),
"films": [{"id": f"{film.id}"}],
},
},
{
"node": {
"id": to_global_id("ReporterType", ap_news_reporter.id),
"films": [{"id": f"{film.id}"}],
},
},
]
},
"cnnReporters": {
"edges": [
{
"node": {
"id": to_global_id("CNNReporterType", cnn_reporter.id),
"films": [{"id": f"{film.id}"}],
}
}
]
},
"apNewsReporters": {
"edges": [
{
"node": {
"id": to_global_id("APNewsReporterType", ap_news_reporter.id),
"films": [{"id": f"{film.id}"}],
}
}
]
},
}
result = schema.execute(query)
assert result.data == expected
def test_model_inheritance_support_local_relationships():
"""
This test asserts that we can query local relationships for all Reporters and proxied Reporters and multi table Reporters.
"""
class PersonType(DjangoObjectType):
class Meta:
model = Person
fields = "__all__"
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
use_connection = True
fields = "__all__"
class CNNReporterType(DjangoObjectType):
class Meta:
model = CNNReporter
interfaces = (Node,)
use_connection = True
fields = "__all__"
class APNewsReporterType(DjangoObjectType):
class Meta:
model = APNewsReporter
interfaces = (Node,)
use_connection = True
fields = "__all__"
film = Film.objects.create(genre="do")
reporter = Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
reporter_fan = Person.objects.create(name="Reporter Fan")
reporter.fans.add(reporter_fan)
reporter.save()
cnn_reporter = CNNReporter.objects.create(
first_name="Some",
last_name="Guy",
email="someguy@cnn.com",
a_choice=1,
reporter_type=2, # set this guy to be CNN
)
cnn_fan = Person.objects.create(name="CNN Fan")
cnn_reporter.fans.add(cnn_fan)
cnn_reporter.save()
ap_news_reporter = APNewsReporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
ap_news_fan = Person.objects.create(name="AP News Fan")
ap_news_reporter.fans.add(ap_news_fan)
ap_news_reporter.save()
film.reporters.add(cnn_reporter, ap_news_reporter)
film.save()
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
cnn_reporters = DjangoConnectionField(CNNReporterType)
ap_news_reporters = DjangoConnectionField(APNewsReporterType)
schema = graphene.Schema(query=Query)
query = """
query ProxyModelQuery {
allReporters {
edges {
node {
id
fans {
name
}
}
}
}
cnnReporters {
edges {
node {
id
fans {
name
}
}
}
}
apNewsReporters {
edges {
node {
id
fans {
name
}
}
}
}
}
"""
expected = {
"allReporters": {
"edges": [
{
"node": {
"id": to_global_id("ReporterType", reporter.id),
"fans": [{"name": f"{reporter_fan.name}"}],
},
},
{
"node": {
"id": to_global_id("ReporterType", cnn_reporter.id),
"fans": [{"name": f"{cnn_fan.name}"}],
},
},
{
"node": {
"id": to_global_id("ReporterType", ap_news_reporter.id),
"fans": [{"name": f"{ap_news_fan.name}"}],
},
},
]
},
"cnnReporters": {
"edges": [
{
"node": {
"id": to_global_id("CNNReporterType", cnn_reporter.id),
"fans": [{"name": f"{cnn_fan.name}"}],
}
}
]
},
"apNewsReporters": {
"edges": [
{
"node": {
"id": to_global_id("APNewsReporterType", ap_news_reporter.id),
"fans": [{"name": f"{ap_news_fan.name}"}],
}
}
]
},
}
result = schema.execute(query)
assert result.data == expected
def test_should_resolve_get_queryset_connectionfields():
Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
CNNReporter.objects.create(
first_name="Some",
last_name="Guy",
email="someguy@cnn.com",
@ -1233,10 +1535,10 @@ def test_should_resolve_get_queryset_connectionfields():
def test_connection_should_limit_after_to_list_length():
reporter_1 = Reporter.objects.create(
Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
reporter_2 = Reporter.objects.create(
Reporter.objects.create(
first_name="Some", last_name="Guy", email="someguy@cnn.com", a_choice=1
)
@ -1263,7 +1565,7 @@ def test_connection_should_limit_after_to_list_length():
"""
after = base64.b64encode(b"arrayconnection:10").decode()
result = schema.execute(query, variable_values=dict(after=after))
result = schema.execute(query, variable_values={"after": after})
expected = {"allReporters": {"edges": []}}
assert not result.errors
assert result.data == expected
@ -1271,12 +1573,12 @@ def test_connection_should_limit_after_to_list_length():
REPORTERS = [
dict(
first_name=f"First {i}",
last_name=f"Last {i}",
email=f"johndoe+{i}@example.com",
a_choice=1,
)
{
"first_name": f"First {i}",
"last_name": f"Last {i}",
"email": f"johndoe+{i}@example.com",
"a_choice": 1,
}
for i in range(6)
]
@ -1353,7 +1655,7 @@ def test_should_have_next_page(graphene_settings):
assert_async_result_equal(schema, query, result, variable_values={})
last_result = result.data["allReporters"]["pageInfo"]["endCursor"]
result2 = schema.execute(query, variable_values=dict(first=4, after=last_result))
result2 = schema.execute(query, variable_values={"first": 4, "after": last_result})
assert not result2.errors
assert len(result2.data["allReporters"]["edges"]) == 2
assert not result2.data["allReporters"]["pageInfo"]["hasNextPage"]
@ -1448,8 +1750,8 @@ class TestBackwardPagination:
after = base64.b64encode(b"arrayconnection:0").decode()
result = schema.execute(
query,
variable_values=dict(after=after),
query_first_last_and_after,
variable_values={"after": after},
)
assert not result.errors
assert len(result.data["allReporters"]["edges"]) == 3
@ -1484,8 +1786,8 @@ class TestBackwardPagination:
before = base64.b64encode(b"arrayconnection:5").decode()
result = schema.execute(
query,
variable_values=dict(before=before),
query_first_last_and_after,
variable_values={"before": before},
)
assert not result.errors
assert len(result.data["allReporters"]["edges"]) == 1
@ -1544,7 +1846,7 @@ def test_should_preserve_prefetch_related(django_assert_num_queries):
"""
schema = graphene.Schema(query=Query)
with django_assert_num_queries(3) as captured:
with django_assert_num_queries(3):
result = schema.execute(query)
assert not result.errors
assert_async_result_equal(schema, query, result)
@ -1715,7 +2017,7 @@ def test_connection_should_forbid_offset_filtering_with_before():
}
"""
before = base64.b64encode(b"arrayconnection:2").decode()
result = schema.execute(query, variable_values=dict(before=before))
result = schema.execute(query, variable_values={"before": before})
expected_error = "You can't provide a `before` value at the same time as an `offset` value to properly paginate the `allReporters` connection."
assert len(result.errors) == 1
assert result.errors[0].message == expected_error
@ -1754,7 +2056,7 @@ def test_connection_should_allow_offset_filtering_with_after():
"""
after = base64.b64encode(b"arrayconnection:0").decode()
result = schema.execute(query, variable_values=dict(after=after))
result = schema.execute(query, variable_values={"after": after})
assert not result.errors
expected = {
"allReporters": {
@ -1791,7 +2093,7 @@ def test_connection_should_succeed_if_last_higher_than_number_of_objects():
}
"""
result = schema.execute(query, variable_values=dict(last=2))
result = schema.execute(query, variable_values={"last": 2})
assert not result.errors
expected = {"allReporters": {"edges": []}}
assert result.data == expected
@ -1802,7 +2104,7 @@ def test_connection_should_succeed_if_last_higher_than_number_of_objects():
Reporter.objects.create(first_name="Jane", last_name="Roe")
Reporter.objects.create(first_name="Some", last_name="Lady")
result = schema.execute(query, variable_values=dict(last=2))
result = schema.execute(query, variable_values={"last": 2})
assert not result.errors
expected = {
"allReporters": {
@ -1815,7 +2117,7 @@ def test_connection_should_succeed_if_last_higher_than_number_of_objects():
assert result.data == expected
assert_async_result_equal(schema, query, result, variable_values=dict(last=2))
result = schema.execute(query, variable_values=dict(last=4))
result = schema.execute(query, variable_values={"last": 4})
assert not result.errors
expected = {
"allReporters": {
@ -1830,7 +2132,7 @@ def test_connection_should_succeed_if_last_higher_than_number_of_objects():
assert result.data == expected
assert_async_result_equal(schema, query, result, variable_values=dict(last=4))
result = schema.execute(query, variable_values=dict(last=20))
result = schema.execute(query, variable_values={"last": 20})
assert not result.errors
expected = {
"allReporters": {
@ -1868,7 +2170,7 @@ def test_should_query_nullable_foreign_key():
schema = graphene.Schema(query=Query)
person = Person.objects.create(name="Jane")
pets = [
[
Pet.objects.create(name="Stray dog", age=1),
Pet.objects.create(name="Jane's dog", owner=person, age=1),
]
@ -1908,3 +2210,74 @@ def test_should_query_nullable_foreign_key():
assert result.data["person"] == {
"pets": [{"name": "Jane's dog"}],
}
def test_should_query_nullable_one_to_one_relation_with_custom_resolver():
class FilmType(DjangoObjectType):
class Meta:
model = Film
@classmethod
def get_queryset(cls, queryset, info):
return queryset
class FilmDetailsType(DjangoObjectType):
class Meta:
model = FilmDetails
@classmethod
def get_queryset(cls, queryset, info):
return queryset
class Query(graphene.ObjectType):
film = graphene.Field(FilmType, genre=graphene.String(required=True))
film_details = graphene.Field(
FilmDetailsType, location=graphene.String(required=True)
)
def resolve_film(self, info, genre):
return Film.objects.filter(genre=genre).first()
def resolve_film_details(self, info, location):
return FilmDetails.objects.filter(location=location).first()
schema = graphene.Schema(query=Query)
Film.objects.create(genre="do")
FilmDetails.objects.create(location="London")
query_film = """
query getFilm($genre: String!) {
film(genre: $genre) {
genre
details {
location
}
}
}
"""
query_film_details = """
query getFilmDetails($location: String!) {
filmDetails(location: $location) {
location
film {
genre
}
}
}
"""
result = schema.execute(query_film, variables={"genre": "do"})
assert not result.errors
assert result.data["film"] == {
"genre": "DO",
"details": None,
}
result = schema.execute(query_film_details, variables={"location": "London"})
assert not result.errors
assert result.data["filmDetails"] == {
"location": "London",
"film": None,
}

View File

@ -33,17 +33,18 @@ def test_should_map_fields_correctly():
fields = "__all__"
fields = list(ReporterType2._meta.fields.keys())
assert fields[:-2] == [
assert fields[:-3] == [
"id",
"first_name",
"last_name",
"email",
"pets",
"a_choice",
"fans",
"reporter_type",
]
assert sorted(fields[-2:]) == ["articles", "films"]
assert sorted(fields[-3:]) == ["apnewsreporter", "articles", "films"]
def test_should_map_only_few_fields():

View File

@ -1,9 +1,9 @@
from collections import OrderedDict, defaultdict
from textwrap import dedent
from unittest.mock import patch
import pytest
from django.db import models
from unittest.mock import patch
from graphene import Connection, Field, Interface, ObjectType, Schema, String
from graphene.relay import Node
@ -11,8 +11,10 @@ from graphene.relay import Node
from .. import registry
from ..filter import DjangoFilterConnectionField
from ..types import DjangoObjectType, DjangoObjectTypeOptions
from .models import Article as ArticleModel
from .models import Reporter as ReporterModel
from .models import (
Article as ArticleModel,
Reporter as ReporterModel,
)
class Reporter(DjangoObjectType):
@ -67,16 +69,17 @@ def test_django_get_node(get):
def test_django_objecttype_map_correct_fields():
fields = Reporter._meta.fields
fields = list(fields.keys())
assert fields[:-2] == [
assert fields[:-3] == [
"id",
"first_name",
"last_name",
"email",
"pets",
"a_choice",
"fans",
"reporter_type",
]
assert sorted(fields[-2:]) == ["articles", "films"]
assert sorted(fields[-3:]) == ["apnewsreporter", "articles", "films"]
def test_django_objecttype_with_node_have_correct_fields():

View File

@ -1,12 +1,12 @@
import json
from unittest.mock import patch
import pytest
from django.utils.translation import gettext_lazy
from unittest.mock import patch
from ..utils import camelize, get_model_fields, GraphQLTestCase
from .models import Film, Reporter
from ..utils import GraphQLTestCase, camelize, get_model_fields, get_reverse_fields
from ..utils.testing import graphql_query
from .models import APNewsReporter, CNNReporter, Film, Reporter
def test_get_model_fields_no_duplication():
@ -19,6 +19,18 @@ def test_get_model_fields_no_duplication():
assert len(film_fields) == len(film_name_set)
def test_get_reverse_fields_includes_proxied_models():
reporter_fields = get_reverse_fields(Reporter, [])
cnn_reporter_fields = get_reverse_fields(CNNReporter, [])
ap_news_reporter_fields = get_reverse_fields(APNewsReporter, [])
assert (
len(list(reporter_fields))
== len(list(cnn_reporter_fields))
== len(list(ap_news_reporter_fields))
)
def test_camelize():
assert camelize({}) == {}
assert camelize("value_a") == "value_a"

View File

@ -1,13 +1,9 @@
import json
import pytest
from unittest.mock import patch
import pytest
from django.db import connection
from graphene_django.settings import graphene_settings
from .models import Pet
try:
@ -31,8 +27,12 @@ def response_json(response):
return json.loads(response.content.decode())
j = lambda **kwargs: json.dumps(kwargs)
jl = lambda **kwargs: json.dumps([kwargs])
def j(**kwargs):
return json.dumps(kwargs)
def jl(**kwargs):
return json.dumps([kwargs])
def test_graphiql_is_enabled(client):
@ -229,7 +229,7 @@ def test_allows_sending_a_mutation_via_post(client):
def test_allows_post_with_url_encoding(client):
response = client.post(
url_string(),
urlencode(dict(query="{test}")),
urlencode({"query": "{test}"}),
"application/x-www-form-urlencoded",
)
@ -303,10 +303,10 @@ def test_supports_post_url_encoded_query_with_string_variables(client):
response = client.post(
url_string(),
urlencode(
dict(
query="query helloWho($who: String){ test(who: $who) }",
variables=json.dumps({"who": "Dolly"}),
)
{
"query": "query helloWho($who: String){ test(who: $who) }",
"variables": json.dumps({"who": "Dolly"}),
}
),
"application/x-www-form-urlencoded",
)
@ -329,7 +329,7 @@ def test_supports_post_json_quey_with_get_variable_values(client):
def test_post_url_encoded_query_with_get_variable_values(client):
response = client.post(
url_string(variables=json.dumps({"who": "Dolly"})),
urlencode(dict(query="query helloWho($who: String){ test(who: $who) }")),
urlencode({"query": "query helloWho($who: String){ test(who: $who) }"}),
"application/x-www-form-urlencoded",
)
@ -511,7 +511,7 @@ def test_handles_django_request_error(client, monkeypatch):
monkeypatch.setattr("django.http.request.HttpRequest.read", mocked_read)
valid_json = json.dumps(dict(foo="bar"))
valid_json = json.dumps({"foo": "bar"})
response = client.post(url_string(), valid_json, "application/json")
assert response.status_code == 400

View File

@ -1,9 +1,10 @@
import warnings
from collections import OrderedDict
from typing import Type
from typing import Type # noqa: F401
from django.db.models import Model # noqa: F401
import graphene
from django.db.models import Model
from graphene.relay import Connection, Node
from graphene.types.objecttype import ObjectType, ObjectTypeOptions
from graphene.types.utils import yank_fields_from_attrs
@ -150,7 +151,7 @@ class DjangoObjectType(ObjectType):
interfaces=(),
convert_choices_to_enum=True,
_meta=None,
**options
**options,
):
assert is_valid_django_model(model), (
'You need to pass a valid Django Model in {}.Meta, received "{}".'
@ -160,9 +161,9 @@ class DjangoObjectType(ObjectType):
registry = get_global_registry()
assert isinstance(registry, Registry), (
"The attribute registry in {} needs to be an instance of "
'Registry, received "{}".'
).format(cls.__name__, registry)
f"The attribute registry in {cls.__name__} needs to be an instance of "
f'Registry, received "{registry}".'
)
if filter_fields and filterset_class:
raise Exception("Can't set both filter_fields and filterset_class")
@ -175,7 +176,7 @@ class DjangoObjectType(ObjectType):
assert not (fields and exclude), (
"Cannot set both 'fields' and 'exclude' options on "
"DjangoObjectType {class_name}.".format(class_name=cls.__name__)
f"DjangoObjectType {cls.__name__}."
)
# Alias only_fields -> fields
@ -214,8 +215,8 @@ class DjangoObjectType(ObjectType):
warnings.warn(
"Creating a DjangoObjectType without either the `fields` "
"or the `exclude` option is deprecated. Add an explicit `fields "
"= '__all__'` option on DjangoObjectType {class_name} to use all "
"fields".format(class_name=cls.__name__),
f"= '__all__'` option on DjangoObjectType {cls.__name__} to use all "
"fields",
DeprecationWarning,
stacklevel=2,
)
@ -240,9 +241,9 @@ class DjangoObjectType(ObjectType):
)
if connection is not None:
assert issubclass(connection, Connection), (
"The connection must be a Connection. Received {}"
).format(connection.__name__)
assert issubclass(
connection, Connection
), f"The connection must be a Connection. Received {connection.__name__}"
if not _meta:
_meta = DjangoObjectTypeOptions(cls)
@ -253,6 +254,7 @@ class DjangoObjectType(ObjectType):
_meta.filterset_class = filterset_class
_meta.fields = django_fields
_meta.connection = connection
_meta.convert_choices_to_enum = convert_choices_to_enum
super().__init_subclass_with_meta__(
_meta=_meta, interfaces=interfaces, **options
@ -272,7 +274,7 @@ class DjangoObjectType(ObjectType):
if isinstance(root, cls):
return True
if not is_valid_django_model(root.__class__):
raise Exception(('Received incompatible instance "{}".').format(root))
raise Exception(f'Received incompatible instance "{root}".')
if cls._meta.model._meta.proxy:
model = root._meta.model

View File

@ -1,6 +1,7 @@
from .testing import GraphQLTestCase
from .utils import (
DJANGO_FILTER_INSTALLED,
bypass_get_queryset,
camelize,
get_model_fields,
get_reverse_fields,
@ -20,4 +21,5 @@ __all__ = [
"GraphQLTestCase",
"is_sync_function",
"is_running_async",
"bypass_get_queryset",
]

View File

@ -1,4 +1,5 @@
import re
from text_unidecode import unidecode

View File

@ -1,10 +1,10 @@
import pytest
from .. import GraphQLTestCase
from ...tests.test_types import with_local_registry
from ...settings import graphene_settings
from django.test import Client
from ...settings import graphene_settings
from ...tests.test_types import with_local_registry
from .. import GraphQLTestCase
@with_local_registry
def test_graphql_test_case_deprecated_client_getter():
@ -23,7 +23,7 @@ def test_graphql_test_case_deprecated_client_getter():
tc.setUpClass()
with pytest.warns(PendingDeprecationWarning):
tc._client
tc._client # noqa: B018
@with_local_registry

View File

@ -38,18 +38,52 @@ def camelize(data):
return data
def get_reverse_fields(model, local_field_names):
for name, attr in model.__dict__.items():
# Don't duplicate any local fields
if name in local_field_names:
continue
def _get_model_ancestry(model):
model_ancestry = [model]
# "rel" for FK and M2M relations and "related" for O2O Relations
related = getattr(attr, "rel", None) or getattr(attr, "related", None)
if isinstance(related, models.ManyToOneRel):
yield (name, related)
elif isinstance(related, models.ManyToManyRel) and not related.symmetrical:
yield (name, related)
for base in model.__bases__:
if is_valid_django_model(base) and getattr(base, "_meta", False):
model_ancestry.append(base)
return model_ancestry
def get_reverse_fields(model, local_field_names):
"""
Searches through the model's ancestry and gets reverse relationships the models
Yields a tuple of (field.name, field)
"""
model_ancestry = _get_model_ancestry(model)
for _model in model_ancestry:
for name, attr in _model.__dict__.items():
# Don't duplicate any local fields
if name in local_field_names:
continue
# "rel" for FK and M2M relations and "related" for O2O Relations
related = getattr(attr, "rel", None) or getattr(attr, "related", None)
if isinstance(related, models.ManyToOneRel):
yield (name, related)
elif isinstance(related, models.ManyToManyRel) and not related.symmetrical:
yield (name, related)
def get_local_fields(model):
"""
Searches through the model's ancestry and gets the fields on the models
Returns a dict of {field.name: field}
"""
model_ancestry = _get_model_ancestry(model)
local_fields_dict = {}
for _model in model_ancestry:
for field in sorted(
list(_model._meta.fields) + list(_model._meta.local_many_to_many)
):
if field.name not in local_fields_dict:
local_fields_dict[field.name] = field
return list(local_fields_dict.items())
def maybe_queryset(value):
@ -59,17 +93,14 @@ def maybe_queryset(value):
def get_model_fields(model):
local_fields = [
(field.name, field)
for field in sorted(
list(model._meta.fields) + list(model._meta.local_many_to_many)
)
]
# Make sure we don't duplicate local fields with "reverse" version
local_field_names = [field[0] for field in local_fields]
"""
Gets all the fields and relationships on the Django model and its ancestry.
Prioritizes local fields and relationships over the reverse relationships of the same name
Returns a tuple of (field.name, field)
"""
local_fields = get_local_fields(model)
local_field_names = {field[0] for field in local_fields}
reverse_fields = get_reverse_fields(model, local_field_names)
all_fields = local_fields + list(reverse_fields)
return all_fields
@ -121,3 +152,11 @@ def is_sync_function(func):
return not inspect.iscoroutinefunction(func) and not inspect.isasyncgenfunction(
func
)
def bypass_get_queryset(resolver):
"""
Adds a bypass_get_queryset attribute to the resolver, which is used to
bypass any custom get_queryset method of the DjangoObjectType.
"""
resolver._bypass_get_queryset = True
return resolver

View File

@ -16,10 +16,9 @@ from django.views.generic import View
from graphql import OperationType, get_operation_ast, parse
from graphql.error import GraphQLError
from graphql.execution import ExecutionResult
from graphene import Schema
from graphql.execution.middleware import MiddlewareManager
from graphene import Schema
from graphene_django.constants import MUTATION_ERRORS_FLAG
from graphene_django.utils.utils import set_rollback
@ -44,9 +43,9 @@ def get_accepted_content_types(request):
raw_content_types = request.META.get("HTTP_ACCEPT", "*/*").split(",")
qualified_content_types = map(qualify, raw_content_types)
return list(
return [
x[0] for x in sorted(qualified_content_types, key=lambda x: x[1], reverse=True)
)
]
def instantiate_middleware(middlewares):
@ -70,18 +69,21 @@ class GraphQLView(View):
react_dom_sri = "sha256-nbMykgB6tsOFJ7OdVmPpdqMFVk4ZsqWocT6issAPUF0="
# The GraphiQL React app.
graphiql_version = "2.4.1" # "1.0.3"
graphiql_sri = "sha256-s+f7CFAPSUIygFnRC2nfoiEKd3liCUy+snSdYFAoLUc=" # "sha256-VR4buIDY9ZXSyCNFHFNik6uSe0MhigCzgN4u7moCOTk="
graphiql_css_sri = "sha256-88yn8FJMyGboGs4Bj+Pbb3kWOWXo7jmb+XCRHE+282k=" # "sha256-LwqxjyZgqXDYbpxQJ5zLQeNcf7WVNSJ+r8yp2rnWE/E="
graphiql_version = "2.4.7"
graphiql_sri = "sha256-n/LKaELupC1H/PU6joz+ybeRJHT2xCdekEt6OYMOOZU="
graphiql_css_sri = "sha256-OsbM+LQHcnFHi0iH7AUKueZvDcEBoy/z4hJ7jx1cpsM="
# The websocket transport library for subscriptions.
subscriptions_transport_ws_version = "5.12.1"
subscriptions_transport_ws_version = "5.13.1"
subscriptions_transport_ws_sri = (
"sha256-EZhvg6ANJrBsgLvLAa0uuHNLepLJVCFYS+xlb5U/bqw="
)
graphiql_plugin_explorer_version = "0.1.15"
graphiql_plugin_explorer_sri = "sha256-3hUuhBXdXlfCj6RTeEkJFtEh/kUG+TCDASFpFPLrzvE="
graphiql_plugin_explorer_css_sri = (
"sha256-fA0LPUlukMNR6L4SPSeFqDTYav8QdWjQ2nr559Zln1U="
)
schema = None
graphiql = False
@ -109,17 +111,19 @@ class GraphQLView(View):
if middleware is None:
middleware = graphene_settings.MIDDLEWARE
self.schema = self.schema or schema
self.schema = schema or self.schema
if middleware is not None:
if isinstance(middleware, MiddlewareManager):
self.middleware = middleware
else:
self.middleware = list(instantiate_middleware(middleware))
self.root_value = root_value
self.pretty = self.pretty or pretty
self.graphiql = self.graphiql or graphiql
self.batch = self.batch or batch
self.execution_context_class = execution_context_class
self.pretty = pretty or self.pretty
self.graphiql = graphiql or self.graphiql
self.batch = batch or self.batch
self.execution_context_class = (
execution_context_class or self.execution_context_class
)
if subscription_path is None:
self.subscription_path = graphene_settings.SUBSCRIPTION_PATH

View File

@ -4,46 +4,9 @@ test=pytest
[bdist_wheel]
universal=1
[flake8]
exclude = docs,graphene_django/debug/sql/*
max-line-length = 120
select =
# Dictionary key repeated
F601,
# Ensure use of ==/!= to compare with str, bytes and int literals
F632,
# Redefinition of unused name
F811,
# Using an undefined variable
F821,
# Defining an undefined variable in __all__
F822,
# Using a variable before it is assigned
F823,
# Duplicate argument in function declaration
F831,
# Black would format this line
BLK,
# Do not use bare except
B001,
# Don't allow ++n. You probably meant n += 1
B002,
# Do not use mutable structures for argument defaults
B006,
# Do not perform calls in argument defaults
B008
[coverage:run]
omit = */tests/*
[isort]
known_first_party=graphene,graphene_django
multi_line_output=3
include_trailing_comma=True
force_grid_wrap=0
use_parentheses=True
line_length=88
[tool:pytest]
DJANGO_SETTINGS_MODULE = examples.django_test_settings
addopts = --random-order

View File

@ -27,10 +27,8 @@ tests_require = [
dev_requires = [
"black==23.3.0",
"flake8==6.0.0",
"flake8-black==0.3.6",
"flake8-bugbear==23.3.23",
"black==23.7.0",
"ruff==0.0.283",
"pre-commit",
] + tests_require
@ -39,6 +37,7 @@ setup(
version=version,
description="Graphene Django integration",
long_description=open("README.md").read(),
long_description_content_type="text/markdown",
url="https://github.com/graphql-python/graphene-django",
author="Syrus Akbary",
author_email="me@syrusakbary.com",
@ -48,7 +47,6 @@ setup(
"Intended Audience :: Developers",
"Topic :: Software Development :: Libraries",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
@ -56,8 +54,8 @@ setup(
"Programming Language :: Python :: Implementation :: PyPy",
"Framework :: Django",
"Framework :: Django :: 3.2",
"Framework :: Django :: 4.0",
"Framework :: Django :: 4.1",
"Framework :: Django :: 4.2",
],
keywords="api graphql protocol rest relay graphene",
packages=find_packages(exclude=["tests", "examples", "examples.*"]),

15
tox.ini
View File

@ -1,13 +1,12 @@
[tox]
envlist =
py{37,38,39,310}-django32,
py{38,39,310}-django{40,41,main},
py311-django{41,main}
py{38,39,310}-django32
py{38,39}-django{41,42}
py{310,311}-django{41,42,main}
pre-commit
[gh-actions]
python =
3.7: py37
3.8: py38
3.9: py39
3.10: py310
@ -16,8 +15,8 @@ python =
[gh-actions:env]
DJANGO =
3.2: django32
4.0: django40
4.1: django41
4.2: django42
main: djangomain
[testenv]
@ -30,13 +29,13 @@ deps =
-e.[test]
psycopg2-binary
django32: Django>=3.2,<4.0
django40: Django>=4.0,<4.1
django41: Django>=4.1,<4.2
django42: Django>=4.2,<4.3
djangomain: https://github.com/django/django/archive/main.zip
commands = {posargs:py.test --cov=graphene_django graphene_django examples}
commands = {posargs:pytest --cov=graphene_django graphene_django examples}
[testenv:pre-commit]
skip_install = true
deps = pre-commit
commands =
pre-commit run --all-files --show-diff-on-failure
pre-commit run {posargs:--all-files --show-diff-on-failure}