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