diff --git a/django/core/checks/__init__.py b/django/core/checks/__init__.py index 998ab9dee2..2502450cdf 100644 --- a/django/core/checks/__init__.py +++ b/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 diff --git a/django/core/checks/commands.py b/django/core/checks/commands.py new file mode 100644 index 0000000000..eee1e937e8 --- /dev/null +++ b/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 [] diff --git a/django/core/checks/registry.py b/django/core/checks/registry.py index 146b28f65e..3139fc3ef4 100644 --- a/django/core/checks/registry.py +++ b/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" diff --git a/django/core/management/commands/makemigrations.py b/django/core/management/commands/makemigrations.py index a4e4d520e6..d5d3466201 100644 --- a/django/core/management/commands/makemigrations.py +++ b/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: diff --git a/django/core/management/commands/migrate.py b/django/core/management/commands/migrate.py index 5e6b19c095..fa420ee6e3 100644 --- a/django/core/management/commands/migrate.py +++ b/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), ) diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt index d78a6f76b2..2308a854c7 100644 --- a/docs/ref/checks.txt +++ b/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**: ```` 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 -------- diff --git a/docs/releases/5.2.txt b/docs/releases/5.2.txt index eabc27c277..78c96688cf 100644 --- a/docs/releases/5.2.txt +++ b/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 ~~~~~~~~~~ diff --git a/tests/check_framework/custom_commands_app/management/commands/makemigrations.py b/tests/check_framework/custom_commands_app/management/commands/makemigrations.py new file mode 100644 index 0000000000..a6494cba4c --- /dev/null +++ b/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 diff --git a/tests/check_framework/test_commands.py b/tests/check_framework/test_commands.py new file mode 100644 index 0000000000..a51db77402 --- /dev/null +++ b/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], + ) diff --git a/tests/migrations/test_commands.py b/tests/migrations/test_commands.py index cab2906ed1..724c88a28f 100644 --- a/tests/migrations/test_commands.py +++ b/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)