From 7b0d046a3fc43329d49a222a508003d3a4cc9a76 Mon Sep 17 00:00:00 2001 From: Kwong Tung Nan Date: Mon, 30 Dec 2024 19:11:00 +0800 Subject: [PATCH 01/15] Update test versions Signed-off-by: Kwong Tung Nan --- .github/workflows/tests.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0a7cf983..5aa56884 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -45,6 +45,7 @@ jobs: django-version: - 4.2.* - 5.0.* + - 5.1.* python-version: - '3.9' - '3.10' @@ -54,10 +55,26 @@ jobs: mode: - std - geos + include: + # Django 4.2 only supports python 3.8-3.12 + - django-version: 4.2.* + python-version: '3.8' + mode: std + - django-version: 4.2.* + python-version: '3.8' + mode: geos exclude: - # Django 5.0 only supports python 3.10+ + # Django 4.2 only supports python 3.8-3.12 + - django-version: 4.2.* + python-version: '3.13' + # Django 5.0 only supports python 3.10-3.13 - django-version: 5.0.* python-version: '3.9' + - django-version: 5.0.* + python-version: '3.13' + # Django 5.1 only supports python 3.9+ + - django-version: 5.1.* + python-version: '3.9' steps: - name: Checkout uses: actions/checkout@v4 From 9834dc3b2d4163708dfe3e25a9ad046c56b00f8e Mon Sep 17 00:00:00 2001 From: Kwong Tung Nan Date: Mon, 30 Dec 2024 19:14:24 +0800 Subject: [PATCH 02/15] Autoupdate pre-commit hooks Signed-off-by: Kwong Tung Nan --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7474fc28..22b1617a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: - id: check-xml - id: check-symlinks - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.3 + rev: v0.8.4 hooks: - id: ruff-format - id: ruff From be5d63ecac4104ff4c8b43e6c01ddae6739f80c7 Mon Sep 17 00:00:00 2001 From: Kwong Tung Nan Date: Mon, 30 Dec 2024 19:46:18 +0800 Subject: [PATCH 03/15] Add compatibility layer for django 5.1 Signed-off-by: Kwong Tung Nan --- tests/relay/mptt/models.py | 125 ++++++++++++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) diff --git a/tests/relay/mptt/models.py b/tests/relay/mptt/models.py index a3096848..f7374c29 100644 --- a/tests/relay/mptt/models.py +++ b/tests/relay/mptt/models.py @@ -1,9 +1,132 @@ +import django from django.db import models from mptt.fields import TreeForeignKey from mptt.models import MPTTModel +from mptt.models import MPTTModelBase as _MPTTModelBase +if django.VERSION >= (5, 1): + from django.utils.translation import gettext as _ + from mptt.managers import TreeManager + from mptt.models import MPTTOptions + from mptt.utils import _get_tree_model # noqa: PLC2701 -class MPTTAuthor(MPTTModel): + class MPTTModelBase(_MPTTModelBase): + @classmethod + def register(meta, cls, **kwargs): # noqa: N804 + # For the weird cases when you need to add tree-ness to an *existing* + # class. For other cases you should subclass MPTTModel instead of calling this. + if not issubclass(cls, models.Model): + raise TypeError(_("register() expects a Django model class argument")) + + if not hasattr(cls, "_mptt_meta"): + cls._mptt_meta = MPTTOptions(**kwargs) + + abstract = getattr(cls._meta, "abstract", False) + + try: + MPTTModel # noqa: B018 + except NameError: + # We're defining the base class right now, so don't do anything + # We only want to add this stuff to the subclasses. + # (Otherwise if field names are customized, we'll end up adding two + # copies) + pass + else: + if not issubclass(cls, MPTTModel): + bases = list(cls.__bases__) + + # strip out bases that are strict superclasses of MPTTModel. + # i.e. Model, object + # this helps linearize the type hierarchy if possible + for i in range(len(bases) - 1, -1, -1): + if issubclass(MPTTModel, bases[i]): + del bases[i] + + bases.insert(0, MPTTModel) + cls.__bases__ = tuple(bases) + + is_cls_tree_model = _get_tree_model(cls) is cls + + if is_cls_tree_model: + # HACK: _meta.get_field() doesn't work before AppCache.ready in Django>=1.8 + # ( see https://code.djangoproject.com/ticket/24231 ) + # So the only way to get existing fields is using local_fields on all superclasses. + existing_field_names = set() + for base in cls.mro(): + if hasattr(base, "_meta"): + existing_field_names.update([ + f.name for f in base._meta.local_fields + ]) + + mptt_meta = cls._mptt_meta + indexed_attrs = (mptt_meta.tree_id_attr,) + field_names = ( + mptt_meta.left_attr, + mptt_meta.right_attr, + mptt_meta.tree_id_attr, + mptt_meta.level_attr, + ) + + for field_name in field_names: + if field_name not in existing_field_names: + field = models.PositiveIntegerField( + db_index=field_name in indexed_attrs, editable=False + ) + field.contribute_to_class(cls, field_name) + + # Add an unique_together on tree_id_attr and left_attr, as these are very + # commonly queried (pretty much all reads). + unique_together = ( + cls._mptt_meta.tree_id_attr, + cls._mptt_meta.left_attr, + ) + if unique_together not in cls._meta.unique_together: + cls._meta.unique_together += (unique_together,) + + # Add a tree manager, if there isn't one already + if not abstract: + # make sure we have a tree manager somewhere + tree_manager = None + # Use the default manager defined on the class if any + if cls._default_manager and isinstance( + cls._default_manager, TreeManager + ): + tree_manager = cls._default_manager + else: + for cls_manager in cls._meta.managers: + if ( + isinstance(cls_manager, TreeManager) + and cls_manager.model is cls + ): + # prefer any locally defined manager (i.e. keep going if not local) + tree_manager = cls_manager + break + + if is_cls_tree_model: + idx_together = ( + cls._mptt_meta.tree_id_attr, + cls._mptt_meta.left_attr, + ) + + if idx_together not in cls._meta.unique_together: + cls._meta.unique_together += (idx_together,) + + if tree_manager and tree_manager.model is not cls: + tree_manager = tree_manager._copy_to_model(cls) + elif tree_manager is None: + tree_manager = TreeManager() + tree_manager.contribute_to_class(cls, "_tree_manager") + + # avoid using ManagerDescriptor, so instances can refer to self._tree_manager + cls._tree_manager = tree_manager + return cls + + +else: + MPTTModelBase = _MPTTModelBase + + +class MPTTAuthor(MPTTModel, metaclass=MPTTModelBase): name = models.CharField(max_length=100) parent = TreeForeignKey( to="self", From de8a3b6e302bfd04d40266900793b4e01cbba815 Mon Sep 17 00:00:00 2001 From: Kwong Tung Nan Date: Mon, 30 Dec 2024 19:57:13 +0800 Subject: [PATCH 04/15] Ignore mptt test models Signed-off-by: Kwong Tung Nan --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 924f4d27..8f3ba55c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -177,7 +177,7 @@ max-nested-blocks = 7 [tool.pyright] pythonVersion = "3.9" useLibraryCodeForTypes = true -exclude = [".venv", "**/migrations", "dist", "docs"] +exclude = [".venv", "**/migrations", "dist", "docs", "tests/relay/mptt/models.py"] reportCallInDefaultInitializer = "warning" reportMatchNotExhaustive = "warning" reportMissingSuperCall = "warning" From 4063dea4b2aaf1cb72d205af0265dfc5d3aa684e Mon Sep 17 00:00:00 2001 From: KwongTN <5886584+kwongtn@users.noreply.github.com> Date: Tue, 31 Dec 2024 10:40:03 +0800 Subject: [PATCH 05/15] Update .github/workflows/tests.yml Co-authored-by: Thiago Bellini Ribeiro --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5aa56884..65eb9325 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -72,7 +72,7 @@ jobs: python-version: '3.9' - django-version: 5.0.* python-version: '3.13' - # Django 5.1 only supports python 3.9+ + # Django 5.1 only supports python 3.10+ - django-version: 5.1.* python-version: '3.9' steps: From d97e2c32886c4bc6884b1d1b20704d938384e7d4 Mon Sep 17 00:00:00 2001 From: KwongTN <5886584+kwongtn@users.noreply.github.com> Date: Tue, 31 Dec 2024 10:40:26 +0800 Subject: [PATCH 06/15] Update .github/workflows/tests.yml Co-authored-by: Thiago Bellini Ribeiro --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 65eb9325..0eb997d4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -67,7 +67,7 @@ jobs: # Django 4.2 only supports python 3.8-3.12 - django-version: 4.2.* python-version: '3.13' - # Django 5.0 only supports python 3.10-3.13 + # Django 5.0 only supports python 3.10-3.12 - django-version: 5.0.* python-version: '3.9' - django-version: 5.0.* From f219f1639f8c8653c1ef39f5243d5a5f5871c37c Mon Sep 17 00:00:00 2001 From: Kwong Tung Nan Date: Thu, 2 Jan 2025 10:23:02 +0800 Subject: [PATCH 07/15] Attempt to use django-tree-node to replace django-mptt --- poetry.lock | 16 ++++- pyproject.toml | 1 + tests/relay/mptt/models.py | 131 ++----------------------------------- 3 files changed, 20 insertions(+), 128 deletions(-) diff --git a/poetry.lock b/poetry.lock index 41d6406d..a74d83f6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -236,6 +236,20 @@ files = [ [package.dependencies] Django = ">=2.1" +[[package]] +name = "django-tree-queries" +version = "0.19.0" +description = "Tree queries with explicit opt-in, without configurability" +optional = false +python-versions = ">=3.8" +files = [ + {file = "django_tree_queries-0.19.0-py3-none-any.whl", hash = "sha256:05b9e3158e31612528f136b4704a8d807e14edc0b4a607a45377e6132517ba2c"}, + {file = "django_tree_queries-0.19.0.tar.gz", hash = "sha256:d1325e75f96e90b86c4316a3d63498101ec05703f4e629786b561e8aaab0e4a7"}, +] + +[package.extras] +tests = ["coverage"] + [[package]] name = "django-types" version = "0.20.0" @@ -911,4 +925,4 @@ enum = ["django-choices-field"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0" -content-hash = "56e83e063c18200cdc6cceb33afa4686060eea914fca7182f2ca5ff73755f4ca" +content-hash = "a4566912e06f3adf657c3a4f3fab14b4cfe546f7e445c985231b35130e82289b" diff --git a/pyproject.toml b/pyproject.toml index 8f3ba55c..8ae8db6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ asgiref = ">=3.8" django-choices-field = { version = ">=2.2.2", optional = true } django-debug-toolbar = { version = ">=3.4", optional = true } strawberry-graphql = ">=0.236.0" +django-tree-queries = "^0.19.0" [tool.poetry.group.dev.dependencies] channels = { version = ">=3.0.5" } diff --git a/tests/relay/mptt/models.py b/tests/relay/mptt/models.py index f7374c29..e712fdb3 100644 --- a/tests/relay/mptt/models.py +++ b/tests/relay/mptt/models.py @@ -1,134 +1,11 @@ -import django from django.db import models -from mptt.fields import TreeForeignKey -from mptt.models import MPTTModel -from mptt.models import MPTTModelBase as _MPTTModelBase +from tree_queries.fields import TreeNodeForeignKey +from tree_queries.models import TreeNode -if django.VERSION >= (5, 1): - from django.utils.translation import gettext as _ - from mptt.managers import TreeManager - from mptt.models import MPTTOptions - from mptt.utils import _get_tree_model # noqa: PLC2701 - class MPTTModelBase(_MPTTModelBase): - @classmethod - def register(meta, cls, **kwargs): # noqa: N804 - # For the weird cases when you need to add tree-ness to an *existing* - # class. For other cases you should subclass MPTTModel instead of calling this. - if not issubclass(cls, models.Model): - raise TypeError(_("register() expects a Django model class argument")) - - if not hasattr(cls, "_mptt_meta"): - cls._mptt_meta = MPTTOptions(**kwargs) - - abstract = getattr(cls._meta, "abstract", False) - - try: - MPTTModel # noqa: B018 - except NameError: - # We're defining the base class right now, so don't do anything - # We only want to add this stuff to the subclasses. - # (Otherwise if field names are customized, we'll end up adding two - # copies) - pass - else: - if not issubclass(cls, MPTTModel): - bases = list(cls.__bases__) - - # strip out bases that are strict superclasses of MPTTModel. - # i.e. Model, object - # this helps linearize the type hierarchy if possible - for i in range(len(bases) - 1, -1, -1): - if issubclass(MPTTModel, bases[i]): - del bases[i] - - bases.insert(0, MPTTModel) - cls.__bases__ = tuple(bases) - - is_cls_tree_model = _get_tree_model(cls) is cls - - if is_cls_tree_model: - # HACK: _meta.get_field() doesn't work before AppCache.ready in Django>=1.8 - # ( see https://code.djangoproject.com/ticket/24231 ) - # So the only way to get existing fields is using local_fields on all superclasses. - existing_field_names = set() - for base in cls.mro(): - if hasattr(base, "_meta"): - existing_field_names.update([ - f.name for f in base._meta.local_fields - ]) - - mptt_meta = cls._mptt_meta - indexed_attrs = (mptt_meta.tree_id_attr,) - field_names = ( - mptt_meta.left_attr, - mptt_meta.right_attr, - mptt_meta.tree_id_attr, - mptt_meta.level_attr, - ) - - for field_name in field_names: - if field_name not in existing_field_names: - field = models.PositiveIntegerField( - db_index=field_name in indexed_attrs, editable=False - ) - field.contribute_to_class(cls, field_name) - - # Add an unique_together on tree_id_attr and left_attr, as these are very - # commonly queried (pretty much all reads). - unique_together = ( - cls._mptt_meta.tree_id_attr, - cls._mptt_meta.left_attr, - ) - if unique_together not in cls._meta.unique_together: - cls._meta.unique_together += (unique_together,) - - # Add a tree manager, if there isn't one already - if not abstract: - # make sure we have a tree manager somewhere - tree_manager = None - # Use the default manager defined on the class if any - if cls._default_manager and isinstance( - cls._default_manager, TreeManager - ): - tree_manager = cls._default_manager - else: - for cls_manager in cls._meta.managers: - if ( - isinstance(cls_manager, TreeManager) - and cls_manager.model is cls - ): - # prefer any locally defined manager (i.e. keep going if not local) - tree_manager = cls_manager - break - - if is_cls_tree_model: - idx_together = ( - cls._mptt_meta.tree_id_attr, - cls._mptt_meta.left_attr, - ) - - if idx_together not in cls._meta.unique_together: - cls._meta.unique_together += (idx_together,) - - if tree_manager and tree_manager.model is not cls: - tree_manager = tree_manager._copy_to_model(cls) - elif tree_manager is None: - tree_manager = TreeManager() - tree_manager.contribute_to_class(cls, "_tree_manager") - - # avoid using ManagerDescriptor, so instances can refer to self._tree_manager - cls._tree_manager = tree_manager - return cls - - -else: - MPTTModelBase = _MPTTModelBase - - -class MPTTAuthor(MPTTModel, metaclass=MPTTModelBase): +class MPTTAuthor(TreeNode): name = models.CharField(max_length=100) - parent = TreeForeignKey( + parent = TreeNodeForeignKey( to="self", on_delete=models.CASCADE, null=True, From 16c1792df32b304a702700d7b88db7eeacd309d2 Mon Sep 17 00:00:00 2001 From: Kwong Tung Nan Date: Thu, 2 Jan 2025 10:32:37 +0800 Subject: [PATCH 08/15] Rename TCH002 to TC002 Signed-off-by: Kwong Tung Nan --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8ae8db6f..3afd7acf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -133,7 +133,7 @@ extend-ignore = [ "TRY003", "PLR6301", "PLC0415", - "TCH002", + "TC002", # ruff formatter recommends to disable those "COM812", "COM819", From 392676c22b53524ba26d41c916c47bfbdf840a64 Mon Sep 17 00:00:00 2001 From: Kwong Tung Nan Date: Thu, 2 Jan 2025 10:33:14 +0800 Subject: [PATCH 09/15] Remove pyright exclusion Signed-off-by: Kwong Tung Nan --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3afd7acf..747f3074 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -178,7 +178,7 @@ max-nested-blocks = 7 [tool.pyright] pythonVersion = "3.9" useLibraryCodeForTypes = true -exclude = [".venv", "**/migrations", "dist", "docs", "tests/relay/mptt/models.py"] +exclude = [".venv", "**/migrations", "dist", "docs"] reportCallInDefaultInitializer = "warning" reportMatchNotExhaustive = "warning" reportMissingSuperCall = "warning" From fd7942b2101ab9c965e7c203642079372736a24f Mon Sep 17 00:00:00 2001 From: Kwong Tung Nan Date: Thu, 2 Jan 2025 10:40:37 +0800 Subject: [PATCH 10/15] Remove django-mptt Signed-off-by: Kwong Tung Nan --- poetry.lock | 36 +----------------------------------- pyproject.toml | 1 - 2 files changed, 1 insertion(+), 36 deletions(-) diff --git a/poetry.lock b/poetry.lock index a74d83f6..a5572379 100644 --- a/poetry.lock +++ b/poetry.lock @@ -188,40 +188,6 @@ files = [ [package.dependencies] Django = ">=2.2" -[[package]] -name = "django-js-asset" -version = "2.2.0" -description = "script tag with additional attributes for django.forms.Media" -optional = false -python-versions = ">=3.8" -files = [ - {file = "django_js_asset-2.2.0-py3-none-any.whl", hash = "sha256:7ef3e858e13d06f10799b56eea62b1e76706f42cf4e709be4e13356bc0ae30d8"}, - {file = "django_js_asset-2.2.0.tar.gz", hash = "sha256:0c57a82cae2317e83951d956110ce847f58ff0cdc24e314dbc18b35033917e94"}, -] - -[package.dependencies] -django = ">=3.2" - -[package.extras] -tests = ["coverage"] - -[[package]] -name = "django-mptt" -version = "0.14.0" -description = "Utilities for implementing Modified Preorder Tree Traversal with your Django Models and working with trees of Model instances." -optional = false -python-versions = ">=3.6" -files = [ - {file = "django-mptt-0.14.0.tar.gz", hash = "sha256:2c92a2b1614c53086278795ccf50580cf1f9b8564f3ff03055dd62bab5987711"}, - {file = "django_mptt-0.14.0-py3-none-any.whl", hash = "sha256:d9a87433ab0e4f35247c6f6d5a93ace6990860a4ba8796f815d185f773b9acfc"}, -] - -[package.dependencies] -django-js-asset = "*" - -[package.extras] -tests = ["coverage", "mock-django"] - [[package]] name = "django-polymorphic" version = "3.1.0" @@ -925,4 +891,4 @@ enum = ["django-choices-field"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0" -content-hash = "a4566912e06f3adf657c3a4f3fab14b4cfe546f7e445c985231b35130e82289b" +content-hash = "aec43ee431a6a68b0cad49b019a241935f51e13d28374a2ff1ad00cf9f342e96" diff --git a/pyproject.toml b/pyproject.toml index 747f3074..7fb5dffe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,6 @@ channels = { version = ">=3.0.5" } django-choices-field = "^2.2.2" django-debug-toolbar = "^4.4.6" django-guardian = "^2.4.0" -django-mptt = "^0.14.0" django-types = "^0.20.0" factory-boy = "^3.2.1" pillow = "^11.0.0" From d98c5fe9c50fc814fd1e893c0e068716a270e0c8 Mon Sep 17 00:00:00 2001 From: Kwong Tung Nan Date: Thu, 2 Jan 2025 10:43:16 +0800 Subject: [PATCH 11/15] Replace all mptt references with treenode Signed-off-by: Kwong Tung Nan --- tests/relay/mptt/a.py | 25 -------- tests/relay/mptt/b.py | 32 ---------- tests/relay/{mptt => treenode}/__init__.py | 0 tests/relay/treenode/a.py | 25 ++++++++ tests/relay/treenode/b.py | 34 +++++++++++ tests/relay/{mptt => treenode}/models.py | 6 +- .../authors_and_books_schema.gql | 58 +++++++++---------- .../test_lazy_annotations.py | 12 ++-- .../test_nested_children.py | 26 +++++---- 9 files changed, 112 insertions(+), 106 deletions(-) delete mode 100644 tests/relay/mptt/a.py delete mode 100644 tests/relay/mptt/b.py rename tests/relay/{mptt => treenode}/__init__.py (100%) create mode 100644 tests/relay/treenode/a.py create mode 100644 tests/relay/treenode/b.py rename tests/relay/{mptt => treenode}/models.py (84%) rename tests/relay/{mptt => treenode}/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schema/authors_and_books_schema.gql (82%) rename tests/relay/{mptt => treenode}/test_lazy_annotations.py (58%) rename tests/relay/{mptt => treenode}/test_nested_children.py (74%) diff --git a/tests/relay/mptt/a.py b/tests/relay/mptt/a.py deleted file mode 100644 index 19560af9..00000000 --- a/tests/relay/mptt/a.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import TYPE_CHECKING, Annotated - -import strawberry -from strawberry import relay -from typing_extensions import TypeAlias - -import strawberry_django -from strawberry_django.relay import ListConnectionWithTotalCount - -from .models import MPTTAuthor - -if TYPE_CHECKING: - from .b import MPTTBookConnection - - -@strawberry_django.type(MPTTAuthor) -class MPTTAuthorType(relay.Node): - name: str - books: Annotated["MPTTBookConnection", strawberry.lazy("tests.relay.mptt.b")] = ( - strawberry_django.connection() - ) - children: "MPTTAuthorConnection" = strawberry_django.connection() - - -MPTTAuthorConnection: TypeAlias = ListConnectionWithTotalCount[MPTTAuthorType] diff --git a/tests/relay/mptt/b.py b/tests/relay/mptt/b.py deleted file mode 100644 index 5a257786..00000000 --- a/tests/relay/mptt/b.py +++ /dev/null @@ -1,32 +0,0 @@ -from typing import TYPE_CHECKING, Annotated - -import strawberry -from strawberry import relay -from typing_extensions import TypeAlias - -import strawberry_django -from strawberry_django.relay import ListConnectionWithTotalCount - -from .models import MPTTBook - -if TYPE_CHECKING: - from .a import MPTTAuthorType - - -@strawberry_django.filter(MPTTBook) -class MPTTBookFilter: - name: str - - -@strawberry_django.order(MPTTBook) -class MPTTBookOrder: - name: str - - -@strawberry_django.type(MPTTBook, filters=MPTTBookFilter, order=MPTTBookOrder) -class MPTTBookType(relay.Node): - name: str - author: Annotated["MPTTAuthorType", strawberry.lazy("tests.relay.mptt.a")] - - -MPTTBookConnection: TypeAlias = ListConnectionWithTotalCount[MPTTBookType] diff --git a/tests/relay/mptt/__init__.py b/tests/relay/treenode/__init__.py similarity index 100% rename from tests/relay/mptt/__init__.py rename to tests/relay/treenode/__init__.py diff --git a/tests/relay/treenode/a.py b/tests/relay/treenode/a.py new file mode 100644 index 00000000..3630ec43 --- /dev/null +++ b/tests/relay/treenode/a.py @@ -0,0 +1,25 @@ +from typing import TYPE_CHECKING, Annotated + +import strawberry +from strawberry import relay +from typing_extensions import TypeAlias + +import strawberry_django +from strawberry_django.relay import ListConnectionWithTotalCount + +from .models import TreeNodeAuthor + +if TYPE_CHECKING: + from .b import TreeNodeBookConnection + + +@strawberry_django.type(TreeNodeAuthor) +class TreeNodeAuthorType(relay.Node): + name: str + books: Annotated[ + "TreeNodeBookConnection", strawberry.lazy("tests.relay.treenode.b") + ] = strawberry_django.connection() + children: "TreeNodeAuthorConnection" = strawberry_django.connection() + + +TreeNodeAuthorConnection: TypeAlias = ListConnectionWithTotalCount[TreeNodeAuthorType] diff --git a/tests/relay/treenode/b.py b/tests/relay/treenode/b.py new file mode 100644 index 00000000..0872e70d --- /dev/null +++ b/tests/relay/treenode/b.py @@ -0,0 +1,34 @@ +from typing import TYPE_CHECKING, Annotated + +import strawberry +from strawberry import relay +from typing_extensions import TypeAlias + +import strawberry_django +from strawberry_django.relay import ListConnectionWithTotalCount + +from .models import TreeNodeBook + +if TYPE_CHECKING: + from .a import TreeNodeAuthorType + + +@strawberry_django.filter(TreeNodeBook) +class TreeNodeBookFilter: + name: str + + +@strawberry_django.order(TreeNodeBook) +class TreeNodeBookOrder: + name: str + + +@strawberry_django.type( + TreeNodeBook, filters=TreeNodeBookFilter, order=TreeNodeBookOrder +) +class TreeNodeBookType(relay.Node): + name: str + author: Annotated["TreeNodeAuthorType", strawberry.lazy("tests.relay.treenode.a")] + + +TreeNodeBookConnection: TypeAlias = ListConnectionWithTotalCount[TreeNodeBookType] diff --git a/tests/relay/mptt/models.py b/tests/relay/treenode/models.py similarity index 84% rename from tests/relay/mptt/models.py rename to tests/relay/treenode/models.py index e712fdb3..58579611 100644 --- a/tests/relay/mptt/models.py +++ b/tests/relay/treenode/models.py @@ -3,7 +3,7 @@ from tree_queries.models import TreeNode -class MPTTAuthor(TreeNode): +class TreeNodeAuthor(TreeNode): name = models.CharField(max_length=100) parent = TreeNodeForeignKey( to="self", @@ -14,10 +14,10 @@ class MPTTAuthor(TreeNode): ) -class MPTTBook(models.Model): +class TreeNodeBook(models.Model): title = models.CharField(max_length=100) author = models.ForeignKey( - MPTTAuthor, + TreeNodeAuthor, on_delete=models.CASCADE, related_name="books", ) diff --git a/tests/relay/mptt/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schema/authors_and_books_schema.gql b/tests/relay/treenode/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schema/authors_and_books_schema.gql similarity index 82% rename from tests/relay/mptt/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schema/authors_and_books_schema.gql rename to tests/relay/treenode/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schema/authors_and_books_schema.gql index 8ddf9851..6302bef9 100644 --- a/tests/relay/mptt/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schema/authors_and_books_schema.gql +++ b/tests/relay/treenode/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schema/authors_and_books_schema.gql @@ -3,13 +3,13 @@ The `ID` scalar type represents a unique identifier, often used to refetch an ob """ scalar GlobalID @specifiedBy(url: "https://relay.dev/graphql/objectidentification.htm") -type MPTTAuthorType implements Node { +type TreeNodeAuthorType implements Node { """The Globally Unique ID of this object""" id: GlobalID! name: String! books( - filters: MPTTBookFilter - order: MPTTBookOrder + filters: TreeNodeBookFilter + order: TreeNodeBookOrder """Returns the items in the list that come before the specified cursor.""" before: String = null @@ -22,7 +22,7 @@ type MPTTAuthorType implements Node { """Returns the items in the list that come after the specified cursor.""" last: Int = null - ): MPTTBookTypeConnection! + ): TreeNodeBookTypeConnection! children( """Returns the items in the list that come before the specified cursor.""" before: String = null @@ -35,68 +35,68 @@ type MPTTAuthorType implements Node { """Returns the items in the list that come after the specified cursor.""" last: Int = null - ): MPTTAuthorTypeConnection! + ): TreeNodeAuthorTypeConnection! } """A connection to a list of items.""" -type MPTTAuthorTypeConnection { +type TreeNodeAuthorTypeConnection { """Pagination data for this connection""" pageInfo: PageInfo! """Contains the nodes in this connection""" - edges: [MPTTAuthorTypeEdge!]! + edges: [TreeNodeAuthorTypeEdge!]! """Total quantity of existing nodes.""" totalCount: Int } """An edge in a connection.""" -type MPTTAuthorTypeEdge { +type TreeNodeAuthorTypeEdge { """A cursor for use in pagination""" cursor: String! """The item at the end of the edge""" - node: MPTTAuthorType! + node: TreeNodeAuthorType! } -input MPTTBookFilter { +input TreeNodeBookFilter { name: String! - AND: MPTTBookFilter - OR: MPTTBookFilter - NOT: MPTTBookFilter + AND: TreeNodeBookFilter + OR: TreeNodeBookFilter + NOT: TreeNodeBookFilter DISTINCT: Boolean } -input MPTTBookOrder { +input TreeNodeBookOrder { name: String } -type MPTTBookType implements Node { +type TreeNodeBookType implements Node { """The Globally Unique ID of this object""" id: GlobalID! name: String! - author: MPTTAuthorType! + author: TreeNodeAuthorType! } """A connection to a list of items.""" -type MPTTBookTypeConnection { +type TreeNodeBookTypeConnection { """Pagination data for this connection""" pageInfo: PageInfo! """Contains the nodes in this connection""" - edges: [MPTTBookTypeEdge!]! + edges: [TreeNodeBookTypeEdge!]! """Total quantity of existing nodes.""" totalCount: Int } """An edge in a connection.""" -type MPTTBookTypeEdge { +type TreeNodeBookTypeEdge { """A cursor for use in pagination""" cursor: String! """The item at the end of the edge""" - node: MPTTBookType! + node: TreeNodeBookType! } """An object with a Globally Unique ID""" @@ -122,8 +122,8 @@ type PageInfo { type Query { booksConn( - filters: MPTTBookFilter - order: MPTTBookOrder + filters: TreeNodeBookFilter + order: TreeNodeBookOrder """Returns the items in the list that come before the specified cursor.""" before: String = null @@ -136,10 +136,10 @@ type Query { """Returns the items in the list that come after the specified cursor.""" last: Int = null - ): MPTTBookTypeConnection! + ): TreeNodeBookTypeConnection! booksConn2( - filters: MPTTBookFilter - order: MPTTBookOrder + filters: TreeNodeBookFilter + order: TreeNodeBookOrder """Returns the items in the list that come before the specified cursor.""" before: String = null @@ -152,7 +152,7 @@ type Query { """Returns the items in the list that come after the specified cursor.""" last: Int = null - ): MPTTBookTypeConnection! + ): TreeNodeBookTypeConnection! authorsConn( """Returns the items in the list that come before the specified cursor.""" before: String = null @@ -165,7 +165,7 @@ type Query { """Returns the items in the list that come after the specified cursor.""" last: Int = null - ): MPTTAuthorTypeConnection! + ): TreeNodeAuthorTypeConnection! authorsConn2( """Returns the items in the list that come before the specified cursor.""" before: String = null @@ -178,5 +178,5 @@ type Query { """Returns the items in the list that come after the specified cursor.""" last: Int = null - ): MPTTAuthorTypeConnection! -} \ No newline at end of file + ): TreeNodeAuthorTypeConnection! +} diff --git a/tests/relay/mptt/test_lazy_annotations.py b/tests/relay/treenode/test_lazy_annotations.py similarity index 58% rename from tests/relay/mptt/test_lazy_annotations.py rename to tests/relay/treenode/test_lazy_annotations.py index ebfce237..c4b4629a 100644 --- a/tests/relay/mptt/test_lazy_annotations.py +++ b/tests/relay/treenode/test_lazy_annotations.py @@ -6,8 +6,8 @@ import strawberry_django from strawberry_django.relay import ListConnectionWithTotalCount -from .a import MPTTAuthorConnection, MPTTAuthorType -from .b import MPTTBookConnection, MPTTBookType +from .a import TreeNodeAuthorConnection, TreeNodeAuthorType +from .b import TreeNodeBookConnection, TreeNodeBookType SNAPSHOTS_DIR = pathlib.Path(__file__).parent / "snapshots" @@ -15,12 +15,12 @@ def test_lazy_type_annotations_in_schema(snapshot: Snapshot): @strawberry.type class Query: - books_conn: MPTTBookConnection = strawberry_django.connection() - books_conn2: ListConnectionWithTotalCount[MPTTBookType] = ( + books_conn: TreeNodeBookConnection = strawberry_django.connection() + books_conn2: ListConnectionWithTotalCount[TreeNodeBookType] = ( strawberry_django.connection() ) - authors_conn: MPTTAuthorConnection = strawberry_django.connection() - authors_conn2: ListConnectionWithTotalCount[MPTTAuthorType] = ( + authors_conn: TreeNodeAuthorConnection = strawberry_django.connection() + authors_conn2: ListConnectionWithTotalCount[TreeNodeAuthorType] = ( strawberry_django.connection() ) diff --git a/tests/relay/mptt/test_nested_children.py b/tests/relay/treenode/test_nested_children.py similarity index 74% rename from tests/relay/mptt/test_nested_children.py rename to tests/relay/treenode/test_nested_children.py index 7c0e62e0..d8fab0bd 100644 --- a/tests/relay/mptt/test_nested_children.py +++ b/tests/relay/treenode/test_nested_children.py @@ -5,13 +5,13 @@ import strawberry_django from strawberry_django.optimizer import DjangoOptimizerExtension -from .a import MPTTAuthorConnection -from .models import MPTTAuthor +from .a import TreeNodeAuthorConnection +from .models import TreeNodeAuthor @strawberry.type class Query: - authors: MPTTAuthorConnection = strawberry_django.connection() + authors: TreeNodeAuthorConnection = strawberry_django.connection() schema = strawberry.Schema(query=Query, extensions=[DjangoOptimizerExtension]) @@ -19,9 +19,9 @@ class Query: @pytest.mark.django_db(transaction=True) def test_nested_children_total_count(): - parent = MPTTAuthor.objects.create(name="Parent") - child1 = MPTTAuthor.objects.create(name="Child1", parent=parent) - child2 = MPTTAuthor.objects.create(name="Child2", parent=parent) + parent = TreeNodeAuthor.objects.create(name="Parent") + child1 = TreeNodeAuthor.objects.create(name="Child1", parent=parent) + child2 = TreeNodeAuthor.objects.create(name="Child2", parent=parent) query = """\ query { authors(first: 1) { @@ -53,20 +53,24 @@ def test_nested_children_total_count(): "edges": [ { "node": { - "id": to_base64("MPTTAuthorType", parent.pk), + "id": to_base64("TreeNodeAuthorType", parent.pk), "name": "Parent", "children": { "totalCount": 2, "edges": [ { "node": { - "id": to_base64("MPTTAuthorType", child1.pk), + "id": to_base64( + "TreeNodeAuthorType", child1.pk + ), "name": "Child1", } }, { "node": { - "id": to_base64("MPTTAuthorType", child2.pk), + "id": to_base64( + "TreeNodeAuthorType", child2.pk + ), "name": "Child2", } }, @@ -81,7 +85,7 @@ def test_nested_children_total_count(): @pytest.mark.django_db(transaction=True) def test_nested_children_total_count_no_children(): - parent = MPTTAuthor.objects.create(name="Parent") + parent = TreeNodeAuthor.objects.create(name="Parent") query = """\ query { authors { @@ -113,7 +117,7 @@ def test_nested_children_total_count_no_children(): "edges": [ { "node": { - "id": to_base64("MPTTAuthorType", parent.pk), + "id": to_base64("TreeNodeAuthorType", parent.pk), "name": "Parent", "children": { "totalCount": 0, From 6b9a1d0f19d3ba9b55795daf6a59af4a9de6572b Mon Sep 17 00:00:00 2001 From: Kwong Tung Nan Date: Thu, 2 Jan 2025 10:54:49 +0800 Subject: [PATCH 12/15] Remove last line for snapshot Signed-off-by: Kwong Tung Nan --- .../authors_and_books_schema.gql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/relay/treenode/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schema/authors_and_books_schema.gql b/tests/relay/treenode/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schema/authors_and_books_schema.gql index 6302bef9..887643c1 100644 --- a/tests/relay/treenode/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schema/authors_and_books_schema.gql +++ b/tests/relay/treenode/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schema/authors_and_books_schema.gql @@ -179,4 +179,4 @@ type Query { """Returns the items in the list that come after the specified cursor.""" last: Int = null ): TreeNodeAuthorTypeConnection! -} +} \ No newline at end of file From 8ccd07e43ebffcce933bcb624bde22647b18d198 Mon Sep 17 00:00:00 2001 From: Kwong Tung Nan Date: Thu, 2 Jan 2025 11:09:33 +0800 Subject: [PATCH 13/15] Update snapshot via --snapshot-update command Signed-off-by: Kwong Tung Nan --- .../authors_and_books_schema.gql | 164 +++++++++--------- 1 file changed, 82 insertions(+), 82 deletions(-) diff --git a/tests/relay/treenode/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schema/authors_and_books_schema.gql b/tests/relay/treenode/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schema/authors_and_books_schema.gql index 887643c1..62ab4f9b 100644 --- a/tests/relay/treenode/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schema/authors_and_books_schema.gql +++ b/tests/relay/treenode/snapshots/test_lazy_annotations/test_lazy_type_annotations_in_schema/authors_and_books_schema.gql @@ -3,6 +3,88 @@ The `ID` scalar type represents a unique identifier, often used to refetch an ob """ scalar GlobalID @specifiedBy(url: "https://relay.dev/graphql/objectidentification.htm") +"""An object with a Globally Unique ID""" +interface Node { + """The Globally Unique ID of this object""" + id: GlobalID! +} + +"""Information to aid in pagination.""" +type PageInfo { + """When paginating forwards, are there more items?""" + hasNextPage: Boolean! + + """When paginating backwards, are there more items?""" + hasPreviousPage: Boolean! + + """When paginating backwards, the cursor to continue.""" + startCursor: String + + """When paginating forwards, the cursor to continue.""" + endCursor: String +} + +type Query { + booksConn( + filters: TreeNodeBookFilter + order: TreeNodeBookOrder + + """Returns the items in the list that come before the specified cursor.""" + before: String = null + + """Returns the items in the list that come after the specified cursor.""" + after: String = null + + """Returns the first n items from the list.""" + first: Int = null + + """Returns the items in the list that come after the specified cursor.""" + last: Int = null + ): TreeNodeBookTypeConnection! + booksConn2( + filters: TreeNodeBookFilter + order: TreeNodeBookOrder + + """Returns the items in the list that come before the specified cursor.""" + before: String = null + + """Returns the items in the list that come after the specified cursor.""" + after: String = null + + """Returns the first n items from the list.""" + first: Int = null + + """Returns the items in the list that come after the specified cursor.""" + last: Int = null + ): TreeNodeBookTypeConnection! + authorsConn( + """Returns the items in the list that come before the specified cursor.""" + before: String = null + + """Returns the items in the list that come after the specified cursor.""" + after: String = null + + """Returns the first n items from the list.""" + first: Int = null + + """Returns the items in the list that come after the specified cursor.""" + last: Int = null + ): TreeNodeAuthorTypeConnection! + authorsConn2( + """Returns the items in the list that come before the specified cursor.""" + before: String = null + + """Returns the items in the list that come after the specified cursor.""" + after: String = null + + """Returns the first n items from the list.""" + first: Int = null + + """Returns the items in the list that come after the specified cursor.""" + last: Int = null + ): TreeNodeAuthorTypeConnection! +} + type TreeNodeAuthorType implements Node { """The Globally Unique ID of this object""" id: GlobalID! @@ -97,86 +179,4 @@ type TreeNodeBookTypeEdge { """The item at the end of the edge""" node: TreeNodeBookType! -} - -"""An object with a Globally Unique ID""" -interface Node { - """The Globally Unique ID of this object""" - id: GlobalID! -} - -"""Information to aid in pagination.""" -type PageInfo { - """When paginating forwards, are there more items?""" - hasNextPage: Boolean! - - """When paginating backwards, are there more items?""" - hasPreviousPage: Boolean! - - """When paginating backwards, the cursor to continue.""" - startCursor: String - - """When paginating forwards, the cursor to continue.""" - endCursor: String -} - -type Query { - booksConn( - filters: TreeNodeBookFilter - order: TreeNodeBookOrder - - """Returns the items in the list that come before the specified cursor.""" - before: String = null - - """Returns the items in the list that come after the specified cursor.""" - after: String = null - - """Returns the first n items from the list.""" - first: Int = null - - """Returns the items in the list that come after the specified cursor.""" - last: Int = null - ): TreeNodeBookTypeConnection! - booksConn2( - filters: TreeNodeBookFilter - order: TreeNodeBookOrder - - """Returns the items in the list that come before the specified cursor.""" - before: String = null - - """Returns the items in the list that come after the specified cursor.""" - after: String = null - - """Returns the first n items from the list.""" - first: Int = null - - """Returns the items in the list that come after the specified cursor.""" - last: Int = null - ): TreeNodeBookTypeConnection! - authorsConn( - """Returns the items in the list that come before the specified cursor.""" - before: String = null - - """Returns the items in the list that come after the specified cursor.""" - after: String = null - - """Returns the first n items from the list.""" - first: Int = null - - """Returns the items in the list that come after the specified cursor.""" - last: Int = null - ): TreeNodeAuthorTypeConnection! - authorsConn2( - """Returns the items in the list that come before the specified cursor.""" - before: String = null - - """Returns the items in the list that come after the specified cursor.""" - after: String = null - - """Returns the first n items from the list.""" - first: Int = null - - """Returns the items in the list that come after the specified cursor.""" - last: Int = null - ): TreeNodeAuthorTypeConnection! } \ No newline at end of file From e84df9dd7d727f1866ee75e8c1fedda41614254a Mon Sep 17 00:00:00 2001 From: Kwong Tung Nan Date: Thu, 2 Jan 2025 11:25:46 +0800 Subject: [PATCH 14/15] Add psycopg v3 into test matrix Signed-off-by: Kwong Tung Nan --- .github/workflows/tests.yml | 37 ++++++++++++++++++++++--------- strawberry_django/fields/types.py | 2 +- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0eb997d4..5e1d5bfa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -47,56 +47,71 @@ jobs: - 5.0.* - 5.1.* python-version: - - '3.9' - - '3.10' - - '3.11' - - '3.12' - - '3.13' + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" mode: - std - geos + psycopg: + - "psycopg2" + - "psycopg" # v3 include: # Django 4.2 only supports python 3.8-3.12 - django-version: 4.2.* - python-version: '3.8' + python-version: "3.8" mode: std - django-version: 4.2.* - python-version: '3.8' + python-version: "3.8" mode: geos exclude: # Django 4.2 only supports python 3.8-3.12 - django-version: 4.2.* - python-version: '3.13' + python-version: "3.13" # Django 5.0 only supports python 3.10-3.12 - django-version: 5.0.* - python-version: '3.9' + python-version: "3.9" - django-version: 5.0.* - python-version: '3.13' + python-version: "3.13" # Django 5.1 only supports python 3.10+ - django-version: 5.1.* - python-version: '3.9' + python-version: "3.9" steps: - name: Checkout uses: actions/checkout@v4 + - name: Install OS Dependencies if: ${{ matrix.mode == 'geos' }} uses: daaku/gh-action-apt-install@v4 with: packages: binutils gdal-bin libproj-dev libsqlite3-mod-spatialite + - name: Install Poetry run: pipx install poetry + - name: Set up Python ${{ matrix.python-version }} id: setup-python uses: actions/setup-python@v5 with: cache: poetry python-version: ${{ matrix.python-version }} + - name: Install Deps run: poetry install + + - name: Install ${{ matrix.psycopg }} + run: | + poetry run pip install ${{ matrix.psycopg }} + poetry run pip install ${{ matrix.psycopg }}-binary + - name: Install Django ${{ matrix.django-version }} run: poetry run pip install "django==${{ matrix.django-version }}" + - name: Test with pytest run: poetry run pytest --showlocals -vvv --cov-report=xml + - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 env: diff --git a/strawberry_django/fields/types.py b/strawberry_django/fields/types.py index b1cf2b41..466f820e 100644 --- a/strawberry_django/fields/types.py +++ b/strawberry_django/fields/types.py @@ -39,7 +39,7 @@ try: from django.contrib.postgres.fields import ArrayField except (ImportError, ModuleNotFoundError): # pragma: no cover - # ArrayField will not be importable if psycopg2 is not installed + # ArrayField will not be importable if psycopg or psycopg2 is not installed ArrayField = None if django.VERSION >= (5, 0): From d13d70c76c6e44f792834cc2305ec63a093c913d Mon Sep 17 00:00:00 2001 From: Kwong Tung Nan Date: Thu, 2 Jan 2025 11:33:53 +0800 Subject: [PATCH 15/15] Add restrictions to matrix Signed-off-by: Kwong Tung Nan --- .github/workflows/tests.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5e1d5bfa..7c192005 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -63,9 +63,21 @@ jobs: - django-version: 4.2.* python-version: "3.8" mode: std + psycopg: psycopg2 - django-version: 4.2.* python-version: "3.8" mode: geos + psycopg: psycopg2 + # Python 3.8 requires psycopg<3.3 + # Ref: https://www.psycopg.org/psycopg3/docs/basic/install.html#supported-systems + - django-version: 4.2.* + python-version: "3.8" + mode: std + psycopg: "psycopg==3.2" + - django-version: 4.2.* + python-version: "3.8" + mode: geos + psycopg: "psycopg==3.2" exclude: # Django 4.2 only supports python 3.8-3.12 - django-version: 4.2.* @@ -103,8 +115,7 @@ jobs: - name: Install ${{ matrix.psycopg }} run: | - poetry run pip install ${{ matrix.psycopg }} - poetry run pip install ${{ matrix.psycopg }}-binary + poetry run pip install "${{ matrix.psycopg }}" - name: Install Django ${{ matrix.django-version }} run: poetry run pip install "django==${{ matrix.django-version }}"