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