From 388ca41d6423559cbdb2296736cb1a50a6e1d019 Mon Sep 17 00:00:00 2001 From: Mykhailo Havelia Date: Wed, 24 May 2023 15:54:44 +0300 Subject: [PATCH 01/31] fix: use execution_context_class attribute for GraphQLView (#1398) * fix: use execution_context_class attribute for GraphQLView --- graphene_django/views.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/graphene_django/views.py b/graphene_django/views.py index bdc0fdb..377b75d 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -105,17 +105,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 From a6596273cf98b50616d8e67cd1248ae972267b94 Mon Sep 17 00:00:00 2001 From: ndpu Date: Wed, 24 May 2023 15:55:20 +0300 Subject: [PATCH 02/31] Update docs/requirements.txt (#1410) * change Sphinx version from 1.5.3 to 7.0.0 * change sphinx-autobuild version from 0.7.1 to 2021.3.14 * add pygments-graphql-lexer to docs/requirements.txt --- docs/conf.py | 7 +++++-- docs/requirements.txt | 5 +++-- docs/settings.rst | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index b83e0f0..1be08b1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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), +} diff --git a/docs/requirements.txt b/docs/requirements.txt index 7c89926..276ae37 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -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 diff --git a/docs/settings.rst b/docs/settings.rst index 5bffd08..d38d0c9 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -224,7 +224,7 @@ Default: ``/graphql`` ``GRAPHIQL_SHOULD_PERSIST_HEADERS`` ---------------------- +----------------------------------- Set to ``True`` if you want to persist GraphiQL headers after refreshing the page. From be17278b4989dd5207513c61d6aa34f8815a5a24 Mon Sep 17 00:00:00 2001 From: ndpu Date: Wed, 24 May 2023 15:58:50 +0300 Subject: [PATCH 03/31] Add DjangoFormInputObjectType to forms/types (#1325) * Add DjangoFormInputObjectType to forms/types InputObjectType derived class which gets fields from django form. Type of fields with choices (converted to enum) is set to custom scalar type (using Meta.object_type) to dynamically convert enum values back. * Correct Reporter model a_choice field type according to CHOICES tuple * Add tests for DjangoFormInputObjectType * Add pyenv files to .gitignore * Fix pyupgrade * Fix tests * Add docs * Fix docs example --------- Co-authored-by: Firas Kafri <3097061+firaskafri@users.noreply.github.com> --- .gitignore | 5 + docs/mutations.rst | 49 +++ .../forms/tests/test_djangoinputobject.py | 333 ++++++++++++++++++ graphene_django/forms/types.py | 117 ++++++ graphene_django/tests/models.py | 2 +- graphene_django/types.py | 1 + 6 files changed, 506 insertions(+), 1 deletion(-) create mode 100644 graphene_django/forms/tests/test_djangoinputobject.py diff --git a/.gitignore b/.gitignore index 150025a..ff6bd96 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,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 diff --git a/docs/mutations.rst b/docs/mutations.rst index 2ce8f16..f43063f 100644 --- a/docs/mutations.rst +++ b/docs/mutations.rst @@ -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 --------------------- diff --git a/graphene_django/forms/tests/test_djangoinputobject.py b/graphene_django/forms/tests/test_djangoinputobject.py new file mode 100644 index 0000000..2809d2f --- /dev/null +++ b/graphene_django/forms/tests/test_djangoinputobject.py @@ -0,0 +1,333 @@ +import graphene + +from django import forms +from pytest import raises + +from graphene_django import DjangoObjectType +from ..types import DjangoFormInputObjectType +from ...tests.models import Reporter, Film, CHOICES + +# 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") + + +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 diff --git a/graphene_django/forms/types.py b/graphene_django/forms/types.py index 74e275e..132095b 100644 --- a/graphene_django/forms/types.py +++ b/graphene_django/forms/types.py @@ -1 +1,118 @@ +import graphene + +from graphene import ID +from graphene.types.inputobjecttype import InputObjectType +from graphene.utils.str_converters import to_camel_case + +from .mutation import fields_for_form from ..types import ErrorType # noqa Import ErrorType for backwards compatability +from ..converter import BlankValueField + + +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() diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py index 636f74c..735f236 100644 --- a/graphene_django/tests/models.py +++ b/graphene_django/tests/models.py @@ -43,7 +43,7 @@ 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() diff --git a/graphene_django/types.py b/graphene_django/types.py index a6e54af..dec8723 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -252,6 +252,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 From 7fe661d423a78569636691c4ec73af2a9ba1e9bc Mon Sep 17 00:00:00 2001 From: Firas Kafri <3097061+firaskafri@users.noreply.github.com> Date: Wed, 24 May 2023 16:03:14 +0300 Subject: [PATCH 04/31] Bump version --- graphene_django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 12408a4..755ed87 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,7 +1,7 @@ from .fields import DjangoConnectionField, DjangoListField from .types import DjangoObjectType -__version__ = "3.0.2" +__version__ = "3.1.0" __all__ = [ "__version__", From b75904d4c853c161459cf3ab75cd6d89bf349c97 Mon Sep 17 00:00:00 2001 From: Firas Kafri <3097061+firaskafri@users.noreply.github.com> Date: Wed, 24 May 2023 16:07:45 +0300 Subject: [PATCH 05/31] long_description_content_type='text/markdown' --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 7407b62..abf6059 100644 --- a/setup.py +++ b/setup.py @@ -38,6 +38,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", From ebf49431e9b9211467ff159cc31b2d90e6c32325 Mon Sep 17 00:00:00 2001 From: Firas Kafri <3097061+firaskafri@users.noreply.github.com> Date: Wed, 24 May 2023 16:10:22 +0300 Subject: [PATCH 06/31] Bump version --- graphene_django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 755ed87..d4ef76d 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,7 +1,7 @@ from .fields import DjangoConnectionField, DjangoListField from .types import DjangoObjectType -__version__ = "3.1.0" +__version__ = "3.1.1" __all__ = [ "__version__", From 4e5acd47025043764ad113cc4613db3f80c050bd Mon Sep 17 00:00:00 2001 From: Firas Kafri <3097061+firaskafri@users.noreply.github.com> Date: Wed, 24 May 2023 16:13:23 +0300 Subject: [PATCH 07/31] Fix linting issues (#1412) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index abf6059..542630e 100644 --- a/setup.py +++ b/setup.py @@ -38,7 +38,7 @@ setup( version=version, description="Graphene Django integration", long_description=open("README.md").read(), - long_description_content_type='text/markdown', + long_description_content_type="text/markdown", url="https://github.com/graphql-python/graphene-django", author="Syrus Akbary", author_email="me@syrusakbary.com", From 63fd98393f5ec725fde28d5de470245910ba0be7 Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Sat, 27 May 2023 21:26:52 +0800 Subject: [PATCH 08/31] Set pypi GH action to latest v1 (#1415) --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 139c6f6..5d5ae27 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 }} From 38709d83967bba6f19d6f0d6888b8b4f1cf2f720 Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Sat, 27 May 2023 21:53:22 +0800 Subject: [PATCH 09/31] Correct schema write test (#1416) .called_once() just returns a Mock, so assert .called_once() always passes. We want .assert_called_once(). --- graphene_django/tests/test_command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/tests/test_command.py b/graphene_django/tests/test_command.py index a281abb..f7325d5 100644 --- a/graphene_django/tests/test_command.py +++ b/graphene_django/tests/test_command.py @@ -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( From 520ddeabf69d40378992b2f90cf9b824730025fb Mon Sep 17 00:00:00 2001 From: Steven DeMartini <1647130+sjdemartini@users.noreply.github.com> Date: Fri, 2 Jun 2023 01:48:53 -0700 Subject: [PATCH 10/31] Fix graphiql explorer styles by including official CSS, and update local example app for testing (#1418) * Add venv and .venv to gitignore since common venv paths * Update cookbook-plain app requirements and local-dev notes This also adds the DEFAULT_AUTO_FIELD to the app's Django settings to resolve this warning when running `migrate`: ``` ingredients.Category: (models.W042) Auto-created primary key used when not defining a primary key type, by default 'django.db.models.AutoField'. HINT: Configure the DEFAULT_AUTO_FIELD setting or the IngredientsConfig.default_auto_field attribute to point to a subclass of AutoField, e.g. 'django.db.models.BigAutoField'. ``` * Fix #1417 graphiql explorer styles by including official CSS Like in the official graphiql-plugin-explorer example here https://github.com/graphql/graphiql/blob/619864691941c46cc0b0848e8713028e20212c36/packages/graphiql-plugin-explorer/examples/index.html#L26-L29 Resolves https://github.com/graphql-python/graphene-django/issues/1417 * Update GraphiQL version --------- Co-authored-by: Steven DeMartini Co-authored-by: Kien Dang --- .gitignore | 2 ++ examples/cookbook-plain/README.md | 9 +++++++++ examples/cookbook-plain/cookbook/settings.py | 16 +++++++++------- examples/cookbook-plain/requirements.txt | 7 +++---- graphene_django/templates/graphene/graphiql.html | 4 ++++ graphene_django/views.py | 11 +++++++---- 6 files changed, 34 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index ff6bd96..5cfaf00 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ __pycache__/ # Distribution / packaging .Python env/ +venv/ +.venv/ build/ develop-eggs/ dist/ diff --git a/examples/cookbook-plain/README.md b/examples/cookbook-plain/README.md index dcd2420..b120b78 100644 --- a/examples/cookbook-plain/README.md +++ b/examples/cookbook-plain/README.md @@ -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 +``` diff --git a/examples/cookbook-plain/cookbook/settings.py b/examples/cookbook-plain/cookbook/settings.py index 7eb9d56..f07f6f6 100644 --- a/examples/cookbook-plain/cookbook/settings.py +++ b/examples/cookbook-plain/cookbook/settings.py @@ -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/" diff --git a/examples/cookbook-plain/requirements.txt b/examples/cookbook-plain/requirements.txt index 85a8963..6e02745 100644 --- a/examples/cookbook-plain/requirements.txt +++ b/examples/cookbook-plain/requirements.txt @@ -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 diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index ddff8fc..52421e8 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -21,6 +21,10 @@ add "&raw" to the end of the URL within a browser. integrity="{{graphiql_css_sri}}" rel="stylesheet" crossorigin="anonymous" /> + diff --git a/graphene_django/views.py b/graphene_django/views.py index 377b75d..3fb87d4 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -66,18 +66,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 From 893439390936d1915d542ba631181dd4556058f0 Mon Sep 17 00:00:00 2001 From: Sezgin ACER Date: Tue, 6 Jun 2023 09:20:32 +0300 Subject: [PATCH 11/31] Add check for `serializers.HiddenField` on `fields_for_serializer` function (#1419) * Add check for `serializers.HiddenField` on fields_for_serializer function * Add pre-commit changes --- graphene_django/rest_framework/mutation.py | 3 +++ .../rest_framework/tests/test_mutation.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py index 4062a44..b7393da 100644 --- a/graphene_django/rest_framework/mutation.py +++ b/graphene_django/rest_framework/mutation.py @@ -39,6 +39,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 ] ) diff --git a/graphene_django/rest_framework/tests/test_mutation.py b/graphene_django/rest_framework/tests/test_mutation.py index 5de8237..91d99f0 100644 --- a/graphene_django/rest_framework/tests/test_mutation.py +++ b/graphene_django/rest_framework/tests/test_mutation.py @@ -164,6 +164,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: From c925a32dc3b9d0633e3f65943d2e94e1f2cc2e39 Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Wed, 7 Jun 2023 21:52:40 +0800 Subject: [PATCH 12/31] CI: add Django 4.2 (#1420) * CI: add Django 4.2 * fix tox --- .github/workflows/tests.yml | 4 +++- tox.ini | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2c5b755..c96bc4c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,13 +8,15 @@ jobs: strategy: max-parallel: 4 matrix: - django: ["3.2", "4.0", "4.1"] + django: ["3.2", "4.0", "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 }} diff --git a/tox.ini b/tox.ini index e186f30..90110e1 100644 --- a/tox.ini +++ b/tox.ini @@ -18,6 +18,7 @@ DJANGO = 3.2: django32 4.0: django40 4.1: django41 + 4.2: django42 main: djangomain [testenv] @@ -32,6 +33,7 @@ deps = 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} From 8fa8aea3c01d1f27babdfe0ff3288e515f56fdb1 Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Wed, 7 Jun 2023 22:36:29 +0800 Subject: [PATCH 13/31] remove JSONField compat (#1421) * remove JSONFIeld compat * fix black --- graphene_django/compat.py | 11 +---------- graphene_django/converter.py | 7 +++---- graphene_django/tests/test_converter.py | 12 ------------ graphene_django/tests/test_query.py | 3 +-- 4 files changed, 5 insertions(+), 28 deletions(-) diff --git a/graphene_django/compat.py b/graphene_django/compat.py index b0e4753..8fccdb0 100644 --- a/graphene_django/compat.py +++ b/graphene_django/compat.py @@ -10,16 +10,7 @@ try: IntegerRangeField, ArrayField, HStoreField, - JSONField as PGJSONField, 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,) * 5 diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 375d683..a43cff7 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -35,7 +35,7 @@ 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 .compat import ArrayField, HStoreField, RangeField from .fields import DjangoListField, DjangoConnectionField from .settings import graphene_settings from .utils.str_converters import to_const @@ -346,9 +346,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 ) diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index 4996505..7f4e350 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -15,8 +15,6 @@ from graphene.types.scalars import BigInt from ..compat import ( ArrayField, HStoreField, - JSONField, - PGJSONField, MissingType, RangeField, ) @@ -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 diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 383ff2e..68bdc7d 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -119,13 +119,12 @@ def test_should_query_postgres_fields(): from django.contrib.postgres.fields import ( IntegerRangeField, ArrayField, - JSONField, HStoreField, ) 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)) From 3e7a16af73311bb5ce998825250e4f35534ebfca Mon Sep 17 00:00:00 2001 From: Dulmandakh Date: Wed, 7 Jun 2023 22:36:51 +0800 Subject: [PATCH 14/31] CI: remove Django 4.0 (#1422) * CI: remove Django 4.0 * fix tags --- .github/workflows/tests.yml | 2 +- setup.py | 2 +- tox.ini | 6 ++---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c96bc4c..dfc5194 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: max-parallel: 4 matrix: - django: ["3.2", "4.0", "4.1", "4.2"] + django: ["3.2", "4.1", "4.2"] python-version: ["3.8", "3.9", "3.10"] include: - django: "3.2" diff --git a/setup.py b/setup.py index 542630e..87842bb 100644 --- a/setup.py +++ b/setup.py @@ -56,8 +56,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.*"]), diff --git a/tox.ini b/tox.ini index 90110e1..9739b1c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,8 @@ [tox] envlist = py{37,38,39,310}-django32, - py{38,39,310}-django{40,41,main}, - py311-django{41,main} + py{38,39,310}-django{41,42,main}, + py311-django{41,42,main} pre-commit [gh-actions] @@ -16,7 +16,6 @@ python = [gh-actions:env] DJANGO = 3.2: django32 - 4.0: django40 4.1: django41 4.2: django42 main: djangomain @@ -31,7 +30,6 @@ 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 From 2358bd30a452859c50b6bdacdc27ae518716153b Mon Sep 17 00:00:00 2001 From: Steven DeMartini <1647130+sjdemartini@users.noreply.github.com> Date: Wed, 7 Jun 2023 10:06:37 -0700 Subject: [PATCH 15/31] Update compat.py MissingType results after PGJSONField removal (#1423) As mentioned in https://github.com/graphql-python/graphene-django/pull/1421/files#r1221711648 --- graphene_django/compat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/compat.py b/graphene_django/compat.py index 8fccdb0..2fcecf6 100644 --- a/graphene_django/compat.py +++ b/graphene_django/compat.py @@ -13,4 +13,4 @@ try: RangeField, ) except ImportError: - IntegerRangeField, ArrayField, HStoreField, RangeField = (MissingType,) * 5 + IntegerRangeField, ArrayField, HStoreField, RangeField = (MissingType,) * 4 From e950164c8ee2a9babf54a0c2d3da27194d7cfddc Mon Sep 17 00:00:00 2001 From: Firas Kafri <3097061+firaskafri@users.noreply.github.com> Date: Sat, 17 Jun 2023 09:29:18 +0300 Subject: [PATCH 16/31] Bump version to 3.1.2 --- graphene_django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index d4ef76d..dab70ce 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,7 +1,7 @@ from .fields import DjangoConnectionField, DjangoListField from .types import DjangoObjectType -__version__ = "3.1.1" +__version__ = "3.1.2" __all__ = [ "__version__", From 3f061a0c50835cef1b3ea91dd88eb1b34ad40865 Mon Sep 17 00:00:00 2001 From: Jeongseok Kang Date: Tue, 18 Jul 2023 21:10:22 +0900 Subject: [PATCH 17/31] docs: Update location of GraphQL Relay Specification (#1432) --- docs/tutorial-relay.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial-relay.rst b/docs/tutorial-relay.rst index a27e255..bf93299 100644 --- a/docs/tutorial-relay.rst +++ b/docs/tutorial-relay.rst @@ -12,7 +12,7 @@ app `__ -* `GraphQL Relay Specification `__ +* `GraphQL Relay Specification `__ Setup the Django project ------------------------ From cd43022283e713154ce2071405190797ecc062b7 Mon Sep 17 00:00:00 2001 From: Steven DeMartini <1647130+sjdemartini@users.noreply.github.com> Date: Tue, 18 Jul 2023 05:11:30 -0700 Subject: [PATCH 18/31] Maintain JSONField in graphene_django.compat module (#1429) Fixes https://github.com/graphql-python/graphene-django/issues/1428 This should improve backwards compatibility, fixing issues in downstream packages (notably graphene-django-cud https://github.com/tOgg1/graphene-django-cud/issues/109, and also graphene-django-extras, both of which depended on `graphene_django.compat.JSONField`). Co-authored-by: Steven DeMartini --- graphene_django/compat.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/graphene_django/compat.py b/graphene_django/compat.py index 2fcecf6..4b48f03 100644 --- a/graphene_django/compat.py +++ b/graphene_django/compat.py @@ -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 From 2fafa881a8e2e16aab4442a755e99246b4bacd43 Mon Sep 17 00:00:00 2001 From: Firas Kafri <3097061+firaskafri@users.noreply.github.com> Date: Tue, 18 Jul 2023 15:13:58 +0300 Subject: [PATCH 19/31] Bump version --- graphene_django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index dab70ce..8dd3dd2 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,7 +1,7 @@ from .fields import DjangoConnectionField, DjangoListField from .types import DjangoObjectType -__version__ = "3.1.2" +__version__ = "3.1.3" __all__ = [ "__version__", From 0de35ca3b077f4e3ea38fd3294905f4d412179ee Mon Sep 17 00:00:00 2001 From: Laurent Date: Tue, 18 Jul 2023 12:16:52 +0000 Subject: [PATCH 20/31] fix: fk resolver permissions leak (#1411) * fix: fk resolver permissions leak * fix: only one query for 1o1 relation * tests: added queries count check * fix: docstring * fix: typo * docs: added warning to authorization * feat: added bypass_get_queryset decorator --- .gitignore | 1 + docs/authorization.rst | 19 +- docs/introspection.rst | 6 +- graphene_django/__init__.py | 2 + graphene_django/converter.py | 131 +++++++- graphene_django/tests/test_get_queryset.py | 334 ++++++++++++++++++++- graphene_django/utils/__init__.py | 2 + graphene_django/utils/utils.py | 9 + 8 files changed, 495 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 5cfaf00..3cf0d9a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ __pycache__/ # Distribution / packaging .Python env/ +.env/ venv/ .venv/ build/ diff --git a/docs/authorization.rst b/docs/authorization.rst index bc88cda..8595def 100644 --- a/docs/authorization.rst +++ b/docs/authorization.rst @@ -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 diff --git a/docs/introspection.rst b/docs/introspection.rst index 2097c30..a4ecaae 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -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) } diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 8dd3dd2..676c674 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,5 +1,6 @@ from .fields import DjangoConnectionField, DjangoListField from .types import DjangoObjectType +from .utils import bypass_get_queryset __version__ = "3.1.3" @@ -8,4 +9,5 @@ __all__ = [ "DjangoObjectType", "DjangoListField", "DjangoConnectionField", + "bypass_get_queryset", ] diff --git a/graphene_django/converter.py b/graphene_django/converter.py index a43cff7..f27119a 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -1,5 +1,6 @@ +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 @@ -25,6 +26,7 @@ from graphene import ( ) from graphene.types.json import JSONString from graphene.types.scalars import BigInt +from graphene.types.resolver import get_default_resolver from graphene.utils.str_converters import to_camel_case from graphql import GraphQLError @@ -258,6 +260,9 @@ 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 +270,52 @@ 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 + return _type.get_queryset( + _type._meta.model.objects.filter( + **{reversed_field_name: root.pk} + ), + info, + ).get() + + return custom_resolver + + return CustomField( + _type, + required=not field.null, + ) return Dynamic(dynamic_type) @@ -313,6 +363,9 @@ 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 +373,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, diff --git a/graphene_django/tests/test_get_queryset.py b/graphene_django/tests/test_get_queryset.py index 7cbaa54..99f50c7 100644 --- a/graphene_django/tests/test_get_queryset.py +++ b/graphene_django/tests/test_get_queryset.py @@ -8,7 +8,7 @@ from graphql_relay import to_global_id from ..fields import DjangoConnectionField from ..types import DjangoObjectType -from .models import Article, Reporter +from .models import Article, Reporter, FilmDetails, Film class TestShouldCallGetQuerySetOnForeignKey: @@ -127,6 +127,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 +296,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." diff --git a/graphene_django/utils/__init__.py b/graphene_django/utils/__init__.py index 671b060..e4780e6 100644 --- a/graphene_django/utils/__init__.py +++ b/graphene_django/utils/__init__.py @@ -6,6 +6,7 @@ from .utils import ( get_reverse_fields, is_valid_django_model, maybe_queryset, + bypass_get_queryset, ) __all__ = [ @@ -16,4 +17,5 @@ __all__ = [ "camelize", "is_valid_django_model", "GraphQLTestCase", + "bypass_get_queryset", ] diff --git a/graphene_django/utils/utils.py b/graphene_django/utils/utils.py index 343a3a7..e0b8b5f 100644 --- a/graphene_django/utils/utils.py +++ b/graphene_django/utils/utils.py @@ -105,3 +105,12 @@ def set_rollback(): atomic_requests = connection.settings_dict.get("ATOMIC_REQUESTS", False) if atomic_requests and connection.in_atomic_block: transaction.set_rollback(True) + + +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 From b1abebdb978d98e52923dcb88cd6df54f8cf3fc1 Mon Sep 17 00:00:00 2001 From: Tom Dror Date: Tue, 18 Jul 2023 13:17:45 -0400 Subject: [PATCH 21/31] Support base class relations and reverse for proxy models (#1380) * support reverse relationship for proxy models * support multi table inheritence * update query test for multi table inheritance * remove debugger * support local many to many in model inheritance * format and lint --------- Co-authored-by: Firas K <3097061+firaskafri@users.noreply.github.com> --- graphene_django/tests/models.py | 11 + graphene_django/tests/test_query.py | 306 ++++++++++++++++++++++++++- graphene_django/tests/test_schema.py | 5 +- graphene_django/tests/test_types.py | 5 +- graphene_django/tests/test_utils.py | 16 +- graphene_django/utils/utils.py | 73 +++++-- 6 files changed, 388 insertions(+), 28 deletions(-) diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py index 735f236..e729838 100644 --- a/graphene_django/tests/models.py +++ b/graphene_django/tests/models.py @@ -46,6 +46,7 @@ class Reporter(models.Model): 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 +91,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) diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 68bdc7d..91bacbd 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -15,7 +15,16 @@ 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 .models import ( + Article, + CNNReporter, + Film, + FilmDetails, + Person, + Pet, + Reporter, + APNewsReporter, +) def test_should_query_only_fields(): @@ -1064,6 +1073,301 @@ def test_proxy_model_support(): assert result.data == expected +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 + ) + + 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_1 = Reporter.objects.create( first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1 diff --git a/graphene_django/tests/test_schema.py b/graphene_django/tests/test_schema.py index ff2d8a6..93cbd9f 100644 --- a/graphene_django/tests/test_schema.py +++ b/graphene_django/tests/test_schema.py @@ -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(): diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index fad26e2..fd85ef1 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -67,16 +67,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(): diff --git a/graphene_django/tests/test_utils.py b/graphene_django/tests/test_utils.py index fa269b4..4e6861e 100644 --- a/graphene_django/tests/test_utils.py +++ b/graphene_django/tests/test_utils.py @@ -4,8 +4,8 @@ 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 camelize, get_model_fields, get_reverse_fields, GraphQLTestCase +from .models import Film, Reporter, CNNReporter, APNewsReporter from ..utils.testing import graphql_query @@ -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" diff --git a/graphene_django/utils/utils.py b/graphene_django/utils/utils.py index e0b8b5f..d7993e7 100644 --- a/graphene_django/utils/utils.py +++ b/graphene_django/utils/utils.py @@ -37,18 +37,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): @@ -58,17 +92,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 From 3172710d1202b5db067f6ef3a7444bd1c7210e7d Mon Sep 17 00:00:00 2001 From: Firas Kafri <3097061+firaskafri@users.noreply.github.com> Date: Tue, 18 Jul 2023 20:35:51 +0300 Subject: [PATCH 22/31] exclude 'fans' from ReporterForm tests (#1434) --- graphene_django/forms/tests/test_djangoinputobject.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/forms/tests/test_djangoinputobject.py b/graphene_django/forms/tests/test_djangoinputobject.py index 2809d2f..c54bbf6 100644 --- a/graphene_django/forms/tests/test_djangoinputobject.py +++ b/graphene_django/forms/tests/test_djangoinputobject.py @@ -31,7 +31,7 @@ class ReporterType(DjangoObjectType): class ReporterForm(forms.ModelForm): class Meta: model = Reporter - exclude = ("pets", "email") + exclude = ("pets", "email", "fans") class MyForm(forms.Form): From 5d7a04fce905571af577d2a88d05aa0ffdf98a3f Mon Sep 17 00:00:00 2001 From: James <33908344+allen-munsch@users.noreply.github.com> Date: Wed, 26 Jul 2023 18:41:40 -0500 Subject: [PATCH 23/31] Update mutation.py to serialize Enum objects into input values (#1431) * Fix for issue #1385: Update mutation.py to serialize Enum objects into input values for ChoiceFields * Update graphene_django/rest_framework/mutation.py Co-authored-by: Steven DeMartini <1647130+sjdemartini@users.noreply.github.com> --------- Co-authored-by: Steven DeMartini <1647130+sjdemartini@users.noreply.github.com> --- graphene_django/rest_framework/models.py | 11 +++++ graphene_django/rest_framework/mutation.py | 6 ++- .../rest_framework/tests/test_mutation.py | 40 ++++++++++++++++++- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/graphene_django/rest_framework/models.py b/graphene_django/rest_framework/models.py index bd84ce5..d31c3eb 100644 --- a/graphene_django/rest_framework/models.py +++ b/graphene_django/rest_framework/models.py @@ -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, + ) diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py index b7393da..837db1e 100644 --- a/graphene_django/rest_framework/mutation.py +++ b/graphene_django/rest_framework/mutation.py @@ -1,3 +1,5 @@ +from enum import Enum + from collections import OrderedDict from django.shortcuts import get_object_or_404 @@ -124,8 +126,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]} diff --git a/graphene_django/rest_framework/tests/test_mutation.py b/graphene_django/rest_framework/tests/test_mutation.py index 91d99f0..98cd11d 100644 --- a/graphene_django/rest_framework/tests/test_mutation.py +++ b/graphene_django/rest_framework/tests/test_mutation.py @@ -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, + MyFakeModelWithDate, + MyFakeModelWithPassword, + MyFakeModelWithChoiceField, +) from ..mutation import SerializerMutation @@ -268,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: From 5eb5fe294addc75f7f9a66c54b123c87773c4ce2 Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Fri, 4 Aug 2023 16:15:23 +0800 Subject: [PATCH 24/31] Remove Python 3.7 (EOL since EOL since 2023-06-27) from CI (#1440) * Remove Python 3.7 (EOL since EOL since 2023-06-27) from CI * Remove unused context * Use pyupgrade --py38-plus in pre-commit --- .github/workflows/tests.yml | 3 --- .pre-commit-config.yaml | 2 +- setup.py | 1 - tox.ini | 3 +-- 4 files changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index dfc5194..c3b9b47 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,8 +11,6 @@ jobs: 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" @@ -31,4 +29,3 @@ jobs: run: tox env: DJANGO: ${{ matrix.django }} - TOXENV: ${{ matrix.toxenv }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9214d35..14da2e8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,7 +19,7 @@ repos: rev: v3.3.2 hooks: - id: pyupgrade - args: [--py37-plus] + args: [--py38-plus] - repo: https://github.com/psf/black rev: 23.3.0 hooks: diff --git a/setup.py b/setup.py index 87842bb..2cba053 100644 --- a/setup.py +++ b/setup.py @@ -48,7 +48,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", diff --git a/tox.ini b/tox.ini index 9739b1c..1f78894 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,12 @@ [tox] envlist = - py{37,38,39,310}-django32, + py{38,39,310}-django32, py{38,39,310}-django{41,42,main}, py311-django{41,42,main} pre-commit [gh-actions] python = - 3.7: py37 3.8: py38 3.9: py39 3.10: py310 From 45a732f1db8cad32cbb62ca1f149b65a37e13d70 Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Sun, 6 Aug 2023 06:45:10 +0800 Subject: [PATCH 25/31] Prevent duplicate CI runs, also work with PRs from forks (#1443) * Prevent duplicate CI runs * Trigger CI on pull requests from forks --- .github/workflows/lint.yml | 5 ++++- .github/workflows/tests.yml | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index bfafa67..920ecf0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,6 +1,9 @@ name: Lint -on: [push, pull_request] +on: + push: + branches: ["main"] + pull_request: jobs: build: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c3b9b47..17876a2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,6 +1,9 @@ name: Tests -on: [push, pull_request] +on: + push: + branches: ["main"] + pull_request: jobs: build: From 9a773b9d7b53ac99d4e417d182dccaadfdb4232d Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Sun, 6 Aug 2023 06:47:00 +0800 Subject: [PATCH 26/31] Use ruff in pre-commit (#1441) * Use ruff in pre-commit * Add pyupgrade * Add isort * Add bugbear * Fix B015 Pointless comparison * Fix B026 * B018 false positive * Remove flake8 and isort config from setup.cfg * Remove black and flake8 from dev dependencies * Update black * Show list of fixes applied with autofix on * Fix typo * Add C4 flake8-comprehensions * Add ruff to dev dependencies * Fix up --- .pre-commit-config.yaml | 14 ++-- .ruff.toml | 33 ++++++++++ examples/cookbook-plain/cookbook/schema.py | 6 +- examples/cookbook-plain/cookbook/urls.py | 3 +- .../cookbook/cookbook/ingredients/schema.py | 3 +- examples/cookbook/cookbook/recipes/models.py | 4 +- examples/cookbook/cookbook/recipes/schema.py | 3 +- examples/cookbook/cookbook/schema.py | 6 +- examples/cookbook/cookbook/urls.py | 1 - examples/django_test_settings.py | 2 +- examples/starwars/schema.py | 8 ++- graphene_django/compat.py | 2 +- graphene_django/converter.py | 14 ++-- graphene_django/debug/middleware.py | 4 +- graphene_django/debug/tests/test_query.py | 3 +- graphene_django/debug/types.py | 2 +- graphene_django/fields.py | 2 - graphene_django/filter/__init__.py | 1 + graphene_django/filter/fields.py | 4 +- graphene_django/filter/filters/__init__.py | 1 + .../filter/filters/global_id_filter.py | 1 - graphene_django/filter/filterset.py | 8 ++- graphene_django/filter/tests/conftest.py | 6 +- .../filter/tests/test_enum_filtering.py | 3 +- graphene_django/filter/tests/test_fields.py | 8 +-- .../filter/tests/test_in_filter.py | 17 +++-- .../filter/tests/test_range_filter.py | 3 +- .../filter/tests/test_typed_filter.py | 4 +- graphene_django/filter/utils.py | 13 ++-- graphene_django/forms/converter.py | 10 +-- graphene_django/forms/forms.py | 1 - graphene_django/forms/tests/test_converter.py | 15 ++--- .../forms/tests/test_djangoinputobject.py | 6 +- graphene_django/forms/tests/test_mutation.py | 3 +- graphene_django/forms/types.py | 5 +- .../management/commands/graphql_schema.py | 6 +- graphene_django/registry.py | 4 +- graphene_django/rest_framework/mutation.py | 3 +- .../rest_framework/serializer_converter.py | 6 +- .../tests/test_field_converter.py | 4 +- .../rest_framework/tests/test_mutation.py | 4 +- graphene_django/settings.py | 5 +- graphene_django/tests/issues/test_520.py | 11 +--- graphene_django/tests/mutations.py | 1 - graphene_django/tests/test_command.py | 4 +- graphene_django/tests/test_converter.py | 4 +- graphene_django/tests/test_fields.py | 4 +- graphene_django/tests/test_forms.py | 1 - graphene_django/tests/test_get_queryset.py | 7 +- graphene_django/tests/test_query.py | 66 +++++++++---------- graphene_django/tests/test_types.py | 8 ++- graphene_django/tests/test_utils.py | 6 +- graphene_django/tests/test_views.py | 28 ++++---- graphene_django/types.py | 15 +++-- graphene_django/utils/__init__.py | 2 +- graphene_django/utils/str_converters.py | 1 + graphene_django/utils/tests/test_testing.py | 10 +-- graphene_django/views.py | 7 +- setup.cfg | 37 ----------- setup.py | 5 +- 60 files changed, 220 insertions(+), 248 deletions(-) create mode 100644 .ruff.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 14da2e8..f894223 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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: [--py38-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.282 hooks: - - id: flake8 + - id: ruff + args: [--fix, --exit-non-zero-on-fix, --show-fixes] diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..b24997c --- /dev/null +++ b/.ruff.toml @@ -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 diff --git a/examples/cookbook-plain/cookbook/schema.py b/examples/cookbook-plain/cookbook/schema.py index bde9372..8c4e5e4 100644 --- a/examples/cookbook-plain/cookbook/schema.py +++ b/examples/cookbook-plain/cookbook/schema.py @@ -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( diff --git a/examples/cookbook-plain/cookbook/urls.py b/examples/cookbook-plain/cookbook/urls.py index a64a875..a5ec5de 100644 --- a/examples/cookbook-plain/cookbook/urls.py +++ b/examples/cookbook-plain/cookbook/urls.py @@ -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)), diff --git a/examples/cookbook/cookbook/ingredients/schema.py b/examples/cookbook/cookbook/ingredients/schema.py index 4ed9eff..941f379 100644 --- a/examples/cookbook/cookbook/ingredients/schema.py +++ b/examples/cookbook/cookbook/ingredients/schema.py @@ -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) diff --git a/examples/cookbook/cookbook/recipes/models.py b/examples/cookbook/cookbook/recipes/models.py index 0bfb434..03da594 100644 --- a/examples/cookbook/cookbook/recipes/models.py +++ b/examples/cookbook/cookbook/recipes/models.py @@ -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): diff --git a/examples/cookbook/cookbook/recipes/schema.py b/examples/cookbook/cookbook/recipes/schema.py index ea5ed38..c0cb13a 100644 --- a/examples/cookbook/cookbook/recipes/schema.py +++ b/examples/cookbook/cookbook/recipes/schema.py @@ -1,8 +1,9 @@ -from cookbook.recipes.models import Recipe, RecipeIngredient from graphene import Node from graphene_django.filter import DjangoFilterConnectionField from graphene_django.types import DjangoObjectType +from cookbook.recipes.models import Recipe, RecipeIngredient + class RecipeNode(DjangoObjectType): class Meta: diff --git a/examples/cookbook/cookbook/schema.py b/examples/cookbook/cookbook/schema.py index bde9372..8c4e5e4 100644 --- a/examples/cookbook/cookbook/schema.py +++ b/examples/cookbook/cookbook/schema.py @@ -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( diff --git a/examples/cookbook/cookbook/urls.py b/examples/cookbook/cookbook/urls.py index 6f8a302..e72b383 100644 --- a/examples/cookbook/cookbook/urls.py +++ b/examples/cookbook/cookbook/urls.py @@ -3,7 +3,6 @@ from django.contrib import admin from graphene_django.views import GraphQLView - urlpatterns = [ url(r"^admin/", admin.site.urls), url(r"^graphql$", GraphQLView.as_view(graphiql=True)), diff --git a/examples/django_test_settings.py b/examples/django_test_settings.py index 7b98861..dcb1f6c 100644 --- a/examples/django_test_settings.py +++ b/examples/django_test_settings.py @@ -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/") diff --git a/examples/starwars/schema.py b/examples/starwars/schema.py index 4bc26e9..07bf9d2 100644 --- a/examples/starwars/schema.py +++ b/examples/starwars/schema.py @@ -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): diff --git a/graphene_django/compat.py b/graphene_django/compat.py index 4b48f03..fde632a 100644 --- a/graphene_django/compat.py +++ b/graphene_django/compat.py @@ -13,9 +13,9 @@ 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, + IntegerRangeField, RangeField, ) except ImportError: diff --git a/graphene_django/converter.py b/graphene_django/converter.py index f27119a..2a46dff 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -6,6 +6,7 @@ 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, @@ -13,6 +14,7 @@ from graphene import ( Boolean, Date, DateTime, + Decimal, Dynamic, Enum, Field, @@ -22,13 +24,11 @@ from graphene import ( NonNull, String, Time, - Decimal, ) from graphene.types.json import JSONString -from graphene.types.scalars import BigInt 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 @@ -38,7 +38,7 @@ except ImportError: from graphql.pyutils import register_description from .compat import ArrayField, HStoreField, RangeField -from .fields import DjangoListField, DjangoConnectionField +from .fields import DjangoConnectionField, DjangoListField from .settings import graphene_settings from .utils.str_converters import to_const @@ -161,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__})" ) @@ -261,6 +259,7 @@ 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 @@ -364,6 +363,7 @@ def convert_field_to_list_or_connection(field, registry=None): @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 diff --git a/graphene_django/debug/middleware.py b/graphene_django/debug/middleware.py index d3052a1..de0d72d 100644 --- a/graphene_django/debug/middleware.py +++ b/graphene_django/debug/middleware.py @@ -1,9 +1,7 @@ from django.db import connections -from promise import Promise - -from .sql.tracking import unwrap_cursor, wrap_cursor from .exception.formating import wrap_exception +from .sql.tracking import unwrap_cursor, wrap_cursor from .types import DjangoDebug diff --git a/graphene_django/debug/tests/test_query.py b/graphene_django/debug/tests/test_query.py index 1ea86b1..1f5e584 100644 --- a/graphene_django/debug/tests/test_query.py +++ b/graphene_django/debug/tests/test_query.py @@ -1,5 +1,6 @@ -import graphene import pytest + +import graphene from graphene.relay import Node from graphene_django import DjangoConnectionField, DjangoObjectType diff --git a/graphene_django/debug/types.py b/graphene_django/debug/types.py index a523b4f..4b0f9d1 100644 --- a/graphene_django/debug/types.py +++ b/graphene_django/debug/types.py @@ -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): diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 0fe123d..3537da3 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -1,14 +1,12 @@ from functools import partial from django.db.models.query import QuerySet - from graphql_relay import ( connection_from_array_slice, cursor_to_offset, get_offset_with_default, offset_to_cursor, ) - from promise import Promise from graphene import Int, NonNull diff --git a/graphene_django/filter/__init__.py b/graphene_django/filter/__init__.py index f02fc6b..e4dbc06 100644 --- a/graphene_django/filter/__init__.py +++ b/graphene_django/filter/__init__.py @@ -1,4 +1,5 @@ import warnings + from ..utils import DJANGO_FILTER_INSTALLED if not DJANGO_FILTER_INSTALLED: diff --git a/graphene_django/filter/fields.py b/graphene_django/filter/fields.py index cdb8f85..f6ad911 100644 --- a/graphene_django/filter/fields.py +++ b/graphene_django/filter/fields.py @@ -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 ..fields import DjangoConnectionField @@ -58,7 +58,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) diff --git a/graphene_django/filter/filters/__init__.py b/graphene_django/filter/filters/__init__.py index fcf75af..a81a96c 100644 --- a/graphene_django/filter/filters/__init__.py +++ b/graphene_django/filter/filters/__init__.py @@ -1,4 +1,5 @@ import warnings + from ...utils import DJANGO_FILTER_INSTALLED if not DJANGO_FILTER_INSTALLED: diff --git a/graphene_django/filter/filters/global_id_filter.py b/graphene_django/filter/filters/global_id_filter.py index 37877d5..e0de1e3 100644 --- a/graphene_django/filter/filters/global_id_filter.py +++ b/graphene_django/filter/filters/global_id_filter.py @@ -1,5 +1,4 @@ from django_filters import Filter, MultipleChoiceFilter - from graphql_relay.node.node import from_global_id from ...forms import GlobalIDFormField, GlobalIDMultipleChoiceField diff --git a/graphene_django/filter/filterset.py b/graphene_django/filter/filterset.py index fa91477..7e0d0c5 100644 --- a/graphene_django/filter/filterset.py +++ b/graphene_django/filter/filterset.py @@ -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}, diff --git a/graphene_django/filter/tests/conftest.py b/graphene_django/filter/tests/conftest.py index f8a65d7..1556f54 100644 --- a/graphene_django/filter/tests/conftest.py +++ b/graphene_django/filter/tests/conftest.py @@ -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 diff --git a/graphene_django/filter/tests/test_enum_filtering.py b/graphene_django/filter/tests/test_enum_filtering.py index a284d08..32238e5 100644 --- a/graphene_django/filter/tests/test_enum_filtering.py +++ b/graphene_django/filter/tests/test_enum_filtering.py @@ -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 diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index bee3c6c..df3b97a 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -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, diff --git a/graphene_django/filter/tests/test_in_filter.py b/graphene_django/filter/tests/test_in_filter.py index a69d6f5..b91475d 100644 --- a/graphene_django/filter/tests/test_in_filter.py +++ b/graphene_django/filter/tests/test_in_filter.py @@ -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"] == [ diff --git a/graphene_django/filter/tests/test_range_filter.py b/graphene_django/filter/tests/test_range_filter.py index 6227a70..e08660c 100644 --- a/graphene_django/filter/tests/test_range_filter.py +++ b/graphene_django/filter/tests/test_range_filter.py @@ -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 diff --git a/graphene_django/filter/tests/test_typed_filter.py b/graphene_django/filter/tests/test_typed_filter.py index f22138f..084affa 100644 --- a/graphene_django/filter/tests/test_typed_filter.py +++ b/graphene_django/filter/tests/test_typed_filter.py @@ -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( diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py index ebd2a00..3dd835f 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -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) diff --git a/graphene_django/forms/converter.py b/graphene_django/forms/converter.py index 47eb51d..3691e9a 100644 --- a/graphene_django/forms/converter.py +++ b/graphene_django/forms/converter.py @@ -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__) + "Don't know how to convert the Django form field {} ({}) " + "to Graphene type".format(field, field.__class__) ) diff --git a/graphene_django/forms/forms.py b/graphene_django/forms/forms.py index 4b81859..f6ed031 100644 --- a/graphene_django/forms/forms.py +++ b/graphene_django/forms/forms.py @@ -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 diff --git a/graphene_django/forms/tests/test_converter.py b/graphene_django/forms/tests/test_converter.py index b61227b..7e2a6d3 100644 --- a/graphene_django/forms/tests/test_converter.py +++ b/graphene_django/forms/tests/test_converter.py @@ -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, ) diff --git a/graphene_django/forms/tests/test_djangoinputobject.py b/graphene_django/forms/tests/test_djangoinputobject.py index c54bbf6..20b816e 100644 --- a/graphene_django/forms/tests/test_djangoinputobject.py +++ b/graphene_django/forms/tests/test_djangoinputobject.py @@ -1,11 +1,11 @@ -import graphene - 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 -from ...tests.models import Reporter, Film, CHOICES # Reporter a_choice CHOICES = ((1, "this"), (2, _("that"))) THIS = CHOICES[0][0] diff --git a/graphene_django/forms/tests/test_mutation.py b/graphene_django/forms/tests/test_mutation.py index 14c407c..230b2fd 100644 --- a/graphene_django/forms/tests/test_mutation.py +++ b/graphene_django/forms/tests/test_mutation.py @@ -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 diff --git a/graphene_django/forms/types.py b/graphene_django/forms/types.py index 132095b..b370afd 100644 --- a/graphene_django/forms/types.py +++ b/graphene_django/forms/types.py @@ -1,12 +1,11 @@ import graphene - from graphene import ID from graphene.types.inputobjecttype import InputObjectType from graphene.utils.str_converters import to_camel_case -from .mutation import fields_for_form -from ..types import ErrorType # noqa Import ErrorType for backwards compatability from ..converter import BlankValueField +from ..types import ErrorType # noqa Import ErrorType for backwards compatability +from .mutation import fields_for_form class DjangoFormInputObjectType(InputObjectType): diff --git a/graphene_django/management/commands/graphql_schema.py b/graphene_django/management/commands/graphql_schema.py index 42c41c1..16b49d2 100644 --- a/graphene_django/management/commands/graphql_schema.py +++ b/graphene_django/management/commands/graphql_schema.py @@ -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 diff --git a/graphene_django/registry.py b/graphene_django/registry.py index 4708637..900feeb 100644 --- a/graphene_django/registry.py +++ b/graphene_django/registry.py @@ -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) diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py index 837db1e..9423d4f 100644 --- a/graphene_django/rest_framework/mutation.py +++ b/graphene_django/rest_framework/mutation.py @@ -1,6 +1,5 @@ -from enum import Enum - from collections import OrderedDict +from enum import Enum from django.shortcuts import get_object_or_404 from rest_framework import serializers diff --git a/graphene_django/rest_framework/serializer_converter.py b/graphene_django/rest_framework/serializer_converter.py index 1d850f0..f99dc44 100644 --- a/graphene_django/rest_framework/serializer_converter.py +++ b/graphene_django/rest_framework/serializer_converter.py @@ -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__) + "Don't know how to convert the serializer field {} ({}) " + "to Graphene type".format(field, field.__class__) ) diff --git a/graphene_django/rest_framework/tests/test_field_converter.py b/graphene_django/rest_framework/tests/test_field_converter.py index 8da8377..b0d7a6d 100644 --- a/graphene_django/rest_framework/tests/test_field_converter.py +++ b/graphene_django/rest_framework/tests/test_field_converter.py @@ -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 diff --git a/graphene_django/rest_framework/tests/test_mutation.py b/graphene_django/rest_framework/tests/test_mutation.py index 98cd11d..bfe53cc 100644 --- a/graphene_django/rest_framework/tests/test_mutation.py +++ b/graphene_django/rest_framework/tests/test_mutation.py @@ -9,9 +9,9 @@ from graphene.types.inputobjecttype import InputObjectType from ...types import DjangoObjectType from ..models import ( MyFakeModel, + MyFakeModelWithChoiceField, MyFakeModelWithDate, MyFakeModelWithPassword, - MyFakeModelWithChoiceField, ) from ..mutation import SerializerMutation @@ -250,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"} ) diff --git a/graphene_django/settings.py b/graphene_django/settings.py index 9c7dc38..d0ef16c 100644 --- a/graphene_django/settings.py +++ b/graphene_django/settings.py @@ -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 diff --git a/graphene_django/tests/issues/test_520.py b/graphene_django/tests/issues/test_520.py index 4e55f96..700ae6f 100644 --- a/graphene_django/tests/issues/test_520.py +++ b/graphene_django/tests/issues/test_520.py @@ -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): diff --git a/graphene_django/tests/mutations.py b/graphene_django/tests/mutations.py index 3aa8bfc..68247a2 100644 --- a/graphene_django/tests/mutations.py +++ b/graphene_django/tests/mutations.py @@ -1,5 +1,4 @@ from graphene import Field - from graphene_django.forms.mutation import DjangoFormMutation, DjangoModelFormMutation from .forms import PetForm diff --git a/graphene_django/tests/test_command.py b/graphene_django/tests/test_command.py index f7325d5..d209e03 100644 --- a/graphene_django/tests/test_command.py +++ b/graphene_django/tests/test_command.py @@ -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 diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index 7f4e350..e8c0920 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -31,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() diff --git a/graphene_django/tests/test_fields.py b/graphene_django/tests/test_fields.py index 8c7b78d..d1c119c 100644 --- a/graphene_django/tests/test_fields.py +++ b/graphene_django/tests/test_fields.py @@ -1,8 +1,8 @@ import datetime import re -from django.db.models import Count, Prefetch import pytest +from django.db.models import Count, Prefetch from graphene import List, NonNull, ObjectType, Schema, String @@ -22,7 +22,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") diff --git a/graphene_django/tests/test_forms.py b/graphene_django/tests/test_forms.py index a42fcee..3957f01 100644 --- a/graphene_django/tests/test_forms.py +++ b/graphene_django/tests/test_forms.py @@ -3,7 +3,6 @@ from pytest import raises from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField - # 'TXlUeXBlOmFiYw==' -> 'MyType', 'abc' diff --git a/graphene_django/tests/test_get_queryset.py b/graphene_django/tests/test_get_queryset.py index 99f50c7..d5b1d93 100644 --- a/graphene_django/tests/test_get_queryset.py +++ b/graphene_django/tests/test_get_queryset.py @@ -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, FilmDetails, Film +from .models import Article, Film, FilmDetails, Reporter class TestShouldCallGetQuerySetOnForeignKey: diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 91bacbd..cdfbc69 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -1,5 +1,5 @@ -import datetime import base64 +import datetime import pytest from django.db import models @@ -16,6 +16,7 @@ from ..fields import DjangoConnectionField from ..types import DjangoObjectType from ..utils import DJANGO_FILTER_INSTALLED from .models import ( + APNewsReporter, Article, CNNReporter, Film, @@ -23,7 +24,6 @@ from .models import ( Person, Pet, Reporter, - APNewsReporter, ) @@ -126,9 +126,9 @@ 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, HStoreField, + IntegerRangeField, ) class Event(models.Model): @@ -355,7 +355,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: @@ -517,7 +517,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 = """ @@ -640,7 +640,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 ) @@ -682,7 +682,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 ) @@ -724,7 +724,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 ) @@ -788,7 +788,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 ) @@ -825,11 +825,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 ) @@ -1369,10 +1369,10 @@ def test_model_inheritance_support_local_relationships(): def test_should_resolve_get_queryset_connectionfields(): - reporter_1 = Reporter.objects.create( + Reporter.objects.create( first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1 ) - reporter_2 = CNNReporter.objects.create( + CNNReporter.objects.create( first_name="Some", last_name="Guy", email="someguy@cnn.com", @@ -1414,10 +1414,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 ) @@ -1444,19 +1444,19 @@ 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 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) ] @@ -1531,7 +1531,7 @@ def test_should_have_next_page(graphene_settings): assert result.data["allReporters"]["pageInfo"]["hasNextPage"] 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"] @@ -1622,7 +1622,7 @@ class TestBackwardPagination: after = base64.b64encode(b"arrayconnection:0").decode() result = schema.execute( query_first_last_and_after, - variable_values=dict(after=after), + variable_values={"after": after}, ) assert not result.errors assert len(result.data["allReporters"]["edges"]) == 3 @@ -1654,7 +1654,7 @@ class TestBackwardPagination: before = base64.b64encode(b"arrayconnection:5").decode() result = schema.execute( query_first_last_and_after, - variable_values=dict(before=before), + variable_values={"before": before}, ) assert not result.errors assert len(result.data["allReporters"]["edges"]) == 1 @@ -1710,7 +1710,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 @@ -1877,7 +1877,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 @@ -1913,7 +1913,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": { @@ -1949,7 +1949,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 @@ -1959,7 +1959,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": { @@ -1971,7 +1971,7 @@ def test_connection_should_succeed_if_last_higher_than_number_of_objects(): } assert result.data == expected - result = schema.execute(query, variable_values=dict(last=4)) + result = schema.execute(query, variable_values={"last": 4}) assert not result.errors expected = { "allReporters": { @@ -1985,7 +1985,7 @@ def test_connection_should_succeed_if_last_higher_than_number_of_objects(): } assert result.data == expected - result = schema.execute(query, variable_values=dict(last=20)) + result = schema.execute(query, variable_values={"last": 20}) assert not result.errors expected = { "allReporters": { @@ -2022,7 +2022,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), ] diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index fd85ef1..34828db 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -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): diff --git a/graphene_django/tests/test_utils.py b/graphene_django/tests/test_utils.py index 4e6861e..3fa8ba4 100644 --- a/graphene_django/tests/test_utils.py +++ b/graphene_django/tests/test_utils.py @@ -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, get_reverse_fields, GraphQLTestCase -from .models import Film, Reporter, CNNReporter, APNewsReporter +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(): diff --git a/graphene_django/tests/test_views.py b/graphene_django/tests/test_views.py index 5cadefe..d64a4f0 100644 --- a/graphene_django/tests/test_views.py +++ b/graphene_django/tests/test_views.py @@ -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 diff --git a/graphene_django/types.py b/graphene_django/types.py index dec8723..ba8e36d 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -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 @@ -149,7 +150,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 "{}".' @@ -239,9 +240,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) @@ -272,7 +273,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 diff --git a/graphene_django/utils/__init__.py b/graphene_django/utils/__init__.py index e4780e6..a64ee36 100644 --- a/graphene_django/utils/__init__.py +++ b/graphene_django/utils/__init__.py @@ -1,12 +1,12 @@ from .testing import GraphQLTestCase from .utils import ( DJANGO_FILTER_INSTALLED, + bypass_get_queryset, camelize, get_model_fields, get_reverse_fields, is_valid_django_model, maybe_queryset, - bypass_get_queryset, ) __all__ = [ diff --git a/graphene_django/utils/str_converters.py b/graphene_django/utils/str_converters.py index 77a0f37..03ad64d 100644 --- a/graphene_django/utils/str_converters.py +++ b/graphene_django/utils/str_converters.py @@ -1,4 +1,5 @@ import re + from text_unidecode import unidecode diff --git a/graphene_django/utils/tests/test_testing.py b/graphene_django/utils/tests/test_testing.py index de56158..801708e 100644 --- a/graphene_django/utils/tests/test_testing.py +++ b/graphene_django/utils/tests/test_testing.py @@ -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 diff --git a/graphene_django/views.py b/graphene_django/views.py index 3fb87d4..ce08d26 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -12,10 +12,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 @@ -40,9 +39,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): diff --git a/setup.cfg b/setup.cfg index c725df1..bd6d271 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 diff --git a/setup.py b/setup.py index 2cba053..bccf8c8 100644 --- a/setup.py +++ b/setup.py @@ -26,10 +26,7 @@ tests_require = [ dev_requires = [ - "black==23.3.0", - "flake8==6.0.0", - "flake8-black==0.3.6", - "flake8-bugbear==23.3.23", + "ruff", "pre-commit", ] + tests_require From db34d2e815b6163d80d94128b224ac94c64052ec Mon Sep 17 00:00:00 2001 From: Laurent Date: Wed, 9 Aug 2023 17:28:26 +0000 Subject: [PATCH 27/31] fix: foreign key nullable and custom resolver (#1446) * fix: nullable one to one relation * fix: makefile --- Makefile | 2 +- graphene_django/converter.py | 15 +++--- graphene_django/tests/models.py | 6 ++- graphene_django/tests/test_query.py | 71 +++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 29c412b..ba00562 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ dev-setup: .PHONY: tests ## Run unit tests tests: - py.test graphene_django --cov=graphene_django -vv + PYTHONPATH=. py.test graphene_django --cov=graphene_django -vv .PHONY: format ## Format code format: diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 2a46dff..f4775e8 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -302,12 +302,15 @@ def convert_onetoone_field_to_djangomodel(field, registry=None): reversed_field_name = root.__class__._meta.get_field( field_name ).remote_field.name - return _type.get_queryset( - _type._meta.model.objects.filter( - **{reversed_field_name: root.pk} - ), - info, - ).get() + 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 diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py index e729838..4afbbbc 100644 --- a/graphene_django/tests/models.py +++ b/graphene_django/tests/models.py @@ -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, ) diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index cdfbc69..42394c2 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -2062,3 +2062,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, + } From 79b4a23ae0a47bf570d2b16e966c206ba8e12a28 Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Thu, 10 Aug 2023 01:48:42 +0800 Subject: [PATCH 28/31] Miscellaneous CI fixes (#1447) * Update Makefile * django master requires at least python 3.10 now * Allow customizing options passed to tox -e pre-commit * py.test -> pytest * Update ruff * Fix E721 Do not compare types, use `isinstance()` * Add back black to dev dependencies * Pin black and ruff versions --- .pre-commit-config.yaml | 2 +- Makefile | 4 ++-- graphene_django/forms/converter.py | 4 ++-- .../management/commands/graphql_schema.py | 2 +- .../rest_framework/serializer_converter.py | 4 ++-- graphene_django/types.py | 12 ++++++------ setup.py | 3 ++- tox.ini | 10 +++++----- 8 files changed, 21 insertions(+), 20 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f894223..5174be3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,7 +20,7 @@ repos: hooks: - id: black - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.282 + rev: v0.0.283 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix, --show-fixes] diff --git a/Makefile b/Makefile index ba00562..31e5c93 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ dev-setup: .PHONY: tests ## Run unit tests tests: - PYTHONPATH=. 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 diff --git a/graphene_django/forms/converter.py b/graphene_django/forms/converter.py index 3691e9a..60996b4 100644 --- a/graphene_django/forms/converter.py +++ b/graphene_django/forms/converter.py @@ -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 {} ({}) " - "to Graphene type".format(field, field.__class__) + f"Don't know how to convert the Django form field {field} ({field.__class__}) " + "to Graphene type" ) diff --git a/graphene_django/management/commands/graphql_schema.py b/graphene_django/management/commands/graphql_schema.py index 16b49d2..25972b8 100644 --- a/graphene_django/management/commands/graphql_schema.py +++ b/graphene_django/management/commands/graphql_schema.py @@ -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) diff --git a/graphene_django/rest_framework/serializer_converter.py b/graphene_django/rest_framework/serializer_converter.py index f99dc44..328c46f 100644 --- a/graphene_django/rest_framework/serializer_converter.py +++ b/graphene_django/rest_framework/serializer_converter.py @@ -13,8 +13,8 @@ from .types import DictType @singledispatch def get_graphene_type_from_serializer_field(field): raise ImproperlyConfigured( - "Don't know how to convert the serializer field {} ({}) " - "to Graphene type".format(field, field.__class__) + f"Don't know how to convert the serializer field {field} ({field.__class__}) " + "to Graphene type" ) diff --git a/graphene_django/types.py b/graphene_django/types.py index ba8e36d..163fe3f 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -160,9 +160,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 +175,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 +214,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, ) diff --git a/setup.py b/setup.py index bccf8c8..51ed637 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,8 @@ tests_require = [ dev_requires = [ - "ruff", + "black==23.7.0", + "ruff==0.0.283", "pre-commit", ] + tests_require diff --git a/tox.ini b/tox.ini index 1f78894..41586ba 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,8 @@ [tox] envlist = - py{38,39,310}-django32, - py{38,39,310}-django{41,42,main}, - py311-django{41,42,main} + py{38,39,310}-django32 + py{38,39}-django{41,42} + py{310,311}-django{41,42,main} pre-commit [gh-actions] @@ -32,10 +32,10 @@ deps = 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} From 05d7fb53962b66c5192c43d0b61500c714d8e36e Mon Sep 17 00:00:00 2001 From: Firas Kafri <3097061+firaskafri@users.noreply.github.com> Date: Wed, 9 Aug 2023 20:49:51 +0300 Subject: [PATCH 29/31] Bump version --- graphene_django/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 676c674..c92e395 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,8 +1,8 @@ -from .fields import DjangoConnectionField, DjangoListField +xfrom .fields import DjangoConnectionField, DjangoListField from .types import DjangoObjectType from .utils import bypass_get_queryset -__version__ = "3.1.3" +__version__ = "3.1.4" __all__ = [ "__version__", From ee7598e71adb2901efcc44ad9ab7395df31206ab Mon Sep 17 00:00:00 2001 From: Firas Kafri <3097061+firaskafri@users.noreply.github.com> Date: Wed, 9 Aug 2023 23:41:57 +0300 Subject: [PATCH 30/31] Remove typo --- graphene_django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index c92e395..e1082e8 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,4 +1,4 @@ -xfrom .fields import DjangoConnectionField, DjangoListField +from .fields import DjangoConnectionField, DjangoListField from .types import DjangoObjectType from .utils import bypass_get_queryset From 4ac3f3f42d08fdcdd252265d59ca4c0f797a3bd1 Mon Sep 17 00:00:00 2001 From: Firas Kafri <3097061+firaskafri@users.noreply.github.com> Date: Thu, 10 Aug 2023 01:12:15 +0300 Subject: [PATCH 31/31] Update __init__.py --- graphene_django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index e1082e8..22a035d 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -2,7 +2,7 @@ from .fields import DjangoConnectionField, DjangoListField from .types import DjangoObjectType from .utils import bypass_get_queryset -__version__ = "3.1.4" +__version__ = "3.1.5" __all__ = [ "__version__",