From a1487deebff7bf27a4946a9f5ddd68154fa4834d Mon Sep 17 00:00:00 2001 From: Markus Holtermann Date: Wed, 3 Sep 2014 10:51:07 +0200 Subject: [PATCH] Fixed #23359 -- Added showmigrations command to list migrations and plan. Thanks to Collin Anderson, Tim Graham, Gabe Jackson, and Marc Tamlyn for their input, ideas, and review. --- django/core/management/commands/migrate.py | 60 ++----- .../management/commands/showmigrations.py | 116 ++++++++++++++ docs/internals/deprecation.txt | 2 + docs/ref/django-admin.txt | 37 ++++- docs/releases/1.8.txt | 10 ++ tests/migrations/test_commands.py | 146 +++++++++++++++++- .../test_migrations_empty/__init__.py | 0 7 files changed, 314 insertions(+), 57 deletions(-) create mode 100644 django/core/management/commands/showmigrations.py create mode 100644 tests/migrations/test_migrations_empty/__init__.py diff --git a/django/core/management/commands/migrate.py b/django/core/management/commands/migrate.py index d63cb1f339..33d7e44703 100644 --- a/django/core/management/commands/migrate.py +++ b/django/core/management/commands/migrate.py @@ -5,6 +5,7 @@ from collections import OrderedDict from importlib import import_module import itertools import traceback +import warnings from django.apps import apps from django.core.management import call_command @@ -13,9 +14,10 @@ from django.core.management.color import no_style from django.core.management.sql import custom_sql_for_model, emit_post_migrate_signal, emit_pre_migrate_signal from django.db import connections, router, transaction, DEFAULT_DB_ALIAS from django.db.migrations.executor import MigrationExecutor -from django.db.migrations.loader import MigrationLoader, AmbiguityError +from django.db.migrations.loader import AmbiguityError from django.db.migrations.state import ProjectState from django.db.migrations.autodetector import MigrationAutodetector +from django.utils.deprecation import RemovedInDjango20Warning from django.utils.module_loading import module_has_submodule @@ -62,7 +64,20 @@ class Command(BaseCommand): # If they asked for a migration listing, quit main execution flow and show it if options.get("list", False): - return self.show_migration_list(connection, [options['app_label']] if options['app_label'] else None) + warnings.warn( + "The 'migrate --list' command is deprecated. Use 'showmigrations' instead.", + RemovedInDjango20Warning, stacklevel=2) + return call_command( + 'showmigrations', + '--list', + app_labels=[options['app_label']] if options['app_label'] else None, + database=db, + no_color=options.get('no-color'), + settings=options.get('settings'), + stdout=options.get('stdout', self.stdout), + traceback=self.show_traceback, + verbosity=self.verbosity, + ) # Hook for backends needing any database preparation connection.prepare_database() @@ -325,44 +340,3 @@ class Command(BaseCommand): ) return created_models - - def show_migration_list(self, connection, app_names=None): - """ - Shows a list of all migrations on the system, or only those of - some named apps. - """ - # Load migrations from disk/DB - loader = MigrationLoader(connection) - graph = loader.graph - # If we were passed a list of apps, validate it - if app_names: - invalid_apps = [] - for app_name in app_names: - if app_name not in loader.migrated_apps: - invalid_apps.append(app_name) - if invalid_apps: - raise CommandError("No migrations present for: %s" % (", ".join(invalid_apps))) - # Otherwise, show all apps in alphabetic order - else: - app_names = sorted(loader.migrated_apps) - # For each app, print its migrations in order from oldest (roots) to - # newest (leaves). - for app_name in app_names: - self.stdout.write(app_name, self.style.MIGRATE_LABEL) - shown = set() - for node in graph.leaf_nodes(app_name): - for plan_node in graph.forwards_plan(node): - if plan_node not in shown and plan_node[0] == app_name: - # Give it a nice title if it's a squashed one - title = plan_node[1] - if graph.nodes[plan_node].replaces: - title += " (%s squashed migrations)" % len(graph.nodes[plan_node].replaces) - # Mark it as applied/unapplied - if plan_node in loader.applied_migrations: - self.stdout.write(" [X] %s" % title) - else: - self.stdout.write(" [ ] %s" % title) - shown.add(plan_node) - # If we didn't print anything, then a small message - if not shown: - self.stdout.write(" (no migrations)", self.style.MIGRATE_FAILURE) diff --git a/django/core/management/commands/showmigrations.py b/django/core/management/commands/showmigrations.py new file mode 100644 index 0000000000..9ed9e1d052 --- /dev/null +++ b/django/core/management/commands/showmigrations.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.core.management.base import BaseCommand, CommandError +from django.db import connections, DEFAULT_DB_ALIAS +from django.db.migrations.loader import MigrationLoader + + +class Command(BaseCommand): + help = "Shows all available migrations for the current project" + + def add_arguments(self, parser): + parser.add_argument('app_labels', nargs='*', + help='App labels of applications to limit the output to.') + parser.add_argument('--database', action='store', dest='database', default=DEFAULT_DB_ALIAS, + help='Nominates a database to synchronize. Defaults to the "default" database.') + + formats = parser.add_mutually_exclusive_group() + formats.add_argument('--list', '-l', action='store_const', dest='format', const='list', + help='Shows a list of all migrations and which are applied.') + formats.add_argument('--plan', '-p', action='store_const', dest='format', const='plan', + help='Shows all migrations in the order they will be applied.') + + parser.set_defaults(format='list') + + def handle(self, *args, **options): + self.verbosity = options.get('verbosity') + + # Get the database we're operating from + db = options.get('database') + connection = connections[db] + + if options['format'] == "plan": + return self.show_plan(connection) + else: + return self.show_list(connection, options['app_labels']) + + def show_list(self, connection, app_names=None): + """ + Shows a list of all migrations on the system, or only those of + some named apps. + """ + # Load migrations from disk/DB + loader = MigrationLoader(connection) + graph = loader.graph + # If we were passed a list of apps, validate it + if app_names: + invalid_apps = [] + for app_name in app_names: + if app_name not in loader.migrated_apps: + invalid_apps.append(app_name) + if invalid_apps: + raise CommandError("No migrations present for: %s" % (", ".join(invalid_apps))) + # Otherwise, show all apps in alphabetic order + else: + app_names = sorted(loader.migrated_apps) + # For each app, print its migrations in order from oldest (roots) to + # newest (leaves). + for app_name in app_names: + self.stdout.write(app_name, self.style.MIGRATE_LABEL) + shown = set() + for node in graph.leaf_nodes(app_name): + for plan_node in graph.forwards_plan(node): + if plan_node not in shown and plan_node[0] == app_name: + # Give it a nice title if it's a squashed one + title = plan_node[1] + if graph.nodes[plan_node].replaces: + title += " (%s squashed migrations)" % len(graph.nodes[plan_node].replaces) + # Mark it as applied/unapplied + if plan_node in loader.applied_migrations: + self.stdout.write(" [X] %s" % title) + else: + self.stdout.write(" [ ] %s" % title) + shown.add(plan_node) + # If we didn't print anything, then a small message + if not shown: + self.stdout.write(" (no migrations)", self.style.MIGRATE_FAILURE) + + def show_plan(self, connection): + """ + Shows all known migrations in the order they will be applied + """ + # Load migrations from disk/DB + loader = MigrationLoader(connection) + graph = loader.graph + targets = graph.leaf_nodes() + plan = [] + seen = set() + + # Generate the plan + for target in targets: + for migration in graph.forwards_plan(target): + if migration not in seen: + plan.append(graph.nodes[migration]) + seen.add(migration) + + # Output + def print_deps(migration): + out = [] + for dep in migration.dependencies: + if dep[1] == "__first__": + roots = graph.root_nodes(dep[0]) + dep = roots[0] if roots else (dep[0], "__first__") + out.append("%s.%s" % dep) + if out: + return " ... (%s)" % ", ".join(out) + return "" + + for migration in plan: + deps = "" + if self.verbosity >= 2: + deps = print_deps(migration) + if (migration.app_label, migration.name) in loader.applied_migrations: + self.stdout.write("[X] %s%s" % (migration, deps)) + else: + self.stdout.write("[ ] %s%s" % (migration, deps)) diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index 8aa2c09c4d..70db0c0497 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -123,6 +123,8 @@ details on these changes. * Private attribute ``django.db.models.Field.related`` will be removed. +* The ``--list`` option of the ``migrate`` management command will be removed. + .. _deprecation-removed-in-1.9: 1.9 diff --git a/docs/ref/django-admin.txt b/docs/ref/django-admin.txt index 08ca142a14..de556b28b7 100644 --- a/docs/ref/django-admin.txt +++ b/docs/ref/django-admin.txt @@ -770,15 +770,10 @@ be warned that using ``--fake`` runs the risk of putting the migration state table into a state where manual recovery will be needed to make migrations run correctly. -.. django-admin-option:: --list, -l - -The ``--list`` option will list all of the apps Django knows about, the -migrations available for each app and if they are applied or not (marked by -an ``[X]`` next to the migration name). - -Apps without migrations are also included in the list, but will have -``(no migrations)`` printed under them. +.. deprecated:: 1.8 + The ``--list`` option has been moved to the :djadmin:`showmigrations` + command. runfcgi [options] ----------------- @@ -1088,6 +1083,32 @@ behavior you can use the ``--no-startup`` option. e.g.:: django-admin shell --plain --no-startup +showmigrations [ []] +------------------------------------------ + +.. django-admin:: showmigrations + +.. versionadded:: 1.8 + +Shows all migrations in a project. + +.. django-admin-option:: --list, -l + +The ``--list`` option lists all of the apps Django knows about, the +migrations available for each app, and whether or not each migrations is +applied (marked by an ``[X]`` next to the migration name). + +Apps without migrations are also listed, but have ``(no migrations)`` printed +under them. + +.. django-admin-option:: --plan, -p + +The ``--plan`` option shows the migration plan Django will follow to apply +migrations. Any supplied app labels are ignored because the plan might go +beyond those apps. Same as ``--list``, applied migrations are marked by an +``[X]``. For a verbosity of 2 and above, all dependencies of a migration will +also be shown. + sql ----------------------------- diff --git a/docs/releases/1.8.txt b/docs/releases/1.8.txt index f1e8eceee6..c869cbc5b7 100644 --- a/docs/releases/1.8.txt +++ b/docs/releases/1.8.txt @@ -394,6 +394,9 @@ Management Commands * :djadmin:`makemigrations` now supports an :djadminopt:`--exit` option to exit with an error code if no migrations are created. +* The new :djadmin:`showmigrations` command allows listing all migrations and + their dependencies in a project. + Middleware ^^^^^^^^^^ @@ -1134,6 +1137,13 @@ The class :class:`~django.core.management.NoArgsCommand` is now deprecated and will be removed in Django 2.0. Use :class:`~django.core.management.BaseCommand` instead, which takes no arguments by default. +Listing all migrations in a project +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``--list`` option of the :djadmin:`migrate` management command is +deprecated and will be removed in Django 2.0. Use :djadmin:`showmigrations` +instead. + ``cache_choices`` option of ``ModelChoiceField`` and ``ModelMultipleChoiceField`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/migrations/test_commands.py b/tests/migrations/test_commands.py index 5995468e98..a9505dd82e 100644 --- a/tests/migrations/test_commands.py +++ b/tests/migrations/test_commands.py @@ -9,8 +9,9 @@ from django.apps import apps from django.db import connection, models from django.core.management import call_command, CommandError from django.db.migrations import questioner -from django.test import override_settings +from django.test import ignore_warnings, override_settings from django.utils import six +from django.utils.deprecation import RemovedInDjango20Warning from django.utils.encoding import force_text from .models import UnicodeModel, UnserializableModel @@ -50,6 +51,15 @@ class MigrateTests(MigrationTestBase): self.assertTableNotExists("migrations_tribble") self.assertTableNotExists("migrations_book") + @override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations_conflict"}) + def test_migrate_conflict_exit(self): + """ + Makes sure that migrate exits if it detects a conflict. + """ + with self.assertRaisesMessage(CommandError, "Conflicting migrations detected"): + call_command("migrate", "migrations") + + @ignore_warnings(category=RemovedInDjango20Warning) @override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations"}) def test_migrate_list(self): """ @@ -72,13 +82,137 @@ class MigrateTests(MigrationTestBase): # Cleanup by unmigrating everything call_command("migrate", "migrations", "zero", verbosity=0) - @override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations_conflict"}) - def test_migrate_conflict_exit(self): + @override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations"}) + def test_showmigrations_list(self): """ - Makes sure that migrate exits if it detects a conflict. + Tests --list output of showmigrations command """ - with self.assertRaises(CommandError): - call_command("migrate", "migrations") + out = six.StringIO() + call_command("showmigrations", format='list', stdout=out, verbosity=0) + self.assertIn("migrations", out.getvalue().lower()) + self.assertIn("[ ] 0001_initial", out.getvalue().lower()) + self.assertIn("[ ] 0002_second", out.getvalue().lower()) + + call_command("migrate", "migrations", "0001", verbosity=0) + + out = six.StringIO() + # Giving the explicit app_label tests for selective `show_list` in the command + call_command("showmigrations", "migrations", format='list', stdout=out, verbosity=0) + self.assertIn("migrations", out.getvalue().lower()) + self.assertIn("[x] 0001_initial", out.getvalue().lower()) + self.assertIn("[ ] 0002_second", out.getvalue().lower()) + # Cleanup by unmigrating everything + call_command("migrate", "migrations", "zero", verbosity=0) + + @override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations_run_before"}) + def test_showmigrations_plan(self): + """ + Tests --plan output of showmigrations command + """ + out = six.StringIO() + call_command("showmigrations", format='plan', stdout=out) + self.assertIn( + "[ ] migrations.0001_initial\n" + "[ ] migrations.0003_third\n" + "[ ] migrations.0002_second", + out.getvalue().lower() + ) + + out = six.StringIO() + call_command("showmigrations", format='plan', stdout=out, verbosity=2) + self.assertIn( + "[ ] migrations.0001_initial\n" + "[ ] migrations.0003_third ... (migrations.0001_initial)\n" + "[ ] migrations.0002_second ... (migrations.0001_initial)", + out.getvalue().lower() + ) + + call_command("migrate", "migrations", "0003", verbosity=0) + + out = six.StringIO() + call_command("showmigrations", format='plan', stdout=out) + self.assertIn( + "[x] migrations.0001_initial\n" + "[x] migrations.0003_third\n" + "[ ] migrations.0002_second", + out.getvalue().lower() + ) + + out = six.StringIO() + call_command("showmigrations", format='plan', stdout=out, verbosity=2) + self.assertIn( + "[x] migrations.0001_initial\n" + "[x] migrations.0003_third ... (migrations.0001_initial)\n" + "[ ] migrations.0002_second ... (migrations.0001_initial)", + out.getvalue().lower() + ) + + # Cleanup by unmigrating everything + call_command("migrate", "migrations", "zero", verbosity=0) + + @override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations_empty"}) + def test_showmigrations_plan_no_migrations(self): + """ + Tests --plan output of showmigrations command without migrations + """ + out = six.StringIO() + call_command("showmigrations", format='plan', stdout=out) + self.assertEqual("", out.getvalue().lower()) + + out = six.StringIO() + call_command("showmigrations", format='plan', stdout=out, verbosity=2) + self.assertEqual("", out.getvalue().lower()) + + @override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations_squashed_complex"}) + def test_showmigrations_plan_squashed(self): + """ + Tests --plan output of showmigrations command with squashed migrations. + """ + out = six.StringIO() + call_command("showmigrations", format='plan', stdout=out) + self.assertEqual( + "[ ] migrations.1_auto\n" + "[ ] migrations.2_auto\n" + "[ ] migrations.3_squashed_5\n" + "[ ] migrations.6_auto\n" + "[ ] migrations.7_auto\n", + out.getvalue().lower() + ) + + out = six.StringIO() + call_command("showmigrations", format='plan', stdout=out, verbosity=2) + self.assertEqual( + "[ ] migrations.1_auto\n" + "[ ] migrations.2_auto ... (migrations.1_auto)\n" + "[ ] migrations.3_squashed_5 ... (migrations.2_auto)\n" + "[ ] migrations.6_auto ... (migrations.3_squashed_5)\n" + "[ ] migrations.7_auto ... (migrations.6_auto)\n", + out.getvalue().lower() + ) + + call_command("migrate", "migrations", "3_squashed_5", verbosity=0) + + out = six.StringIO() + call_command("showmigrations", format='plan', stdout=out) + self.assertEqual( + "[x] migrations.1_auto\n" + "[x] migrations.2_auto\n" + "[x] migrations.3_squashed_5\n" + "[ ] migrations.6_auto\n" + "[ ] migrations.7_auto\n", + out.getvalue().lower() + ) + + out = six.StringIO() + call_command("showmigrations", format='plan', stdout=out, verbosity=2) + self.assertEqual( + "[x] migrations.1_auto\n" + "[x] migrations.2_auto ... (migrations.1_auto)\n" + "[x] migrations.3_squashed_5 ... (migrations.2_auto)\n" + "[ ] migrations.6_auto ... (migrations.3_squashed_5)\n" + "[ ] migrations.7_auto ... (migrations.6_auto)\n", + out.getvalue().lower() + ) @override_settings(MIGRATION_MODULES={"migrations": "migrations.test_migrations"}) def test_sqlmigrate(self): diff --git a/tests/migrations/test_migrations_empty/__init__.py b/tests/migrations/test_migrations_empty/__init__.py new file mode 100644 index 0000000000..e69de29bb2