1import os
2import subprocess
3import sys
4import time
5
6import click
7
8from plain.cli import register_cli
9from plain.packages import packages_registry
10from plain.runtime import settings
11from plain.utils.text import Truncator
12
13from . import migrations
14from .backups.cli import cli as backups_cli
15from .backups.cli import create_backup
16from .db import OperationalError, db_connection
17from .migrations.autodetector import MigrationAutodetector
18from .migrations.executor import MigrationExecutor
19from .migrations.loader import AmbiguityError, MigrationLoader
20from .migrations.migration import Migration, SettingsTuple
21from .migrations.optimizer import MigrationOptimizer
22from .migrations.questioner import (
23 InteractiveMigrationQuestioner,
24 NonInteractiveMigrationQuestioner,
25)
26from .migrations.recorder import MigrationRecorder
27from .migrations.state import ModelState, ProjectState
28from .migrations.writer import MigrationWriter
29from .registry import models_registry
30
31
32@register_cli("models")
33@click.group()
34def cli():
35 pass
36
37
38cli.add_command(backups_cli)
39
40
41@cli.command()
42@click.argument("parameters", nargs=-1)
43def db_shell(parameters):
44 """Runs the command-line client for specified database, or the default database if none is provided."""
45 try:
46 db_connection.client.runshell(parameters)
47 except FileNotFoundError:
48 # Note that we're assuming the FileNotFoundError relates to the
49 # command missing. It could be raised for some other reason, in
50 # which case this error message would be inaccurate. Still, this
51 # message catches the common case.
52 click.secho(
53 f"You appear not to have the {db_connection.client.executable_name!r} program installed or on your path.",
54 fg="red",
55 err=True,
56 )
57 sys.exit(1)
58 except subprocess.CalledProcessError as e:
59 click.secho(
60 '"{}" returned non-zero exit status {}.'.format(
61 " ".join(e.cmd),
62 e.returncode,
63 ),
64 fg="red",
65 err=True,
66 )
67 sys.exit(e.returncode)
68
69
70@cli.command()
71def db_wait():
72 """Wait for the database to be ready"""
73 attempts = 0
74 while True:
75 attempts += 1
76 waiting_for = False
77
78 try:
79 db_connection.ensure_connection()
80 except OperationalError:
81 waiting_for = True
82
83 if waiting_for:
84 if attempts > 1:
85 # After the first attempt, start printing them
86 click.secho(
87 f"Waiting for database (attempt {attempts})",
88 fg="yellow",
89 )
90 time.sleep(1.5)
91 else:
92 click.secho("✔ Database ready", fg="green")
93 break
94
95
96@cli.command(name="list")
97@click.argument("package_labels", nargs=-1)
98@click.option(
99 "--app-only",
100 is_flag=True,
101 help="Only show models from packages that start with 'app'.",
102)
103def list_models(package_labels, app_only):
104 """List installed models."""
105
106 packages = set(package_labels)
107
108 for model in sorted(
109 models_registry.get_models(),
110 key=lambda m: (m._meta.package_label, m._meta.model_name),
111 ):
112 pkg = model._meta.package_label
113 pkg_name = packages_registry.get_package_config(pkg).name
114 if app_only and not pkg_name.startswith("app"):
115 continue
116 if packages and pkg not in packages:
117 continue
118 fields = ", ".join(f.name for f in model._meta.get_fields())
119 click.echo(
120 f"{click.style(pkg, fg='cyan')}.{click.style(model.__name__, fg='blue')}"
121 )
122 click.echo(f" table: {model._meta.db_table}")
123 click.echo(f" fields: {fields}")
124 click.echo(f" package: {pkg_name}\n")
125
126
127@register_cli("makemigrations")
128@cli.command()
129@click.argument("package_labels", nargs=-1)
130@click.option(
131 "--dry-run",
132 is_flag=True,
133 help="Just show what migrations would be made; don't actually write them.",
134)
135@click.option("--empty", is_flag=True, help="Create an empty migration.")
136@click.option(
137 "--noinput",
138 "--no-input",
139 "no_input",
140 is_flag=True,
141 help="Tells Plain to NOT prompt the user for input of any kind.",
142)
143@click.option("-n", "--name", help="Use this name for migration file(s).")
144@click.option(
145 "--check",
146 is_flag=True,
147 help="Exit with a non-zero status if model changes are missing migrations and don't actually write them.",
148)
149@click.option(
150 "-v",
151 "--verbosity",
152 type=int,
153 default=1,
154 help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",
155)
156def makemigrations(package_labels, dry_run, empty, no_input, name, check, verbosity):
157 """Creates new migration(s) for packages."""
158
159 written_files = []
160 interactive = not no_input
161 migration_name = name
162 check_changes = check
163
164 def log(msg, level=1):
165 if verbosity >= level:
166 click.echo(msg)
167
168 def write_migration_files(changes, update_previous_migration_paths=None):
169 """Take a changes dict and write them out as migration files."""
170 directory_created = {}
171 for package_label, package_migrations in changes.items():
172 log(
173 click.style(f"Migrations for '{package_label}':", fg="cyan", bold=True),
174 level=1,
175 )
176 for migration in package_migrations:
177 writer = MigrationWriter(migration)
178 migration_string = os.path.relpath(writer.path)
179 log(f" {click.style(migration_string, fg='yellow')}\n", level=1)
180 for operation in migration.operations:
181 log(f" - {operation.describe()}", level=1)
182
183 if not dry_run:
184 migrations_directory = os.path.dirname(writer.path)
185 if not directory_created.get(package_label):
186 os.makedirs(migrations_directory, exist_ok=True)
187 init_path = os.path.join(migrations_directory, "__init__.py")
188 if not os.path.isfile(init_path):
189 open(init_path, "w").close()
190 directory_created[package_label] = True
191
192 migration_string = writer.as_string()
193 with open(writer.path, "w", encoding="utf-8") as fh:
194 fh.write(migration_string)
195 written_files.append(writer.path)
196
197 if update_previous_migration_paths:
198 prev_path = update_previous_migration_paths[package_label]
199 if writer.needs_manual_porting:
200 log(
201 click.style(
202 f"Updated migration {migration_string} requires manual porting.\n"
203 f"Previous migration {os.path.relpath(prev_path)} was kept and "
204 f"must be deleted after porting functions manually.",
205 fg="yellow",
206 ),
207 level=1,
208 )
209 else:
210 os.remove(prev_path)
211 log(f"Deleted {os.path.relpath(prev_path)}", level=1)
212 elif verbosity >= 3:
213 log(
214 click.style(
215 f"Full migrations file '{writer.filename}':",
216 fg="cyan",
217 bold=True,
218 ),
219 level=3,
220 )
221 log(writer.as_string(), level=3)
222
223 # Validate package labels
224 package_labels = set(package_labels)
225 has_bad_labels = False
226 for package_label in package_labels:
227 try:
228 packages_registry.get_package_config(package_label)
229 except LookupError as err:
230 click.echo(str(err), err=True)
231 has_bad_labels = True
232 if has_bad_labels:
233 sys.exit(2)
234
235 # Load the current graph state
236 loader = MigrationLoader(None, ignore_no_migrations=True)
237
238 # Raise an error if any migrations are applied before their dependencies.
239 # Only the default db_connection is supported.
240 loader.check_consistent_history(db_connection)
241
242 # Check for conflicts
243 conflicts = loader.detect_conflicts()
244 if package_labels:
245 conflicts = {
246 package_label: conflict
247 for package_label, conflict in conflicts.items()
248 if package_label in package_labels
249 }
250
251 if conflicts:
252 name_str = "; ".join(
253 "{} in {}".format(", ".join(names), package)
254 for package, names in conflicts.items()
255 )
256 raise click.ClickException(
257 f"Conflicting migrations detected; multiple leaf nodes in the "
258 f"migration graph: ({name_str})."
259 )
260
261 # Set up questioner
262 if interactive:
263 questioner = InteractiveMigrationQuestioner(
264 specified_packages=package_labels,
265 dry_run=dry_run,
266 )
267 else:
268 questioner = NonInteractiveMigrationQuestioner(
269 specified_packages=package_labels,
270 dry_run=dry_run,
271 verbosity=verbosity,
272 )
273
274 # Set up autodetector
275 autodetector = MigrationAutodetector(
276 loader.project_state(),
277 ProjectState.from_models_registry(models_registry),
278 questioner,
279 )
280
281 # Handle empty migrations if requested
282 if empty:
283 if not package_labels:
284 raise click.ClickException(
285 "You must supply at least one package label when using --empty."
286 )
287 changes = {
288 package: [Migration("custom", package)] for package in package_labels
289 }
290 changes = autodetector.arrange_for_graph(
291 changes=changes,
292 graph=loader.graph,
293 migration_name=migration_name,
294 )
295 write_migration_files(changes)
296 return
297
298 # Detect changes
299 changes = autodetector.changes(
300 graph=loader.graph,
301 trim_to_packages=package_labels or None,
302 convert_packages=package_labels or None,
303 migration_name=migration_name,
304 )
305
306 if not changes:
307 log(
308 "No changes detected"
309 if not package_labels
310 else f"No changes detected in {'package' if len(package_labels) == 1 else 'packages'} "
311 f"'{', '.join(package_labels)}'",
312 level=1,
313 )
314 else:
315 if check_changes:
316 sys.exit(1)
317
318 write_migration_files(changes)
319
320
321@register_cli("migrate")
322@cli.command()
323@click.argument("package_label", required=False)
324@click.argument("migration_name", required=False)
325@click.option(
326 "--fake", is_flag=True, help="Mark migrations as run without actually running them."
327)
328@click.option(
329 "--fake-initial",
330 is_flag=True,
331 help="Detect if tables already exist and fake-apply initial migrations if so. Make sure that the current database schema matches your initial migration before using this flag. Plain will only check for an existing table name.",
332)
333@click.option(
334 "--plan",
335 is_flag=True,
336 help="Shows a list of the migration actions that will be performed.",
337)
338@click.option(
339 "--check",
340 "check_unapplied",
341 is_flag=True,
342 help="Exits with a non-zero status if unapplied migrations exist and does not actually apply migrations.",
343)
344@click.option(
345 "--backup/--no-backup",
346 "backup",
347 is_flag=True,
348 default=None,
349 help="Explicitly enable/disable pre-migration backups.",
350)
351@click.option(
352 "--prune",
353 is_flag=True,
354 help="Delete nonexistent migrations from the plainmigrations table.",
355)
356@click.option(
357 "-v",
358 "--verbosity",
359 type=int,
360 default=1,
361 help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",
362)
363def migrate(
364 package_label,
365 migration_name,
366 fake,
367 fake_initial,
368 plan,
369 check_unapplied,
370 backup,
371 prune,
372 verbosity,
373):
374 """Updates database schema. Manages both packages with migrations and those without."""
375
376 def migration_progress_callback(action, migration=None, fake=False):
377 if verbosity >= 1:
378 if action == "apply_start":
379 click.echo(f" Applying {migration}...", nl=False)
380 elif action == "apply_success":
381 if fake:
382 click.echo(click.style(" FAKED", fg="green"))
383 else:
384 click.echo(click.style(" OK", fg="green"))
385 elif action == "render_start":
386 click.echo(" Rendering model states...", nl=False)
387 elif action == "render_success":
388 click.echo(click.style(" DONE", fg="green"))
389
390 def describe_operation(operation):
391 """Return a string that describes a migration operation for --plan."""
392 prefix = ""
393 is_error = False
394 if hasattr(operation, "code"):
395 code = operation.code
396 action = (code.__doc__ or "") if code else None
397 elif hasattr(operation, "sql"):
398 action = operation.sql
399 else:
400 action = ""
401 if action is not None:
402 action = str(action).replace("\n", "")
403 if action:
404 action = " -> " + action
405 truncated = Truncator(action)
406 return prefix + operation.describe() + truncated.chars(40), is_error
407
408 # Get the database we're operating from
409 # Hook for backends needing any database preparation
410 db_connection.prepare_database()
411
412 # Work out which packages have migrations and which do not
413 executor = MigrationExecutor(db_connection, migration_progress_callback)
414
415 # Raise an error if any migrations are applied before their dependencies.
416 executor.loader.check_consistent_history(db_connection)
417
418 # Before anything else, see if there's conflicting packages and drop out
419 # hard if there are any
420 conflicts = executor.loader.detect_conflicts()
421 if conflicts:
422 name_str = "; ".join(
423 "{} in {}".format(", ".join(names), package)
424 for package, names in conflicts.items()
425 )
426 raise click.ClickException(
427 "Conflicting migrations detected; multiple leaf nodes in the "
428 f"migration graph: ({name_str})."
429 )
430
431 # If they supplied command line arguments, work out what they mean.
432 target_package_labels_only = True
433 if package_label:
434 try:
435 packages_registry.get_package_config(package_label)
436 except LookupError as err:
437 raise click.ClickException(str(err))
438
439 if package_label not in executor.loader.migrated_packages:
440 raise click.ClickException(
441 f"Package '{package_label}' does not have migrations."
442 )
443
444 if package_label and migration_name:
445 try:
446 migration = executor.loader.get_migration_by_prefix(
447 package_label, migration_name
448 )
449 except AmbiguityError:
450 raise click.ClickException(
451 f"More than one migration matches '{migration_name}' in package '{package_label}'. "
452 "Please be more specific."
453 )
454 except KeyError:
455 raise click.ClickException(
456 f"Cannot find a migration matching '{migration_name}' from package '{package_label}'."
457 )
458 target = (package_label, migration.name)
459 if (
460 target not in executor.loader.graph.nodes
461 and target in executor.loader.replacements
462 ):
463 incomplete_migration = executor.loader.replacements[target]
464 target = incomplete_migration.replaces[-1]
465 targets = [target]
466 target_package_labels_only = False
467 elif package_label:
468 targets = [
469 key for key in executor.loader.graph.leaf_nodes() if key[0] == package_label
470 ]
471 else:
472 targets = executor.loader.graph.leaf_nodes()
473
474 if prune:
475 if not package_label:
476 raise click.ClickException(
477 "Migrations can be pruned only when a package is specified."
478 )
479 if verbosity > 0:
480 click.echo("Pruning migrations:", color="cyan")
481 to_prune = set(executor.loader.applied_migrations) - set(
482 executor.loader.disk_migrations
483 )
484 squashed_migrations_with_deleted_replaced_migrations = [
485 migration_key
486 for migration_key, migration_obj in executor.loader.replacements.items()
487 if any(replaced in to_prune for replaced in migration_obj.replaces)
488 ]
489 if squashed_migrations_with_deleted_replaced_migrations:
490 click.echo(
491 click.style(
492 " Cannot use --prune because the following squashed "
493 "migrations have their 'replaces' attributes and may not "
494 "be recorded as applied:",
495 fg="yellow",
496 )
497 )
498 for migration in squashed_migrations_with_deleted_replaced_migrations:
499 package, name = migration
500 click.echo(f" {package}.{name}")
501 click.echo(
502 click.style(
503 " Re-run 'manage.py migrate' if they are not marked as "
504 "applied, and remove 'replaces' attributes in their "
505 "Migration classes.",
506 fg="yellow",
507 )
508 )
509 else:
510 to_prune = sorted(
511 migration for migration in to_prune if migration[0] == package_label
512 )
513 if to_prune:
514 for migration in to_prune:
515 package, name = migration
516 if verbosity > 0:
517 click.echo(
518 click.style(f" Pruning {package}.{name}", fg="yellow"),
519 nl=False,
520 )
521 executor.recorder.record_unapplied(package, name)
522 if verbosity > 0:
523 click.echo(click.style(" OK", fg="green"))
524 elif verbosity > 0:
525 click.echo(" No migrations to prune.")
526
527 migration_plan = executor.migration_plan(targets)
528
529 if plan:
530 click.echo("Planned operations:", color="cyan")
531 if not migration_plan:
532 click.echo(" No planned migration operations.")
533 else:
534 for migration in migration_plan:
535 click.echo(str(migration), color="cyan")
536 for operation in migration.operations:
537 message, is_error = describe_operation(operation)
538 if is_error:
539 click.echo(" " + message, fg="yellow")
540 else:
541 click.echo(" " + message)
542 if check_unapplied:
543 sys.exit(1)
544 return
545
546 if check_unapplied:
547 if migration_plan:
548 sys.exit(1)
549 return
550
551 if prune:
552 return
553
554 # Print some useful info
555 if verbosity >= 1:
556 click.echo("Operations to perform:", color="cyan")
557
558 if target_package_labels_only:
559 click.echo(
560 " Apply all migrations: "
561 + (", ".join(sorted({a for a, n in targets})) or "(none)"),
562 color="yellow",
563 )
564 else:
565 click.echo(
566 f" Target specific migration: {targets[0][1]}, from {targets[0][0]}",
567 color="yellow",
568 )
569
570 pre_migrate_state = executor._create_project_state(with_applied_migrations=True)
571
572 # sql = executor.loader.collect_sql(migration_plan)
573 # pprint(sql)
574
575 if migration_plan:
576 if backup or (
577 backup is None
578 and settings.DEBUG
579 and click.confirm(
580 "\nYou are in DEBUG mode. Would you like to make a database backup before running migrations?",
581 default=True,
582 )
583 ):
584 backup_name = f"migrate_{time.strftime('%Y%m%d_%H%M%S')}"
585 # Can't use ctx.invoke because this is called by the test db creation currently,
586 # which doesn't have a context.
587 create_backup.callback(
588 backup_name=backup_name,
589 pg_dump=os.environ.get(
590 "PG_DUMP", "pg_dump"
591 ), # Have to this again manually
592 )
593 print()
594
595 if verbosity >= 1:
596 click.echo("Running migrations:", color="cyan")
597
598 post_migrate_state = executor.migrate(
599 targets,
600 plan=migration_plan,
601 state=pre_migrate_state.clone(),
602 fake=fake,
603 fake_initial=fake_initial,
604 )
605 # post_migrate signals have access to all models. Ensure that all models
606 # are reloaded in case any are delayed.
607 post_migrate_state.clear_delayed_models_cache()
608 post_migrate_packages = post_migrate_state.models_registry
609
610 # Re-render models of real packages to include relationships now that
611 # we've got a final state. This wouldn't be necessary if real packages
612 # models were rendered with relationships in the first place.
613 with post_migrate_packages.bulk_update():
614 model_keys = []
615 for model_state in post_migrate_packages.real_models:
616 model_key = model_state.package_label, model_state.name_lower
617 model_keys.append(model_key)
618 post_migrate_packages.unregister_model(*model_key)
619 post_migrate_packages.render_multiple(
620 [
621 ModelState.from_model(models_registry.get_model(*model))
622 for model in model_keys
623 ]
624 )
625
626 elif verbosity >= 1:
627 click.echo(" No migrations to apply.")
628 # If there's changes that aren't in migrations yet, tell them
629 # how to fix it.
630 autodetector = MigrationAutodetector(
631 executor.loader.project_state(),
632 ProjectState.from_models_registry(models_registry),
633 )
634 changes = autodetector.changes(graph=executor.loader.graph)
635 if changes:
636 click.echo(
637 click.style(
638 f" Your models in package(s): {', '.join(repr(package) for package in sorted(changes))} "
639 "have changes that are not yet reflected in a migration, and so won't be applied.",
640 fg="yellow",
641 )
642 )
643 click.echo(
644 click.style(
645 " Run 'manage.py makemigrations' to make new "
646 "migrations, and then re-run 'manage.py migrate' to "
647 "apply them.",
648 fg="yellow",
649 )
650 )
651
652
653@cli.command()
654@click.argument("package_label")
655@click.argument("migration_name")
656@click.option(
657 "--check",
658 is_flag=True,
659 help="Exit with a non-zero status if the migration can be optimized.",
660)
661@click.option(
662 "-v",
663 "--verbosity",
664 type=int,
665 default=1,
666 help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",
667)
668def optimize_migration(package_label, migration_name, check, verbosity):
669 """Optimizes the operations for the named migration."""
670 try:
671 packages_registry.get_package_config(package_label)
672 except LookupError as err:
673 raise click.ClickException(str(err))
674
675 # Load the current graph state.
676 loader = MigrationLoader(None)
677 if package_label not in loader.migrated_packages:
678 raise click.ClickException(
679 f"Package '{package_label}' does not have migrations."
680 )
681
682 # Find a migration.
683 try:
684 migration = loader.get_migration_by_prefix(package_label, migration_name)
685 except AmbiguityError:
686 raise click.ClickException(
687 f"More than one migration matches '{migration_name}' in package "
688 f"'{package_label}'. Please be more specific."
689 )
690 except KeyError:
691 raise click.ClickException(
692 f"Cannot find a migration matching '{migration_name}' from package "
693 f"'{package_label}'."
694 )
695
696 # Optimize the migration.
697 optimizer = MigrationOptimizer()
698 new_operations = optimizer.optimize(migration.operations, migration.package_label)
699 if len(migration.operations) == len(new_operations):
700 if verbosity > 0:
701 click.echo("No optimizations possible.")
702 return
703 else:
704 if verbosity > 0:
705 click.echo(
706 f"Optimizing from {len(migration.operations)} operations to {len(new_operations)} operations."
707 )
708 if check:
709 sys.exit(1)
710
711 # Set the new migration optimizations.
712 migration.operations = new_operations
713
714 # Write out the optimized migration file.
715 writer = MigrationWriter(migration)
716 migration_file_string = writer.as_string()
717 if writer.needs_manual_porting:
718 if migration.replaces:
719 raise click.ClickException(
720 "Migration will require manual porting but is already a squashed "
721 "migration.\nTransition to a normal migration first."
722 )
723 # Make a new migration with those operations.
724 subclass = type(
725 "Migration",
726 (migrations.Migration,),
727 {
728 "dependencies": migration.dependencies,
729 "operations": new_operations,
730 "replaces": [(migration.package_label, migration.name)],
731 },
732 )
733 optimized_migration_name = f"{migration.name}_optimized"
734 optimized_migration = subclass(optimized_migration_name, package_label)
735 writer = MigrationWriter(optimized_migration)
736 migration_file_string = writer.as_string()
737 if verbosity > 0:
738 click.echo(click.style("Manual porting required", fg="yellow", bold=True))
739 click.echo(
740 " Your migrations contained functions that must be manually "
741 "copied over,\n"
742 " as we could not safely copy their implementation.\n"
743 " See the comment at the top of the optimized migration for "
744 "details."
745 )
746
747 with open(writer.path, "w", encoding="utf-8") as fh:
748 fh.write(migration_file_string)
749
750 if verbosity > 0:
751 click.echo(
752 click.style(f"Optimized migration {writer.path}", fg="green", bold=True)
753 )
754
755
756@cli.command()
757@click.argument("package_labels", nargs=-1)
758@click.option(
759 "--format",
760 type=click.Choice(["list", "plan"]),
761 default="list",
762 help="Output format.",
763)
764@click.option(
765 "-v",
766 "--verbosity",
767 type=int,
768 default=1,
769 help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",
770)
771def show_migrations(package_labels, format, verbosity):
772 """Shows all available migrations for the current project"""
773
774 def _validate_package_names(package_names):
775 has_bad_names = False
776 for package_name in package_names:
777 try:
778 packages_registry.get_package_config(package_name)
779 except LookupError as err:
780 click.echo(str(err), err=True)
781 has_bad_names = True
782 if has_bad_names:
783 sys.exit(2)
784
785 def show_list(db_connection, package_names):
786 """
787 Show a list of all migrations on the system, or only those of
788 some named packages.
789 """
790 # Load migrations from disk/DB
791 loader = MigrationLoader(db_connection, ignore_no_migrations=True)
792 recorder = MigrationRecorder(db_connection)
793 recorded_migrations = recorder.applied_migrations()
794
795 graph = loader.graph
796 # If we were passed a list of packages, validate it
797 if package_names:
798 _validate_package_names(package_names)
799 # Otherwise, show all packages in alphabetic order
800 else:
801 package_names = sorted(loader.migrated_packages)
802 # For each app, print its migrations in order from oldest (roots) to
803 # newest (leaves).
804 for package_name in package_names:
805 click.secho(package_name, fg="cyan", bold=True)
806 shown = set()
807 for node in graph.leaf_nodes(package_name):
808 for plan_node in graph.forwards_plan(node):
809 if plan_node not in shown and plan_node[0] == package_name:
810 # Give it a nice title if it's a squashed one
811 title = plan_node[1]
812 if graph.nodes[plan_node].replaces:
813 title += f" ({len(graph.nodes[plan_node].replaces)} squashed migrations)"
814 applied_migration = loader.applied_migrations.get(plan_node)
815 # Mark it as applied/unapplied
816 if applied_migration:
817 if plan_node in recorded_migrations:
818 output = f" [X] {title}"
819 else:
820 title += " Run 'manage.py migrate' to finish recording."
821 output = f" [-] {title}"
822 if verbosity >= 2 and hasattr(applied_migration, "applied"):
823 output += f" (applied at {applied_migration.applied.strftime('%Y-%m-%d %H:%M:%S')})"
824 click.echo(output)
825 else:
826 click.echo(f" [ ] {title}")
827 shown.add(plan_node)
828 # If we didn't print anything, then a small message
829 if not shown:
830 click.secho(" (no migrations)", fg="red")
831
832 # Find recorded migrations that aren't in the graph (prunable)
833 prunable_migrations = [
834 migration
835 for migration in recorded_migrations
836 if (
837 migration not in loader.disk_migrations
838 and (not package_names or migration[0] in package_names)
839 )
840 ]
841
842 if prunable_migrations:
843 click.echo()
844 click.secho(
845 "Recorded migrations not in migration files (candidates for pruning):",
846 fg="yellow",
847 bold=True,
848 )
849 prunable_by_package = {}
850 for migration in prunable_migrations:
851 package, name = migration
852 if package not in prunable_by_package:
853 prunable_by_package[package] = []
854 prunable_by_package[package].append(name)
855
856 for package in sorted(prunable_by_package.keys()):
857 click.secho(f" {package}:", fg="yellow")
858 for name in sorted(prunable_by_package[package]):
859 click.echo(f" - {name}")
860
861 def show_plan(db_connection, package_names):
862 """
863 Show all known migrations (or only those of the specified package_names)
864 in the order they will be applied.
865 """
866 # Load migrations from disk/DB
867 loader = MigrationLoader(db_connection)
868 graph = loader.graph
869 if package_names:
870 _validate_package_names(package_names)
871 targets = [key for key in graph.leaf_nodes() if key[0] in package_names]
872 else:
873 targets = graph.leaf_nodes()
874 plan = []
875 seen = set()
876
877 # Generate the plan
878 for target in targets:
879 for migration in graph.forwards_plan(target):
880 if migration not in seen:
881 node = graph.node_map[migration]
882 plan.append(node)
883 seen.add(migration)
884
885 # Output
886 def print_deps(node):
887 out = []
888 for parent in sorted(node.parents):
889 out.append(f"{parent.key[0]}.{parent.key[1]}")
890 if out:
891 return f" ... ({', '.join(out)})"
892 return ""
893
894 for node in plan:
895 deps = ""
896 if verbosity >= 2:
897 deps = print_deps(node)
898 if node.key in loader.applied_migrations:
899 click.echo(f"[X] {node.key[0]}.{node.key[1]}{deps}")
900 else:
901 click.echo(f"[ ] {node.key[0]}.{node.key[1]}{deps}")
902 if not plan:
903 click.secho("(no migrations)", fg="red")
904
905 # Get the database we're operating from
906
907 if format == "plan":
908 show_plan(db_connection, package_labels)
909 else:
910 show_list(db_connection, package_labels)
911
912
913@cli.command()
914@click.argument("package_label")
915@click.argument("start_migration_name", required=False)
916@click.argument("migration_name")
917@click.option(
918 "--no-optimize",
919 is_flag=True,
920 help="Do not try to optimize the squashed operations.",
921)
922@click.option(
923 "--noinput",
924 "--no-input",
925 "no_input",
926 is_flag=True,
927 help="Tells Plain to NOT prompt the user for input of any kind.",
928)
929@click.option("--squashed-name", help="Sets the name of the new squashed migration.")
930@click.option(
931 "-v",
932 "--verbosity",
933 type=int,
934 default=1,
935 help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",
936)
937def squash_migrations(
938 package_label,
939 start_migration_name,
940 migration_name,
941 no_optimize,
942 no_input,
943 squashed_name,
944 verbosity,
945):
946 """
947 Squashes an existing set of migrations (from first until specified) into a single new one.
948 """
949 interactive = not no_input
950
951 def find_migration(loader, package_label, name):
952 try:
953 return loader.get_migration_by_prefix(package_label, name)
954 except AmbiguityError:
955 raise click.ClickException(
956 f"More than one migration matches '{name}' in package '{package_label}'. Please be more specific."
957 )
958 except KeyError:
959 raise click.ClickException(
960 f"Cannot find a migration matching '{name}' from package '{package_label}'."
961 )
962
963 # Validate package_label
964 try:
965 packages_registry.get_package_config(package_label)
966 except LookupError as err:
967 raise click.ClickException(str(err))
968
969 # Load the current graph state, check the app and migration they asked for exists
970 loader = MigrationLoader(db_connection)
971 if package_label not in loader.migrated_packages:
972 raise click.ClickException(
973 f"Package '{package_label}' does not have migrations (so squashmigrations on it makes no sense)"
974 )
975
976 migration = find_migration(loader, package_label, migration_name)
977
978 # Work out the list of predecessor migrations
979 migrations_to_squash = [
980 loader.get_migration(al, mn)
981 for al, mn in loader.graph.forwards_plan(
982 (migration.package_label, migration.name)
983 )
984 if al == migration.package_label
985 ]
986
987 if start_migration_name:
988 start_migration = find_migration(loader, package_label, start_migration_name)
989 start = loader.get_migration(
990 start_migration.package_label, start_migration.name
991 )
992 try:
993 start_index = migrations_to_squash.index(start)
994 migrations_to_squash = migrations_to_squash[start_index:]
995 except ValueError:
996 raise click.ClickException(
997 f"The migration '{start_migration}' cannot be found. Maybe it comes after "
998 f"the migration '{migration}'?\n"
999 f"Have a look at:\n"
1000 f" python manage.py showmigrations {package_label}\n"
1001 f"to debug this issue."
1002 )
1003
1004 # Tell them what we're doing and optionally ask if we should proceed
1005 if verbosity > 0 or interactive:
1006 click.secho("Will squash the following migrations:", fg="cyan", bold=True)
1007 for migration in migrations_to_squash:
1008 click.echo(f" - {migration.name}")
1009
1010 if interactive:
1011 if not click.confirm("Do you wish to proceed?"):
1012 return
1013
1014 # Load the operations from all those migrations and concat together,
1015 # along with collecting external dependencies and detecting double-squashing
1016 operations = []
1017 dependencies = set()
1018 # We need to take all dependencies from the first migration in the list
1019 # as it may be 0002 depending on 0001
1020 first_migration = True
1021 for smigration in migrations_to_squash:
1022 if smigration.replaces:
1023 raise click.ClickException(
1024 "You cannot squash squashed migrations! Please transition it to a "
1025 "normal migration first"
1026 )
1027 operations.extend(smigration.operations)
1028 for dependency in smigration.dependencies:
1029 if isinstance(dependency, SettingsTuple):
1030 dependencies.add(dependency)
1031 elif dependency[0] != smigration.package_label or first_migration:
1032 dependencies.add(dependency)
1033 first_migration = False
1034
1035 if no_optimize:
1036 if verbosity > 0:
1037 click.secho("(Skipping optimization.)", fg="yellow")
1038 new_operations = operations
1039 else:
1040 if verbosity > 0:
1041 click.secho("Optimizing...", fg="cyan")
1042
1043 optimizer = MigrationOptimizer()
1044 new_operations = optimizer.optimize(operations, migration.package_label)
1045
1046 if verbosity > 0:
1047 if len(new_operations) == len(operations):
1048 click.echo(" No optimizations possible.")
1049 else:
1050 click.echo(
1051 f" Optimized from {len(operations)} operations to {len(new_operations)} operations."
1052 )
1053
1054 # Work out the value of replaces (any squashed ones we're re-squashing)
1055 # need to feed their replaces into ours
1056 replaces = []
1057 for migration in migrations_to_squash:
1058 if migration.replaces:
1059 replaces.extend(migration.replaces)
1060 else:
1061 replaces.append((migration.package_label, migration.name))
1062
1063 # Make a new migration with those operations
1064 subclass = type(
1065 "Migration",
1066 (migrations.Migration,),
1067 {
1068 "dependencies": dependencies,
1069 "operations": new_operations,
1070 "replaces": replaces,
1071 },
1072 )
1073 if start_migration_name:
1074 if squashed_name:
1075 # Use the name from --squashed-name
1076 prefix, _ = start_migration.name.split("_", 1)
1077 name = f"{prefix}_{squashed_name}"
1078 else:
1079 # Generate a name
1080 name = f"{start_migration.name}_squashed_{migration.name}"
1081 new_migration = subclass(name, package_label)
1082 else:
1083 name = f"0001_{'squashed_' + migration.name if not squashed_name else squashed_name}"
1084 new_migration = subclass(name, package_label)
1085 new_migration.initial = True
1086
1087 # Write out the new migration file
1088 writer = MigrationWriter(new_migration)
1089 if os.path.exists(writer.path):
1090 raise click.ClickException(
1091 f"Migration {new_migration.name} already exists. Use a different name."
1092 )
1093 with open(writer.path, "w", encoding="utf-8") as fh:
1094 fh.write(writer.as_string())
1095
1096 if verbosity > 0:
1097 click.secho(
1098 f"Created new squashed migration {writer.path}", fg="green", bold=True
1099 )
1100 click.echo(
1101 " You should commit this migration but leave the old ones in place;\n"
1102 " the new migration will be used for new installs. Once you are sure\n"
1103 " all instances of the codebase have applied the migrations you squashed,\n"
1104 " you can delete them."
1105 )
1106 if writer.needs_manual_porting:
1107 click.secho("Manual porting required", fg="yellow", bold=True)
1108 click.echo(
1109 " Your migrations contained functions that must be manually copied over,\n"
1110 " as we could not safely copy their implementation.\n"
1111 " See the comment at the top of the squashed migration for details."
1112 )