Browse Source

Fixed #35656 -- Added an autodetector attribute to the makemigrations and migrate commands.

leondaz 6 months ago
parent
commit
06bf06a911

+ 1 - 0
django/core/checks/__init__.py

@@ -16,6 +16,7 @@ from .registry import Tags, register, run_checks, tag_exists
 # Import these to force registration of checks
 # Import these to force registration of checks
 import django.core.checks.async_checks  # NOQA isort:skip
 import django.core.checks.async_checks  # NOQA isort:skip
 import django.core.checks.caches  # NOQA isort:skip
 import django.core.checks.caches  # NOQA isort:skip
+import django.core.checks.commands  # NOQA isort:skip
 import django.core.checks.compatibility.django_4_0  # NOQA isort:skip
 import django.core.checks.compatibility.django_4_0  # NOQA isort:skip
 import django.core.checks.database  # NOQA isort:skip
 import django.core.checks.database  # NOQA isort:skip
 import django.core.checks.files  # NOQA isort:skip
 import django.core.checks.files  # NOQA isort:skip

+ 28 - 0
django/core/checks/commands.py

@@ -0,0 +1,28 @@
+from django.core.checks import Error, Tags, register
+
+
+@register(Tags.commands)
+def migrate_and_makemigrations_autodetector(**kwargs):
+    from django.core.management import get_commands, load_command_class
+
+    commands = get_commands()
+
+    make_migrations = load_command_class(commands["makemigrations"], "makemigrations")
+    migrate = load_command_class(commands["migrate"], "migrate")
+
+    if make_migrations.autodetector is not migrate.autodetector:
+        return [
+            Error(
+                "The migrate and makemigrations commands must have the same "
+                "autodetector.",
+                hint=(
+                    f"makemigrations.Command.autodetector is "
+                    f"{make_migrations.autodetector.__name__}, but "
+                    f"migrate.Command.autodetector is "
+                    f"{migrate.autodetector.__name__}."
+                ),
+                id="commands.E001",
+            )
+        ]
+
+    return []

+ 1 - 0
django/core/checks/registry.py

@@ -12,6 +12,7 @@ class Tags:
     admin = "admin"
     admin = "admin"
     async_support = "async_support"
     async_support = "async_support"
     caches = "caches"
     caches = "caches"
+    commands = "commands"
     compatibility = "compatibility"
     compatibility = "compatibility"
     database = "database"
     database = "database"
     files = "files"
     files = "files"

+ 3 - 2
django/core/management/commands/makemigrations.py

@@ -24,6 +24,7 @@ from django.db.migrations.writer import MigrationWriter
 
 
 
 
 class Command(BaseCommand):
 class Command(BaseCommand):
+    autodetector = MigrationAutodetector
     help = "Creates new migration(s) for apps."
     help = "Creates new migration(s) for apps."
 
 
     def add_arguments(self, parser):
     def add_arguments(self, parser):
@@ -209,7 +210,7 @@ class Command(BaseCommand):
                 log=self.log,
                 log=self.log,
             )
             )
         # Set up autodetector
         # Set up autodetector
-        autodetector = MigrationAutodetector(
+        autodetector = self.autodetector(
             loader.project_state(),
             loader.project_state(),
             ProjectState.from_apps(apps),
             ProjectState.from_apps(apps),
             questioner,
             questioner,
@@ -461,7 +462,7 @@ class Command(BaseCommand):
                 # If they still want to merge it, then write out an empty
                 # If they still want to merge it, then write out an empty
                 # file depending on the migrations needing merging.
                 # file depending on the migrations needing merging.
                 numbers = [
                 numbers = [
-                    MigrationAutodetector.parse_number(migration.name)
+                    self.autodetector.parse_number(migration.name)
                     for migration in merge_migrations
                     for migration in merge_migrations
                 ]
                 ]
                 try:
                 try:

+ 2 - 1
django/core/management/commands/migrate.py

@@ -15,6 +15,7 @@ from django.utils.text import Truncator
 
 
 
 
 class Command(BaseCommand):
 class Command(BaseCommand):
+    autodetector = MigrationAutodetector
     help = (
     help = (
         "Updates database schema. Manages both apps with migrations and those without."
         "Updates database schema. Manages both apps with migrations and those without."
     )
     )
@@ -329,7 +330,7 @@ class Command(BaseCommand):
                 self.stdout.write("  No migrations to apply.")
                 self.stdout.write("  No migrations to apply.")
                 # If there's changes that aren't in migrations yet, tell them
                 # If there's changes that aren't in migrations yet, tell them
                 # how to fix it.
                 # how to fix it.
-                autodetector = MigrationAutodetector(
+                autodetector = self.autodetector(
                     executor.loader.project_state(),
                     executor.loader.project_state(),
                     ProjectState.from_apps(apps),
                     ProjectState.from_apps(apps),
                 )
                 )

+ 9 - 0
docs/ref/checks.txt

@@ -77,6 +77,7 @@ Django's system checks are organized using the following tags:
 * ``async_support``: Checks asynchronous-related configuration.
 * ``async_support``: Checks asynchronous-related configuration.
 * ``caches``: Checks cache related configuration.
 * ``caches``: Checks cache related configuration.
 * ``compatibility``: Flags potential problems with version upgrades.
 * ``compatibility``: Flags potential problems with version upgrades.
+* ``commands``: Checks custom management commands related configuration.
 * ``database``: Checks database-related configuration issues. Database checks
 * ``database``: Checks database-related configuration issues. Database checks
   are not run by default because they do more than static code analysis as
   are not run by default because they do more than static code analysis as
   regular checks do. They are only run by the :djadmin:`migrate` command or if
   regular checks do. They are only run by the :djadmin:`migrate` command or if
@@ -428,6 +429,14 @@ Models
 * **models.W047**: ``<database>`` does not support unique constraints with
 * **models.W047**: ``<database>`` does not support unique constraints with
   nulls distinct.
   nulls distinct.
 
 
+Management Commands
+-------------------
+
+The following checks verify custom management commands are correctly configured:
+
+* **commands.E001**: The ``migrate`` and ``makemigrations`` commands must have
+  the same ``autodetector``.
+
 Security
 Security
 --------
 --------
 
 

+ 4 - 0
docs/releases/5.2.txt

@@ -230,6 +230,10 @@ Management Commands
   setting the :envvar:`HIDE_PRODUCTION_WARNING` environment variable to
   setting the :envvar:`HIDE_PRODUCTION_WARNING` environment variable to
   ``"true"``.
   ``"true"``.
 
 
+* The :djadmin:`makemigrations` and :djadmin:`migrate` commands  have a new
+  ``Command.autodetector`` attribute for subclasses to override in order to use
+  a custom autodetector class.
+
 Migrations
 Migrations
 ~~~~~~~~~~
 ~~~~~~~~~~
 
 

+ 7 - 0
tests/check_framework/custom_commands_app/management/commands/makemigrations.py

@@ -0,0 +1,7 @@
+from django.core.management.commands.makemigrations import (
+    Command as MakeMigrationsCommand,
+)
+
+
+class Command(MakeMigrationsCommand):
+    autodetector = int

+ 25 - 0
tests/check_framework/test_commands.py

@@ -0,0 +1,25 @@
+from django.core import checks
+from django.core.checks import Error
+from django.test import SimpleTestCase
+from django.test.utils import isolate_apps, override_settings, override_system_checks
+
+
+@isolate_apps("check_framework.custom_commands_app", attr_name="apps")
+@override_settings(INSTALLED_APPS=["check_framework.custom_commands_app"])
+@override_system_checks([checks.commands.migrate_and_makemigrations_autodetector])
+class CommandCheckTests(SimpleTestCase):
+    def test_migrate_and_makemigrations_autodetector_different(self):
+        expected_error = Error(
+            "The migrate and makemigrations commands must have the same "
+            "autodetector.",
+            hint=(
+                "makemigrations.Command.autodetector is int, but "
+                "migrate.Command.autodetector is MigrationAutodetector."
+            ),
+            id="commands.E001",
+        )
+
+        self.assertEqual(
+            checks.run_checks(app_configs=self.apps.get_app_configs()),
+            [expected_error],
+        )

+ 62 - 1
tests/migrations/test_commands.py

@@ -9,6 +9,10 @@ from unittest import mock
 
 
 from django.apps import apps
 from django.apps import apps
 from django.core.management import CommandError, call_command
 from django.core.management import CommandError, call_command
+from django.core.management.commands.makemigrations import (
+    Command as MakeMigrationsCommand,
+)
+from django.core.management.commands.migrate import Command as MigrateCommand
 from django.db import (
 from django.db import (
     ConnectionHandler,
     ConnectionHandler,
     DatabaseError,
     DatabaseError,
@@ -19,10 +23,11 @@ from django.db import (
 )
 )
 from django.db.backends.base.schema import BaseDatabaseSchemaEditor
 from django.db.backends.base.schema import BaseDatabaseSchemaEditor
 from django.db.backends.utils import truncate_name
 from django.db.backends.utils import truncate_name
+from django.db.migrations.autodetector import MigrationAutodetector
 from django.db.migrations.exceptions import InconsistentMigrationHistory
 from django.db.migrations.exceptions import InconsistentMigrationHistory
 from django.db.migrations.recorder import MigrationRecorder
 from django.db.migrations.recorder import MigrationRecorder
 from django.test import TestCase, override_settings, skipUnlessDBFeature
 from django.test import TestCase, override_settings, skipUnlessDBFeature
-from django.test.utils import captured_stdout, extend_sys_path
+from django.test.utils import captured_stdout, extend_sys_path, isolate_apps
 from django.utils import timezone
 from django.utils import timezone
 from django.utils.version import get_docs_version
 from django.utils.version import get_docs_version
 
 
@@ -3296,3 +3301,59 @@ class OptimizeMigrationTests(MigrationTestBase):
         msg = "Cannot find a migration matching 'nonexistent' from app 'migrations'."
         msg = "Cannot find a migration matching 'nonexistent' from app 'migrations'."
         with self.assertRaisesMessage(CommandError, msg):
         with self.assertRaisesMessage(CommandError, msg):
             call_command("optimizemigration", "migrations", "nonexistent")
             call_command("optimizemigration", "migrations", "nonexistent")
+
+
+class CustomMigrationCommandTests(MigrationTestBase):
+    @override_settings(
+        MIGRATION_MODULES={"migrations": "migrations.test_migrations"},
+        INSTALLED_APPS=["migrations.migrations_test_apps.migrated_app"],
+    )
+    @isolate_apps("migrations.migrations_test_apps.migrated_app")
+    def test_makemigrations_custom_autodetector(self):
+        class CustomAutodetector(MigrationAutodetector):
+            def changes(self, *args, **kwargs):
+                return []
+
+        class CustomMakeMigrationsCommand(MakeMigrationsCommand):
+            autodetector = CustomAutodetector
+
+        class NewModel(models.Model):
+            class Meta:
+                app_label = "migrated_app"
+
+        out = io.StringIO()
+        command = CustomMakeMigrationsCommand(stdout=out)
+        call_command(command, "migrated_app", stdout=out)
+        self.assertIn("No changes detected", out.getvalue())
+
+    @override_settings(INSTALLED_APPS=["migrations.migrations_test_apps.migrated_app"])
+    @isolate_apps("migrations.migrations_test_apps.migrated_app")
+    def test_migrate_custom_autodetector(self):
+        class CustomAutodetector(MigrationAutodetector):
+            def changes(self, *args, **kwargs):
+                return []
+
+        class CustomMigrateCommand(MigrateCommand):
+            autodetector = CustomAutodetector
+
+        class NewModel(models.Model):
+            class Meta:
+                app_label = "migrated_app"
+
+        out = io.StringIO()
+        command = CustomMigrateCommand(stdout=out)
+
+        out = io.StringIO()
+        try:
+            call_command(command, verbosity=0)
+            call_command(command, stdout=out, no_color=True)
+            command_stdout = out.getvalue().lower()
+            self.assertEqual(
+                "operations to perform:\n"
+                "  apply all migrations: migrated_app\n"
+                "running migrations:\n"
+                "  no migrations to apply.\n",
+                command_stdout,
+            )
+        finally:
+            call_command(command, "migrated_app", "zero", verbosity=0)