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