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