From e27cff68a32a0183c6b8d110b359c1c858f68cd7 Mon Sep 17 00:00:00 2001 From: Jayden Kneller Date: Mon, 3 Nov 2025 12:05:59 -0500 Subject: [PATCH] Fixed #36652 -- Increased determinism when loading migrations from disk. Ordering still depends on pkgutil.iter_modules, which does not guarantee order, but at least now Django is not introducing additional indeterminism, causing CircularDependencyError to appear or not appear in some edge cases. Co-authored-by: Jacob Walls --- django/db/migrations/loader.py | 4 +- tests/migrations/test_loader.py | 69 +++++++++++++++++++ .../__init__.py | 0 .../app1/0001_initial.py | 9 +++ .../app1/0001_squashed_initial.py | 13 ++++ .../app1/0002_squashed_initial.py | 16 +++++ .../app1/__init__.py | 0 .../app2/0001_initial.py | 11 +++ .../app2/0001_squashed_initial.py | 13 ++++ .../app2/__init__.py | 0 10 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 tests/migrations/test_migrations_squashed_replaced_order/__init__.py create mode 100644 tests/migrations/test_migrations_squashed_replaced_order/app1/0001_initial.py create mode 100644 tests/migrations/test_migrations_squashed_replaced_order/app1/0001_squashed_initial.py create mode 100644 tests/migrations/test_migrations_squashed_replaced_order/app1/0002_squashed_initial.py create mode 100644 tests/migrations/test_migrations_squashed_replaced_order/app1/__init__.py create mode 100644 tests/migrations/test_migrations_squashed_replaced_order/app2/0001_initial.py create mode 100644 tests/migrations/test_migrations_squashed_replaced_order/app2/0001_squashed_initial.py create mode 100644 tests/migrations/test_migrations_squashed_replaced_order/app2/__init__.py diff --git a/django/db/migrations/loader.py b/django/db/migrations/loader.py index 66944c7ab7..db81f6f78f 100644 --- a/django/db/migrations/loader.py +++ b/django/db/migrations/loader.py @@ -109,11 +109,11 @@ class MigrationLoader: if was_loaded: reload(module) self.migrated_apps.add(app_config.label) - migration_names = { + migration_names = [ name for _, name, is_pkg in pkgutil.iter_modules(module.__path__) if not is_pkg and name[0] not in "_~" - } + ] # Load migrations for migration_name in migration_names: migration_path = "%s.%s" % (module_name, migration_name) diff --git a/tests/migrations/test_loader.py b/tests/migrations/test_loader.py index 7cf11f7faa..efa7a94166 100644 --- a/tests/migrations/test_loader.py +++ b/tests/migrations/test_loader.py @@ -1,7 +1,12 @@ import compileall import os +import subprocess +import sys +import tempfile from importlib import import_module +from pathlib import Path +from django.conf import settings from django.db import connection, connections from django.db.migrations.exceptions import ( AmbiguityError, @@ -649,6 +654,70 @@ class LoaderTests(TestCase): test_module.__spec__.origin = module_origin test_module.__spec__.has_location = module_has_location + def test_loading_order_does_not_create_circular_dependency(self): + """ + Before, for these migrations: + app1 + [ ] 0001_squashed_initial <- replaces app1.0001 + [ ] 0002_squashed_initial <- replaces app1.0001 + depends on app1.0001_squashed_initial & app2.0001_squashed_initial + app2 + [ ] 0001_squashed_initial <- replaces app2.0001 + + When loading app1's migrations, if 0002_squashed_initial was first: + {'0002_squashed_initial', '0001_initial', '0001_squashed_initial'} + Then CircularDependencyError was raised, but it's resolvable as: + {'0001_initial', '0001_squashed_initial', '0002_squashed_initial'} + """ + # Create a test settings file to provide to the subprocess. + MIGRATION_MODULES = { + "app1": "migrations.test_migrations_squashed_replaced_order.app1", + "app2": "migrations.test_migrations_squashed_replaced_order.app2", + } + INSTALLED_APPS = [ + "migrations.test_migrations_squashed_replaced_order.app1", + "migrations.test_migrations_squashed_replaced_order.app2", + ] + tests_dir = Path(__file__).parent.parent + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", suffix=".py", dir=tests_dir, delete=False + ) as test_settings: + for attr, value in settings._wrapped.__dict__.items(): + if attr.isupper(): + test_settings.write(f"{attr} = {value!r}\n") + # Provide overrides here, instead of via decorators. + test_settings.write(f"DATABASES = {settings.DATABASES}\n") + test_settings.write(f"MIGRATION_MODULES = {MIGRATION_MODULES}\n") + # Isolate away other test apps. + test_settings.write( + "INSTALLED_APPS=[a for a in INSTALLED_APPS if a.startswith('django')]\n" + ) + test_settings.write(f"INSTALLED_APPS += {INSTALLED_APPS}\n") + test_settings_name = test_settings.name + self.addCleanup(os.remove, test_settings_name) + + test_environ = os.environ.copy() + test_environ["PYTHONPATH"] = str(tests_dir) + # Ensure deterministic failures. + test_environ["PYTHONHASHSEED"] = "1" + + args = [ + sys.executable, + "-m", + "django", + "showmigrations", + "app1", + "--skip-checks", + "--settings", + Path(test_settings_name).stem, + ] + try: + subprocess.run( + args, capture_output=True, env=test_environ, check=True, text=True + ) + except subprocess.CalledProcessError as err: + self.fail(err.stderr) + class PycLoaderTests(MigrationTestBase): def test_valid(self): diff --git a/tests/migrations/test_migrations_squashed_replaced_order/__init__.py b/tests/migrations/test_migrations_squashed_replaced_order/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/migrations/test_migrations_squashed_replaced_order/app1/0001_initial.py b/tests/migrations/test_migrations_squashed_replaced_order/app1/0001_initial.py new file mode 100644 index 0000000000..cf311d1f93 --- /dev/null +++ b/tests/migrations/test_migrations_squashed_replaced_order/app1/0001_initial.py @@ -0,0 +1,9 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [] diff --git a/tests/migrations/test_migrations_squashed_replaced_order/app1/0001_squashed_initial.py b/tests/migrations/test_migrations_squashed_replaced_order/app1/0001_squashed_initial.py new file mode 100644 index 0000000000..7a77d339bc --- /dev/null +++ b/tests/migrations/test_migrations_squashed_replaced_order/app1/0001_squashed_initial.py @@ -0,0 +1,13 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + initial = True + + replaces = [ + ("app1", "0001_initial"), + ] + + dependencies = [] + + operations = [] diff --git a/tests/migrations/test_migrations_squashed_replaced_order/app1/0002_squashed_initial.py b/tests/migrations/test_migrations_squashed_replaced_order/app1/0002_squashed_initial.py new file mode 100644 index 0000000000..fc16ed6d6e --- /dev/null +++ b/tests/migrations/test_migrations_squashed_replaced_order/app1/0002_squashed_initial.py @@ -0,0 +1,16 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + initial = True + + replaces = [ + ("app1", "0001_initial"), + ] + + dependencies = [ + ("app1", "0001_squashed_initial"), + ("app2", "0001_squashed_initial"), + ] + + operations = [] diff --git a/tests/migrations/test_migrations_squashed_replaced_order/app1/__init__.py b/tests/migrations/test_migrations_squashed_replaced_order/app1/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/migrations/test_migrations_squashed_replaced_order/app2/0001_initial.py b/tests/migrations/test_migrations_squashed_replaced_order/app2/0001_initial.py new file mode 100644 index 0000000000..05d0409496 --- /dev/null +++ b/tests/migrations/test_migrations_squashed_replaced_order/app2/0001_initial.py @@ -0,0 +1,11 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("app1", "0001_initial"), + ] + + operations = [] diff --git a/tests/migrations/test_migrations_squashed_replaced_order/app2/0001_squashed_initial.py b/tests/migrations/test_migrations_squashed_replaced_order/app2/0001_squashed_initial.py new file mode 100644 index 0000000000..de6bfe4135 --- /dev/null +++ b/tests/migrations/test_migrations_squashed_replaced_order/app2/0001_squashed_initial.py @@ -0,0 +1,13 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + initial = True + + replaces = [ + ("app2", "0001_initial"), + ] + + dependencies = [] + + operations = [] diff --git a/tests/migrations/test_migrations_squashed_replaced_order/app2/__init__.py b/tests/migrations/test_migrations_squashed_replaced_order/app2/__init__.py new file mode 100644 index 0000000000..e69de29bb2