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

656 statements  

« prev     ^ index     » next       coverage.py v7.6.9, created at 2024-12-23 11:16 -0600

1import os 

2import subprocess 

3import sys 

4import time 

5from itertools import takewhile 

6 

7import click 

8 

9from plain.models import migrations 

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

11from plain.models.migrations.autodetector import MigrationAutodetector 

12from plain.models.migrations.executor import MigrationExecutor 

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

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

15from plain.models.migrations.optimizer import MigrationOptimizer 

16from plain.models.migrations.questioner import ( 

17 InteractiveMigrationQuestioner, 

18 MigrationQuestioner, 

19 NonInteractiveMigrationQuestioner, 

20) 

21from plain.models.migrations.recorder import MigrationRecorder 

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

23from plain.models.migrations.utils import get_migration_name_timestamp 

24from plain.models.migrations.writer import MigrationWriter 

25from plain.packages import packages 

26from plain.runtime import settings 

27from plain.utils.text import Truncator 

28 

29 

30@click.group() 

31def 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) 

45def 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() 

74def 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) 

132def 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) 

536def 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) 

929def 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) 

1037def 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) 

1174def 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 )