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 django.core.checks.async_checks  # 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.database  # 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"
     async_support = "async_support"
     caches = "caches"
+    commands = "commands"
     compatibility = "compatibility"
     database = "database"
     files = "files"

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

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

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

@@ -15,6 +15,7 @@ from django.utils.text import Truncator
 
 
 class Command(BaseCommand):
+    autodetector = MigrationAutodetector
     help = (
         "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.")
                 # If there's changes that aren't in migrations yet, tell them
                 # how to fix it.
-                autodetector = MigrationAutodetector(
+                autodetector = self.autodetector(
                     executor.loader.project_state(),
                     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.
 * ``caches``: Checks cache related configuration.
 * ``compatibility``: Flags potential problems with version upgrades.
+* ``commands``: Checks custom management commands related configuration.
 * ``database``: Checks database-related configuration issues. Database checks
   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
@@ -428,6 +429,14 @@ Models
 * **models.W047**: ``<database>`` does not support unique constraints with
   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
 --------
 

+ 4 - 0
docs/releases/5.2.txt

@@ -230,6 +230,10 @@ Management Commands
   setting the :envvar:`HIDE_PRODUCTION_WARNING` environment variable to
   ``"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
 ~~~~~~~~~~
 

+ 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.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 (
     ConnectionHandler,
     DatabaseError,
@@ -19,10 +23,11 @@ from django.db import (
 )
 from django.db.backends.base.schema import BaseDatabaseSchemaEditor
 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.recorder import MigrationRecorder
 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.version import get_docs_version
 
@@ -3296,3 +3301,59 @@ class OptimizeMigrationTests(MigrationTestBase):
         msg = "Cannot find a migration matching 'nonexistent' from app 'migrations'."
         with self.assertRaisesMessage(CommandError, msg):
             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)