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 "--plan",
330 is_flag=True,
331 help="Shows a list of the migration actions that will be performed.",
332)
333@click.option(
334 "--check",
335 "check_unapplied",
336 is_flag=True,
337 help="Exits with a non-zero status if unapplied migrations exist and does not actually apply migrations.",
338)
339@click.option(
340 "--backup/--no-backup",
341 "backup",
342 is_flag=True,
343 default=None,
344 help="Explicitly enable/disable pre-migration backups.",
345)
346@click.option(
347 "--prune",
348 is_flag=True,
349 help="Delete nonexistent migrations from the plainmigrations table.",
350)
351@click.option(
352 "--no-input",
353 "--noinput",
354 "no_input",
355 is_flag=True,
356 help="Tells Plain to NOT prompt the user for input of any kind.",
357)
358@click.option(
359 "-v",
360 "--verbosity",
361 type=int,
362 default=1,
363 help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",
364)
365def migrate(
366 package_label,
367 migration_name,
368 fake,
369 plan,
370 check_unapplied,
371 backup,
372 prune,
373 no_input,
374 verbosity,
375):
376 """Updates database schema. Manages both packages with migrations and those without."""
377
378 def migration_progress_callback(action, migration=None, fake=False):
379 if verbosity >= 1:
380 if action == "apply_start":
381 click.echo(f" Applying {migration}...", nl=False)
382 elif action == "apply_success":
383 if fake:
384 click.echo(click.style(" FAKED", fg="green"))
385 else:
386 click.echo(click.style(" OK", fg="green"))
387 elif action == "render_start":
388 click.echo(" Rendering model states...", nl=False)
389 elif action == "render_success":
390 click.echo(click.style(" DONE", fg="green"))
391
392 def describe_operation(operation):
393 """Return a string that describes a migration operation for --plan."""
394 prefix = ""
395 is_error = False
396 if hasattr(operation, "code"):
397 code = operation.code
398 action = (code.__doc__ or "") if code else None
399 elif hasattr(operation, "sql"):
400 action = operation.sql
401 else:
402 action = ""
403 if action is not None:
404 action = str(action).replace("\n", "")
405 if action:
406 action = " -> " + action
407 truncated = Truncator(action)
408 return prefix + operation.describe() + truncated.chars(40), is_error
409
410 # Get the database we're operating from
411 # Hook for backends needing any database preparation
412 db_connection.prepare_database()
413
414 # Work out which packages have migrations and which do not
415 executor = MigrationExecutor(db_connection, migration_progress_callback)
416
417 # Raise an error if any migrations are applied before their dependencies.
418 executor.loader.check_consistent_history(db_connection)
419
420 # Before anything else, see if there's conflicting packages and drop out
421 # hard if there are any
422 conflicts = executor.loader.detect_conflicts()
423 if conflicts:
424 name_str = "; ".join(
425 "{} in {}".format(", ".join(names), package)
426 for package, names in conflicts.items()
427 )
428 raise click.ClickException(
429 "Conflicting migrations detected; multiple leaf nodes in the "
430 f"migration graph: ({name_str})."
431 )
432
433 # If they supplied command line arguments, work out what they mean.
434 target_package_labels_only = True
435 if package_label:
436 try:
437 packages_registry.get_package_config(package_label)
438 except LookupError as err:
439 raise click.ClickException(str(err))
440
441 if package_label not in executor.loader.migrated_packages:
442 raise click.ClickException(
443 f"Package '{package_label}' does not have migrations."
444 )
445
446 if package_label and migration_name:
447 try:
448 migration = executor.loader.get_migration_by_prefix(
449 package_label, migration_name
450 )
451 except AmbiguityError:
452 raise click.ClickException(
453 f"More than one migration matches '{migration_name}' in package '{package_label}'. "
454 "Please be more specific."
455 )
456 except KeyError:
457 raise click.ClickException(
458 f"Cannot find a migration matching '{migration_name}' from package '{package_label}'."
459 )
460 target = (package_label, migration.name)
461 if (
462 target not in executor.loader.graph.nodes
463 and target in executor.loader.replacements
464 ):
465 incomplete_migration = executor.loader.replacements[target]
466 target = incomplete_migration.replaces[-1]
467 targets = [target]
468 target_package_labels_only = False
469 elif package_label:
470 targets = [
471 key for key in executor.loader.graph.leaf_nodes() if key[0] == package_label
472 ]
473 else:
474 targets = executor.loader.graph.leaf_nodes()
475
476 if prune:
477 if not package_label:
478 raise click.ClickException(
479 "Migrations can be pruned only when a package is specified."
480 )
481 if verbosity > 0:
482 click.secho("Pruning migrations:", fg="cyan")
483 to_prune = set(executor.loader.applied_migrations) - set(
484 executor.loader.disk_migrations
485 )
486 squashed_migrations_with_deleted_replaced_migrations = [
487 migration_key
488 for migration_key, migration_obj in executor.loader.replacements.items()
489 if any(replaced in to_prune for replaced in migration_obj.replaces)
490 ]
491 if squashed_migrations_with_deleted_replaced_migrations:
492 click.echo(
493 click.style(
494 " Cannot use --prune because the following squashed "
495 "migrations have their 'replaces' attributes and may not "
496 "be recorded as applied:",
497 fg="yellow",
498 )
499 )
500 for migration in squashed_migrations_with_deleted_replaced_migrations:
501 package, name = migration
502 click.echo(f" {package}.{name}")
503 click.echo(
504 click.style(
505 " Re-run `plain migrate` if they are not marked as "
506 "applied, and remove 'replaces' attributes in their "
507 "Migration classes.",
508 fg="yellow",
509 )
510 )
511 else:
512 to_prune = sorted(
513 migration for migration in to_prune if migration[0] == package_label
514 )
515 if to_prune:
516 for migration in to_prune:
517 package, name = migration
518 if verbosity > 0:
519 click.echo(
520 click.style(f" Pruning {package}.{name}", fg="yellow"),
521 nl=False,
522 )
523 executor.recorder.record_unapplied(package, name)
524 if verbosity > 0:
525 click.echo(click.style(" OK", fg="green"))
526 elif verbosity > 0:
527 click.echo(" No migrations to prune.")
528
529 migration_plan = executor.migration_plan(targets)
530
531 if plan:
532 click.secho("Planned operations:", fg="cyan")
533 if not migration_plan:
534 click.echo(" No planned migration operations.")
535 else:
536 for migration in migration_plan:
537 click.secho(str(migration), fg="cyan")
538 for operation in migration.operations:
539 message, is_error = describe_operation(operation)
540 if is_error:
541 click.secho(" " + message, fg="yellow")
542 else:
543 click.echo(" " + message)
544 if check_unapplied:
545 sys.exit(1)
546 return
547
548 if check_unapplied:
549 if migration_plan:
550 sys.exit(1)
551 return
552
553 if prune:
554 return
555
556 # Print some useful info
557 if verbosity >= 1:
558 click.secho("Operations to perform:", fg="cyan")
559
560 if target_package_labels_only:
561 click.secho(
562 " Apply all migrations: "
563 + (", ".join(sorted({a for a, n in targets})) or "(none)"),
564 fg="yellow",
565 )
566 else:
567 click.secho(
568 f" Target specific migration: {targets[0][1]}, from {targets[0][0]}",
569 fg="yellow",
570 )
571
572 pre_migrate_state = executor._create_project_state(with_applied_migrations=True)
573
574 # sql = executor.loader.collect_sql(migration_plan)
575 # pprint(sql)
576
577 if migration_plan:
578 if backup or (backup is None and settings.DEBUG):
579 backup_name = f"migrate_{time.strftime('%Y%m%d_%H%M%S')}"
580 click.secho(
581 f"Creating backup before applying migrations: {backup_name}",
582 bold=True,
583 )
584 # Can't use ctx.invoke because this is called by the test db creation currently,
585 # which doesn't have a context.
586 create_backup.callback(
587 backup_name=backup_name,
588 pg_dump=os.environ.get(
589 "PG_DUMP", "pg_dump"
590 ), # Have to pass this in manually
591 )
592 print()
593
594 if verbosity >= 1:
595 click.secho("Running migrations:", fg="cyan")
596
597 post_migrate_state = executor.migrate(
598 targets,
599 plan=migration_plan,
600 state=pre_migrate_state.clone(),
601 fake=fake,
602 )
603 # post_migrate signals have access to all models. Ensure that all models
604 # are reloaded in case any are delayed.
605 post_migrate_state.clear_delayed_models_cache()
606 post_migrate_packages = post_migrate_state.models_registry
607
608 # Re-render models of real packages to include relationships now that
609 # we've got a final state. This wouldn't be necessary if real packages
610 # models were rendered with relationships in the first place.
611 with post_migrate_packages.bulk_update():
612 model_keys = []
613 for model_state in post_migrate_packages.real_models:
614 model_key = model_state.package_label, model_state.name_lower
615 model_keys.append(model_key)
616 post_migrate_packages.unregister_model(*model_key)
617 post_migrate_packages.render_multiple(
618 [
619 ModelState.from_model(models_registry.get_model(*model))
620 for model in model_keys
621 ]
622 )
623
624 elif verbosity >= 1:
625 click.echo(" No migrations to apply.")
626 # If there's changes that aren't in migrations yet, tell them
627 # how to fix it.
628 autodetector = MigrationAutodetector(
629 executor.loader.project_state(),
630 ProjectState.from_models_registry(models_registry),
631 )
632 changes = autodetector.changes(graph=executor.loader.graph)
633 if changes:
634 click.echo(
635 click.style(
636 f" Your models in package(s): {', '.join(repr(package) for package in sorted(changes))} "
637 "have changes that are not yet reflected in a migration, and so won't be applied.",
638 fg="yellow",
639 )
640 )
641 click.echo(
642 click.style(
643 " Run `plain makemigrations` to make new "
644 "migrations, and then re-run `plain migrate` to "
645 "apply them.",
646 fg="yellow",
647 )
648 )
649
650
651@cli.command()
652@click.argument("package_labels", nargs=-1)
653@click.option(
654 "--format",
655 type=click.Choice(["list", "plan"]),
656 default="list",
657 help="Output format.",
658)
659@click.option(
660 "-v",
661 "--verbosity",
662 type=int,
663 default=1,
664 help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",
665)
666def show_migrations(package_labels, format, verbosity):
667 """Shows all available migrations for the current project"""
668
669 def _validate_package_names(package_names):
670 has_bad_names = False
671 for package_name in package_names:
672 try:
673 packages_registry.get_package_config(package_name)
674 except LookupError as err:
675 click.echo(str(err), err=True)
676 has_bad_names = True
677 if has_bad_names:
678 sys.exit(2)
679
680 def show_list(db_connection, package_names):
681 """
682 Show a list of all migrations on the system, or only those of
683 some named packages.
684 """
685 # Load migrations from disk/DB
686 loader = MigrationLoader(db_connection, ignore_no_migrations=True)
687 recorder = MigrationRecorder(db_connection)
688 recorded_migrations = recorder.applied_migrations()
689
690 graph = loader.graph
691 # If we were passed a list of packages, validate it
692 if package_names:
693 _validate_package_names(package_names)
694 # Otherwise, show all packages in alphabetic order
695 else:
696 package_names = sorted(loader.migrated_packages)
697 # For each app, print its migrations in order from oldest (roots) to
698 # newest (leaves).
699 for package_name in package_names:
700 click.secho(package_name, fg="cyan", bold=True)
701 shown = set()
702 for node in graph.leaf_nodes(package_name):
703 for plan_node in graph.forwards_plan(node):
704 if plan_node not in shown and plan_node[0] == package_name:
705 # Give it a nice title if it's a squashed one
706 title = plan_node[1]
707 if graph.nodes[plan_node].replaces:
708 title += f" ({len(graph.nodes[plan_node].replaces)} squashed migrations)"
709 applied_migration = loader.applied_migrations.get(plan_node)
710 # Mark it as applied/unapplied
711 if applied_migration:
712 if plan_node in recorded_migrations:
713 output = f" [X] {title}"
714 else:
715 title += " Run `plain migrate` to finish recording."
716 output = f" [-] {title}"
717 if verbosity >= 2 and hasattr(applied_migration, "applied"):
718 output += f" (applied at {applied_migration.applied.strftime('%Y-%m-%d %H:%M:%S')})"
719 click.echo(output)
720 else:
721 click.echo(f" [ ] {title}")
722 shown.add(plan_node)
723 # If we didn't print anything, then a small message
724 if not shown:
725 click.secho(" (no migrations)", fg="red")
726
727 # Find recorded migrations that aren't in the graph (prunable)
728 prunable_migrations = [
729 migration
730 for migration in recorded_migrations
731 if (
732 migration not in loader.disk_migrations
733 and (not package_names or migration[0] in package_names)
734 )
735 ]
736
737 if prunable_migrations:
738 click.echo()
739 click.secho(
740 "Recorded migrations not in migration files (candidates for pruning):",
741 fg="yellow",
742 bold=True,
743 )
744 prunable_by_package = {}
745 for migration in prunable_migrations:
746 package, name = migration
747 if package not in prunable_by_package:
748 prunable_by_package[package] = []
749 prunable_by_package[package].append(name)
750
751 for package in sorted(prunable_by_package.keys()):
752 click.secho(f" {package}:", fg="yellow")
753 for name in sorted(prunable_by_package[package]):
754 click.echo(f" - {name}")
755
756 def show_plan(db_connection, package_names):
757 """
758 Show all known migrations (or only those of the specified package_names)
759 in the order they will be applied.
760 """
761 # Load migrations from disk/DB
762 loader = MigrationLoader(db_connection)
763 graph = loader.graph
764 if package_names:
765 _validate_package_names(package_names)
766 targets = [key for key in graph.leaf_nodes() if key[0] in package_names]
767 else:
768 targets = graph.leaf_nodes()
769 plan = []
770 seen = set()
771
772 # Generate the plan
773 for target in targets:
774 for migration in graph.forwards_plan(target):
775 if migration not in seen:
776 node = graph.node_map[migration]
777 plan.append(node)
778 seen.add(migration)
779
780 # Output
781 def print_deps(node):
782 out = []
783 for parent in sorted(node.parents):
784 out.append(f"{parent.key[0]}.{parent.key[1]}")
785 if out:
786 return f" ... ({', '.join(out)})"
787 return ""
788
789 for node in plan:
790 deps = ""
791 if verbosity >= 2:
792 deps = print_deps(node)
793 if node.key in loader.applied_migrations:
794 click.echo(f"[X] {node.key[0]}.{node.key[1]}{deps}")
795 else:
796 click.echo(f"[ ] {node.key[0]}.{node.key[1]}{deps}")
797 if not plan:
798 click.secho("(no migrations)", fg="red")
799
800 # Get the database we're operating from
801
802 if format == "plan":
803 show_plan(db_connection, package_labels)
804 else:
805 show_list(db_connection, package_labels)
806
807
808@cli.command()
809@click.argument("package_label")
810@click.argument("start_migration_name", required=False)
811@click.argument("migration_name")
812@click.option(
813 "--no-optimize",
814 is_flag=True,
815 help="Do not try to optimize the squashed operations.",
816)
817@click.option(
818 "--noinput",
819 "--no-input",
820 "no_input",
821 is_flag=True,
822 help="Tells Plain to NOT prompt the user for input of any kind.",
823)
824@click.option("--squashed-name", help="Sets the name of the new squashed migration.")
825@click.option(
826 "-v",
827 "--verbosity",
828 type=int,
829 default=1,
830 help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",
831)
832def squash_migrations(
833 package_label,
834 start_migration_name,
835 migration_name,
836 no_optimize,
837 no_input,
838 squashed_name,
839 verbosity,
840):
841 """
842 Squashes an existing set of migrations (from first until specified) into a single new one.
843 """
844 interactive = not no_input
845
846 def find_migration(loader, package_label, name):
847 try:
848 return loader.get_migration_by_prefix(package_label, name)
849 except AmbiguityError:
850 raise click.ClickException(
851 f"More than one migration matches '{name}' in package '{package_label}'. Please be more specific."
852 )
853 except KeyError:
854 raise click.ClickException(
855 f"Cannot find a migration matching '{name}' from package '{package_label}'."
856 )
857
858 # Validate package_label
859 try:
860 packages_registry.get_package_config(package_label)
861 except LookupError as err:
862 raise click.ClickException(str(err))
863
864 # Load the current graph state, check the app and migration they asked for exists
865 loader = MigrationLoader(db_connection)
866 if package_label not in loader.migrated_packages:
867 raise click.ClickException(
868 f"Package '{package_label}' does not have migrations (so squashmigrations on it makes no sense)"
869 )
870
871 migration = find_migration(loader, package_label, migration_name)
872
873 # Work out the list of predecessor migrations
874 migrations_to_squash = [
875 loader.get_migration(al, mn)
876 for al, mn in loader.graph.forwards_plan(
877 (migration.package_label, migration.name)
878 )
879 if al == migration.package_label
880 ]
881
882 if start_migration_name:
883 start_migration = find_migration(loader, package_label, start_migration_name)
884 start = loader.get_migration(
885 start_migration.package_label, start_migration.name
886 )
887 try:
888 start_index = migrations_to_squash.index(start)
889 migrations_to_squash = migrations_to_squash[start_index:]
890 except ValueError:
891 raise click.ClickException(
892 f"The migration '{start_migration}' cannot be found. Maybe it comes after "
893 f"the migration '{migration}'?\n"
894 f"Have a look at:\n"
895 f" plain models show-migrations {package_label}\n"
896 f"to debug this issue."
897 )
898
899 # Tell them what we're doing and optionally ask if we should proceed
900 if verbosity > 0 or interactive:
901 click.secho("Will squash the following migrations:", fg="cyan", bold=True)
902 for migration in migrations_to_squash:
903 click.echo(f" - {migration.name}")
904
905 if interactive:
906 if not click.confirm("Do you wish to proceed?"):
907 return
908
909 # Load the operations from all those migrations and concat together,
910 # along with collecting external dependencies and detecting double-squashing
911 operations = []
912 dependencies = set()
913 # We need to take all dependencies from the first migration in the list
914 # as it may be 0002 depending on 0001
915 first_migration = True
916 for smigration in migrations_to_squash:
917 if smigration.replaces:
918 raise click.ClickException(
919 "You cannot squash squashed migrations! Please transition it to a "
920 "normal migration first"
921 )
922 operations.extend(smigration.operations)
923 for dependency in smigration.dependencies:
924 if isinstance(dependency, SettingsTuple):
925 dependencies.add(dependency)
926 elif dependency[0] != smigration.package_label or first_migration:
927 dependencies.add(dependency)
928 first_migration = False
929
930 if no_optimize:
931 if verbosity > 0:
932 click.secho("(Skipping optimization.)", fg="yellow")
933 new_operations = operations
934 else:
935 if verbosity > 0:
936 click.secho("Optimizing...", fg="cyan")
937
938 optimizer = MigrationOptimizer()
939 new_operations = optimizer.optimize(operations, migration.package_label)
940
941 if verbosity > 0:
942 if len(new_operations) == len(operations):
943 click.echo(" No optimizations possible.")
944 else:
945 click.echo(
946 f" Optimized from {len(operations)} operations to {len(new_operations)} operations."
947 )
948
949 # Work out the value of replaces (any squashed ones we're re-squashing)
950 # need to feed their replaces into ours
951 replaces = []
952 for migration in migrations_to_squash:
953 if migration.replaces:
954 replaces.extend(migration.replaces)
955 else:
956 replaces.append((migration.package_label, migration.name))
957
958 # Make a new migration with those operations
959 subclass = type(
960 "Migration",
961 (migrations.Migration,),
962 {
963 "dependencies": dependencies,
964 "operations": new_operations,
965 "replaces": replaces,
966 },
967 )
968 if start_migration_name:
969 if squashed_name:
970 # Use the name from --squashed-name
971 prefix, _ = start_migration.name.split("_", 1)
972 name = f"{prefix}_{squashed_name}"
973 else:
974 # Generate a name
975 name = f"{start_migration.name}_squashed_{migration.name}"
976 new_migration = subclass(name, package_label)
977 else:
978 name = f"0001_{'squashed_' + migration.name if not squashed_name else squashed_name}"
979 new_migration = subclass(name, package_label)
980 new_migration.initial = True
981
982 # Write out the new migration file
983 writer = MigrationWriter(new_migration)
984 if os.path.exists(writer.path):
985 raise click.ClickException(
986 f"Migration {new_migration.name} already exists. Use a different name."
987 )
988 with open(writer.path, "w", encoding="utf-8") as fh:
989 fh.write(writer.as_string())
990
991 if verbosity > 0:
992 click.secho(
993 f"Created new squashed migration {writer.path}", fg="green", bold=True
994 )
995 click.echo(
996 " You should commit this migration but leave the old ones in place;\n"
997 " the new migration will be used for new installs. Once you are sure\n"
998 " all instances of the codebase have applied the migrations you squashed,\n"
999 " you can delete them."
1000 )
1001 if writer.needs_manual_porting:
1002 click.secho("Manual porting required", fg="yellow", bold=True)
1003 click.echo(
1004 " Your migrations contained functions that must be manually copied over,\n"
1005 " as we could not safely copy their implementation.\n"
1006 " See the comment at the top of the squashed migration for details."
1007 )