From 20ccc992bf87a954c82ea56184bbbc84d9c1b7e1 Mon Sep 17 00:00:00 2001 From: abondar Date: Thu, 19 Feb 2026 13:54:08 +0200 Subject: [PATCH 1/3] Fixed tuple index migrations --- CHANGELOG.rst | 7 +++++ tests/migrations/test_writer.py | 30 ++++++++++++++++++++ tortoise/__init__.py | 50 ++++++++++++++++----------------- tortoise/migrations/writer.py | 7 ++++- 4 files changed, 68 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7bbbe6d0b..ee7e13d2a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,13 @@ Changelog 1.1 === +1.1.5 +----- + +Fixed +^^^^^ +- ``makemigrations`` no longer crashes with ``AttributeError: 'tuple' object has no attribute 'deconstruct'`` when generating a fresh ``CreateModel`` migration for models using tuple-style ``Meta.indexes`` (e.g. ``indexes = [("field_a", "field_b")]``). Tuple entries are now normalised to ``Index`` objects before rendering. + 1.1.4 ----- diff --git a/tests/migrations/test_writer.py b/tests/migrations/test_writer.py index 12de3ff21..248d679a8 100644 --- a/tests/migrations/test_writer.py +++ b/tests/migrations/test_writer.py @@ -187,6 +187,36 @@ class Migration(migrations.Migration): _write_migration(tmp_path, monkeypatch, "0003_options", operations, expected) +def test_writer_handles_tuple_indexes_in_options(tmp_path: Path, monkeypatch) -> None: + """Tuple-style indexes in options should be normalised to Index objects without crashing.""" + operations = [ + CreateModel( + name="Token", + fields=[ + ("id", fields.IntField(primary_key=True)), + ("user_id", fields.IntField()), + ("revoked_at", fields.DatetimeField(null=True)), + ], + options={ + "indexes": [ + ("user_id", "revoked_at"), + ], + }, + ), + ] + module_path = _prepare_migration_package(tmp_path, "app") + monkeypatch.syspath_prepend(str(tmp_path)) + writer = MigrationWriter( + "0001_initial", + "app", + operations, + migrations_module=module_path, + ) + content = writer.as_string() + assert "Index(fields=['user_id', 'revoked_at'])" in content + assert "from tortoise.indexes import Index" in content + + def test_writer_renders_fk_field(tmp_path: Path, monkeypatch) -> None: operations = [ CreateModel( diff --git a/tortoise/__init__.py b/tortoise/__init__.py index 6cb6bc7dd..28b0ee278 100644 --- a/tortoise/__init__.py +++ b/tortoise/__init__.py @@ -144,7 +144,7 @@ def get_connection(cls, connection_name: str) -> BaseDBAsyncClient: @classmethod def describe_model( - cls, model: type[Model], serializable: bool = True + cls, model: type[Model], serializable: bool = True ) -> dict[str, Any]: # pragma: nocoverage """ Describes the given list of models or ALL registered models. @@ -170,7 +170,7 @@ def describe_model( @classmethod def describe_models( - cls, models: list[type[Model]] | None = None, serializable: bool = True + cls, models: list[type[Model]] | None = None, serializable: bool = True ) -> dict[str, dict[str, Any]]: """ Describes the given list of models or ALL registered models. @@ -211,10 +211,10 @@ def _init_relations(cls) -> None: @classmethod def init_models( - cls, - models_paths: Iterable[ModuleType | str], - app_label: str, - _init_relations: bool = True, + cls, + models_paths: Iterable[ModuleType | str], + app_label: str, + _init_relations: bool = True, ) -> None: """ Early initialisation of Tortoise ORM Models. @@ -232,10 +232,10 @@ def init_models( @classmethod def init_app( - cls, - label: str, - model_paths: Iterable[ModuleType | str], - _init_relations: bool = True, + cls, + label: str, + model_paths: Iterable[ModuleType | str], + _init_relations: bool = True, ) -> dict[str, type[Model]]: """ Early initialization of Tortoise ORM Models for a single app. @@ -260,7 +260,7 @@ def init_app( @classmethod def _init_apps( - cls, apps_config: dict[str, dict[str, Any]], *, validate_connections: bool = True + cls, apps_config: dict[str, dict[str, Any]], *, validate_connections: bool = True ) -> None: """Internal: Initialize Apps registry on current context.""" ctx = cls._require_context() @@ -295,18 +295,18 @@ def _build_initial_querysets(cls) -> None: @classmethod async def init( - cls, - config: dict[str, Any] | TortoiseConfig | None = None, - config_file: str | None = None, - _create_db: bool = False, - db_url: str | None = None, - modules: dict[str, Iterable[str | ModuleType]] | None = None, - use_tz: bool = True, - timezone: str = "UTC", - routers: list[str | type] | None = None, - table_name_generator: Callable[[type[Model]], str] | None = None, - init_connections: bool = True, - _enable_global_fallback: bool = False, + cls, + config: dict[str, Any] | TortoiseConfig | None = None, + config_file: str | None = None, + _create_db: bool = False, + db_url: str | None = None, + modules: dict[str, Iterable[str | ModuleType]] | None = None, + use_tz: bool = True, + timezone: str = "UTC", + routers: list[str | type] | None = None, + table_name_generator: Callable[[type[Model]], str] | None = None, + init_connections: bool = True, + _enable_global_fallback: bool = False, ) -> TortoiseContext: """ Sets up Tortoise-ORM: loads apps and models, configures database connections but does not @@ -459,7 +459,7 @@ def star_password(connections_config) -> str: str_connection_config = str_connection_config.replace( password, # Show one third of the password at beginning (may be better for debugging purposes) - f"{password[0 : len(password) // 3]}***", + f"{password[0: len(password) // 3]}***", ) return str_connection_config @@ -585,7 +585,7 @@ async def main() -> None: portal.call(main) -__version__ = "1.1.4" +__version__ = "1.1.5" __all__ = [ "BackwardFKRelation", diff --git a/tortoise/migrations/writer.py b/tortoise/migrations/writer.py index 1a7f4724e..96bb313fb 100644 --- a/tortoise/migrations/writer.py +++ b/tortoise/migrations/writer.py @@ -16,6 +16,7 @@ from pypika_tortoise.context import DEFAULT_SQL_CONTEXT +from tortoise.indexes import Index from tortoise.migrations.constraints import CheckConstraint, UniqueConstraint from tortoise.migrations.operations import ( AddConstraint, @@ -455,8 +456,12 @@ def _render_model_options(self, options: dict[str, Any], imports: ImportManager) rendered: dict[str, str] = {} for key, value in options.items(): if key == "indexes": + normalized = [ + item if isinstance(item, Index) else Index(fields=tuple(item)) + for item in value + ] rendered[key] = ( - "[" + ", ".join(self._render_index(item, imports) for item in value) + "]" + "[" + ", ".join(self._render_index(item, imports) for item in normalized) + "]" ) continue if key == "constraints": From ebb4d9f8c9f498ad306df2b34fb3fabc9766a36c Mon Sep 17 00:00:00 2001 From: abondar Date: Thu, 19 Feb 2026 13:57:54 +0200 Subject: [PATCH 2/3] Fix lint --- tortoise/__init__.py | 48 +++++++++++++++++------------------ tortoise/migrations/writer.py | 3 +-- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/tortoise/__init__.py b/tortoise/__init__.py index 28b0ee278..845920f32 100644 --- a/tortoise/__init__.py +++ b/tortoise/__init__.py @@ -144,7 +144,7 @@ def get_connection(cls, connection_name: str) -> BaseDBAsyncClient: @classmethod def describe_model( - cls, model: type[Model], serializable: bool = True + cls, model: type[Model], serializable: bool = True ) -> dict[str, Any]: # pragma: nocoverage """ Describes the given list of models or ALL registered models. @@ -170,7 +170,7 @@ def describe_model( @classmethod def describe_models( - cls, models: list[type[Model]] | None = None, serializable: bool = True + cls, models: list[type[Model]] | None = None, serializable: bool = True ) -> dict[str, dict[str, Any]]: """ Describes the given list of models or ALL registered models. @@ -211,10 +211,10 @@ def _init_relations(cls) -> None: @classmethod def init_models( - cls, - models_paths: Iterable[ModuleType | str], - app_label: str, - _init_relations: bool = True, + cls, + models_paths: Iterable[ModuleType | str], + app_label: str, + _init_relations: bool = True, ) -> None: """ Early initialisation of Tortoise ORM Models. @@ -232,10 +232,10 @@ def init_models( @classmethod def init_app( - cls, - label: str, - model_paths: Iterable[ModuleType | str], - _init_relations: bool = True, + cls, + label: str, + model_paths: Iterable[ModuleType | str], + _init_relations: bool = True, ) -> dict[str, type[Model]]: """ Early initialization of Tortoise ORM Models for a single app. @@ -260,7 +260,7 @@ def init_app( @classmethod def _init_apps( - cls, apps_config: dict[str, dict[str, Any]], *, validate_connections: bool = True + cls, apps_config: dict[str, dict[str, Any]], *, validate_connections: bool = True ) -> None: """Internal: Initialize Apps registry on current context.""" ctx = cls._require_context() @@ -295,18 +295,18 @@ def _build_initial_querysets(cls) -> None: @classmethod async def init( - cls, - config: dict[str, Any] | TortoiseConfig | None = None, - config_file: str | None = None, - _create_db: bool = False, - db_url: str | None = None, - modules: dict[str, Iterable[str | ModuleType]] | None = None, - use_tz: bool = True, - timezone: str = "UTC", - routers: list[str | type] | None = None, - table_name_generator: Callable[[type[Model]], str] | None = None, - init_connections: bool = True, - _enable_global_fallback: bool = False, + cls, + config: dict[str, Any] | TortoiseConfig | None = None, + config_file: str | None = None, + _create_db: bool = False, + db_url: str | None = None, + modules: dict[str, Iterable[str | ModuleType]] | None = None, + use_tz: bool = True, + timezone: str = "UTC", + routers: list[str | type] | None = None, + table_name_generator: Callable[[type[Model]], str] | None = None, + init_connections: bool = True, + _enable_global_fallback: bool = False, ) -> TortoiseContext: """ Sets up Tortoise-ORM: loads apps and models, configures database connections but does not @@ -459,7 +459,7 @@ def star_password(connections_config) -> str: str_connection_config = str_connection_config.replace( password, # Show one third of the password at beginning (may be better for debugging purposes) - f"{password[0: len(password) // 3]}***", + f"{password[0 : len(password) // 3]}***", ) return str_connection_config diff --git a/tortoise/migrations/writer.py b/tortoise/migrations/writer.py index 96bb313fb..d99332a9f 100644 --- a/tortoise/migrations/writer.py +++ b/tortoise/migrations/writer.py @@ -457,8 +457,7 @@ def _render_model_options(self, options: dict[str, Any], imports: ImportManager) for key, value in options.items(): if key == "indexes": normalized = [ - item if isinstance(item, Index) else Index(fields=tuple(item)) - for item in value + item if isinstance(item, Index) else Index(fields=tuple(item)) for item in value ] rendered[key] = ( "[" + ", ".join(self._render_index(item, imports) for item in normalized) + "]" From b98bf63b370f94921dc5b07f6d9b39b85846f4fb Mon Sep 17 00:00:00 2001 From: abondar Date: Thu, 19 Feb 2026 16:30:12 +0200 Subject: [PATCH 3/3] Fix flaky test --- tests/contrib/test_decorator.py | 2 +- tests/test_relations.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/contrib/test_decorator.py b/tests/contrib/test_decorator.py index e8f03e51e..1ef0966a8 100644 --- a/tests/contrib/test_decorator.py +++ b/tests/contrib/test_decorator.py @@ -18,7 +18,7 @@ async def test_basic_example_script(db) -> None: r = subprocess.run( # nosec [sys.executable, "examples/basic.py"], capture_output=True, text=True, env=env ) - assert not r.stderr, f"Script had errors: {r.stderr}" + assert r.returncode == 0, f"Script failed (rc={r.returncode}): {r.stderr}" output = r.stdout s = "[{'id': 1, 'name': 'Updated name'}, {'id': 2, 'name': 'Test 2'}]" assert s in output diff --git a/tests/test_relations.py b/tests/test_relations.py index 6afe270ec..b1ab786ab 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -599,7 +599,7 @@ async def test_reverse_relation_create_fk_errors_for_unsaved_instance(db): async def test_recursive(db) -> None: file = "examples/relations_recursive.py" r = subprocess.run([sys.executable, file], capture_output=True, text=True) # nosec - assert not r.stderr, f"Script had errors: {r.stderr}" + assert r.returncode == 0, f"Script failed (rc={r.returncode}): {r.stderr}" output = r.stdout s = "2.1. Second H2 (to: ) (from: 2.2. Third H2, Loose, 1.1. First H2)" assert s in output