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