diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9b81501a6..6444b4434 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: strategy: max-parallel: 4 matrix: - django: ["3.2", "4.2", "5.0"] + django: ["3.2", "4.2", "5.0", "5.1"] python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] exclude: - django: "3.2" @@ -23,6 +23,10 @@ jobs: python-version: "3.8" - django: "5.0" python-version: "3.9" + - django: "5.1" + python-version: "3.8" + - django: "5.1" + python-version: "3.9" steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} diff --git a/.ruff.toml b/.ruff.toml index bcb85c377..ac08d478f 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -25,7 +25,6 @@ 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"] diff --git a/examples/django_test_settings.py b/examples/django_test_settings.py index dcb1f6c80..4b5689e9b 100644 --- a/examples/django_test_settings.py +++ b/examples/django_test_settings.py @@ -28,3 +28,5 @@ GRAPHENE = {"SCHEMA": "graphene_django.tests.schema_view.schema"} ROOT_URLCONF = "graphene_django.tests.urls" + +USE_TZ = True diff --git a/examples/starwars/schema.py b/examples/starwars/schema.py index 07bf9d286..e81c8b2bb 100644 --- a/examples/starwars/schema.py +++ b/examples/starwars/schema.py @@ -1,5 +1,5 @@ import graphene -from graphene import Schema, relay, resolve_only_args +from graphene import Schema, relay from graphene_django import DjangoConnectionField, DjangoObjectType from .data import create_ship, get_empire, get_faction, get_rebels, get_ship, get_ships @@ -62,16 +62,13 @@ class Query(graphene.ObjectType): node = relay.Node.Field() ships = DjangoConnectionField(Ship, description="All the ships.") - @resolve_only_args - def resolve_ships(self): + def resolve_ships(self, info): return get_ships() - @resolve_only_args - def resolve_rebels(self): + def resolve_rebels(self, info): return get_rebels() - @resolve_only_args - def resolve_empire(self): + def resolve_empire(self, info): return get_empire() diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index ac15e354a..6cc87340d 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -2,7 +2,7 @@ from .types import DjangoObjectType from .utils import bypass_get_queryset -__version__ = "3.2.2" +__version__ = "3.2.3" __all__ = [ "__version__", diff --git a/graphene_django/compat.py b/graphene_django/compat.py index b3d160a13..92a871fa1 100644 --- a/graphene_django/compat.py +++ b/graphene_django/compat.py @@ -1,10 +1,11 @@ import sys +from collections.abc import Callable from pathlib import PurePath # For backwards compatibility, we import JSONField to have it available for import via # this compat module (https://github.com/graphql-python/graphene-django/issues/1428). # Django's JSONField is available in Django 3.2+ (the minimum version we support) -from django.db.models import JSONField +from django.db.models import Choices, JSONField class MissingType: @@ -42,3 +43,23 @@ def __init__(self, *args, **kwargs): else: ArrayField = MissingType + + +try: + from django.utils.choices import normalize_choices +except ImportError: + + def normalize_choices(choices): + if isinstance(choices, type) and issubclass(choices, Choices): + choices = choices.choices + + if isinstance(choices, Callable): + choices = choices() + + # In restframework==3.15.0, choices are not passed + # as OrderedDict anymore, so it's safer to check + # for a dict + if isinstance(choices, dict): + choices = choices.items() + + return choices diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 7eba22a1d..467804dab 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -1,5 +1,4 @@ import inspect -from collections.abc import Callable from functools import partial, singledispatch, wraps from django.db import models @@ -37,7 +36,7 @@ from graphql import assert_valid_name as assert_name from graphql.pyutils import register_description -from .compat import ArrayField, HStoreField, RangeField +from .compat import ArrayField, HStoreField, RangeField, normalize_choices from .fields import DjangoConnectionField, DjangoListField from .settings import graphene_settings from .utils.str_converters import to_const @@ -61,6 +60,24 @@ def wrapped_resolver(*args, **kwargs): return blank_field_wrapper(resolver) +class EnumValueField(BlankValueField): + def wrap_resolve(self, parent_resolver): + resolver = super().wrap_resolve(parent_resolver) + + # create custom resolver + def enum_field_wrapper(func): + @wraps(func) + def wrapped_resolver(*args, **kwargs): + return_value = func(*args, **kwargs) + if isinstance(return_value, models.Choices): + return_value = return_value.value + return return_value + + return wrapped_resolver + + return enum_field_wrapper(resolver) + + def convert_choice_name(name): name = to_const(force_str(name)) try: @@ -72,15 +89,7 @@ def convert_choice_name(name): def get_choices(choices): converted_names = [] - if isinstance(choices, Callable): - choices = choices() - - # In restframework==3.15.0, choices are not passed - # as OrderedDict anymore, so it's safer to check - # for a dict - if isinstance(choices, dict): - choices = choices.items() - + choices = normalize_choices(choices) for value, help_text in choices: if isinstance(help_text, (tuple, list)): yield from get_choices(help_text) @@ -157,7 +166,7 @@ def convert_django_field_with_choices( converted = EnumCls( description=get_django_field_description(field), required=required - ).mount_as(BlankValueField) + ).mount_as(EnumValueField) else: converted = convert_django_field(field, registry) if registry is not None: @@ -190,19 +199,13 @@ def convert_field_to_string(field, registry=None): ) -@convert_django_field.register(models.BigAutoField) @convert_django_field.register(models.AutoField) +@convert_django_field.register(models.BigAutoField) +@convert_django_field.register(models.SmallAutoField) def convert_field_to_id(field, registry=None): return ID(description=get_django_field_description(field), required=not field.null) -if hasattr(models, "SmallAutoField"): - - @convert_django_field.register(models.SmallAutoField) - def convert_field_small_to_id(field, registry=None): - return convert_field_to_id(field, registry) - - @convert_django_field.register(models.UUIDField) def convert_field_to_uuid(field, registry=None): return UUID( diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 1bbe1f3d2..9e9f45728 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -247,7 +247,7 @@ def connection_resolver( def wrap_resolve(self, parent_resolver): return partial( self.connection_resolver, - parent_resolver, + self.resolver or parent_resolver, self.connection_type, self.get_manager(), self.get_queryset_resolver(), diff --git a/graphene_django/forms/tests/test_converter.py b/graphene_django/forms/tests/test_converter.py index 7e2a6d342..64fa8507e 100644 --- a/graphene_django/forms/tests/test_converter.py +++ b/graphene_django/forms/tests/test_converter.py @@ -1,4 +1,4 @@ -from django import forms +from django import VERSION as DJANGO_VERSION, forms from pytest import raises from graphene import ( @@ -19,12 +19,16 @@ from ..converter import convert_form_field -def assert_conversion(django_field, graphene_field, *args): - field = django_field(*args, help_text="Custom Help Text") +def assert_conversion(django_field, graphene_field, *args, **kwargs): + # Arrange + help_text = kwargs.setdefault("help_text", "Custom Help Text") + field = django_field(*args, **kwargs) + # Act graphene_type = convert_form_field(field) + # Assert assert isinstance(graphene_type, graphene_field) field = graphene_type.Field() - assert field.description == "Custom Help Text" + assert field.description == help_text return field @@ -59,7 +63,12 @@ def test_should_slug_convert_string(): def test_should_url_convert_string(): - assert_conversion(forms.URLField, String) + kwargs = {} + if DJANGO_VERSION >= (5, 0): + # silence RemovedInDjango60Warning + kwargs["assume_scheme"] = "https" + + assert_conversion(forms.URLField, String, **kwargs) def test_should_choice_convert_string(): @@ -75,8 +84,7 @@ def test_should_regex_convert_string(): def test_should_uuid_convert_string(): - if hasattr(forms, "UUIDField"): - assert_conversion(forms.UUIDField, UUID) + assert_conversion(forms.UUIDField, UUID) def test_should_integer_convert_int(): diff --git a/graphene_django/forms/types.py b/graphene_django/forms/types.py index 0e311e5d6..68ffa6635 100644 --- a/graphene_django/forms/types.py +++ b/graphene_django/forms/types.py @@ -3,7 +3,7 @@ from graphene.types.inputobjecttype import InputObjectType from graphene.utils.str_converters import to_camel_case -from ..converter import BlankValueField +from ..converter import EnumValueField from ..types import ErrorType # noqa Import ErrorType for backwards compatibility from .mutation import fields_for_form @@ -57,11 +57,10 @@ def mutate(_root, _info, data): if ( object_type and name in object_type._meta.fields - and isinstance(object_type._meta.fields[name], BlankValueField) + and isinstance(object_type._meta.fields[name], EnumValueField) ): - # Field type BlankValueField here means that field + # Field type EnumValueField here means that field # with choices 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 diff --git a/graphene_django/rest_framework/tests/test_field_converter.py b/graphene_django/rest_framework/tests/test_field_converter.py index b0d7a6d6c..7eb907ac8 100644 --- a/graphene_django/rest_framework/tests/test_field_converter.py +++ b/graphene_django/rest_framework/tests/test_field_converter.py @@ -96,8 +96,7 @@ def test_should_regex_convert_string(): def test_should_uuid_convert_string(): - if hasattr(serializers, "UUIDField"): - assert_conversion(serializers.UUIDField, graphene.String) + assert_conversion(serializers.UUIDField, graphene.String) def test_should_model_convert_field(): diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py index ece1bb6db..d190a9b94 100644 --- a/graphene_django/tests/models.py +++ b/graphene_django/tests/models.py @@ -1,9 +1,38 @@ +import django from django.db import models from django.utils.translation import gettext_lazy as _ CHOICES = ((1, "this"), (2, _("that"))) +def get_choices_as_class(choices_class): + if django.VERSION >= (5, 0): + return choices_class + else: + return choices_class.choices + + +def get_choices_as_callable(choices_class): + if django.VERSION >= (5, 0): + + def choices(): + return choices_class.choices + + return choices + else: + return choices_class.choices + + +class TypedIntChoice(models.IntegerChoices): + CHOICE_THIS = 1 + CHOICE_THAT = 2 + + +class TypedStrChoice(models.TextChoices): + CHOICE_THIS = "this" + CHOICE_THAT = "that" + + class Person(models.Model): name = models.CharField(max_length=30) parent = models.ForeignKey( @@ -51,6 +80,21 @@ class Reporter(models.Model): email = models.EmailField() pets = models.ManyToManyField("self") a_choice = models.IntegerField(choices=CHOICES, null=True, blank=True) + typed_choice = models.IntegerField( + choices=TypedIntChoice.choices, + null=True, + blank=True, + ) + class_choice = models.IntegerField( + choices=get_choices_as_class(TypedIntChoice), + null=True, + blank=True, + ) + callable_choice = models.IntegerField( + choices=get_choices_as_callable(TypedStrChoice), + null=True, + blank=True, + ) objects = models.Manager() doe_objects = DoeReporterManager() fans = models.ManyToManyField(Person) diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index 2f8b1d515..d08d07b11 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -25,7 +25,7 @@ ) from ..registry import Registry from ..types import DjangoObjectType -from .models import Article, Film, FilmDetails, Reporter +from .models import Article, Film, FilmDetails, Reporter, TypedIntChoice, TypedStrChoice # from graphene.core.types.custom_scalars import DateTime, Time, JSONString @@ -53,9 +53,8 @@ def assert_conversion(django_field, graphene_field, *args, **kwargs): def test_should_unknown_django_field_raise_exception(): - with raises(Exception) as excinfo: + with raises(Exception, match="Don't know how to convert the Django field"): convert_django_field(None) - assert "Don't know how to convert the Django field" in str(excinfo.value) def test_should_date_time_convert_string(): @@ -115,8 +114,7 @@ def test_should_big_auto_convert_id(): def test_should_small_auto_convert_id(): - if hasattr(models, "SmallAutoField"): - assert_conversion(models.SmallAutoField, graphene.ID, primary_key=True) + assert_conversion(models.SmallAutoField, graphene.ID, primary_key=True) def test_should_uuid_convert_id(): @@ -166,14 +164,14 @@ def test_field_with_choices_convert_enum(): help_text="Language", choices=(("es", "Spanish"), ("en", "English")) ) - class TranslatedModel(models.Model): + class ChoicesModel(models.Model): language = field class Meta: app_label = "test" graphene_type = convert_django_field_with_choices(field).type.of_type - assert graphene_type._meta.name == "TestTranslatedModelLanguageChoices" + assert graphene_type._meta.name == "TestChoicesModelLanguageChoices" assert graphene_type._meta.enum.__members__["ES"].value == "es" assert graphene_type._meta.enum.__members__["ES"].description == "Spanish" assert graphene_type._meta.enum.__members__["EN"].value == "en" @@ -186,14 +184,14 @@ def get_choices(): field = models.CharField(help_text="Language", choices=get_choices) - class TranslatedModel(models.Model): + class CallableChoicesModel(models.Model): language = field class Meta: app_label = "test" graphene_type = convert_django_field_with_choices(field).type.of_type - assert graphene_type._meta.name == "TestTranslatedModelLanguageChoices" + assert graphene_type._meta.name == "TestCallableChoicesModelLanguageChoices" assert graphene_type._meta.enum.__members__["ES"].value == "es" assert graphene_type._meta.enum.__members__["ES"].description == "Spanish" assert graphene_type._meta.enum.__members__["EN"].value == "en" @@ -443,35 +441,102 @@ def test_choice_enum_blank_value(): class ReporterType(DjangoObjectType): class Meta: model = Reporter - fields = ( - "first_name", - "a_choice", - ) + fields = ("callable_choice",) class Query(graphene.ObjectType): reporter = graphene.Field(ReporterType) def resolve_reporter(root, info): - return Reporter.objects.first() + # return a model instance with blank choice field value + return Reporter(callable_choice="") schema = graphene.Schema(query=Query) - # Create model with empty choice option - Reporter.objects.create( - first_name="Bridget", last_name="Jones", email="bridget@example.com" - ) - result = schema.execute( """ query { reporter { - firstName - aChoice + callableChoice } } """ ) assert not result.errors assert result.data == { - "reporter": {"firstName": "Bridget", "aChoice": None}, + "reporter": {"callableChoice": None}, + } + + +def test_typed_choice_value(): + """Test that typed choices fields are resolved correctly to the enum values""" + + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + fields = ("typed_choice", "class_choice", "callable_choice") + + class Query(graphene.ObjectType): + reporter = graphene.Field(ReporterType) + + def resolve_reporter(root, info): + # assign choice values to the fields instead of their str or int values + return Reporter( + typed_choice=TypedIntChoice.CHOICE_THIS, + class_choice=TypedIntChoice.CHOICE_THAT, + callable_choice=TypedStrChoice.CHOICE_THIS, + ) + + class CreateReporter(graphene.Mutation): + reporter = graphene.Field(ReporterType) + + def mutate(root, info, **kwargs): + return CreateReporter( + reporter=Reporter( + typed_choice=TypedIntChoice.CHOICE_THIS, + class_choice=TypedIntChoice.CHOICE_THAT, + callable_choice=TypedStrChoice.CHOICE_THIS, + ), + ) + + class Mutation(graphene.ObjectType): + create_reporter = CreateReporter.Field() + + schema = graphene.Schema(query=Query, mutation=Mutation) + + reporter_fragment = """ + fragment reporter on ReporterType { + typedChoice + classChoice + callableChoice + } + """ + + expected_reporter = { + "typedChoice": "A_1", + "classChoice": "A_2", + "callableChoice": "THIS", } + + result = schema.execute( + reporter_fragment + + """ + query { + reporter { ...reporter } + } + """ + ) + assert not result.errors + assert result.data["reporter"] == expected_reporter + + result = schema.execute( + reporter_fragment + + """ + mutation { + createReporter { + reporter { ...reporter } + } + } + """ + ) + assert not result.errors + assert result.data["createReporter"]["reporter"] == expected_reporter diff --git a/graphene_django/tests/test_get_queryset.py b/graphene_django/tests/test_get_queryset.py index d5b1d938a..d0930e4af 100644 --- a/graphene_django/tests/test_get_queryset.py +++ b/graphene_django/tests/test_get_queryset.py @@ -26,6 +26,7 @@ def setup_schema(self): class ReporterType(DjangoObjectType): class Meta: model = Reporter + fields = "__all__" @classmethod def get_queryset(cls, queryset, info): @@ -36,6 +37,7 @@ def get_queryset(cls, queryset, info): class ArticleType(DjangoObjectType): class Meta: model = Article + fields = "__all__" @classmethod def get_queryset(cls, queryset, info): @@ -200,6 +202,7 @@ def setup_schema(self): class ReporterType(DjangoObjectType): class Meta: model = Reporter + fields = "__all__" interfaces = (Node,) @classmethod @@ -211,6 +214,7 @@ def get_queryset(cls, queryset, info): class ArticleType(DjangoObjectType): class Meta: model = Article + fields = "__all__" interfaces = (Node,) @classmethod @@ -370,6 +374,7 @@ def setup_schema(self): class FilmDetailsType(DjangoObjectType): class Meta: model = FilmDetails + fields = "__all__" @classmethod def get_queryset(cls, queryset, info): @@ -380,6 +385,7 @@ def get_queryset(cls, queryset, info): class FilmType(DjangoObjectType): class Meta: model = Film + fields = "__all__" @classmethod def get_queryset(cls, queryset, info): diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 42394c204..85ea14520 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -1,5 +1,6 @@ import base64 import datetime +from unittest.mock import ANY, Mock import pytest from django.db import models @@ -2000,14 +2001,62 @@ class Query(graphene.ObjectType): assert result.data == expected +def test_connection_should_call_resolver_function(): + resolver_mock = Mock( + name="resolver", + return_value=[ + Reporter(first_name="Some", last_name="One"), + Reporter(first_name="John", last_name="Doe"), + ], + ) + + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + fields = "__all__" + interfaces = [Node] + + class Query(graphene.ObjectType): + reporters = DjangoConnectionField(ReporterType, resolver=resolver_mock) + + schema = graphene.Schema(query=Query) + result = schema.execute( + """ + query { + reporters { + edges { + node { + firstName + lastName + } + } + } + } + """ + ) + + resolver_mock.assert_called_once_with(None, ANY) + assert not result.errors + assert result.data == { + "reporters": { + "edges": [ + {"node": {"firstName": "Some", "lastName": "One"}}, + {"node": {"firstName": "John", "lastName": "Doe"}}, + ], + }, + } + + def test_should_query_nullable_foreign_key(): class PetType(DjangoObjectType): class Meta: model = Pet + fields = "__all__" class PersonType(DjangoObjectType): class Meta: model = Person + fields = "__all__" class Query(graphene.ObjectType): pet = graphene.Field(PetType, name=graphene.String(required=True)) @@ -2022,10 +2071,8 @@ def resolve_person(self, info, name): schema = graphene.Schema(query=Query) person = Person.objects.create(name="Jane") - [ - Pet.objects.create(name="Stray dog", age=1), - Pet.objects.create(name="Jane's dog", owner=person, age=1), - ] + Pet.objects.create(name="Stray dog", age=1) + Pet.objects.create(name="Jane's dog", owner=person, age=1) query_pet = """ query getPet($name: String!) { @@ -2068,6 +2115,7 @@ def test_should_query_nullable_one_to_one_relation_with_custom_resolver(): class FilmType(DjangoObjectType): class Meta: model = Film + fields = "__all__" @classmethod def get_queryset(cls, queryset, info): @@ -2076,6 +2124,7 @@ def get_queryset(cls, queryset, info): class FilmDetailsType(DjangoObjectType): class Meta: model = FilmDetails + fields = "__all__" @classmethod def get_queryset(cls, queryset, info): diff --git a/graphene_django/tests/test_schema.py b/graphene_django/tests/test_schema.py index 93cbd9f05..009211294 100644 --- a/graphene_django/tests/test_schema.py +++ b/graphene_django/tests/test_schema.py @@ -40,6 +40,9 @@ class Meta: "email", "pets", "a_choice", + "typed_choice", + "class_choice", + "callable_choice", "fans", "reporter_type", ] diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 72514d23b..0491bcd30 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -77,6 +77,9 @@ def test_django_objecttype_map_correct_fields(): "email", "pets", "a_choice", + "typed_choice", + "class_choice", + "callable_choice", "fans", "reporter_type", ] @@ -186,6 +189,9 @@ def test_schema_representation(): email: String! pets: [Reporter!]! aChoice: TestsReporterAChoiceChoices + typedChoice: TestsReporterTypedChoiceChoices + classChoice: TestsReporterClassChoiceChoices + callableChoice: TestsReporterCallableChoiceChoices reporterType: TestsReporterReporterTypeChoices articles(offset: Int, before: String, after: String, first: Int, last: Int): ArticleConnection! } @@ -199,6 +205,33 @@ def test_schema_representation(): A_2 } + \"""An enumeration.\""" + enum TestsReporterTypedChoiceChoices { + \"""Choice This\""" + A_1 + + \"""Choice That\""" + A_2 + } + + \"""An enumeration.\""" + enum TestsReporterClassChoiceChoices { + \"""Choice This\""" + A_1 + + \"""Choice That\""" + A_2 + } + + \"""An enumeration.\""" + enum TestsReporterCallableChoiceChoices { + \"""Choice This\""" + THIS + + \"""Choice That\""" + THAT + } + \"""An enumeration.\""" enum TestsReporterReporterTypeChoices { \"""Regular\""" diff --git a/graphene_django/utils/utils.py b/graphene_django/utils/utils.py index 364eff9b4..5f84a1716 100644 --- a/graphene_django/utils/utils.py +++ b/graphene_django/utils/utils.py @@ -111,24 +111,7 @@ def is_valid_django_model(model): def import_single_dispatch(): - try: - from functools import singledispatch - except ImportError: - singledispatch = None - - if not singledispatch: - try: - from singledispatch import singledispatch - except ImportError: - pass - - if not singledispatch: - raise Exception( - "It seems your python version does not include " - "functools.singledispatch. Please install the 'singledispatch' " - "package. More information here: " - "https://pypi.python.org/pypi/singledispatch" - ) + from functools import singledispatch return singledispatch diff --git a/setup.cfg b/setup.cfg index bd6d271f0..09dcf69cc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,3 +10,7 @@ omit = */tests/* [tool:pytest] DJANGO_SETTINGS_MODULE = examples.django_test_settings addopts = --random-order +filterwarnings = + error + # we can't do anything about the DeprecationWarning about typing.ByteString in graphql + default:'typing\.ByteString' is deprecated:DeprecationWarning:graphql\.pyutils\.is_iterable diff --git a/tox.ini b/tox.ini index 9a9dc14af..a2263800a 100644 --- a/tox.ini +++ b/tox.ini @@ -2,8 +2,7 @@ envlist = py{38,39,310}-django32 py{38,39}-django42 - py{310,311}-django{42,50,main} - py312-django{42,50,main} + py{310,311,312}-django{42,50,51,main} pre-commit [gh-actions] @@ -19,6 +18,7 @@ DJANGO = 3.2: django32 4.2: django42 5.0: django50 + 5.1: django51 main: djangomain [testenv] @@ -33,6 +33,7 @@ deps = django32: Django>=3.2,<4.0 django42: Django>=4.2,<4.3 django50: Django>=5.0,<5.1 + django51: Django>=5.1,<5.2 djangomain: https://github.com/django/django/archive/main.zip commands = {posargs:pytest --cov=graphene_django graphene_django examples}