Merge branch 'v3' into test-utils-rename-op-name

This commit is contained in:
Jonathan Kim 2020-05-09 12:18:28 +01:00 committed by GitHub
commit 270108d472
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 823 additions and 662 deletions

25
.github/workflows/deploy.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: 🚀 Deploy to PyPI
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Set up Python 3.8
uses: actions/setup-python@v1
with:
python-version: 3.8
- name: Build wheel and source tarball
run: |
python setup.py sdist bdist_wheel
- name: Publish a Python distribution to PyPI
uses: pypa/gh-action-pypi-publish@v1.1.0
with:
user: __token__
password: ${{ secrets.pypi_password }}

22
.github/workflows/lint.yml vendored Normal file
View File

@ -0,0 +1,22 @@
name: Lint
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Set up Python 3.8
uses: actions/setup-python@v1
with:
python-version: 3.8
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install tox
- name: Run lint 💅
run: tox
env:
TOXENV: flake8

28
.github/workflows/tests.yml vendored Normal file
View File

@ -0,0 +1,28 @@
name: Tests
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
django: ["2.2", "3.0"]
python-version: ["3.6", "3.7", "3.8"]
steps:
- uses: actions/checkout@v1
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install tox tox-gh-actions
- name: Test with tox
run: tox
env:
DJANGO: ${{ matrix.django }}
TOXENV: ${{ matrix.toxenv }}

View File

@ -1,68 +0,0 @@
language: python
cache: pip
dist: xenial
install:
- pip install tox tox-travis
script:
- tox
after_success:
- pip install coveralls
- coveralls
stages:
- test
- name: deploy
if: tag IS present
jobs:
fast_finish: true
allow_failures:
- env: DJANGO=master
include:
- python: 3.6
env: DJANGO=1.11
- python: 3.6
env: DJANGO=2.2
- python: 3.6
env: DJANGO=3.0
- python: 3.6
env: DJANGO=master
- python: 3.7
env: DJANGO=1.11
- python: 3.7
env: DJANGO=2.2
- python: 3.7
env: DJANGO=3.0
- python: 3.7
env: DJANGO=master
- python: 3.8
env: DJANGO=1.11
- python: 3.8
env: DJANGO=2.2
- python: 3.8
env: DJANGO=3.0
- python: 3.8
env: DJANGO=master
- python: 3.8
env: TOXENV=black,flake8
- stage: deploy
script: skip
python: 3.8
after_success: true
deploy:
provider: pypi
user: syrusakbary
on:
tags: true
password:
secure: kymIFCEPUbkgRqe2NAXkWfxMmGRfWvWBOP6LIXdVdkOOkm91fU7bndPGrAjos+/7gN0Org609ZmHSlVXNMJUWcsL2or/x5LcADJ4cZDe+79qynuoRb9xs1Ri4O4SBAuVMZxuVJvs8oUzT2R11ql5vASSMtXgbX+ZDGpmPRVZStkCuXgOc4LBhbPKyl3OFy7UQFPgAEmy3Yjh4ZSKzlXheK+S6mmr60+DCIjpaA0BWPxYK9FUE0qm7JJbHLUbwsUP/QMp5MmGjwFisXCNsIe686B7QKRaiOw62eJc2R7He8AuEC8T9OM4kRwDlecSn8mMpkoSB7QWtlJ+6XdLrJFPNvtrOfgfzS9/96Qrw9WlOslk68hMlhJeRb0s2YUD8tiV3UUkvbL1mfFoS4SI9U+rojS55KhUEJWHg1w7DjoOPoZmaIL2ChRupmvrFYNAGae1cxwG3Urh+t3wYlN3gpKsRDe5GOT7Wm2tr0ad3McCpDGUwSChX59BAJXe/MoLxkKScTrMyR8yMxHOF0b4zpVn5l7xB/o2Ik4zavx5q/0rGBMK2D+5d+gpQogKShoquTPsZUwO7sB5hYeH2hqGqpeGzZtb76E2zZYd18pJ0FsBudm5+KWjYdZ+vbtGrLxdTXJ1EEtzVXm0lscykTpqUucbXSa51dhStJvW2xEEz6p3rHo=
distributions: "sdist bdist_wheel"

View File

@ -28,7 +28,7 @@ A [Django](https://www.djangoproject.com/) integration for [Graphene](http://gra
For installing graphene, just run this command in your shell For installing graphene, just run this command in your shell
```bash ```bash
pip install "graphene-django>=2.0" pip install "graphene-django>=3"
``` ```
### Settings ### Settings

View File

@ -23,7 +23,7 @@ For installing graphene, just run this command in your shell
.. code:: bash .. code:: bash
pip install "graphene-django>=2.0" pip install "graphene-django>=3"
Settings Settings
~~~~~~~~ ~~~~~~~~

View File

@ -166,16 +166,7 @@ To restrict users from accessing the GraphQL API page the standard Django LoginR
After this, you can use the new ``PrivateGraphQLView`` in the project's URL Configuration file ``url.py``: After this, you can use the new ``PrivateGraphQLView`` in the project's URL Configuration file ``url.py``:
For Django 1.11: For Django 2.2 and above:
.. code:: python
urlpatterns = [
# some other urls
url(r'^graphql$', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
]
For Django 2.0 and above:
.. code:: python .. code:: python

View File

@ -2,8 +2,8 @@ Filtering
========= =========
Graphene integrates with Graphene integrates with
`django-filter <https://django-filter.readthedocs.io/en/master/>`__ to provide filtering of results. See the `usage `django-filter <https://django-filter.readthedocs.io/en/master/>`__ to provide filtering of results.
documentation <https://django-filter.readthedocs.io/en/master/guide/usage.html#the-filter>`__ See the `usage documentation <https://django-filter.readthedocs.io/en/master/guide/usage.html#the-filter>`__
for details on the format for ``filter_fields``. for details on the format for ``filter_fields``.
This filtering is automatically available when implementing a ``relay.Node``. This filtering is automatically available when implementing a ``relay.Node``.
@ -123,6 +123,15 @@ create your own ``FilterSet``. You can pass it directly as follows:
class AnimalFilter(django_filters.FilterSet): class AnimalFilter(django_filters.FilterSet):
# Do case-insensitive lookups on 'name' # Do case-insensitive lookups on 'name'
name = django_filters.CharFilter(lookup_expr=['iexact']) name = django_filters.CharFilter(lookup_expr=['iexact'])
# Allow multiple genera to be selected at once
genera = django_filters.MultipleChoiceFilter(
field_name='genus',
choices=(
('Canis', 'Canis'),
('Panthera', 'Panthera'),
('Seahorse', 'Seahorse')
)
)
class Meta: class Meta:
model = Animal model = Animal
@ -135,6 +144,22 @@ create your own ``FilterSet``. You can pass it directly as follows:
all_animals = DjangoFilterConnectionField(AnimalNode, all_animals = DjangoFilterConnectionField(AnimalNode,
filterset_class=AnimalFilter) filterset_class=AnimalFilter)
If you were interested in selecting all dogs and cats, you might query as follows:
.. code::
query {
allAnimals(genera: ["Canis", "Panthera"]) {
edges {
node {
id,
name
}
}
}
}
You can also specify the ``FilterSet`` class using the ``filterset_class`` You can also specify the ``FilterSet`` class using the ``filterset_class``
parameter when defining your ``DjangoObjectType``, however, this can't be used parameter when defining your ``DjangoObjectType``, however, this can't be used
in unison with the ``filter_fields`` parameter: in unison with the ``filter_fields`` parameter:
@ -162,6 +187,7 @@ in unison with the ``filter_fields`` parameter:
animal = relay.Node.Field(AnimalNode) animal = relay.Node.Field(AnimalNode)
all_animals = DjangoFilterConnectionField(AnimalNode) all_animals = DjangoFilterConnectionField(AnimalNode)
The context argument is passed on as the `request argument <http://django-filter.readthedocs.io/en/master/guide/usage.html#request-based-filtering>`__ The context argument is passed on as the `request argument <http://django-filter.readthedocs.io/en/master/guide/usage.html#request-based-filtering>`__
in a ``django_filters.FilterSet`` instance. You can use this to customize your in a ``django_filters.FilterSet`` instance. You can use this to customize your
filters to be context-dependent. We could modify the ``AnimalFilter`` above to filters to be context-dependent. We could modify the ``AnimalFilter`` above to

View File

@ -8,7 +8,7 @@ Requirements
Graphene-Django currently supports the following versions of Django: Graphene-Django currently supports the following versions of Django:
* >= Django 1.11 * >= Django 2.2
Installation Installation
------------ ------------
@ -32,19 +32,7 @@ Add ``graphene_django`` to the ``INSTALLED_APPS`` in the ``settings.py`` file of
We need to add a ``graphql`` URL to the ``urls.py`` of your Django project: We need to add a ``graphql`` URL to the ``urls.py`` of your Django project:
For Django 1.11: For Django 2.2 and above:
.. code:: python
from django.conf.urls import url
from graphene_django.views import GraphQLView
urlpatterns = [
# ...
url(r"graphql", GraphQLView.as_view(graphiql=True)),
]
For Django 2.0 and above:
.. code:: python .. code:: python

View File

@ -0,0 +1,18 @@
import pytest
from graphene_django.settings import graphene_settings as gsettings
from .registry import reset_global_registry
@pytest.fixture(autouse=True)
def reset_registry_fixture(db):
yield None
reset_global_registry()
@pytest.fixture()
def graphene_settings():
settings = dict(gsettings.__dict__)
yield gsettings
gsettings.__dict__ = settings

View File

@ -3,11 +3,14 @@ from functools import singledispatch
from django.db import models from django.db import models
from django.utils.encoding import force_str from django.utils.encoding import force_str
from django.utils.functional import Promise
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
from graphene import ( from graphene import (
ID, ID,
UUID,
Boolean, Boolean,
Date,
DateTime,
Dynamic, Dynamic,
Enum, Enum,
Field, Field,
@ -16,25 +19,23 @@ from graphene import (
List, List,
NonNull, NonNull,
String, String,
UUID,
DateTime,
Date,
Time, Time,
) )
from graphene.types.json import JSONString from graphene.types.json import JSONString
from graphene.utils.str_converters import to_camel_case, to_const from graphene.utils.str_converters import to_camel_case, to_const
from graphql import assert_valid_name from graphql import GraphQLError, assert_valid_name
from graphql.pyutils import register_description
from .settings import graphene_settings
from .compat import ArrayField, HStoreField, JSONField, RangeField from .compat import ArrayField, HStoreField, JSONField, RangeField
from .fields import DjangoListField, DjangoConnectionField from .fields import DjangoConnectionField, DjangoListField
from .settings import graphene_settings
def convert_choice_name(name): def convert_choice_name(name):
name = to_const(force_str(name)) name = to_const(force_str(name))
try: try:
assert_valid_name(name) assert_valid_name(name)
except AssertionError: except GraphQLError:
name = "A_%s" % name name = "A_%s" % name
return name return name
@ -52,7 +53,9 @@ def get_choices(choices):
while name in converted_names: while name in converted_names:
name += "_" + str(len(converted_names)) name += "_" + str(len(converted_names))
converted_names.append(name) converted_names.append(name)
description = help_text description = str(
help_text
) # TODO: translatable description: https://github.com/graphql-python/graphql-core-next/issues/58
yield name, value, description yield name, value, description
@ -64,7 +67,7 @@ def convert_choices_to_named_enum_with_descriptions(name, choices):
class EnumWithDescriptionsType(object): class EnumWithDescriptionsType(object):
@property @property
def description(self): def description(self):
return named_choices_descriptions[self.name] return str(named_choices_descriptions[self.name])
return Enum(name, list(named_choices), type=EnumWithDescriptionsType) return Enum(name, list(named_choices), type=EnumWithDescriptionsType)
@ -276,3 +279,8 @@ def convert_postgres_range_to_string(field, registry=None):
if not isinstance(inner_type, (List, NonNull)): if not isinstance(inner_type, (List, NonNull)):
inner_type = type(inner_type) inner_type = type(inner_type)
return List(inner_type, description=field.help_text, required=not field.null) return List(inner_type, description=field.help_text, required=not field.null)
# Register Django lazy()-wrapped values as GraphQL description/help_text.
# This is needed for using lazy translations, see https://github.com/graphql-python/graphql-core-next/issues/58.
register_description(Promise)

View File

@ -17,7 +17,7 @@ class DjangoDebugContext(object):
if not self.debug_promise: if not self.debug_promise:
self.debug_promise = Promise.all(self.promises) self.debug_promise = Promise.all(self.promises)
self.promises = [] self.promises = []
return self.debug_promise.then(self.on_resolve_all_promises) return self.debug_promise.then(self.on_resolve_all_promises).get()
def on_resolve_all_promises(self, values): def on_resolve_all_promises(self, values):
if self.promises: if self.promises:

View File

@ -1,5 +1,3 @@
import pytest
import graphene import graphene
from graphene.relay import Node from graphene.relay import Node
from graphene_django import DjangoConnectionField, DjangoObjectType from graphene_django import DjangoConnectionField, DjangoObjectType
@ -13,11 +11,6 @@ class context(object):
pass pass
# from examples.starwars_django.models import Character
pytestmark = pytest.mark.django_db
def test_should_query_field(): def test_should_query_field():
r1 = Reporter(last_name="ABA") r1 = Reporter(last_name="ABA")
r1.save() r1.save()
@ -75,7 +68,7 @@ def test_should_query_nested_field():
class Query(graphene.ObjectType): class Query(graphene.ObjectType):
reporter = graphene.Field(ReporterType) reporter = graphene.Field(ReporterType)
debug = graphene.Field(DjangoDebug, name="__debug") debug = graphene.Field(DjangoDebug, name="_debug")
def resolve_reporter(self, info, **args): def resolve_reporter(self, info, **args):
return Reporter.objects.first() return Reporter.objects.first()
@ -89,7 +82,7 @@ def test_should_query_nested_field():
pets { edges { node { lastName } } } pets { edges { node { lastName } } }
} } } } } }
} }
__debug { _debug {
sql { sql {
rawSql rawSql
} }
@ -117,12 +110,12 @@ def test_should_query_nested_field():
) )
assert not result.errors assert not result.errors
query = str(Reporter.objects.order_by("pk")[:1].query) query = str(Reporter.objects.order_by("pk")[:1].query)
assert result.data["__debug"]["sql"][0]["rawSql"] == query assert result.data["_debug"]["sql"][0]["rawSql"] == query
assert "COUNT" in result.data["__debug"]["sql"][1]["rawSql"] assert "COUNT" in result.data["_debug"]["sql"][1]["rawSql"]
assert "tests_reporter_pets" in result.data["__debug"]["sql"][2]["rawSql"] assert "tests_reporter_pets" in result.data["_debug"]["sql"][2]["rawSql"]
assert "COUNT" in result.data["__debug"]["sql"][3]["rawSql"] assert "COUNT" in result.data["_debug"]["sql"][3]["rawSql"]
assert "tests_reporter_pets" in result.data["__debug"]["sql"][4]["rawSql"] assert "tests_reporter_pets" in result.data["_debug"]["sql"][4]["rawSql"]
assert len(result.data["__debug"]["sql"]) == 5 assert len(result.data["_debug"]["sql"]) == 5
assert result.data["reporter"] == expected["reporter"] assert result.data["reporter"] == expected["reporter"]

View File

@ -1,11 +1,12 @@
from functools import partial from functools import partial
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from graphql_relay.connection.arrayconnection import connection_from_list_slice from graphql_relay.connection.arrayconnection import connection_from_array_slice
from promise import Promise from promise import Promise
from graphene import NonNull from graphene import NonNull
from graphene.relay import ConnectionField, PageInfo from graphene.relay import ConnectionField
from graphene.relay.connection import connection_adapter, page_info_adapter
from graphene.types import Field, List from graphene.types import Field, List
from .settings import graphene_settings from .settings import graphene_settings
@ -122,15 +123,15 @@ class DjangoConnectionField(ConnectionField):
_len = iterable.count() _len = iterable.count()
else: else:
_len = len(iterable) _len = len(iterable)
connection = connection_from_list_slice( connection = connection_from_array_slice(
iterable, iterable,
args, args,
slice_start=0, slice_start=0,
list_length=_len, array_length=_len,
list_slice_length=_len, array_slice_length=_len,
connection_type=connection, connection_type=partial(connection_adapter, connection),
edge_type=connection.Edge, edge_type=connection.Edge,
pageinfo_type=PageInfo, page_info_type=page_info_adapter,
) )
connection.iterable = iterable connection.iterable = iterable
connection.length = _len connection.length = _len

View File

@ -35,9 +35,6 @@ else:
) )
) )
pytestmark.append(pytest.mark.django_db)
if DJANGO_FILTER_INSTALLED: if DJANGO_FILTER_INSTALLED:
class ArticleNode(DjangoObjectType): class ArticleNode(DjangoObjectType):
@ -809,38 +806,56 @@ def test_integer_field_filter_type():
assert str(schema) == dedent( assert str(schema) == dedent(
"""\ """\
schema { type Query {
query: Query pets(before: String = null, after: String = null, first: Int = null, last: Int = null, age: Int = null): PetTypeConnection
}
interface Node {
id: ID!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type PetType implements Node {
age: Int!
id: ID!
} }
type PetTypeConnection { type PetTypeConnection {
\"""Pagination data for this connection.\"""
pageInfo: PageInfo! pageInfo: PageInfo!
\"""Contains the nodes in this connection.\"""
edges: [PetTypeEdge]! edges: [PetTypeEdge]!
} }
\"""
The Relay compliant `PageInfo` type, containing data necessary to paginate this connection.
\"""
type PageInfo {
\"""When paginating forwards, are there more items?\"""
hasNextPage: Boolean!
\"""When paginating backwards, are there more items?\"""
hasPreviousPage: Boolean!
\"""When paginating backwards, the cursor to continue.\"""
startCursor: String
\"""When paginating forwards, the cursor to continue.\"""
endCursor: String
}
\"""A Relay edge containing a `PetType` and its cursor.\"""
type PetTypeEdge { type PetTypeEdge {
\"""The item at the end of the edge\"""
node: PetType node: PetType
\"""A cursor for use in pagination\"""
cursor: String! cursor: String!
} }
type Query { type PetType implements Node {
pets(before: String, after: String, first: Int, last: Int, age: Int): PetTypeConnection \"""\"""
age: Int!
\"""The ID of the object\"""
id: ID!
}
\"""An object with an ID\"""
interface Node {
\"""The ID of the object\"""
id: ID!
} }
""" """
) )
@ -861,40 +876,58 @@ def test_other_filter_types():
assert str(schema) == dedent( assert str(schema) == dedent(
"""\ """\
schema { type Query {
query: Query pets(before: String = null, after: String = null, first: Int = null, last: Int = null, age: Int = null, age_Isnull: Boolean = null, age_Lt: Int = null): PetTypeConnection
}
interface Node {
id: ID!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type PetType implements Node {
age: Int!
id: ID!
} }
type PetTypeConnection { type PetTypeConnection {
\"""Pagination data for this connection.\"""
pageInfo: PageInfo! pageInfo: PageInfo!
\"""Contains the nodes in this connection.\"""
edges: [PetTypeEdge]! edges: [PetTypeEdge]!
} }
\"""
The Relay compliant `PageInfo` type, containing data necessary to paginate this connection.
\"""
type PageInfo {
\"""When paginating forwards, are there more items?\"""
hasNextPage: Boolean!
\"""When paginating backwards, are there more items?\"""
hasPreviousPage: Boolean!
\"""When paginating backwards, the cursor to continue.\"""
startCursor: String
\"""When paginating forwards, the cursor to continue.\"""
endCursor: String
}
\"""A Relay edge containing a `PetType` and its cursor.\"""
type PetTypeEdge { type PetTypeEdge {
\"""The item at the end of the edge\"""
node: PetType node: PetType
\"""A cursor for use in pagination\"""
cursor: String! cursor: String!
} }
type Query { type PetType implements Node {
pets(before: String, after: String, first: Int, last: Int, age: Int, age_Isnull: Boolean, age_Lt: Int): PetTypeConnection \"""\"""
age: Int!
\"""The ID of the object\"""
id: ID!
} }
"""
\"""An object with an ID\"""
interface Node {
\"""The ID of the object\"""
id: ID!
}
"""
) )

View File

@ -55,9 +55,14 @@ def convert_form_field_to_float(field):
return Float(description=field.help_text, required=field.required) return Float(description=field.help_text, required=field.required)
@convert_form_field.register(forms.MultipleChoiceField)
def convert_form_field_to_string_list(field):
return List(String, description=field.help_text, required=field.required)
@convert_form_field.register(forms.ModelMultipleChoiceField) @convert_form_field.register(forms.ModelMultipleChoiceField)
@convert_form_field.register(GlobalIDMultipleChoiceField) @convert_form_field.register(GlobalIDMultipleChoiceField)
def convert_form_field_to_list(field): def convert_form_field_to_id_list(field):
return List(ID, required=field.required) return List(ID, required=field.required)

View File

@ -66,6 +66,10 @@ def test_should_choice_convert_string():
assert_conversion(forms.ChoiceField, String) assert_conversion(forms.ChoiceField, String)
def test_should_multiple_choice_convert_list():
assert_conversion(forms.MultipleChoiceField, List)
def test_should_base_field_convert_string(): def test_should_base_field_convert_string():
assert_conversion(forms.Field, String) assert_conversion(forms.Field, String)

View File

@ -1,16 +1,25 @@
import pytest
from django import forms from django import forms
from django.test import TestCase
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from py.test import raises from py.test import raises
from graphene import ObjectType, Schema, String, Field from graphene import Field, ObjectType, Schema, String
from graphene_django import DjangoObjectType from graphene_django import DjangoObjectType
from graphene_django.tests.models import Film, Pet from graphene_django.tests.models import Pet
from ...settings import graphene_settings
from ..mutation import DjangoFormMutation, DjangoModelFormMutation from ..mutation import DjangoFormMutation, DjangoModelFormMutation
@pytest.fixture()
def pet_type():
class PetType(DjangoObjectType):
class Meta:
model = Pet
fields = "__all__"
return PetType
class MyForm(forms.Form): class MyForm(forms.Form):
text = forms.CharField() text = forms.CharField()
@ -36,18 +45,6 @@ class PetForm(forms.ModelForm):
return age return age
class PetType(DjangoObjectType):
class Meta:
model = Pet
fields = "__all__"
class FilmType(DjangoObjectType):
class Meta:
model = Film
fields = "__all__"
def test_needs_form_class(): def test_needs_form_class():
with raises(Exception) as exc: with raises(Exception) as exc:
@ -73,7 +70,7 @@ def test_has_input_fields():
assert "text" in MyMutation.Input._meta.fields assert "text" in MyMutation.Input._meta.fields
def test_mutation_error_camelcased(): def test_mutation_error_camelcased(pet_type, graphene_settings):
class ExtraPetForm(PetForm): class ExtraPetForm(PetForm):
test_field = forms.CharField(required=True) test_field = forms.CharField(required=True)
@ -86,234 +83,237 @@ def test_mutation_error_camelcased():
graphene_settings.CAMELCASE_ERRORS = True graphene_settings.CAMELCASE_ERRORS = True
result = PetMutation.mutate_and_get_payload(None, None) result = PetMutation.mutate_and_get_payload(None, None)
assert {f.field for f in result.errors} == {"name", "age", "testField"} assert {f.field for f in result.errors} == {"name", "age", "testField"}
graphene_settings.CAMELCASE_ERRORS = False
class MockQuery(ObjectType): class MockQuery(ObjectType):
a = String() a = String()
class FormMutationTests(TestCase): def test_form_invalid_form():
def test_form_invalid_form(self): class MyMutation(DjangoFormMutation):
class MyMutation(DjangoFormMutation): class Meta:
class Meta: form_class = MyForm
form_class = MyForm
class Mutation(ObjectType): class Mutation(ObjectType):
my_mutation = MyMutation.Field() my_mutation = MyMutation.Field()
schema = Schema(query=MockQuery, mutation=Mutation) schema = Schema(query=MockQuery, mutation=Mutation)
result = schema.execute( result = schema.execute(
""" mutation MyMutation { """ mutation MyMutation {
myMutation(input: { text: "INVALID_INPUT" }) { myMutation(input: { text: "INVALID_INPUT" }) {
errors { errors {
field field
messages messages
} }
text text
}
}
"""
)
assert result.errors is None
assert result.data["myMutation"]["errors"] == [
{"field": "text", "messages": ["Invalid input"]}
]
def test_form_valid_input():
class MyMutation(DjangoFormMutation):
class Meta:
form_class = MyForm
class Mutation(ObjectType):
my_mutation = MyMutation.Field()
schema = Schema(query=MockQuery, mutation=Mutation)
result = schema.execute(
""" mutation MyMutation {
myMutation(input: { text: "VALID_INPUT" }) {
errors {
field
messages
}
text
}
}
"""
)
assert result.errors is None
assert result.data["myMutation"]["errors"] == []
assert result.data["myMutation"]["text"] == "VALID_INPUT"
def test_default_meta_fields(pet_type):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
assert PetMutation._meta.model is Pet
assert PetMutation._meta.return_field_name == "pet"
assert "pet" in PetMutation._meta.fields
def test_default_input_meta_fields(pet_type):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
assert PetMutation._meta.model is Pet
assert PetMutation._meta.return_field_name == "pet"
assert "name" in PetMutation.Input._meta.fields
assert "client_mutation_id" in PetMutation.Input._meta.fields
assert "id" in PetMutation.Input._meta.fields
def test_exclude_fields_input_meta_fields(pet_type):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
exclude_fields = ["id"]
assert PetMutation._meta.model is Pet
assert PetMutation._meta.return_field_name == "pet"
assert "name" in PetMutation.Input._meta.fields
assert "age" in PetMutation.Input._meta.fields
assert "client_mutation_id" in PetMutation.Input._meta.fields
assert "id" not in PetMutation.Input._meta.fields
def test_custom_return_field_name(pet_type):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
model = Pet
return_field_name = "animal"
assert PetMutation._meta.model is Pet
assert PetMutation._meta.return_field_name == "animal"
assert "animal" in PetMutation._meta.fields
def test_model_form_mutation_mutate_existing(pet_type):
class PetMutation(DjangoModelFormMutation):
pet = Field(pet_type)
class Meta:
form_class = PetForm
class Mutation(ObjectType):
pet_mutation = PetMutation.Field()
schema = Schema(query=MockQuery, mutation=Mutation)
pet = Pet.objects.create(name="Axel", age=10)
result = schema.execute(
""" mutation PetMutation($pk: ID!) {
petMutation(input: { id: $pk, name: "Mia", age: 10 }) {
pet {
name
age
} }
} }
""" }
) """,
variable_values={"pk": pet.pk},
)
self.assertIs(result.errors, None) assert result.errors is None
self.assertEqual( assert result.data["petMutation"]["pet"] == {"name": "Mia", "age": 10}
result.data["myMutation"]["errors"],
[{"field": "text", "messages": ["Invalid input"]}],
)
def test_form_valid_input(self): assert Pet.objects.count() == 1
class MyMutation(DjangoFormMutation): pet.refresh_from_db()
class Meta: assert pet.name == "Mia"
form_class = MyForm
class Mutation(ObjectType):
my_mutation = MyMutation.Field()
schema = Schema(query=MockQuery, mutation=Mutation) def test_model_form_mutation_creates_new(pet_type):
class PetMutation(DjangoModelFormMutation):
pet = Field(pet_type)
result = schema.execute( class Meta:
""" mutation MyMutation { form_class = PetForm
myMutation(input: { text: "VALID_INPUT" }) {
errors { class Mutation(ObjectType):
field pet_mutation = PetMutation.Field()
messages
} schema = Schema(query=MockQuery, mutation=Mutation)
text
result = schema.execute(
""" mutation PetMutation {
petMutation(input: { name: "Mia", age: 10 }) {
pet {
name
age
}
errors {
field
messages
} }
} }
""" }
) """
)
assert result.errors is None
assert result.data["petMutation"]["pet"] == {"name": "Mia", "age": 10}
self.assertIs(result.errors, None) assert Pet.objects.count() == 1
self.assertEqual(result.data["myMutation"]["errors"], []) pet = Pet.objects.get()
self.assertEqual(result.data["myMutation"]["text"], "VALID_INPUT") assert pet.name == "Mia"
assert pet.age == 10
class ModelFormMutationTests(TestCase): def test_model_form_mutation_invalid_input(pet_type):
def test_default_meta_fields(self): class PetMutation(DjangoModelFormMutation):
class PetMutation(DjangoModelFormMutation): pet = Field(pet_type)
class Meta:
form_class = PetForm
self.assertEqual(PetMutation._meta.model, Pet) class Meta:
self.assertEqual(PetMutation._meta.return_field_name, "pet") form_class = PetForm
self.assertIn("pet", PetMutation._meta.fields)
def test_default_input_meta_fields(self): class Mutation(ObjectType):
class PetMutation(DjangoModelFormMutation): pet_mutation = PetMutation.Field()
class Meta:
form_class = PetForm
self.assertEqual(PetMutation._meta.model, Pet) schema = Schema(query=MockQuery, mutation=Mutation)
self.assertEqual(PetMutation._meta.return_field_name, "pet")
self.assertIn("name", PetMutation.Input._meta.fields)
self.assertIn("client_mutation_id", PetMutation.Input._meta.fields)
self.assertIn("id", PetMutation.Input._meta.fields)
def test_exclude_fields_input_meta_fields(self): result = schema.execute(
class PetMutation(DjangoModelFormMutation): """ mutation PetMutation {
class Meta: petMutation(input: { name: "Mia", age: 99 }) {
form_class = PetForm pet {
exclude_fields = ["id"] name
age
self.assertEqual(PetMutation._meta.model, Pet) }
self.assertEqual(PetMutation._meta.return_field_name, "pet") errors {
self.assertIn("name", PetMutation.Input._meta.fields) field
self.assertIn("age", PetMutation.Input._meta.fields) messages
self.assertIn("client_mutation_id", PetMutation.Input._meta.fields)
self.assertNotIn("id", PetMutation.Input._meta.fields)
def test_custom_return_field_name(self):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
model = Pet
return_field_name = "animal"
self.assertEqual(PetMutation._meta.model, Pet)
self.assertEqual(PetMutation._meta.return_field_name, "animal")
self.assertIn("animal", PetMutation._meta.fields)
def test_model_form_mutation_mutate_existing(self):
class PetMutation(DjangoModelFormMutation):
pet = Field(PetType)
class Meta:
form_class = PetForm
class Mutation(ObjectType):
pet_mutation = PetMutation.Field()
schema = Schema(query=MockQuery, mutation=Mutation)
pet = Pet.objects.create(name="Axel", age=10)
result = schema.execute(
""" mutation PetMutation($pk: ID!) {
petMutation(input: { id: $pk, name: "Mia", age: 10 }) {
pet {
name
age
}
} }
} }
""", }
variable_values={"pk": pet.pk}, """
) )
assert result.errors is None
assert result.data["petMutation"]["pet"] is None
assert result.data["petMutation"]["errors"] == [
{"field": "age", "messages": ["Too old"]}
]
self.assertIs(result.errors, None) assert Pet.objects.count() == 0
self.assertEqual(result.data["petMutation"]["pet"], {"name": "Mia", "age": 10})
self.assertEqual(Pet.objects.count(), 1)
pet.refresh_from_db()
self.assertEqual(pet.name, "Mia")
def test_model_form_mutation_creates_new(self): def test_model_form_mutation_mutate_invalid_form(pet_type):
class PetMutation(DjangoModelFormMutation): class PetMutation(DjangoModelFormMutation):
pet = Field(PetType) class Meta:
form_class = PetForm
class Meta: result = PetMutation.mutate_and_get_payload(None, None)
form_class = PetForm
class Mutation(ObjectType): # A pet was not created
pet_mutation = PetMutation.Field() Pet.objects.count() == 0
schema = Schema(query=MockQuery, mutation=Mutation) fields_w_error = [e.field for e in result.errors]
assert len(result.errors) == 2
result = schema.execute( assert result.errors[0].messages == ["This field is required."]
""" mutation PetMutation { assert result.errors[1].messages == ["This field is required."]
petMutation(input: { name: "Mia", age: 10 }) { assert "age" in fields_w_error
pet { assert "name" in fields_w_error
name
age
}
errors {
field
messages
}
}
}
"""
)
self.assertIs(result.errors, None)
self.assertEqual(result.data["petMutation"]["pet"], {"name": "Mia", "age": 10})
self.assertEqual(Pet.objects.count(), 1)
pet = Pet.objects.get()
self.assertEqual(pet.name, "Mia")
self.assertEqual(pet.age, 10)
def test_model_form_mutation_invalid_input(self):
class PetMutation(DjangoModelFormMutation):
pet = Field(PetType)
class Meta:
form_class = PetForm
class Mutation(ObjectType):
pet_mutation = PetMutation.Field()
schema = Schema(query=MockQuery, mutation=Mutation)
result = schema.execute(
""" mutation PetMutation {
petMutation(input: { name: "Mia", age: 99 }) {
pet {
name
age
}
errors {
field
messages
}
}
}
"""
)
self.assertIs(result.errors, None)
self.assertEqual(result.data["petMutation"]["pet"], None)
self.assertEqual(
result.data["petMutation"]["errors"],
[{"field": "age", "messages": ["Too old"],}],
)
self.assertEqual(Pet.objects.count(), 0)
def test_model_form_mutation_mutate_invalid_form(self):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
result = PetMutation.mutate_and_get_payload(None, None)
# A pet was not created
self.assertEqual(Pet.objects.count(), 0)
fields_w_error = [e.field for e in result.errors]
self.assertEqual(len(result.errors), 2)
self.assertIn("name", fields_w_error)
self.assertEqual(result.errors[0].messages, ["This field is required."])
self.assertIn("age", fields_w_error)
self.assertEqual(result.errors[1].messages, ["This field is required."])

View File

@ -48,6 +48,7 @@ class CommandArguments(BaseCommand):
class Command(CommandArguments): class Command(CommandArguments):
help = "Dump Graphene schema as a JSON or GraphQL file" help = "Dump Graphene schema as a JSON or GraphQL file"
can_import_settings = True can_import_settings = True
requires_system_checks = False
def save_json_file(self, out, schema_dict, indent): def save_json_file(self, out, schema_dict, indent):
with open(out, "w") as outfile: with open(out, "w") as outfile:
@ -55,7 +56,7 @@ class Command(CommandArguments):
def save_graphql_file(self, out, schema): def save_graphql_file(self, out, schema):
with open(out, "w") as outfile: with open(out, "w") as outfile:
outfile.write(print_schema(schema)) outfile.write(print_schema(schema.graphql_schema))
def get_schema(self, schema, out, indent): def get_schema(self, schema, out, indent):
schema_dict = {"data": schema.introspect()} schema_dict = {"data": schema.introspect()}

View File

@ -1,14 +1,11 @@
import graphene
import pytest
from django.db import models from django.db import models
from graphene import Schema
from rest_framework import serializers from rest_framework import serializers
import graphene
from graphene import Schema
from graphene_django import DjangoObjectType from graphene_django import DjangoObjectType
from graphene_django.rest_framework.mutation import SerializerMutation from graphene_django.rest_framework.mutation import SerializerMutation
pytestmark = pytest.mark.django_db
class MyFakeChildModel(models.Model): class MyFakeChildModel(models.Model):
name = models.CharField(max_length=50) name = models.CharField(max_length=50)

View File

@ -1,14 +1,13 @@
import datetime import datetime
from py.test import mark, raises from py.test import raises
from rest_framework import serializers from rest_framework import serializers
from graphene import Field, ResolveInfo from graphene import Field, ResolveInfo
from graphene.types.inputobjecttype import InputObjectType from graphene.types.inputobjecttype import InputObjectType
from ...settings import graphene_settings
from ...types import DjangoObjectType from ...types import DjangoObjectType
from ..models import MyFakeModel, MyFakeModelWithPassword, MyFakeModelWithDate from ..models import MyFakeModel, MyFakeModelWithDate, MyFakeModelWithPassword
from ..mutation import SerializerMutation from ..mutation import SerializerMutation
@ -18,12 +17,14 @@ def mock_info():
None, None,
None, None,
None, None,
path=None,
schema=None, schema=None,
fragments=None, fragments=None,
root_value=None, root_value=None,
operation=None, operation=None,
variable_values=None, variable_values=None,
context=None, context=None,
is_awaitable=None,
) )
@ -99,7 +100,6 @@ def test_exclude_fields():
assert "created" not in MyMutation.Input._meta.fields assert "created" not in MyMutation.Input._meta.fields
@mark.django_db
def test_write_only_field(): def test_write_only_field():
class WriteOnlyFieldModelSerializer(serializers.ModelSerializer): class WriteOnlyFieldModelSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True) password = serializers.CharField(write_only=True)
@ -122,7 +122,6 @@ def test_write_only_field():
), "'password' is write_only field and shouldn't be visible" ), "'password' is write_only field and shouldn't be visible"
@mark.django_db
def test_write_only_field_using_extra_kwargs(): def test_write_only_field_using_extra_kwargs():
class WriteOnlyFieldModelSerializer(serializers.ModelSerializer): class WriteOnlyFieldModelSerializer(serializers.ModelSerializer):
class Meta: class Meta:
@ -144,7 +143,6 @@ def test_write_only_field_using_extra_kwargs():
), "'password' is write_only field and shouldn't be visible" ), "'password' is write_only field and shouldn't be visible"
@mark.django_db
def test_read_only_fields(): def test_read_only_fields():
class ReadOnlyFieldModelSerializer(serializers.ModelSerializer): class ReadOnlyFieldModelSerializer(serializers.ModelSerializer):
cool_name = serializers.CharField(read_only=True) cool_name = serializers.CharField(read_only=True)
@ -194,7 +192,6 @@ def test_mutate_and_get_payload_success():
assert result.errors is None assert result.errors is None
@mark.django_db
def test_model_add_mutate_and_get_payload_success(): def test_model_add_mutate_and_get_payload_success():
result = MyModelMutation.mutate_and_get_payload( result = MyModelMutation.mutate_and_get_payload(
None, mock_info(), **{"cool_name": "Narf"} None, mock_info(), **{"cool_name": "Narf"}
@ -204,7 +201,6 @@ def test_model_add_mutate_and_get_payload_success():
assert isinstance(result.created, datetime.datetime) assert isinstance(result.created, datetime.datetime)
@mark.django_db
def test_model_update_mutate_and_get_payload_success(): def test_model_update_mutate_and_get_payload_success():
instance = MyFakeModel.objects.create(cool_name="Narf") instance = MyFakeModel.objects.create(cool_name="Narf")
result = MyModelMutation.mutate_and_get_payload( result = MyModelMutation.mutate_and_get_payload(
@ -214,7 +210,6 @@ def test_model_update_mutate_and_get_payload_success():
assert result.cool_name == "New Narf" assert result.cool_name == "New Narf"
@mark.django_db
def test_model_partial_update_mutate_and_get_payload_success(): def test_model_partial_update_mutate_and_get_payload_success():
instance = MyFakeModel.objects.create(cool_name="Narf") instance = MyFakeModel.objects.create(cool_name="Narf")
result = MyModelMutation.mutate_and_get_payload( result = MyModelMutation.mutate_and_get_payload(
@ -224,7 +219,6 @@ def test_model_partial_update_mutate_and_get_payload_success():
assert result.cool_name == "Narf" assert result.cool_name == "Narf"
@mark.django_db
def test_model_invalid_update_mutate_and_get_payload_success(): def test_model_invalid_update_mutate_and_get_payload_success():
class InvalidModelMutation(SerializerMutation): class InvalidModelMutation(SerializerMutation):
class Meta: class Meta:
@ -239,7 +233,6 @@ def test_model_invalid_update_mutate_and_get_payload_success():
assert '"id" required' in str(exc.value) assert '"id" required' in str(exc.value)
@mark.django_db
def test_perform_mutate_success(): def test_perform_mutate_success():
class MyMethodMutation(SerializerMutation): class MyMethodMutation(SerializerMutation):
class Meta: class Meta:
@ -272,11 +265,10 @@ def test_model_mutate_and_get_payload_error():
assert len(result.errors) > 0 assert len(result.errors) > 0
def test_mutation_error_camelcased(): def test_mutation_error_camelcased(graphene_settings):
graphene_settings.CAMELCASE_ERRORS = True graphene_settings.CAMELCASE_ERRORS = True
result = MyModelMutation.mutate_and_get_payload(None, mock_info(), **{}) result = MyModelMutation.mutate_and_get_payload(None, mock_info(), **{})
assert result.errors[0].field == "coolName" assert result.errors[0].field == "coolName"
graphene_settings.CAMELCASE_ERRORS = False
def test_invalid_serializer_operations(): def test_invalid_serializer_operations():

View File

@ -8,7 +8,7 @@ from graphene import ObjectType, Schema, String
@patch("graphene_django.management.commands.graphql_schema.Command.save_json_file") @patch("graphene_django.management.commands.graphql_schema.Command.save_json_file")
def test_generate_json_file_on_call_graphql_schema(savefile_mock, settings): def test_generate_json_file_on_call_graphql_schema(savefile_mock):
out = StringIO() out = StringIO()
management.call_command("graphql_schema", schema="", stdout=out) management.call_command("graphql_schema", schema="", stdout=out)
assert "Successfully dumped GraphQL schema to schema.json" in out.getvalue() assert "Successfully dumped GraphQL schema to schema.json" in out.getvalue()
@ -51,10 +51,6 @@ def test_generate_graphql_file_on_call_graphql_schema():
schema_output = handle.write.call_args[0][0] schema_output = handle.write.call_args[0][0]
assert schema_output == dedent( assert schema_output == dedent(
"""\ """\
schema {
query: Query
}
type Query { type Query {
hi: String hi: String
} }

View File

@ -1,16 +1,17 @@
import pytest
from collections import namedtuple from collections import namedtuple
import pytest
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from graphene import NonNull
from py.test import raises from py.test import raises
import graphene import graphene
from graphene import NonNull
from graphene.relay import ConnectionField, Node from graphene.relay import ConnectionField, Node
from graphene.types.datetime import DateTime, Date, Time from graphene.types.datetime import Date, DateTime, Time
from graphene.types.json import JSONString from graphene.types.json import JSONString
from ..compat import JSONField, ArrayField, HStoreField, RangeField, MissingType from ..compat import ArrayField, HStoreField, JSONField, MissingType, RangeField
from ..converter import ( from ..converter import (
convert_django_field, convert_django_field,
convert_django_field_with_choices, convert_django_field_with_choices,
@ -18,10 +19,8 @@ from ..converter import (
) )
from ..registry import Registry from ..registry import Registry
from ..types import DjangoObjectType from ..types import DjangoObjectType
from ..settings import graphene_settings
from .models import Article, Film, FilmDetails, Reporter from .models import Article, Film, FilmDetails, Reporter
# from graphene.core.types.custom_scalars import DateTime, Time, JSONString # from graphene.core.types.custom_scalars import DateTime, Time, JSONString
@ -333,7 +332,7 @@ def test_should_postgres_range_convert_list():
assert field.type.of_type.of_type == graphene.Int assert field.type.of_type.of_type == graphene.Int
def test_generate_enum_name(): def test_generate_enum_name(graphene_settings):
MockDjangoModelMeta = namedtuple("DjangoMeta", ["app_label", "object_name"]) MockDjangoModelMeta = namedtuple("DjangoMeta", ["app_label", "object_name"])
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = True graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = True
@ -351,5 +350,3 @@ def test_generate_enum_name():
generate_enum_name(model_meta, field) generate_enum_name(model_meta, field)
== "SomeLongAppNameSomeObjectFizzBuzzChoices" == "SomeLongAppNameSomeObjectFizzBuzzChoices"
) )
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = False

View File

@ -10,7 +10,6 @@ from .models import Article as ArticleModel
from .models import Reporter as ReporterModel from .models import Reporter as ReporterModel
@pytest.mark.django_db
class TestDjangoListField: class TestDjangoListField:
def test_only_django_object_types(self): def test_only_django_object_types(self):
class TestType(ObjectType): class TestType(ObjectType):

View File

@ -1,25 +1,20 @@
import base64
import datetime import datetime
import pytest import pytest
from django.db import models from django.db import models
from django.db.models import Q
from django.utils.functional import SimpleLazyObject from django.utils.functional import SimpleLazyObject
from graphql_relay import to_global_id
from py.test import raises from py.test import raises
from django.db.models import Q
from graphql_relay import to_global_id
import graphene import graphene
from graphene.relay import Node from graphene.relay import Node
from ..utils import DJANGO_FILTER_INSTALLED from ..compat import JSONField, MissingType
from ..compat import MissingType, JSONField
from ..fields import DjangoConnectionField from ..fields import DjangoConnectionField
from ..types import DjangoObjectType from ..types import DjangoObjectType
from ..settings import graphene_settings from ..utils import DJANGO_FILTER_INSTALLED
from .models import Article, CNNReporter, Reporter, Film, FilmDetails from .models import Article, CNNReporter, Film, FilmDetails, Reporter
pytestmark = pytest.mark.django_db
def test_should_query_only_fields(): def test_should_query_only_fields():
@ -147,9 +142,6 @@ def test_should_query_postgres_fields():
def test_should_node(): def test_should_node():
# reset_global_registry()
# Node._meta.registry = get_global_registry()
class ReporterNode(DjangoObjectType): class ReporterNode(DjangoObjectType):
class Meta: class Meta:
model = Reporter model = Reporter
@ -588,7 +580,7 @@ def test_should_query_node_multiple_filtering():
assert result.data == expected assert result.data == expected
def test_should_enforce_first_or_last(): def test_should_enforce_first_or_last(graphene_settings):
graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST = True graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST = True
class ReporterType(DjangoObjectType): class ReporterType(DjangoObjectType):
@ -620,14 +612,14 @@ def test_should_enforce_first_or_last():
result = schema.execute(query) result = schema.execute(query)
assert len(result.errors) == 1 assert len(result.errors) == 1
assert str(result.errors[0]) == ( assert str(result.errors[0]).startswith(
"You must provide a `first` or `last` value to properly " "You must provide a `first` or `last` value to properly "
"paginate the `allReporters` connection." "paginate the `allReporters` connection.\n"
) )
assert result.data == expected assert result.data == expected
def test_should_error_if_first_is_greater_than_max(): def test_should_error_if_first_is_greater_than_max(graphene_settings):
graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 100 graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 100
class ReporterType(DjangoObjectType): class ReporterType(DjangoObjectType):
@ -661,16 +653,14 @@ def test_should_error_if_first_is_greater_than_max():
result = schema.execute(query) result = schema.execute(query)
assert len(result.errors) == 1 assert len(result.errors) == 1
assert str(result.errors[0]) == ( assert str(result.errors[0]).startswith(
"Requesting 101 records on the `allReporters` connection " "Requesting 101 records on the `allReporters` connection "
"exceeds the `first` limit of 100 records." "exceeds the `first` limit of 100 records.\n"
) )
assert result.data == expected assert result.data == expected
graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST = False
def test_should_error_if_last_is_greater_than_max(graphene_settings):
def test_should_error_if_last_is_greater_than_max():
graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 100 graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 100
class ReporterType(DjangoObjectType): class ReporterType(DjangoObjectType):
@ -704,14 +694,12 @@ def test_should_error_if_last_is_greater_than_max():
result = schema.execute(query) result = schema.execute(query)
assert len(result.errors) == 1 assert len(result.errors) == 1
assert str(result.errors[0]) == ( assert str(result.errors[0]).startswith(
"Requesting 101 records on the `allReporters` connection " "Requesting 101 records on the `allReporters` connection "
"exceeds the `last` limit of 100 records." "exceeds the `last` limit of 100 records.\n"
) )
assert result.data == expected assert result.data == expected
graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST = False
def test_should_query_promise_connectionfields(): def test_should_query_promise_connectionfields():
from promise import Promise from promise import Promise
@ -725,7 +713,7 @@ def test_should_query_promise_connectionfields():
all_reporters = DjangoConnectionField(ReporterType) all_reporters = DjangoConnectionField(ReporterType)
def resolve_all_reporters(self, info, **args): def resolve_all_reporters(self, info, **args):
return Promise.resolve([Reporter(id=1)]) return Promise.resolve([Reporter(id=1)]).get()
schema = graphene.Schema(query=Query) schema = graphene.Schema(query=Query)
query = """ query = """
@ -854,7 +842,7 @@ def test_should_query_dataloader_fields():
articles = DjangoConnectionField(ArticleType) articles = DjangoConnectionField(ArticleType)
def resolve_articles(self, info, **args): def resolve_articles(self, info, **args):
return article_loader.load(self.id) return article_loader.load(self.id).get()
class Query(graphene.ObjectType): class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType) all_reporters = DjangoConnectionField(ReporterType)
@ -1087,7 +1075,7 @@ def test_should_preserve_prefetch_related(django_assert_num_queries):
class Query(graphene.ObjectType): class Query(graphene.ObjectType):
films = DjangoConnectionField(FilmType) films = DjangoConnectionField(FilmType)
def resolve_films(root, info): def resolve_films(root, info, **kwargs):
qs = Film.objects.prefetch_related("reporters") qs = Film.objects.prefetch_related("reporters")
return qs return qs
@ -1117,9 +1105,10 @@ def test_should_preserve_prefetch_related(django_assert_num_queries):
} }
""" """
schema = graphene.Schema(query=Query) schema = graphene.Schema(query=Query)
with django_assert_num_queries(3) as captured: with django_assert_num_queries(3) as captured:
result = schema.execute(query) result = schema.execute(query)
assert not result.errors assert not result.errors
def test_should_preserve_annotations(): def test_should_preserve_annotations():
@ -1139,7 +1128,7 @@ def test_should_preserve_annotations():
class Query(graphene.ObjectType): class Query(graphene.ObjectType):
films = DjangoConnectionField(FilmType) films = DjangoConnectionField(FilmType)
def resolve_films(root, info): def resolve_films(root, info, **kwargs):
qs = Film.objects.prefetch_related("reporters") qs = Film.objects.prefetch_related("reporters")
return qs.annotate(reporters_count=models.Count("reporters")) return qs.annotate(reporters_count=models.Count("reporters"))
@ -1172,3 +1161,4 @@ def test_should_preserve_annotations():
} }
} }
assert result.data == expected, str(result.data) assert result.data == expected, str(result.data)
assert not result.errors

View File

@ -9,14 +9,10 @@ from graphene import Connection, Field, Interface, ObjectType, Schema, String
from graphene.relay import Node from graphene.relay import Node
from .. import registry from .. import registry
from ..settings import graphene_settings
from ..types import DjangoObjectType, DjangoObjectTypeOptions from ..types import DjangoObjectType, DjangoObjectTypeOptions
from ..converter import convert_choice_field_to_enum
from .models import Article as ArticleModel from .models import Article as ArticleModel
from .models import Reporter as ReporterModel from .models import Reporter as ReporterModel
registry.reset_global_registry()
class Reporter(DjangoObjectType): class Reporter(DjangoObjectType):
"""Reporter description""" """Reporter description"""
@ -115,90 +111,171 @@ def test_django_objecttype_with_custom_meta():
def test_schema_representation(): def test_schema_representation():
expected = """ expected = dedent(
schema { """\
query: RootQuery schema {
} query: RootQuery
}
type Article implements Node { \"""Article description\"""
id: ID! type Article implements Node {
headline: String! \"""The ID of the object\"""
pubDate: Date! id: ID!
pubDateTime: DateTime!
reporter: Reporter!
editor: Reporter!
lang: ArticleLang!
importance: ArticleImportance
}
type ArticleConnection { \"""\"""
pageInfo: PageInfo! headline: String!
edges: [ArticleEdge]!
test: String
}
type ArticleEdge { \"""\"""
node: Article pubDate: Date!
cursor: String!
}
enum ArticleImportance { \"""\"""
A_1 pubDateTime: DateTime!
A_2
}
enum ArticleLang { \"""\"""
ES reporter: Reporter!
EN
}
scalar Date \"""\"""
editor: Reporter!
scalar DateTime \"""Language\"""
lang: ArticleLang!
interface Node { \"""\"""
id: ID! importance: ArticleImportance
} }
type PageInfo { \"""An object with an ID\"""
hasNextPage: Boolean! interface Node {
hasPreviousPage: Boolean! \"""The ID of the object\"""
startCursor: String id: ID!
endCursor: String }
}
type Reporter { \"""
id: ID! The `Date` scalar type represents a Date
firstName: String! value as specified by
lastName: String! [iso8601](https://en.wikipedia.org/wiki/ISO_8601).
email: String! \"""
pets: [Reporter!]! scalar Date
aChoice: ReporterAChoice
reporterType: ReporterReporterType
articles(before: String, after: String, first: Int, last: Int): ArticleConnection!
}
enum ReporterAChoice { \"""
A_1 The `DateTime` scalar type represents a DateTime
A_2 value as specified by
} [iso8601](https://en.wikipedia.org/wiki/ISO_8601).
\"""
scalar DateTime
enum ReporterReporterType { \"""An enumeration.\"""
A_1 enum ArticleLang {
A_2 \"""Spanish\"""
} ES
type RootQuery { \"""English\"""
node(id: ID!): Node EN
} }
""".lstrip()
\"""An enumeration.\"""
enum ArticleImportance {
\"""Very important\"""
A_1
\"""Not as important\"""
A_2
}
\"""Reporter description\"""
type Reporter {
\"""\"""
id: ID!
\"""\"""
firstName: String!
\"""\"""
lastName: String!
\"""\"""
email: String!
\"""\"""
pets: [Reporter!]!
\"""\"""
aChoice: ReporterAChoice
\"""\"""
reporterType: ReporterReporterType
\"""\"""
articles(before: String = null, after: String = null, first: Int = null, last: Int = null): ArticleConnection!
}
\"""An enumeration.\"""
enum ReporterAChoice {
\"""this\"""
A_1
\"""that\"""
A_2
}
\"""An enumeration.\"""
enum ReporterReporterType {
\"""Regular\"""
A_1
\"""CNN Reporter\"""
A_2
}
type ArticleConnection {
\"""Pagination data for this connection.\"""
pageInfo: PageInfo!
\"""Contains the nodes in this connection.\"""
edges: [ArticleEdge]!
test: String
}
\"""
The Relay compliant `PageInfo` type, containing data necessary to paginate this connection.
\"""
type PageInfo {
\"""When paginating forwards, are there more items?\"""
hasNextPage: Boolean!
\"""When paginating backwards, are there more items?\"""
hasPreviousPage: Boolean!
\"""When paginating backwards, the cursor to continue.\"""
startCursor: String
\"""When paginating forwards, the cursor to continue.\"""
endCursor: String
}
\"""A Relay edge containing a `Article` and its cursor.\"""
type ArticleEdge {
\"""The item at the end of the edge\"""
node: Article
\"""A cursor for use in pagination\"""
cursor: String!
}
type RootQuery {
node(
\"""The ID of the object\"""
id: ID!
): Node
}
"""
)
assert str(schema) == expected assert str(schema) == expected
def with_local_registry(func): def with_local_registry(func):
def inner(*args, **kwargs): def inner(*args, **kwargs):
old = registry.get_global_registry() old = registry.get_global_registry()
registry.reset_global_registry()
try: try:
retval = func(*args, **kwargs) retval = func(*args, **kwargs)
except Exception as e: except Exception as e:
@ -420,20 +497,21 @@ class TestDjangoObjectType:
assert str(schema) == dedent( assert str(schema) == dedent(
"""\ """\
schema { type Query {
query: Query pet: Pet
} }
type Pet { type Pet {
id: ID! \"""\"""
kind: String! id: ID!
cuteness: Int!
}
type Query { \"""\"""
pet: Pet kind: String!
}
""" \"""\"""
cuteness: Int!
}
"""
) )
def test_django_objecttype_convert_choices_enum_list(self, PetModel): def test_django_objecttype_convert_choices_enum_list(self, PetModel):
@ -449,25 +527,30 @@ class TestDjangoObjectType:
assert str(schema) == dedent( assert str(schema) == dedent(
"""\ """\
schema { type Query {
query: Query pet: Pet
} }
type Pet { type Pet {
id: ID! \"""\"""
kind: PetModelKind! id: ID!
cuteness: Int!
}
enum PetModelKind { \"""\"""
CAT kind: PetModelKind!
DOG
}
type Query { \"""\"""
pet: Pet cuteness: Int!
} }
"""
\"""An enumeration.\"""
enum PetModelKind {
\"""Cat\"""
CAT
\"""Dog\"""
DOG
}
"""
) )
def test_django_objecttype_convert_choices_enum_empty_list(self, PetModel): def test_django_objecttype_convert_choices_enum_empty_list(self, PetModel):
@ -483,23 +566,26 @@ class TestDjangoObjectType:
assert str(schema) == dedent( assert str(schema) == dedent(
"""\ """\
schema { type Query {
query: Query pet: Pet
} }
type Pet { type Pet {
id: ID! \"""\"""
kind: String! id: ID!
cuteness: Int!
}
type Query { \"""\"""
pet: Pet kind: String!
}
""" \"""\"""
cuteness: Int!
}
"""
) )
def test_django_objecttype_convert_choices_enum_naming_collisions(self, PetModel): def test_django_objecttype_convert_choices_enum_naming_collisions(
self, PetModel, graphene_settings
):
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = True graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = True
class PetModelKind(DjangoObjectType): class PetModelKind(DjangoObjectType):
@ -514,28 +600,32 @@ class TestDjangoObjectType:
assert str(schema) == dedent( assert str(schema) == dedent(
"""\ """\
schema { type Query {
query: Query pet: PetModelKind
} }
type PetModelKind { type PetModelKind {
id: ID! \"""\"""
kind: TestsPetModelKindChoices! id: ID!
}
type Query { \"""\"""
pet: PetModelKind kind: TestsPetModelKindChoices!
} }
enum TestsPetModelKindChoices { \"""An enumeration.\"""
CAT enum TestsPetModelKindChoices {
DOG \"""Cat\"""
} CAT
"""
\"""Dog\"""
DOG
}
"""
) )
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = False
def test_django_objecttype_choices_custom_enum_name(self, PetModel): def test_django_objecttype_choices_custom_enum_name(
self, PetModel, graphene_settings
):
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME = ( graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME = (
"graphene_django.tests.test_types.custom_enum_name" "graphene_django.tests.test_types.custom_enum_name"
) )
@ -552,24 +642,25 @@ class TestDjangoObjectType:
assert str(schema) == dedent( assert str(schema) == dedent(
"""\ """\
schema { type Query {
query: Query pet: PetModelKind
} }
enum CustomEnumKind { type PetModelKind {
CAT \"""\"""
DOG id: ID!
}
type PetModelKind { \"""\"""
id: ID! kind: CustomEnumKind!
kind: CustomEnumKind! }
}
type Query { \"""An enumeration.\"""
pet: PetModelKind enum CustomEnumKind {
} \"""Cat\"""
""" CAT
\"""Dog\"""
DOG
}
"""
) )
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME = None

View File

@ -1,6 +1,10 @@
from django.utils.translation import gettext_lazy import json
from ..utils import camelize, get_model_fields import pytest
from django.utils.translation import gettext_lazy
from mock import patch
from ..utils import camelize, get_model_fields, GraphQLTestCase
from .models import Film, Reporter from .models import Film, Reporter
@ -30,3 +34,27 @@ def test_camelize():
"valueA": "value_b" "valueA": "value_b"
} }
assert camelize({0: {"field_a": ["errors"]}}) == {0: {"fieldA": ["errors"]}} assert camelize({0: {"field_a": ["errors"]}}) == {0: {"fieldA": ["errors"]}}
@pytest.mark.django_db
@patch("graphene_django.utils.testing.Client.post")
def test_graphql_test_case_op_name(post_mock):
"""
Test that `GraphQLTestCase.query()`'s `op_name` argument produces an `operationName` field.
"""
class TestClass(GraphQLTestCase):
GRAPHQL_SCHEMA = True
def runTest(self):
pass
tc = TestClass()
tc.setUpClass()
tc.query("query { }", op_name="QueryName")
body = json.loads(post_mock.call_args.args[1])
# `operationName` field from https://graphql.org/learn/serving-over-http/#post-request
assert (
"operationName",
"QueryName",
) in body.items(), "Field 'operationName' is not present in the final request."

View File

@ -99,12 +99,14 @@ def test_reports_validation_errors(client):
assert response_json(response) == { assert response_json(response) == {
"errors": [ "errors": [
{ {
"message": 'Cannot query field "unknownOne" on type "QueryRoot".', "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.",
"locations": [{"line": 1, "column": 9}], "locations": [{"line": 1, "column": 9}],
"path": None,
}, },
{ {
"message": 'Cannot query field "unknownTwo" on type "QueryRoot".', "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.",
"locations": [{"line": 1, "column": 21}], "locations": [{"line": 1, "column": 21}],
"path": None,
}, },
] ]
} }
@ -124,7 +126,9 @@ def test_errors_when_missing_operation_name(client):
assert response_json(response) == { assert response_json(response) == {
"errors": [ "errors": [
{ {
"message": "Must provide operation name if query contains multiple operations." "message": "Must provide operation name if query contains multiple operations.",
"locations": None,
"path": None,
} }
] ]
} }
@ -464,8 +468,8 @@ def test_handles_syntax_errors_caught_by_graphql(client):
"errors": [ "errors": [
{ {
"locations": [{"column": 1, "line": 1}], "locations": [{"column": 1, "line": 1}],
"message": "Syntax Error GraphQL (1:1) " "message": "Syntax Error: Unexpected Name 'syntaxerror'.",
'Unexpected Name "syntaxerror"\n\n1: syntaxerror\n ^\n', "path": None,
} }
] ]
} }

View File

@ -34,20 +34,20 @@ class GraphQLTestCase(TestCase):
supply the op_name. For annon queries ("{ ... }"), supply the op_name. For annon queries ("{ ... }"),
should be None (default). should be None (default).
input_data (dict) - If provided, the $input variable in GraphQL will be set input_data (dict) - If provided, the $input variable in GraphQL will be set
to this value. If both ``input_data`` and ``variables``, to this value. If both ``input_data`` and ``variables``,
are provided, the ``input`` field in the ``variables`` are provided, the ``input`` field in the ``variables``
dict will be overwritten with this value. dict will be overwritten with this value.
variables (dict) - If provided, the "variables" field in GraphQL will be variables (dict) - If provided, the "variables" field in GraphQL will be
set to this value. set to this value.
headers (dict) - If provided, the headers in POST request to GRAPHQL_URL headers (dict) - If provided, the headers in POST request to GRAPHQL_URL
will be set to this value. will be set to this value.
Returns: Returns:
Response object from client Response object from client
""" """
body = {"query": query} body = {"query": query}
if operation_name: if op_name:
body["operationName"] = operation_name body["operationName"] = op_name
if variables: if variables:
body["variables"] = variables body["variables"] = variables
if input_data: if input_data:

View File

@ -6,14 +6,14 @@ from django.http import HttpResponse, HttpResponseNotAllowed
from django.http.response import HttpResponseBadRequest from django.http.response import HttpResponseBadRequest
from django.shortcuts import render from django.shortcuts import render
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.generic import View
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.generic import View
from graphql import get_default_backend from graphql import OperationType, get_operation_ast, parse, validate
from graphql.error import format_error as format_graphql_error
from graphql.error import GraphQLError from graphql.error import GraphQLError
from graphql.error import format_error as format_graphql_error
from graphql.execution import ExecutionResult from graphql.execution import ExecutionResult
from graphql.type.schema import GraphQLSchema
from graphene import Schema
from .settings import graphene_settings from .settings import graphene_settings
@ -56,8 +56,6 @@ class GraphQLView(View):
schema = None schema = None
graphiql = False graphiql = False
executor = None
backend = None
middleware = None middleware = None
root_value = None root_value = None
pretty = False pretty = False
@ -66,35 +64,28 @@ class GraphQLView(View):
def __init__( def __init__(
self, self,
schema=None, schema=None,
executor=None,
middleware=None, middleware=None,
root_value=None, root_value=None,
graphiql=False, graphiql=False,
pretty=False, pretty=False,
batch=False, batch=False,
backend=None,
): ):
if not schema: if not schema:
schema = graphene_settings.SCHEMA schema = graphene_settings.SCHEMA
if backend is None:
backend = get_default_backend()
if middleware is None: if middleware is None:
middleware = graphene_settings.MIDDLEWARE middleware = graphene_settings.MIDDLEWARE
self.schema = self.schema or schema self.schema = self.schema or schema
if middleware is not None: if middleware is not None:
self.middleware = list(instantiate_middleware(middleware)) self.middleware = list(instantiate_middleware(middleware))
self.executor = executor
self.root_value = root_value self.root_value = root_value
self.pretty = self.pretty or pretty self.pretty = self.pretty or pretty
self.graphiql = self.graphiql or graphiql self.graphiql = self.graphiql or graphiql
self.batch = self.batch or batch self.batch = self.batch or batch
self.backend = backend
assert isinstance( assert isinstance(
self.schema, GraphQLSchema self.schema, Schema
), "A Schema is required to be provided to GraphQLView." ), "A Schema is required to be provided to GraphQLView."
assert not all((graphiql, batch)), "Use either graphiql or batch processing" assert not all((graphiql, batch)), "Use either graphiql or batch processing"
@ -108,9 +99,6 @@ class GraphQLView(View):
def get_context(self, request): def get_context(self, request):
return request return request
def get_backend(self, request):
return self.backend
@method_decorator(ensure_csrf_cookie) @method_decorator(ensure_csrf_cookie)
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
try: try:
@ -172,7 +160,9 @@ class GraphQLView(View):
self.format_error(e) for e in execution_result.errors self.format_error(e) for e in execution_result.errors
] ]
if execution_result.invalid: if execution_result.errors and any(
not e.path for e in execution_result.errors
):
status_code = 400 status_code = 400
else: else:
response["data"] = execution_result.data response["data"] = execution_result.data
@ -245,14 +235,13 @@ class GraphQLView(View):
raise HttpError(HttpResponseBadRequest("Must provide query string.")) raise HttpError(HttpResponseBadRequest("Must provide query string."))
try: try:
backend = self.get_backend(request) document = parse(query)
document = backend.document_from_string(self.schema, query)
except Exception as e: except Exception as e:
return ExecutionResult(errors=[e], invalid=True) return ExecutionResult(errors=[e])
if request.method.lower() == "get": if request.method.lower() == "get":
operation_type = document.get_operation_type(operation_name) operation_ast = get_operation_ast(document, operation_name)
if operation_type and operation_type != "query": if operation_ast and operation_ast.operation != OperationType.QUERY:
if show_graphiql: if show_graphiql:
return None return None
@ -260,28 +249,23 @@ class GraphQLView(View):
HttpResponseNotAllowed( HttpResponseNotAllowed(
["POST"], ["POST"],
"Can only perform a {} operation from a POST request.".format( "Can only perform a {} operation from a POST request.".format(
operation_type operation_ast.operation.value
), ),
) )
) )
try: validation_errors = validate(self.schema.graphql_schema, document)
extra_options = {} if validation_errors:
if self.executor: return ExecutionResult(data=None, errors=validation_errors)
# We only include it optionally since
# executor is not a valid argument in all backends
extra_options["executor"] = self.executor
return document.execute( return self.schema.execute(
root_value=self.get_root_value(request), source=query,
variable_values=variables, root_value=self.get_root_value(request),
operation_name=operation_name, variable_values=variables,
context_value=self.get_context(request), operation_name=operation_name,
middleware=self.get_middleware(request), context_value=self.get_context(request),
**extra_options middleware=self.get_middleware(request),
) )
except Exception as e:
return ExecutionResult(errors=[e], invalid=True)
@classmethod @classmethod
def can_display_graphiql(cls, request, data): def can_display_graphiql(cls, request, data):

View File

@ -1,2 +0,0 @@
[pytest]
DJANGO_SETTINGS_MODULE = django_test_settings

View File

@ -43,3 +43,7 @@ include_trailing_comma=True
force_grid_wrap=0 force_grid_wrap=0
use_parentheses=True use_parentheses=True
line_length=88 line_length=88
[tool:pytest]
DJANGO_SETTINGS_MODULE = django_test_settings
addopts = --random-order

View File

@ -1,7 +1,8 @@
from setuptools import find_packages, setup
import ast import ast
import re import re
from setuptools import find_packages, setup
_version_re = re.compile(r"__version__\s+=\s+(.*)") _version_re = re.compile(r"__version__\s+=\s+(.*)")
with open("graphene_django/__init__.py", "rb") as f: with open("graphene_django/__init__.py", "rb") as f:
@ -15,6 +16,7 @@ rest_framework_require = ["djangorestframework>=3.6.3"]
tests_require = [ tests_require = [
"pytest>=3.6.3", "pytest>=3.6.3",
"pytest-cov", "pytest-cov",
"pytest-random-order",
"coveralls", "coveralls",
"mock", "mock",
"pytz", "pytz",
@ -52,9 +54,9 @@ setup(
keywords="api graphql protocol rest relay graphene", keywords="api graphql protocol rest relay graphene",
packages=find_packages(exclude=["tests"]), packages=find_packages(exclude=["tests"]),
install_requires=[ install_requires=[
"graphene>=2.1.7,<3", "graphene>=3.0.0b1,<4",
"graphql-core>=2.1.0,<3", "graphql-core>=3.1.0,<4",
"Django>=1.11,!=2.0.*,!=2.1.*", "Django>=2.2",
"promise>=2.1", "promise>=2.1",
], ],
setup_requires=["pytest-runner"], setup_requires=["pytest-runner"],

14
tox.ini
View File

@ -1,11 +1,16 @@
[tox] [tox]
envlist = envlist =
py{36,37,38}-django{111,22,30,master}, py{36,37,38}-django{22,30,master},
black,flake8 black,flake8
[travis:env] [gh-actions]
python =
3.6: py36
3.7: py37
3.8: py38
[gh-actions:env]
DJANGO = DJANGO =
1.11: django111
2.2: django22 2.2: django22
3.0: django30 3.0: django30
master: djangomaster master: djangomaster
@ -18,7 +23,6 @@ setenv =
deps = deps =
-e.[test] -e.[test]
psycopg2-binary psycopg2-binary
django111: Django>=1.11,<2.0
django22: Django>=2.2,<3.0 django22: Django>=2.2,<3.0
django30: Django>=3.0a1,<3.1 django30: Django>=3.0a1,<3.1
djangomaster: https://github.com/django/django/archive/master.zip djangomaster: https://github.com/django/django/archive/master.zip
@ -34,4 +38,4 @@ commands =
basepython = python3.8 basepython = python3.8
deps = -e.[dev] deps = -e.[dev]
commands = commands =
flake8 graphene_django examples flake8 graphene_django examples setup.py