Plain is headed towards 1.0! Subscribe for development updates →

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