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