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