From e64c1d8055a3e476122633da141f16b50f0c4a2d Mon Sep 17 00:00:00 2001 From: William Schwartz Date: Mon, 28 Dec 2020 16:05:18 -0600 Subject: [PATCH] Fixed #32302 -- Allowed migrations to be loaded from regular packages with no __file__ attribute. The migrations loader prevents the use of PEP-420 namespace packages for holding apps' migrations modules. Previously the loader tested for this only by checking that app.migrations.__file__ is present. This prevented migrations' being found in frozen Python environments that don't set __file__ on any modules. Now the loader *additionally* checks whether app.migrations.__path__ is a list because namespace packages use a different type for __path__. Namespace packages continue to be forbidden, and, in fact, users of normal Python environments should experience no change whatsoever. --- django/db/migrations/loader.py | 14 +++++++++----- tests/migrations/test_loader.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/django/db/migrations/loader.py b/django/db/migrations/loader.py index 95a5062ec9..eb370164f3 100644 --- a/django/db/migrations/loader.py +++ b/django/db/migrations/loader.py @@ -88,15 +88,19 @@ class MigrationLoader: continue raise else: - # Empty directories are namespaces. - # getattr() needed on PY36 and older (replace w/attribute access). - if getattr(module, '__file__', None) is None: - self.unmigrated_apps.add(app_config.label) - continue # Module is not a package (e.g. migrations.py). if not hasattr(module, '__path__'): self.unmigrated_apps.add(app_config.label) continue + # Empty directories are namespaces. Namespace packages have no + # __file__ and don't use a list for __path__. See + # https://docs.python.org/3/reference/import.html#namespace-packages + if ( + getattr(module, '__file__', None) is None and + not isinstance(module.__path__, list) + ): + self.unmigrated_apps.add(app_config.label) + continue # Force a reload if it's already loaded (tests need this) if was_loaded: reload(module) diff --git a/tests/migrations/test_loader.py b/tests/migrations/test_loader.py index 27a052ddac..d8f07851d2 100644 --- a/tests/migrations/test_loader.py +++ b/tests/migrations/test_loader.py @@ -1,5 +1,6 @@ import compileall import os +from importlib import import_module from django.db import connection, connections from django.db.migrations.exceptions import ( @@ -512,6 +513,35 @@ class LoaderTests(TestCase): migrations = [name for app, name in loader.disk_migrations if app == 'migrations'] self.assertEqual(migrations, []) + @override_settings(MIGRATION_MODULES={'migrations': 'migrations.test_migrations'}) + def test_loading_package_without__file__(self): + """ + To support frozen environments, MigrationLoader loads migrations from + regular packages with no __file__ attribute. + """ + test_module = import_module('migrations.test_migrations') + loader = MigrationLoader(connection) + # __file__ == __spec__.origin or the latter is None and former is + # undefined. + module_file = test_module.__file__ + module_origin = test_module.__spec__.origin + module_has_location = test_module.__spec__.has_location + try: + del test_module.__file__ + test_module.__spec__.origin = None + test_module.__spec__.has_location = False + loader.load_disk() + migrations = [ + name + for app, name in loader.disk_migrations + if app == 'migrations' + ] + self.assertCountEqual(migrations, ['0001_initial', '0002_second']) + finally: + test_module.__file__ = module_file + test_module.__spec__.origin = module_origin + test_module.__spec__.has_location = module_has_location + class PycLoaderTests(MigrationTestBase):