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