Coverage for /Users/davegaeddert/Developer/dropseed/plain/plain-models/plain/models/cli.py: 20%

1 import os

2 import subprocess

3 import sys

4 import time

5 from itertools import takewhile

6

7 import click

8

9 from plain.models import migrations

10 from plain.models.db import DEFAULT_DB_ALIAS, OperationalError, connections, router

11 from plain.models.migrations.autodetector import MigrationAutodetector

12 from plain.models.migrations.executor import MigrationExecutor

13 from plain.models.migrations.loader import AmbiguityError, MigrationLoader

14 from plain.models.migrations.migration import Migration, SwappableTuple

15 from plain.models.migrations.optimizer import MigrationOptimizer

16 from plain.models.migrations.questioner import (

17 InteractiveMigrationQuestioner,

18 MigrationQuestioner,

19 NonInteractiveMigrationQuestioner,

20)

21 from plain.models.migrations.recorder import MigrationRecorder

22 from plain.models.migrations.state import ModelState, ProjectState

23 from plain.models.migrations.utils import get_migration_name_timestamp

24 from plain.models.migrations.writer import MigrationWriter

25 from plain.packages import packages

26 from plain.runtime import settings

27 from plain.utils.text import Truncator

28

29

30@click.group()

31 def cli():

32 pass

33

34

35@cli.command()

36@click.option(

37 "--database",

38 default=DEFAULT_DB_ALIAS,

39 help=(

40 "Nominates a database onto which to open a shell. Defaults to the "

41 '"default" database.'

42 ),

43)

44@click.argument("parameters", nargs=-1)

45 def db_shell(database, parameters):

46 """Runs the command-line client for specified database, or the default database if none is provided."""

47 connection = connections[database]

48 try:

49 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 {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()

74 def db_wait():

75 """Wait for the database to be ready"""

76 attempts = 0

77 while True:

78 attempts += 1

79 waiting_for = []

80

81 for conn in connections.all():

82 try:

83 conn.ensure_connection()

84 except OperationalError:

85 waiting_for.append(conn.alias)

86

87 if waiting_for:

88 click.secho(

89 f"Waiting for database (attempt {attempts}): {', '.join(waiting_for)}",

90 fg="yellow",

91 )

92 time.sleep(1.5)

93 else:

94 click.secho(f"Database ready: {', '.join(connections)}", fg="green")

95 break

96

97

98@cli.command()

99@click.argument("package_labels", nargs=-1)

100@click.option(

101 "--dry-run",

102 is_flag=True,

103 help="Just show what migrations would be made; don't actually write them.",

104)

105@click.option("--merge", is_flag=True, help="Enable fixing of migration conflicts.")

106@click.option("--empty", is_flag=True, help="Create an empty migration.")

107@click.option(

108 "--noinput",

109 "--no-input",

110 "no_input",

111 is_flag=True,

112 help="Tells Plain to NOT prompt the user for input of any kind.",

113)

114@click.option("-n", "--name", help="Use this name for migration file(s).")

115@click.option(

116 "--check",

117 is_flag=True,

118 help="Exit with a non-zero status if model changes are missing migrations and don't actually write them.",

119)

120@click.option(

121 "--update",

122 is_flag=True,

123 help="Merge model changes into the latest migration and optimize the resulting operations.",

124)

125@click.option(

126 "-v",

127 "--verbosity",

128 type=int,

129 default=1,

130 help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",

131)

132 def makemigrations(

133 package_labels, dry_run, merge, empty, no_input, name, check, update, verbosity

134):

135 """Creates new migration(s) for packages."""

136

137 written_files = []

138 interactive = not no_input

139 migration_name = name

140 check_changes = check

141

142 def log(msg, level=1):

143 if verbosity >= level:

144 click.echo(msg)

145

146 def write_migration_files(changes, update_previous_migration_paths=None):

147 """Take a changes dict and write them out as migration files."""

148 directory_created = {}

149 for package_label, package_migrations in changes.items():

150 log(

151 click.style(f"Migrations for '{package_label}':", fg="cyan", bold=True),

152 level=1,

153 )

154 for migration in package_migrations:

155 writer = MigrationWriter(migration)

156 migration_string = os.path.relpath(writer.path)

157 log(f" {click.style(migration_string, fg='yellow')}\n", level=1)

158 for operation in migration.operations:

159 log(f" - {operation.describe()}", level=1)

160

161 if not dry_run:

162 migrations_directory = os.path.dirname(writer.path)

163 if not directory_created.get(package_label):

164 os.makedirs(migrations_directory, exist_ok=True)

165 init_path = os.path.join(migrations_directory, "__init__.py")

166 if not os.path.isfile(init_path):

167 open(init_path, "w").close()

168 directory_created[package_label] = True

169

170 migration_string = writer.as_string()

171 with open(writer.path, "w", encoding="utf-8") as fh:

172 fh.write(migration_string)

173 written_files.append(writer.path)

174

175 if update_previous_migration_paths:

176 prev_path = update_previous_migration_paths[package_label]

177 if writer.needs_manual_porting:

178 log(

179 click.style(

180 f"Updated migration {migration_string} requires manual porting.\n"

181 f"Previous migration {os.path.relpath(prev_path)} was kept and "

182 f"must be deleted after porting functions manually.",

183 fg="yellow",

184 ),

185 level=1,

186 )

187 else:

188 os.remove(prev_path)

189 log(f"Deleted {os.path.relpath(prev_path)}", level=1)

190 elif verbosity >= 3:

191 log(

192 click.style(

193 f"Full migrations file '{writer.filename}':",

194 fg="cyan",

195 bold=True,

196 ),

197 level=3,

198 )

199 log(writer.as_string(), level=3)

200

201 def write_to_last_migration_files(changes):

202 """Write changes to the last migration file for each package."""

203 loader = MigrationLoader(connections[DEFAULT_DB_ALIAS])

204 new_changes = {}

205 update_previous_migration_paths = {}

206 for package_label, package_migrations in changes.items():

207 leaf_migration_nodes = loader.graph.leaf_nodes(app=package_label)

208 if len(leaf_migration_nodes) == 0:

209 raise click.ClickException(

210 f"Package {package_label} has no migration, cannot update last migration."

211 )

212 leaf_migration_node = leaf_migration_nodes[0]

213 leaf_migration = loader.graph.nodes[leaf_migration_node]

214

215 if leaf_migration.replaces:

216 raise click.ClickException(

217 f"Cannot update squash migration '{leaf_migration}'."

218 )

219 if leaf_migration_node in loader.applied_migrations:

220 raise click.ClickException(

221 f"Cannot update applied migration '{leaf_migration}'."

222 )

223

224 depending_migrations = [

225 migration

226 for migration in loader.disk_migrations.values()

227 if leaf_migration_node in migration.dependencies

228 ]

229 if depending_migrations:

230 formatted_migrations = ", ".join(

231 [f"'{migration}'" for migration in depending_migrations]

232 )

233 raise click.ClickException(

234 f"Cannot update migration '{leaf_migration}' that migrations "

235 f"{formatted_migrations} depend on."

236 )

237

238 for migration in package_migrations:

239 leaf_migration.operations.extend(migration.operations)

240 for dependency in migration.dependencies:

241 if isinstance(dependency, SwappableTuple):

242 if settings.AUTH_USER_MODEL == dependency.setting:

243 leaf_migration.dependencies.append(

244 ("__setting__", "AUTH_USER_MODEL")

245 )

246 else:

247 leaf_migration.dependencies.append(dependency)

248 elif dependency[0] != migration.package_label:

249 leaf_migration.dependencies.append(dependency)

250

251 optimizer = MigrationOptimizer()

252 leaf_migration.operations = optimizer.optimize(

253 leaf_migration.operations, package_label

254 )

255

256 previous_migration_path = MigrationWriter(leaf_migration).path

257 suggested_name = (

258 leaf_migration.name[:4] + "_" + leaf_migration.suggest_name()

259 )

260 new_name = (

261 suggested_name

262 if leaf_migration.name != suggested_name

263 else leaf_migration.name + "_updated"

264 )

265 leaf_migration.name = new_name

266

267 new_changes[package_label] = [leaf_migration]

268 update_previous_migration_paths[package_label] = previous_migration_path

269

270 write_migration_files(new_changes, update_previous_migration_paths)

271

272 def handle_merge(loader, conflicts):

273 """Handle merging conflicting migrations."""

274 if interactive:

275 questioner = InteractiveMigrationQuestioner()

276 else:

277 questioner = MigrationQuestioner(defaults={"ask_merge": True})

278

279 for package_label, migration_names in conflicts.items():

280 log(click.style(f"Merging {package_label}", fg="cyan", bold=True), level=1)

281

282 merge_migrations = []

283 for migration_name in migration_names:

284 migration = loader.get_migration(package_label, migration_name)

285 migration.ancestry = [

286 mig

287 for mig in loader.graph.forwards_plan(

288 (package_label, migration_name)

289 )

290 if mig[0] == migration.package_label

291 ]

292 merge_migrations.append(migration)

293

294 def all_items_equal(seq):

295 return all(item == seq[0] for item in seq[1:])

296

297 merge_migrations_generations = zip(*(m.ancestry for m in merge_migrations))

298 common_ancestor_count = sum(

299 1 for _ in takewhile(all_items_equal, merge_migrations_generations)

300 )

301 if not common_ancestor_count:

302 raise ValueError(f"Could not find common ancestor of {migration_names}")

303

304 for migration in merge_migrations:

305 migration.branch = migration.ancestry[common_ancestor_count:]

306 migrations_ops = (

307 loader.get_migration(node_package, node_name).operations

308 for node_package, node_name in migration.branch

309 )

310 migration.merged_operations = sum(migrations_ops, [])

311

312 for migration in merge_migrations:

313 log(click.style(f" Branch {migration.name}", fg="yellow"), level=1)

314 for operation in migration.merged_operations:

315 log(f" - {operation.describe()}", level=1)

316

317 if questioner.ask_merge(package_label):

318 numbers = [

319 MigrationAutodetector.parse_number(migration.name)

320 for migration in merge_migrations

321 ]

322 biggest_number = (

323 max(x for x in numbers if x is not None) if numbers else 0

324 )

325

326 subclass = type(

327 "Migration",

328 (Migration,),

329 {

330 "dependencies": [

331 (package_label, migration.name)

332 for migration in merge_migrations

333 ],

334 },

335 )

336

337 parts = [f"{biggest_number + 1:04d}"]

338 if migration_name:

339 parts.append(migration_name)

340 else:

341 parts.append("merge")

342 leaf_names = "_".join(

343 sorted(migration.name for migration in merge_migrations)

344 )

345 if len(leaf_names) > 47:

346 parts.append(get_migration_name_timestamp())

347 else:

348 parts.append(leaf_names)

349

350 new_migration_name = "_".join(parts)

351 new_migration = subclass(new_migration_name, package_label)

352 writer = MigrationWriter(new_migration)

353

354 if not dry_run:

355 with open(writer.path, "w", encoding="utf-8") as fh:

356 fh.write(writer.as_string())

357 log(f"\nCreated new merge migration {writer.path}", level=1)

358 elif verbosity == 3:

359 log(

360 click.style(

361 f"Full merge migrations file '{writer.filename}':",

362 fg="cyan",

363 bold=True,

364 ),

365 level=3,

366 )

367 log(writer.as_string(), level=3)

368

369 # Validate package labels

370 package_labels = set(package_labels)

371 has_bad_labels = False

372 for package_label in package_labels:

373 try:

374 packages.get_package_config(package_label)

375 except LookupError as err:

376 click.echo(str(err), err=True)

377 has_bad_labels = True

378 if has_bad_labels:

379 sys.exit(2)

380

381 # Load the current graph state

382 loader = MigrationLoader(None, ignore_no_migrations=True)

383

384 # Raise an error if any migrations are applied before their dependencies.

385 consistency_check_labels = {

386 config.label for config in packages.get_package_configs()

387 }

388 # Non-default databases are only checked if database routers used.

389 aliases_to_check = connections if settings.DATABASE_ROUTERS else [DEFAULT_DB_ALIAS]

390 for alias in sorted(aliases_to_check):

391 connection = connections[alias]

392 if connection.settings_dict["ENGINE"] != "plain.models.backends.dummy" and any(

393 router.allow_migrate(

394 connection.alias, package_label, model_name=model._meta.object_name

395 )

396 for package_label in consistency_check_labels

397 for model in packages.get_package_config(package_label).get_models()

398 ):

399 loader.check_consistent_history(connection)

400

401 # Check for conflicts

402 conflicts = loader.detect_conflicts()

403 if package_labels:

404 conflicts = {

405 package_label: conflict

406 for package_label, conflict in conflicts.items()

407 if package_label in package_labels

408 }

409

410 if conflicts and not merge:

411 name_str = "; ".join(

412 "{} in {}".format(", ".join(names), package)

413 for package, names in conflicts.items()

414 )

415 raise click.ClickException(

416 f"Conflicting migrations detected; multiple leaf nodes in the "

417 f"migration graph: ({name_str}).\nTo fix them run "

418 f"'python manage.py makemigrations --merge'"

419 )

420

421 # Handle merge if requested

422 if merge and conflicts:

423 return handle_merge(loader, conflicts)

424

425 # Set up questioner

426 if interactive:

427 questioner = InteractiveMigrationQuestioner(

428 specified_packages=package_labels,

429 dry_run=dry_run,

430 )

431 else:

432 questioner = NonInteractiveMigrationQuestioner(

433 specified_packages=package_labels,

434 dry_run=dry_run,

435 verbosity=verbosity,

436 )

437

438 # Set up autodetector

439 autodetector = MigrationAutodetector(

440 loader.project_state(),

441 ProjectState.from_packages(packages),

442 questioner,

443 )

444

445 # Handle empty migrations if requested

446 if empty:

447 if not package_labels:

448 raise click.ClickException(

449 "You must supply at least one package label when using --empty."

450 )

451 changes = {

452 package: [Migration("custom", package)] for package in package_labels

453 }

454 changes = autodetector.arrange_for_graph(

455 changes=changes,

456 graph=loader.graph,

457 migration_name=migration_name,

458 )

459 write_migration_files(changes)

460 return

461

462 # Detect changes

463 changes = autodetector.changes(

464 graph=loader.graph,

465 trim_to_packages=package_labels or None,

466 convert_packages=package_labels or None,

467 migration_name=migration_name,

468 )

469

470 if not changes:

471 log(

472 "No changes detected"

473 if not package_labels

474 else f"No changes detected in {'package' if len(package_labels) == 1 else 'packages'} "

475 f"'{', '.join(package_labels)}'",

476 level=1,

477 )

478 else:

479 if check_changes:

480 sys.exit(1)

481 if update:

482 write_to_last_migration_files(changes)

483 else:

484 write_migration_files(changes)

485

486

487@cli.command()

488@click.argument("package_label", required=False)

489@click.argument("migration_name", required=False)

490@click.option(

491 "--noinput",

492 "--no-input",

493 "no_input",

494 is_flag=True,

495 help="Tells Plain to NOT prompt the user for input of any kind.",

496)

497@click.option(

498 "--database",

499 default=DEFAULT_DB_ALIAS,

500 help="Nominates a database to synchronize. Defaults to the 'default' database.",

501)

502@click.option(

503 "--fake", is_flag=True, help="Mark migrations as run without actually running them."

504)

505@click.option(

506 "--fake-initial",

507 is_flag=True,

508 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.",

509)

510@click.option(

511 "--plan",

512 is_flag=True,

513 help="Shows a list of the migration actions that will be performed.",

514)

515@click.option(

516 "--check",

517 "check_unapplied",

518 is_flag=True,

519 help="Exits with a non-zero status if unapplied migrations exist and does not actually apply migrations.",

520)

521@click.option(

522 "--run-syncdb", is_flag=True, help="Creates tables for packages without migrations."

523)

524@click.option(

525 "--prune",

526 is_flag=True,

527 help="Delete nonexistent migrations from the plainmigrations table.",

528)

529@click.option(

530 "-v",

531 "--verbosity",

532 type=int,

533 default=1,

534 help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",

535)

536 def migrate(

537 package_label,

538 migration_name,

539 no_input,

540 database,

541 fake,

542 fake_initial,

543 plan,

544 check_unapplied,

545 run_syncdb,

546 prune,

547 verbosity,

548):

549 """Updates database schema. Manages both packages with migrations and those without."""

550

551 def migration_progress_callback(action, migration=None, fake=False):

552 if verbosity >= 1:

553 compute_time = verbosity > 1

554 if action == "apply_start":

555 if compute_time:

556 start = time.monotonic()

557 click.echo(f" Applying {migration}...", nl=False)

558 elif action == "apply_success":

559 elapsed = f" ({time.monotonic() - start:.3f}s)" if compute_time else ""

560 if fake:

561 click.echo(click.style(f" FAKED{elapsed}", fg="green"))

562 else:

563 click.echo(click.style(f" OK{elapsed}", fg="green"))

564 elif action == "unapply_start":

565 if compute_time:

566 start = time.monotonic()

567 click.echo(f" Unapplying {migration}...", nl=False)

568 elif action == "unapply_success":

569 elapsed = f" ({time.monotonic() - start:.3f}s)" if compute_time else ""

570 if fake:

571 click.echo(click.style(f" FAKED{elapsed}", fg="green"))

572 else:

573 click.echo(click.style(f" OK{elapsed}", fg="green"))

574 elif action == "render_start":

575 if compute_time:

576 start = time.monotonic()

577 click.echo(" Rendering model states...", nl=False)

578 elif action == "render_success":

579 elapsed = f" ({time.monotonic() - start:.3f}s)" if compute_time else ""

580 click.echo(click.style(f" DONE{elapsed}", fg="green"))

581

582 def sync_packages(connection, package_labels):

583 """Run the old syncdb-style operation on a list of package_labels."""

584 with connection.cursor() as cursor:

585 tables = connection.introspection.table_names(cursor)

586

587 # Build the manifest of packages and models that are to be synchronized.

588 all_models = [

589 (

590 package_config.label,

591 router.get_migratable_models(

592 package_config, connection.alias, include_auto_created=False

593 ),

594 )

595 for package_config in packages.get_package_configs()

596 if package_config.models_module is not None

597 and package_config.label in package_labels

598 ]

599

600 def model_installed(model):

601 opts = model._meta

602 converter = connection.introspection.identifier_converter

603 return not (

604 (converter(opts.db_table) in tables)

605 or (

606 opts.auto_created

607 and converter(opts.auto_created._meta.db_table) in tables

608 )

609 )

610

611 manifest = {

612 package_name: list(filter(model_installed, model_list))

613 for package_name, model_list in all_models

614 }

615

616 # Create the tables for each model

617 if verbosity >= 1:

618 click.echo(" Creating tables...", color="cyan")

619 with connection.schema_editor() as editor:

620 for package_name, model_list in manifest.items():

621 for model in model_list:

622 # Never install unmanaged models, etc.

623 if not model._meta.can_migrate(connection):

624 continue

625 if verbosity >= 3:

626 click.echo(

627 f" Processing {package_name}.{model._meta.object_name} model"

628 )

629 if verbosity >= 1:

630 click.echo(f" Creating table {model._meta.db_table}")

631 editor.create_model(model)

632

633 # Deferred SQL is executed when exiting the editor's context.

634 if verbosity >= 1:

635 click.echo(" Running deferred SQL...", color="cyan")

636

637 def describe_operation(operation, backwards):

638 """Return a string that describes a migration operation for --plan."""

639 prefix = ""

640 is_error = False

641 if hasattr(operation, "code"):

642 code = operation.reverse_code if backwards else operation.code

643 action = (code.__doc__ or "") if code else None

644 elif hasattr(operation, "sql"):

645 action = operation.reverse_sql if backwards else operation.sql

646 else:

647 action = ""

648 if backwards:

649 prefix = "Undo "

650 if action is not None:

651 action = str(action).replace("\n", "")

652 elif backwards:

653 action = "IRREVERSIBLE"

654 is_error = True

655 if action:

656 action = " -> " + action

657 truncated = Truncator(action)

658 return prefix + operation.describe() + truncated.chars(40), is_error

659

660 # Get the database we're operating from

661 connection = connections[database]

662

663 # Hook for backends needing any database preparation

664 connection.prepare_database()

665

666 # Work out which packages have migrations and which do not

667 executor = MigrationExecutor(connection, migration_progress_callback)

668

669 # Raise an error if any migrations are applied before their dependencies.

670 executor.loader.check_consistent_history(connection)

671

672 # Before anything else, see if there's conflicting packages and drop out

673 # hard if there are any

674 conflicts = executor.loader.detect_conflicts()

675 if conflicts:

676 name_str = "; ".join(

677 "{} in {}".format(", ".join(names), package)

678 for package, names in conflicts.items()

679 )

680 raise click.ClickException(

681 "Conflicting migrations detected; multiple leaf nodes in the "

682 f"migration graph: ({name_str}).\nTo fix them run "

683 "'python manage.py makemigrations --merge'"

684 )

685

686 # If they supplied command line arguments, work out what they mean.

687 target_package_labels_only = True

688 if package_label:

689 try:

690 packages.get_package_config(package_label)

691 except LookupError as err:

692 raise click.ClickException(str(err))

693 if run_syncdb:

694 if package_label in executor.loader.migrated_packages:

695 raise click.ClickException(

696 f"Can't use run_syncdb with package '{package_label}' as it has migrations."

697 )

698 elif package_label not in executor.loader.migrated_packages:

699 raise click.ClickException(

700 f"Package '{package_label}' does not have migrations."

701 )

702

703 if package_label and migration_name:

704 if migration_name == "zero":

705 targets = [(package_label, None)]

706 else:

707 try:

708 migration = executor.loader.get_migration_by_prefix(

709 package_label, migration_name

710 )

711 except AmbiguityError:

712 raise click.ClickException(

713 f"More than one migration matches '{migration_name}' in package '{package_label}'. "

714 "Please be more specific."

715 )

716 except KeyError:

717 raise click.ClickException(

718 f"Cannot find a migration matching '{migration_name}' from package '{package_label}'."

719 )

720 target = (package_label, migration.name)

721 if (

722 target not in executor.loader.graph.nodes

723 and target in executor.loader.replacements

724 ):

725 incomplete_migration = executor.loader.replacements[target]

726 target = incomplete_migration.replaces[-1]

727 targets = [target]

728 target_package_labels_only = False

729 elif package_label:

730 targets = [

731 key for key in executor.loader.graph.leaf_nodes() if key[0] == package_label

732 ]

733 else:

734 targets = executor.loader.graph.leaf_nodes()

735

736 if prune:

737 if not package_label:

738 raise click.ClickException(

739 "Migrations can be pruned only when a package is specified."

740 )

741 if verbosity > 0:

742 click.echo("Pruning migrations:", color="cyan")

743 to_prune = set(executor.loader.applied_migrations) - set(

744 executor.loader.disk_migrations

745 )

746 squashed_migrations_with_deleted_replaced_migrations = [

747 migration_key

748 for migration_key, migration_obj in executor.loader.replacements.items()

749 if any(replaced in to_prune for replaced in migration_obj.replaces)

750 ]

751 if squashed_migrations_with_deleted_replaced_migrations:

752 click.echo(

753 click.style(

754 " Cannot use --prune because the following squashed "

755 "migrations have their 'replaces' attributes and may not "

756 "be recorded as applied:",

757 fg="yellow",

758 )

759 )

760 for migration in squashed_migrations_with_deleted_replaced_migrations:

761 package, name = migration

762 click.echo(f" {package}.{name}")

763 click.echo(

764 click.style(

765 " Re-run 'manage.py migrate' if they are not marked as "

766 "applied, and remove 'replaces' attributes in their "

767 "Migration classes.",

768 fg="yellow",

769 )

770 )

771 else:

772 to_prune = sorted(

773 migration for migration in to_prune if migration[0] == package_label

774 )

775 if to_prune:

776 for migration in to_prune:

777 package, name = migration

778 if verbosity > 0:

779 click.echo(

780 click.style(f" Pruning {package}.{name}", fg="yellow"),

781 nl=False,

782 )

783 executor.recorder.record_unapplied(package, name)

784 if verbosity > 0:

785 click.echo(click.style(" OK", fg="green"))

786 elif verbosity > 0:

787 click.echo(" No migrations to prune.")

788

789 migration_plan = executor.migration_plan(targets)

790

791 if plan:

792 click.echo("Planned operations:", color="cyan")

793 if not migration_plan:

794 click.echo(" No planned migration operations.")

795 else:

796 for migration, backwards in migration_plan:

797 click.echo(str(migration), color="cyan")

798 for operation in migration.operations:

799 message, is_error = describe_operation(operation, backwards)

800 if is_error:

801 click.echo(" " + message, fg="yellow")

802 else:

803 click.echo(" " + message)

804 if check_unapplied:

805 sys.exit(1)

806 return

807

808 if check_unapplied:

809 if migration_plan:

810 sys.exit(1)

811 return

812

813 if prune:

814 return

815

816 # At this point, ignore run_syncdb if there aren't any packages to sync.

817 run_syncdb = run_syncdb and executor.loader.unmigrated_packages

818 # Print some useful info

819 if verbosity >= 1:

820 click.echo("Operations to perform:", color="cyan")

821 if run_syncdb:

822 if package_label:

823 click.echo(

824 f" Synchronize unmigrated package: {package_label}", color="yellow"

825 )

826 else:

827 click.echo(

828 " Synchronize unmigrated packages: "

829 + (", ".join(sorted(executor.loader.unmigrated_packages))),

830 color="yellow",

831 )

832 if target_package_labels_only:

833 click.echo(

834 " Apply all migrations: "

835 + (", ".join(sorted({a for a, n in targets})) or "(none)"),

836 color="yellow",

837 )

838 else:

839 if targets[0][1] is None:

840 click.echo(f" Unapply all migrations: {targets[0][0]}", color="yellow")

841 else:

842 click.echo(

843 f" Target specific migration: {targets[0][1]}, from {targets[0][0]}",

844 color="yellow",

845 )

846

847 pre_migrate_state = executor._create_project_state(with_applied_migrations=True)

848

849 # Run the syncdb phase.

850 if run_syncdb:

851 if verbosity >= 1:

852 click.echo("Synchronizing packages without migrations:", color="cyan")

853 if package_label:

854 sync_packages(connection, [package_label])

855 else:

856 sync_packages(connection, executor.loader.unmigrated_packages)

857

858 # Migrate!

859 if verbosity >= 1:

860 click.echo("Running migrations:", color="cyan")

861 if not migration_plan:

862 if verbosity >= 1:

863 click.echo(" No migrations to apply.")

864 # If there's changes that aren't in migrations yet, tell them

865 # how to fix it.

866 autodetector = MigrationAutodetector(

867 executor.loader.project_state(),

868 ProjectState.from_packages(packages),

869 )

870 changes = autodetector.changes(graph=executor.loader.graph)

871 if changes:

872 click.echo(

873 click.style(

874 f" Your models in package(s): {', '.join(repr(package) for package in sorted(changes))} "

875 "have changes that are not yet reflected in a migration, and so won't be applied.",

876 fg="yellow",

877 )

878 )

879 click.echo(

880 click.style(

881 " Run 'manage.py makemigrations' to make new "

882 "migrations, and then re-run 'manage.py migrate' to "

883 "apply them.",

884 fg="yellow",

885 )

886 )

887 else:

888 post_migrate_state = executor.migrate(

889 targets,

890 plan=migration_plan,

891 state=pre_migrate_state.clone(),

892 fake=fake,

893 fake_initial=fake_initial,

894 )

895 # post_migrate signals have access to all models. Ensure that all models

896 # are reloaded in case any are delayed.

897 post_migrate_state.clear_delayed_packages_cache()

898 post_migrate_packages = post_migrate_state.packages

899

900 # Re-render models of real packages to include relationships now that

901 # we've got a final state. This wouldn't be necessary if real packages

902 # models were rendered with relationships in the first place.

903 with post_migrate_packages.bulk_update():

904 model_keys = []

905 for model_state in post_migrate_packages.real_models:

906 model_key = model_state.package_label, model_state.name_lower

907 model_keys.append(model_key)

908 post_migrate_packages.unregister_model(*model_key)

909 post_migrate_packages.render_multiple(

910 [ModelState.from_model(packages.get_model(*model)) for model in model_keys]

911 )

912

913

914@cli.command()

915@click.argument("package_label")

916@click.argument("migration_name")

917@click.option(

918 "--check",

919 is_flag=True,

920 help="Exit with a non-zero status if the migration can be optimized.",

921)

922@click.option(

923 "-v",

924 "--verbosity",

925 type=int,

926 default=1,

927 help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",

928)

929 def optimize_migration(package_label, migration_name, check, verbosity):

930 """Optimizes the operations for the named migration."""

931 try:

932 packages.get_package_config(package_label)

933 except LookupError as err:

934 raise click.ClickException(str(err))

935

936 # Load the current graph state.

937 loader = MigrationLoader(None)

938 if package_label not in loader.migrated_packages:

939 raise click.ClickException(

940 f"Package '{package_label}' does not have migrations."

941 )

942

943 # Find a migration.

944 try:

945 migration = loader.get_migration_by_prefix(package_label, migration_name)

946 except AmbiguityError:

947 raise click.ClickException(

948 f"More than one migration matches '{migration_name}' in package "

949 f"'{package_label}'. Please be more specific."

950 )

951 except KeyError:

952 raise click.ClickException(

953 f"Cannot find a migration matching '{migration_name}' from package "

954 f"'{package_label}'."

955 )

956

957 # Optimize the migration.

958 optimizer = MigrationOptimizer()

959 new_operations = optimizer.optimize(migration.operations, migration.package_label)

960 if len(migration.operations) == len(new_operations):

961 if verbosity > 0:

962 click.echo("No optimizations possible.")

963 return

964 else:

965 if verbosity > 0:

966 click.echo(

967 f"Optimizing from {len(migration.operations)} operations to {len(new_operations)} operations."

968 )

969 if check:

970 sys.exit(1)

971

972 # Set the new migration optimizations.

973 migration.operations = new_operations

974

975 # Write out the optimized migration file.

976 writer = MigrationWriter(migration)

977 migration_file_string = writer.as_string()

978 if writer.needs_manual_porting:

979 if migration.replaces:

980 raise click.ClickException(

981 "Migration will require manual porting but is already a squashed "

982 "migration.\nTransition to a normal migration first."

983 )

984 # Make a new migration with those operations.

985 subclass = type(

986 "Migration",

987 (migrations.Migration,),

988 {

989 "dependencies": migration.dependencies,

990 "operations": new_operations,

991 "replaces": [(migration.package_label, migration.name)],

992 },

993 )

994 optimized_migration_name = f"{migration.name}_optimized"

995 optimized_migration = subclass(optimized_migration_name, package_label)

996 writer = MigrationWriter(optimized_migration)

997 migration_file_string = writer.as_string()

998 if verbosity > 0:

999 click.echo(click.style("Manual porting required", fg="yellow", bold=True))

1000 click.echo(

1001 " Your migrations contained functions that must be manually "

1002 "copied over,\n"

1003 " as we could not safely copy their implementation.\n"

1004 " See the comment at the top of the optimized migration for "

1005 "details."

1006 )

1007

1008 with open(writer.path, "w", encoding="utf-8") as fh:

1009 fh.write(migration_file_string)

1010

1011 if verbosity > 0:

1012 click.echo(

1013 click.style(f"Optimized migration {writer.path}", fg="green", bold=True)

1014 )

1015

1016

1017@cli.command()

1018@click.argument("package_labels", nargs=-1)

1019@click.option(

1020 "--database",

1021 default=DEFAULT_DB_ALIAS,

1022 help="Nominates a database to show migrations for. Defaults to the 'default' database.",

1023)

1024@click.option(

1025 "--format",

1026 type=click.Choice(["list", "plan"]),

1027 default="list",

1028 help="Output format.",

1029)

1030@click.option(

1031 "-v",

1032 "--verbosity",

1033 type=int,

1034 default=1,

1035 help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",

1036)

1037 def show_migrations(package_labels, database, format, verbosity):

1038 """Shows all available migrations for the current project"""

1039

1040 def _validate_package_names(package_names):

1041 has_bad_names = False

1042 for package_name in package_names:

1043 try:

1044 packages.get_package_config(package_name)

1045 except LookupError as err:

1046 click.echo(str(err), err=True)

1047 has_bad_names = True

1048 if has_bad_names:

1049 sys.exit(2)

1050

1051 def show_list(connection, package_names):

1052 """

1053 Show a list of all migrations on the system, or only those of

1054 some named packages.

1055 """

1056 # Load migrations from disk/DB

1057 loader = MigrationLoader(connection, ignore_no_migrations=True)

1058 recorder = MigrationRecorder(connection)

1059 recorded_migrations = recorder.applied_migrations()

1060 graph = loader.graph

1061 # If we were passed a list of packages, validate it

1062 if package_names:

1063 _validate_package_names(package_names)

1064 # Otherwise, show all packages in alphabetic order

1065 else:

1066 package_names = sorted(loader.migrated_packages)

1067 # For each app, print its migrations in order from oldest (roots) to

1068 # newest (leaves).

1069 for package_name in package_names:

1070 click.secho(package_name, fg="cyan", bold=True)

1071 shown = set()

1072 for node in graph.leaf_nodes(package_name):

1073 for plan_node in graph.forwards_plan(node):

1074 if plan_node not in shown and plan_node[0] == package_name:

1075 # Give it a nice title if it's a squashed one

1076 title = plan_node[1]

1077 if graph.nodes[plan_node].replaces:

1078 title += f" ({len(graph.nodes[plan_node].replaces)} squashed migrations)"

1079 applied_migration = loader.applied_migrations.get(plan_node)

1080 # Mark it as applied/unapplied

1081 if applied_migration:

1082 if plan_node in recorded_migrations:

1083 output = f" [X] {title}"

1084 else:

1085 title += " Run 'manage.py migrate' to finish recording."

1086 output = f" [-] {title}"

1087 if verbosity >= 2 and hasattr(applied_migration, "applied"):

1088 output += f" (applied at {applied_migration.applied.strftime('%Y-%m-%d %H:%M:%S')})"

1089 click.echo(output)

1090 else:

1091 click.echo(f" [ ] {title}")

1092 shown.add(plan_node)

1093 # If we didn't print anything, then a small message

1094 if not shown:

1095 click.secho(" (no migrations)", fg="red")

1096

1097 def show_plan(connection, package_names):

1098 """

1099 Show all known migrations (or only those of the specified package_names)

1100 in the order they will be applied.

1101 """

1102 # Load migrations from disk/DB

1103 loader = MigrationLoader(connection)

1104 graph = loader.graph

1105 if package_names:

1106 _validate_package_names(package_names)

1107 targets = [key for key in graph.leaf_nodes() if key[0] in package_names]

1108 else:

1109 targets = graph.leaf_nodes()

1110 plan = []

1111 seen = set()

1112

1113 # Generate the plan

1114 for target in targets:

1115 for migration in graph.forwards_plan(target):

1116 if migration not in seen:

1117 node = graph.node_map[migration]

1118 plan.append(node)

1119 seen.add(migration)

1120

1121 # Output

1122 def print_deps(node):

1123 out = []

1124 for parent in sorted(node.parents):

1125 out.append(f"{parent.key[0]}.{parent.key[1]}")

1126 if out:

1127 return f" ... ({', '.join(out)})"

1128 return ""

1129

1130 for node in plan:

1131 deps = ""

1132 if verbosity >= 2:

1133 deps = print_deps(node)

1134 if node.key in loader.applied_migrations:

1135 click.echo(f"[X] {node.key[0]}.{node.key[1]}{deps}")

1136 else:

1137 click.echo(f"[ ] {node.key[0]}.{node.key[1]}{deps}")

1138 if not plan:

1139 click.secho("(no migrations)", fg="red")

1140

1141 # Get the database we're operating from

1142 connection = connections[database]

1143

1144 if format == "plan":

1145 show_plan(connection, package_labels)

1146 else:

1147 show_list(connection, package_labels)

1148

1149

1150@cli.command()

1151@click.argument("package_label")

1152@click.argument("start_migration_name", required=False)

1153@click.argument("migration_name")

1154@click.option(

1155 "--no-optimize",

1156 is_flag=True,

1157 help="Do not try to optimize the squashed operations.",

1158)

1159@click.option(

1160 "--noinput",

1161 "--no-input",

1162 "no_input",

1163 is_flag=True,

1164 help="Tells Plain to NOT prompt the user for input of any kind.",

1165)

1166@click.option("--squashed-name", help="Sets the name of the new squashed migration.")

1167@click.option(

1168 "-v",

1169 "--verbosity",

1170 type=int,

1171 default=1,

1172 help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",

1173)

1174 def squash_migrations(

1175 package_label,

1176 start_migration_name,

1177 migration_name,

1178 no_optimize,

1179 no_input,

1180 squashed_name,

1181 verbosity,

1182):

1183 """

1184 Squashes an existing set of migrations (from first until specified) into a single new one.

1185 """

1186 interactive = not no_input

1187

1188 def find_migration(loader, package_label, name):

1189 try:

1190 return loader.get_migration_by_prefix(package_label, name)

1191 except AmbiguityError:

1192 raise click.ClickException(

1193 f"More than one migration matches '{name}' in package '{package_label}'. Please be more specific."

1194 )

1195 except KeyError:

1196 raise click.ClickException(

1197 f"Cannot find a migration matching '{name}' from package '{package_label}'."

1198 )

1199

1200 # Validate package_label

1201 try:

1202 packages.get_package_config(package_label)

1203 except LookupError as err:

1204 raise click.ClickException(str(err))

1205

1206 # Load the current graph state, check the app and migration they asked for exists

1207 loader = MigrationLoader(connections[DEFAULT_DB_ALIAS])

1208 if package_label not in loader.migrated_packages:

1209 raise click.ClickException(

1210 f"Package '{package_label}' does not have migrations (so squashmigrations on it makes no sense)"

1211 )

1212

1213 migration = find_migration(loader, package_label, migration_name)

1214

1215 # Work out the list of predecessor migrations

1216 migrations_to_squash = [

1217 loader.get_migration(al, mn)

1218 for al, mn in loader.graph.forwards_plan(

1219 (migration.package_label, migration.name)

1220 )

1221 if al == migration.package_label

1222 ]

1223

1224 if start_migration_name:

1225 start_migration = find_migration(loader, package_label, start_migration_name)

1226 start = loader.get_migration(

1227 start_migration.package_label, start_migration.name

1228 )

1229 try:

1230 start_index = migrations_to_squash.index(start)

1231 migrations_to_squash = migrations_to_squash[start_index:]

1232 except ValueError:

1233 raise click.ClickException(

1234 f"The migration '{start_migration}' cannot be found. Maybe it comes after "

1235 f"the migration '{migration}'?\n"

1236 f"Have a look at:\n"

1237 f" python manage.py showmigrations {package_label}\n"

1238 f"to debug this issue."

1239 )

1240

1241 # Tell them what we're doing and optionally ask if we should proceed

1242 if verbosity > 0 or interactive:

1243 click.secho("Will squash the following migrations:", fg="cyan", bold=True)

1244 for migration in migrations_to_squash:

1245 click.echo(f" - {migration.name}")

1246

1247 if interactive:

1248 if not click.confirm("Do you wish to proceed?"):

1249 return

1250

1251 # Load the operations from all those migrations and concat together,

1252 # along with collecting external dependencies and detecting double-squashing

1253 operations = []

1254 dependencies = set()

1255 # We need to take all dependencies from the first migration in the list

1256 # as it may be 0002 depending on 0001

1257 first_migration = True

1258 for smigration in migrations_to_squash:

1259 if smigration.replaces:

1260 raise click.ClickException(

1261 "You cannot squash squashed migrations! Please transition it to a "

1262 "normal migration first"

1263 )

1264 operations.extend(smigration.operations)

1265 for dependency in smigration.dependencies:

1266 if isinstance(dependency, SwappableTuple):

1267 if settings.AUTH_USER_MODEL == dependency.setting:

1268 dependencies.add(("__setting__", "AUTH_USER_MODEL"))

1269 else:

1270 dependencies.add(dependency)

1271 elif dependency[0] != smigration.package_label or first_migration:

1272 dependencies.add(dependency)

1273 first_migration = False

1274

1275 if no_optimize:

1276 if verbosity > 0:

1277 click.secho("(Skipping optimization.)", fg="yellow")

1278 new_operations = operations

1279 else:

1280 if verbosity > 0:

1281 click.secho("Optimizing...", fg="cyan")

1282

1283 optimizer = MigrationOptimizer()

1284 new_operations = optimizer.optimize(operations, migration.package_label)

1285

1286 if verbosity > 0:

1287 if len(new_operations) == len(operations):

1288 click.echo(" No optimizations possible.")

1289 else:

1290 click.echo(

1291 f" Optimized from {len(operations)} operations to {len(new_operations)} operations."

1292 )

1293

1294 # Work out the value of replaces (any squashed ones we're re-squashing)

1295 # need to feed their replaces into ours

1296 replaces = []

1297 for migration in migrations_to_squash:

1298 if migration.replaces:

1299 replaces.extend(migration.replaces)

1300 else:

1301 replaces.append((migration.package_label, migration.name))

1302

1303 # Make a new migration with those operations

1304 subclass = type(

1305 "Migration",

1306 (migrations.Migration,),

1307 {

1308 "dependencies": dependencies,

1309 "operations": new_operations,

1310 "replaces": replaces,

1311 },

1312 )

1313 if start_migration_name:

1314 if squashed_name:

1315 # Use the name from --squashed-name

1316 prefix, _ = start_migration.name.split("_", 1)

1317 name = f"{prefix}_{squashed_name}"

1318 else:

1319 # Generate a name

1320 name = f"{start_migration.name}_squashed_{migration.name}"

1321 new_migration = subclass(name, package_label)

1322 else:

1323 name = f"0001_{'squashed_' + migration.name if not squashed_name else squashed_name}"

1324 new_migration = subclass(name, package_label)

1325 new_migration.initial = True

1326

1327 # Write out the new migration file

1328 writer = MigrationWriter(new_migration)

1329 if os.path.exists(writer.path):

1330 raise click.ClickException(

1331 f"Migration {new_migration.name} already exists. Use a different name."

1332 )

1333 with open(writer.path, "w", encoding="utf-8") as fh:

1334 fh.write(writer.as_string())

1335

1336 if verbosity > 0:

1337 click.secho(

1338 f"Created new squashed migration {writer.path}", fg="green", bold=True

1339 )

1340 click.echo(

1341 " You should commit this migration but leave the old ones in place;\n"

1342 " the new migration will be used for new installs. Once you are sure\n"

1343 " all instances of the codebase have applied the migrations you squashed,\n"

1344 " you can delete them."

1345 )

1346 if writer.needs_manual_porting:

1347 click.secho("Manual porting required", fg="yellow", bold=True)

1348 click.echo(

1349 " Your migrations contained functions that must be manually copied over,\n"

1350 " as we could not safely copy their implementation.\n"

1351 " See the comment at the top of the squashed migration for details."

1352 )