Plain is headed towards 1.0! Subscribe for development updates →

   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            )