plain.postgres 0.84.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. plain/postgres/CHANGELOG.md +1028 -0
  2. plain/postgres/README.md +925 -0
  3. plain/postgres/__init__.py +120 -0
  4. plain/postgres/agents/.claude/rules/plain-postgres.md +78 -0
  5. plain/postgres/aggregates.py +236 -0
  6. plain/postgres/backups/__init__.py +0 -0
  7. plain/postgres/backups/cli.py +148 -0
  8. plain/postgres/backups/clients.py +94 -0
  9. plain/postgres/backups/core.py +172 -0
  10. plain/postgres/base.py +1415 -0
  11. plain/postgres/cli/__init__.py +3 -0
  12. plain/postgres/cli/db.py +142 -0
  13. plain/postgres/cli/migrations.py +1085 -0
  14. plain/postgres/config.py +18 -0
  15. plain/postgres/connection.py +1331 -0
  16. plain/postgres/connections.py +77 -0
  17. plain/postgres/constants.py +13 -0
  18. plain/postgres/constraints.py +495 -0
  19. plain/postgres/database_url.py +94 -0
  20. plain/postgres/db.py +59 -0
  21. plain/postgres/default_settings.py +38 -0
  22. plain/postgres/deletion.py +475 -0
  23. plain/postgres/dialect.py +640 -0
  24. plain/postgres/entrypoints.py +4 -0
  25. plain/postgres/enums.py +103 -0
  26. plain/postgres/exceptions.py +217 -0
  27. plain/postgres/expressions.py +1912 -0
  28. plain/postgres/fields/__init__.py +2118 -0
  29. plain/postgres/fields/encrypted.py +354 -0
  30. plain/postgres/fields/json.py +413 -0
  31. plain/postgres/fields/mixins.py +30 -0
  32. plain/postgres/fields/related.py +1192 -0
  33. plain/postgres/fields/related_descriptors.py +290 -0
  34. plain/postgres/fields/related_lookups.py +223 -0
  35. plain/postgres/fields/related_managers.py +661 -0
  36. plain/postgres/fields/reverse_descriptors.py +229 -0
  37. plain/postgres/fields/reverse_related.py +328 -0
  38. plain/postgres/fields/timezones.py +143 -0
  39. plain/postgres/forms.py +773 -0
  40. plain/postgres/functions/__init__.py +189 -0
  41. plain/postgres/functions/comparison.py +127 -0
  42. plain/postgres/functions/datetime.py +454 -0
  43. plain/postgres/functions/math.py +140 -0
  44. plain/postgres/functions/mixins.py +59 -0
  45. plain/postgres/functions/text.py +282 -0
  46. plain/postgres/functions/window.py +125 -0
  47. plain/postgres/indexes.py +286 -0
  48. plain/postgres/lookups.py +758 -0
  49. plain/postgres/meta.py +584 -0
  50. plain/postgres/migrations/__init__.py +53 -0
  51. plain/postgres/migrations/autodetector.py +1379 -0
  52. plain/postgres/migrations/exceptions.py +54 -0
  53. plain/postgres/migrations/executor.py +188 -0
  54. plain/postgres/migrations/graph.py +364 -0
  55. plain/postgres/migrations/loader.py +377 -0
  56. plain/postgres/migrations/migration.py +180 -0
  57. plain/postgres/migrations/operations/__init__.py +34 -0
  58. plain/postgres/migrations/operations/base.py +139 -0
  59. plain/postgres/migrations/operations/fields.py +373 -0
  60. plain/postgres/migrations/operations/models.py +798 -0
  61. plain/postgres/migrations/operations/special.py +184 -0
  62. plain/postgres/migrations/optimizer.py +74 -0
  63. plain/postgres/migrations/questioner.py +340 -0
  64. plain/postgres/migrations/recorder.py +119 -0
  65. plain/postgres/migrations/serializer.py +378 -0
  66. plain/postgres/migrations/state.py +882 -0
  67. plain/postgres/migrations/utils.py +147 -0
  68. plain/postgres/migrations/writer.py +302 -0
  69. plain/postgres/options.py +207 -0
  70. plain/postgres/otel.py +231 -0
  71. plain/postgres/preflight.py +336 -0
  72. plain/postgres/query.py +2242 -0
  73. plain/postgres/query_utils.py +456 -0
  74. plain/postgres/registry.py +217 -0
  75. plain/postgres/schema.py +1885 -0
  76. plain/postgres/sql/__init__.py +40 -0
  77. plain/postgres/sql/compiler.py +1869 -0
  78. plain/postgres/sql/constants.py +22 -0
  79. plain/postgres/sql/datastructures.py +222 -0
  80. plain/postgres/sql/query.py +2947 -0
  81. plain/postgres/sql/where.py +374 -0
  82. plain/postgres/test/__init__.py +0 -0
  83. plain/postgres/test/pytest.py +117 -0
  84. plain/postgres/test/utils.py +18 -0
  85. plain/postgres/transaction.py +222 -0
  86. plain/postgres/types.py +92 -0
  87. plain/postgres/types.pyi +751 -0
  88. plain/postgres/utils.py +345 -0
  89. plain_postgres-0.84.0.dist-info/METADATA +937 -0
  90. plain_postgres-0.84.0.dist-info/RECORD +93 -0
  91. plain_postgres-0.84.0.dist-info/WHEEL +4 -0
  92. plain_postgres-0.84.0.dist-info/entry_points.txt +5 -0
  93. plain_postgres-0.84.0.dist-info/licenses/LICENSE +61 -0
@@ -0,0 +1,1085 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ import time
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import click
9
+
10
+ from plain.cli import register_cli
11
+ from plain.cli.runtime import common_command
12
+ from plain.packages import packages_registry
13
+ from plain.runtime import settings
14
+ from plain.utils.text import Truncator
15
+
16
+ from .. import migrations
17
+ from ..backups.core import DatabaseBackups
18
+ from ..db import get_connection
19
+ from ..migrations.autodetector import MigrationAutodetector
20
+ from ..migrations.executor import MigrationExecutor
21
+ from ..migrations.loader import AmbiguityError, MigrationLoader
22
+ from ..migrations.migration import Migration, SettingsTuple
23
+ from ..migrations.optimizer import MigrationOptimizer
24
+ from ..migrations.questioner import (
25
+ InteractiveMigrationQuestioner,
26
+ NonInteractiveMigrationQuestioner,
27
+ )
28
+ from ..migrations.recorder import MigrationRecorder
29
+ from ..migrations.state import ModelState, ProjectState
30
+ from ..migrations.writer import MigrationWriter
31
+ from ..registry import models_registry
32
+
33
+ if TYPE_CHECKING:
34
+ from ..connection import DatabaseConnection
35
+ from ..migrations.operations.base import Operation
36
+
37
+
38
+ @register_cli("migrations")
39
+ @click.group()
40
+ def cli() -> None:
41
+ """Database migration management"""
42
+
43
+
44
+ @common_command
45
+ @register_cli("makemigrations", shortcut_for="migrations make")
46
+ @cli.command("make")
47
+ @click.argument("package_labels", nargs=-1)
48
+ @click.option(
49
+ "--dry-run",
50
+ is_flag=True,
51
+ help="Just show what migrations would be made; don't actually write them.",
52
+ )
53
+ @click.option("--empty", is_flag=True, help="Create an empty migration.")
54
+ @click.option(
55
+ "--noinput",
56
+ "--no-input",
57
+ "no_input",
58
+ is_flag=True,
59
+ help="Tells Plain to NOT prompt the user for input of any kind.",
60
+ )
61
+ @click.option("-n", "--name", help="Use this name for migration file(s).")
62
+ @click.option(
63
+ "--check",
64
+ is_flag=True,
65
+ help="Exit with a non-zero status if model changes are missing migrations and don't actually write them.",
66
+ )
67
+ @click.option(
68
+ "-v",
69
+ "--verbosity",
70
+ type=int,
71
+ default=1,
72
+ help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",
73
+ )
74
+ def make(
75
+ package_labels: tuple[str, ...],
76
+ dry_run: bool,
77
+ empty: bool,
78
+ no_input: bool,
79
+ name: str | None,
80
+ check: bool,
81
+ verbosity: int,
82
+ ) -> None:
83
+ """Create new database migrations"""
84
+
85
+ written_files: list[str] = []
86
+ interactive = not no_input
87
+ migration_name = name
88
+ check_changes = check
89
+
90
+ def log(msg: str, level: int = 1) -> None:
91
+ if verbosity >= level:
92
+ click.echo(msg)
93
+
94
+ def collect_sql_for_migration(
95
+ migration: Migration,
96
+ project_state: ProjectState,
97
+ ) -> tuple[list[str], ProjectState]:
98
+ """Apply a migration in collect mode and return the SQL statements."""
99
+ with get_connection().schema_editor(collect_sql=True) as editor:
100
+ new_state = migration.apply(project_state, editor)
101
+ return list(editor.executed_sql), new_state
102
+
103
+ def write_migration_files(
104
+ changes: dict[str, list[Migration]],
105
+ update_previous_migration_paths: dict[str, str] | None = None,
106
+ ) -> None:
107
+ """Take a changes dict and write them out as migration files."""
108
+ directory_created = {}
109
+ # Track state for SQL collection in dry-run mode.
110
+ sql_state = loader.project_state() if dry_run else None
111
+ for package_label, package_migrations in changes.items():
112
+ log(
113
+ click.style(f"Migrations for '{package_label}':", fg="cyan", bold=True),
114
+ level=1,
115
+ )
116
+ for migration in package_migrations:
117
+ writer = MigrationWriter(migration)
118
+ migration_string = os.path.relpath(writer.path)
119
+ log(f" {click.style(migration_string, fg='yellow')}\n", level=1)
120
+ for operation in migration.operations:
121
+ log(f" - {operation.describe()}", level=1)
122
+
123
+ if not dry_run:
124
+ migrations_directory = os.path.dirname(writer.path)
125
+ if not directory_created.get(package_label):
126
+ os.makedirs(migrations_directory, exist_ok=True)
127
+ init_path = os.path.join(migrations_directory, "__init__.py")
128
+ if not os.path.isfile(init_path):
129
+ open(init_path, "w").close()
130
+ directory_created[package_label] = True
131
+
132
+ migration_string = writer.as_string()
133
+ with open(writer.path, "w", encoding="utf-8") as fh:
134
+ fh.write(migration_string)
135
+ written_files.append(writer.path)
136
+
137
+ if update_previous_migration_paths:
138
+ prev_path = update_previous_migration_paths[package_label]
139
+ if writer.needs_manual_porting:
140
+ log(
141
+ click.style(
142
+ f"Updated migration {migration_string} requires manual porting.\n"
143
+ f"Previous migration {os.path.relpath(prev_path)} was kept and "
144
+ f"must be deleted after porting functions manually.",
145
+ fg="yellow",
146
+ ),
147
+ level=1,
148
+ )
149
+ else:
150
+ os.remove(prev_path)
151
+ log(f"Deleted {os.path.relpath(prev_path)}", level=1)
152
+ else:
153
+ # dry_run is True — show SQL preview and optionally the full file.
154
+ assert sql_state is not None
155
+ sql_statements, sql_state = collect_sql_for_migration(
156
+ migration, sql_state
157
+ )
158
+ if sql_statements:
159
+ log("", level=1)
160
+ log(
161
+ click.style(" SQL:", fg="green", bold=True),
162
+ level=1,
163
+ )
164
+ for sql in sql_statements:
165
+ log(f" {sql};", level=1)
166
+
167
+ if verbosity >= 3:
168
+ log(
169
+ click.style(
170
+ f"\n Full migrations file '{writer.filename}':",
171
+ fg="cyan",
172
+ bold=True,
173
+ ),
174
+ level=3,
175
+ )
176
+ log(writer.as_string(), level=3)
177
+
178
+ # Validate package labels
179
+ package_labels_set = set(package_labels)
180
+ has_bad_labels = False
181
+ for package_label in package_labels_set:
182
+ try:
183
+ packages_registry.get_package_config(package_label)
184
+ except LookupError as err:
185
+ click.echo(str(err), err=True)
186
+ has_bad_labels = True
187
+ if has_bad_labels:
188
+ sys.exit(2)
189
+
190
+ # Load the current graph state
191
+ loader = MigrationLoader(None, ignore_no_migrations=True)
192
+
193
+ # Raise an error if any migrations are applied before their dependencies.
194
+ loader.check_consistent_history(get_connection())
195
+
196
+ # Check for conflicts
197
+ conflicts = loader.detect_conflicts()
198
+ if package_labels_set:
199
+ conflicts = {
200
+ package_label: conflict
201
+ for package_label, conflict in conflicts.items()
202
+ if package_label in package_labels_set
203
+ }
204
+
205
+ if conflicts:
206
+ name_str = "; ".join(
207
+ "{} in {}".format(", ".join(names), package)
208
+ for package, names in conflicts.items()
209
+ )
210
+ raise click.ClickException(
211
+ f"Conflicting migrations detected; multiple leaf nodes in the "
212
+ f"migration graph: ({name_str})."
213
+ )
214
+
215
+ # Set up questioner
216
+ if interactive:
217
+ questioner = InteractiveMigrationQuestioner(
218
+ specified_packages=package_labels_set,
219
+ dry_run=dry_run,
220
+ )
221
+ else:
222
+ questioner = NonInteractiveMigrationQuestioner(
223
+ specified_packages=package_labels_set,
224
+ dry_run=dry_run,
225
+ verbosity=verbosity,
226
+ )
227
+
228
+ # Set up autodetector
229
+ autodetector = MigrationAutodetector(
230
+ loader.project_state(),
231
+ ProjectState.from_models_registry(models_registry),
232
+ questioner,
233
+ )
234
+
235
+ # Handle empty migrations if requested
236
+ if empty:
237
+ if not package_labels_set:
238
+ raise click.ClickException(
239
+ "You must supply at least one package label when using --empty."
240
+ )
241
+ changes = {
242
+ package: [Migration("custom", package)] for package in package_labels_set
243
+ }
244
+ changes = autodetector.arrange_for_graph(
245
+ changes=changes,
246
+ graph=loader.graph,
247
+ migration_name=migration_name,
248
+ )
249
+ write_migration_files(changes)
250
+ return
251
+
252
+ # Detect changes
253
+ changes = autodetector.changes(
254
+ graph=loader.graph,
255
+ trim_to_packages=package_labels_set or None,
256
+ convert_packages=package_labels_set or None,
257
+ migration_name=migration_name,
258
+ )
259
+
260
+ if not changes:
261
+ log(
262
+ "No changes detected"
263
+ if not package_labels_set
264
+ else f"No changes detected in {'package' if len(package_labels_set) == 1 else 'packages'} "
265
+ f"'{', '.join(package_labels_set)}'",
266
+ level=1,
267
+ )
268
+ else:
269
+ if check_changes:
270
+ sys.exit(1)
271
+
272
+ write_migration_files(changes)
273
+
274
+ # Warn about packages that have models but no migrations directory.
275
+ # These are silently skipped by the autodetector, which can be confusing
276
+ # when setting up a new app (makemigrations says "No changes detected").
277
+ unmigrated_with_models = []
278
+ for package_label in sorted(loader.unmigrated_packages):
279
+ module_name, _explicit = MigrationLoader.migrations_module(package_label)
280
+ # Skip packages that explicitly opt out of migrations (module_name is None).
281
+ if module_name is not None and models_registry.all_models.get(package_label):
282
+ unmigrated_with_models.append((package_label, module_name))
283
+ if unmigrated_with_models:
284
+ click.echo()
285
+ click.echo(
286
+ click.style(
287
+ "Warning: The following packages have models but no migrations directory:",
288
+ fg="yellow",
289
+ )
290
+ )
291
+ for package_label, module_name in unmigrated_with_models:
292
+ module_path = module_name.replace(".", "/")
293
+ click.echo(
294
+ f" - {package_label} (create {module_path}/ to enable migrations)"
295
+ )
296
+ click.echo()
297
+ click.echo(
298
+ "To create initial migrations, add the directory and run "
299
+ + click.style("plain makemigrations", bold=True)
300
+ + " again."
301
+ )
302
+
303
+
304
+ @common_command
305
+ @register_cli("migrate", shortcut_for="migrations apply")
306
+ @cli.command("apply")
307
+ @click.argument("package_label", required=False)
308
+ @click.argument("migration_name", required=False)
309
+ @click.option(
310
+ "--fake", is_flag=True, help="Mark migrations as run without actually running them."
311
+ )
312
+ @click.option(
313
+ "--plan",
314
+ is_flag=True,
315
+ help="Shows a list of the migration actions that will be performed.",
316
+ )
317
+ @click.option(
318
+ "--check",
319
+ "check_unapplied",
320
+ is_flag=True,
321
+ help="Exits with a non-zero status if unapplied migrations exist and does not actually apply migrations.",
322
+ )
323
+ @click.option(
324
+ "--backup/--no-backup",
325
+ "backup",
326
+ is_flag=True,
327
+ default=None,
328
+ help="Explicitly enable/disable pre-migration backups.",
329
+ )
330
+ @click.option(
331
+ "--no-input",
332
+ "--noinput",
333
+ "no_input",
334
+ is_flag=True,
335
+ help="Tells Plain to NOT prompt the user for input of any kind.",
336
+ )
337
+ @click.option(
338
+ "--atomic-batch/--no-atomic-batch",
339
+ default=None,
340
+ help="Run migrations in a single transaction (auto-detected by default)",
341
+ )
342
+ @click.option(
343
+ "--quiet",
344
+ is_flag=True,
345
+ help="Suppress migration output (used for test database creation).",
346
+ )
347
+ def apply(
348
+ package_label: str | None,
349
+ migration_name: str | None,
350
+ fake: bool,
351
+ plan: bool,
352
+ check_unapplied: bool,
353
+ backup: bool | None,
354
+ no_input: bool,
355
+ atomic_batch: bool | None,
356
+ quiet: bool,
357
+ ) -> None:
358
+ """Apply database migrations"""
359
+
360
+ def migration_progress_callback(
361
+ action: str,
362
+ *,
363
+ migration: Migration | None = None,
364
+ fake: bool = False,
365
+ operation: Operation | None = None,
366
+ sql_statements: list[str] | None = None,
367
+ ) -> None:
368
+ if quiet:
369
+ return
370
+
371
+ if action == "apply_start":
372
+ click.echo() # Always add newline between migrations
373
+ if fake:
374
+ click.secho(f"{migration} (faked)", fg="cyan")
375
+ else:
376
+ click.secho(f"{migration}", fg="cyan")
377
+ elif action == "apply_success":
378
+ pass # Already shown via operations
379
+ elif action == "operation_start":
380
+ if operation is not None:
381
+ click.echo(f" {operation.describe()}", nl=False)
382
+ click.secho("... ", dim=True, nl=False)
383
+ elif action == "operation_success":
384
+ # Show SQL statements (no OK needed, SQL implies success)
385
+ if sql_statements:
386
+ click.echo() # newline after "..."
387
+ for sql in sql_statements:
388
+ click.secho(f" {sql}", dim=True)
389
+ else:
390
+ # No SQL: just add a newline
391
+ click.echo()
392
+
393
+ def describe_operation(operation: Any) -> tuple[str, bool]:
394
+ """Return a string that describes a migration operation for --plan."""
395
+ prefix = ""
396
+ is_error = False
397
+ if hasattr(operation, "code"):
398
+ code = operation.code
399
+ action = (code.__doc__ or "") if code else None
400
+ elif hasattr(operation, "sql"):
401
+ action = operation.sql
402
+ else:
403
+ action = ""
404
+ if action is not None:
405
+ action = str(action).replace("\n", "")
406
+ if action:
407
+ action = " -> " + action
408
+ truncated = Truncator(action)
409
+ return prefix + operation.describe() + truncated.chars(40), is_error
410
+
411
+ # Work out which packages have migrations and which do not
412
+ executor = MigrationExecutor(get_connection(), migration_progress_callback)
413
+
414
+ # Raise an error if any migrations are applied before their dependencies.
415
+ executor.loader.check_consistent_history(executor.connection)
416
+
417
+ # Before anything else, see if there's conflicting packages and drop out
418
+ # hard if there are any
419
+ conflicts = executor.loader.detect_conflicts()
420
+ if conflicts:
421
+ name_str = "; ".join(
422
+ "{} in {}".format(", ".join(names), package)
423
+ for package, names in conflicts.items()
424
+ )
425
+ raise click.ClickException(
426
+ "Conflicting migrations detected; multiple leaf nodes in the "
427
+ f"migration graph: ({name_str})."
428
+ )
429
+
430
+ # If they supplied command line arguments, work out what they mean.
431
+ target_package_labels_only = True
432
+ targets: list[tuple[str, str]]
433
+ if package_label:
434
+ try:
435
+ packages_registry.get_package_config(package_label)
436
+ except LookupError as err:
437
+ raise click.ClickException(str(err))
438
+
439
+ if package_label not in executor.loader.migrated_packages:
440
+ raise click.ClickException(
441
+ f"Package '{package_label}' does not have migrations."
442
+ )
443
+
444
+ if package_label and migration_name:
445
+ try:
446
+ migration = executor.loader.get_migration_by_prefix(
447
+ package_label, migration_name
448
+ )
449
+ except AmbiguityError:
450
+ raise click.ClickException(
451
+ f"More than one migration matches '{migration_name}' in package '{package_label}'. "
452
+ "Please be more specific."
453
+ )
454
+ except KeyError:
455
+ raise click.ClickException(
456
+ f"Cannot find a migration matching '{migration_name}' from package '{package_label}'."
457
+ )
458
+ target: tuple[str, str] = (package_label, migration.name)
459
+ if (
460
+ target not in executor.loader.graph.nodes
461
+ and target in executor.loader.replacements
462
+ ):
463
+ incomplete_migration = executor.loader.replacements[target]
464
+ target = incomplete_migration.replaces[-1]
465
+ targets = [target]
466
+ target_package_labels_only = False
467
+ elif package_label:
468
+ targets = [
469
+ key for key in executor.loader.graph.leaf_nodes() if key[0] == package_label
470
+ ]
471
+ else:
472
+ targets = list(executor.loader.graph.leaf_nodes())
473
+
474
+ migration_plan = executor.migration_plan(targets)
475
+
476
+ if plan:
477
+ if not quiet:
478
+ click.secho("Planned operations:", fg="cyan")
479
+ if not migration_plan:
480
+ click.echo(" No planned migration operations.")
481
+ else:
482
+ for migration in migration_plan:
483
+ click.secho(str(migration), fg="cyan")
484
+ for operation in migration.operations:
485
+ message, is_error = describe_operation(operation)
486
+ if is_error:
487
+ click.secho(" " + message, fg="yellow")
488
+ else:
489
+ click.echo(" " + message)
490
+ if check_unapplied:
491
+ sys.exit(1)
492
+ return
493
+
494
+ if check_unapplied:
495
+ if migration_plan:
496
+ sys.exit(1)
497
+ return
498
+
499
+ # Print some useful info
500
+ if not quiet:
501
+ if not target_package_labels_only:
502
+ click.secho("Target: ", bold=True, nl=False)
503
+ click.secho(f"{targets[0][1]} from {targets[0][0]}", dim=True)
504
+ click.echo() # Add newline after target
505
+ elif package_label:
506
+ # Only show package name when explicitly targeting a single package
507
+ click.secho("Package: ", bold=True, nl=False)
508
+ click.secho(package_label, dim=True)
509
+ click.echo() # Add newline after package
510
+
511
+ pre_migrate_state = executor._create_project_state(with_applied_migrations=True)
512
+
513
+ if migration_plan:
514
+ # Determine whether to use atomic batch
515
+ use_atomic_batch = False
516
+ atomic_batch_message = None
517
+ if len(migration_plan) > 1:
518
+ # Check if all migrations support atomic
519
+ non_atomic_migrations = [m for m in migration_plan if not m.atomic]
520
+
521
+ if atomic_batch is True:
522
+ # User explicitly requested atomic batch
523
+ if non_atomic_migrations:
524
+ names = ", ".join(
525
+ f"{m.package_label}.{m.name}" for m in non_atomic_migrations[:3]
526
+ )
527
+ if len(non_atomic_migrations) > 3:
528
+ names += f", and {len(non_atomic_migrations) - 3} more"
529
+ raise click.UsageError(
530
+ f"--atomic-batch requested but these migrations have atomic=False: {names}"
531
+ )
532
+ use_atomic_batch = True
533
+ atomic_batch_message = (
534
+ f"Running {len(migration_plan)} migrations in atomic batch"
535
+ )
536
+ elif atomic_batch is False:
537
+ # User explicitly disabled atomic batch
538
+ use_atomic_batch = False
539
+ if len(migration_plan) > 1:
540
+ atomic_batch_message = (
541
+ f"Running {len(migration_plan)} migrations separately"
542
+ )
543
+ else:
544
+ # Auto-detect (atomic_batch is None)
545
+ # Use atomic batch by default
546
+ if not non_atomic_migrations:
547
+ use_atomic_batch = True
548
+ atomic_batch_message = (
549
+ f"Running {len(migration_plan)} migrations in atomic batch"
550
+ )
551
+ else:
552
+ use_atomic_batch = False
553
+ if len(migration_plan) > 1:
554
+ atomic_batch_message = f"Running {len(migration_plan)} migrations separately (some have atomic=False)"
555
+
556
+ if backup or (backup is None and settings.DEBUG):
557
+ backup_name = time.strftime("%Y%m%d_%H%M%S")
558
+ if not quiet:
559
+ click.secho("Creating backup: ", bold=True, nl=False)
560
+ click.secho(f"{backup_name}", dim=True, nl=False)
561
+ click.secho("... ", dim=True, nl=False)
562
+
563
+ backups_handler = DatabaseBackups()
564
+ backups_handler.create(
565
+ backup_name,
566
+ source="migrate",
567
+ pg_dump=os.environ.get("PG_DUMP", "pg_dump"),
568
+ )
569
+
570
+ if not quiet:
571
+ click.echo(click.style("OK", fg="green"))
572
+ click.echo() # Add blank line after backup output
573
+ else:
574
+ if not quiet:
575
+ click.echo() # Add blank line after packages/target info
576
+
577
+ if not quiet:
578
+ if atomic_batch_message:
579
+ click.secho(
580
+ f"Applying migrations ({atomic_batch_message.lower()}):", bold=True
581
+ )
582
+ else:
583
+ click.secho("Applying migrations:", bold=True)
584
+ post_migrate_state = executor.migrate(
585
+ targets,
586
+ plan=migration_plan,
587
+ state=pre_migrate_state.clone(),
588
+ fake=fake,
589
+ atomic_batch=use_atomic_batch,
590
+ )
591
+ # post_migrate signals have access to all models. Ensure that all models
592
+ # are reloaded in case any are delayed.
593
+ post_migrate_state.clear_delayed_models_cache()
594
+ post_migrate_packages = post_migrate_state.models_registry
595
+
596
+ # Re-render models of real packages to include relationships now that
597
+ # we've got a final state. This wouldn't be necessary if real packages
598
+ # models were rendered with relationships in the first place.
599
+ with post_migrate_packages.bulk_update():
600
+ model_keys = []
601
+ for model_state in post_migrate_packages.real_models:
602
+ model_key = model_state.package_label, model_state.name_lower
603
+ model_keys.append(model_key)
604
+ post_migrate_packages.unregister_model(*model_key)
605
+ post_migrate_packages.render_multiple(
606
+ [
607
+ ModelState.from_model(models_registry.get_model(*model))
608
+ for model in model_keys
609
+ ]
610
+ )
611
+
612
+ else:
613
+ if not quiet:
614
+ click.echo("No migrations to apply.")
615
+ # If there's changes that aren't in migrations yet, tell them
616
+ # how to fix it.
617
+ autodetector = MigrationAutodetector(
618
+ executor.loader.project_state(),
619
+ ProjectState.from_models_registry(models_registry),
620
+ )
621
+ changes = autodetector.changes(graph=executor.loader.graph)
622
+ if changes:
623
+ packages = ", ".join(sorted(changes))
624
+ click.echo(
625
+ f"Your models have changes that are not yet reflected in migrations ({packages})."
626
+ )
627
+ click.echo(
628
+ "Run 'plain makemigrations' to create migrations for these changes."
629
+ )
630
+
631
+
632
+ @cli.command("list")
633
+ @click.argument("package_labels", nargs=-1)
634
+ @click.option(
635
+ "--format",
636
+ type=click.Choice(["list", "plan"]),
637
+ default="list",
638
+ help="Output format.",
639
+ )
640
+ @click.option(
641
+ "-v",
642
+ "--verbosity",
643
+ type=int,
644
+ default=1,
645
+ help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",
646
+ )
647
+ def list_migrations(
648
+ package_labels: tuple[str, ...], format: str, verbosity: int
649
+ ) -> None:
650
+ """Show all migrations"""
651
+
652
+ def _validate_package_names(package_names: tuple[str, ...]) -> None:
653
+ has_bad_names = False
654
+ for package_name in package_names:
655
+ try:
656
+ packages_registry.get_package_config(package_name)
657
+ except LookupError as err:
658
+ click.echo(str(err), err=True)
659
+ has_bad_names = True
660
+ if has_bad_names:
661
+ sys.exit(2)
662
+
663
+ def show_list(
664
+ connection: DatabaseConnection, package_names: tuple[str, ...]
665
+ ) -> None:
666
+ """
667
+ Show a list of all migrations on the system, or only those of
668
+ some named packages.
669
+ """
670
+ # Load migrations from disk/DB
671
+ loader = MigrationLoader(connection, ignore_no_migrations=True)
672
+ recorder = MigrationRecorder(connection)
673
+ recorded_migrations = recorder.applied_migrations()
674
+
675
+ graph = loader.graph
676
+ # If we were passed a list of packages, validate it
677
+ package_names_list: list[str]
678
+ if package_names:
679
+ _validate_package_names(package_names)
680
+ package_names_list = list(package_names)
681
+ # Otherwise, show all packages in alphabetic order
682
+ else:
683
+ package_names_list = sorted(loader.migrated_packages)
684
+ # For each app, print its migrations in order from oldest (roots) to
685
+ # newest (leaves).
686
+ for package_name in package_names_list:
687
+ click.secho(package_name, fg="cyan", bold=True)
688
+ shown = set()
689
+ for node in graph.leaf_nodes(package_name):
690
+ for plan_node in graph.forwards_plan(node):
691
+ if plan_node not in shown and plan_node[0] == package_name:
692
+ # Give it a nice title if it's a squashed one
693
+ title = plan_node[1]
694
+ migration_node = graph.nodes[plan_node]
695
+ if migration_node and migration_node.replaces:
696
+ title += (
697
+ f" ({len(migration_node.replaces)} squashed migrations)"
698
+ )
699
+ applied_migration = (
700
+ loader.applied_migrations.get(plan_node)
701
+ if loader.applied_migrations
702
+ else None
703
+ )
704
+ # Mark it as applied/unapplied
705
+ if applied_migration:
706
+ if plan_node in recorded_migrations:
707
+ output = f" [X] {title}"
708
+ else:
709
+ title += " Run `plain migrate` to finish recording."
710
+ output = f" [-] {title}"
711
+ if verbosity >= 2 and hasattr(applied_migration, "applied"):
712
+ output += f" (applied at {applied_migration.applied.strftime('%Y-%m-%d %H:%M:%S')})"
713
+ click.echo(output)
714
+ else:
715
+ click.echo(f" [ ] {title}")
716
+ shown.add(plan_node)
717
+ # If we didn't print anything, then a small message
718
+ if not shown:
719
+ click.secho(" (no migrations)", fg="red")
720
+
721
+ def show_plan(
722
+ connection: DatabaseConnection, package_names: tuple[str, ...]
723
+ ) -> None:
724
+ """
725
+ Show all known migrations (or only those of the specified package_names)
726
+ in the order they will be applied.
727
+ """
728
+ # Load migrations from disk/DB
729
+ loader = MigrationLoader(connection)
730
+ assert loader.applied_migrations is not None
731
+ graph = loader.graph
732
+ if package_names:
733
+ _validate_package_names(package_names)
734
+ targets = [key for key in graph.leaf_nodes() if key[0] in package_names]
735
+ else:
736
+ targets = graph.leaf_nodes()
737
+ plan = []
738
+ seen = set()
739
+
740
+ # Generate the plan
741
+ for target in targets:
742
+ for migration in graph.forwards_plan(target):
743
+ if migration not in seen:
744
+ node = graph.node_map[migration]
745
+ plan.append(node)
746
+ seen.add(migration)
747
+
748
+ # Output
749
+ def print_deps(node: Any) -> str:
750
+ out = []
751
+ for parent in sorted(node.parents):
752
+ out.append(f"{parent.key[0]}.{parent.key[1]}")
753
+ if out:
754
+ return f" ... ({', '.join(out)})"
755
+ return ""
756
+
757
+ for node in plan:
758
+ deps = ""
759
+ if verbosity >= 2:
760
+ deps = print_deps(node)
761
+ if node.key in loader.applied_migrations:
762
+ click.echo(f"[X] {node.key[0]}.{node.key[1]}{deps}")
763
+ else:
764
+ click.echo(f"[ ] {node.key[0]}.{node.key[1]}{deps}")
765
+ if not plan:
766
+ click.secho("(no migrations)", fg="red")
767
+
768
+ # Get the database we're operating from
769
+
770
+ conn = get_connection()
771
+ if format == "plan":
772
+ show_plan(conn, package_labels)
773
+ else:
774
+ show_list(conn, package_labels)
775
+
776
+
777
+ @cli.command("prune")
778
+ @click.option(
779
+ "--yes",
780
+ is_flag=True,
781
+ help="Skip confirmation prompt (for non-interactive use).",
782
+ )
783
+ def prune(yes: bool) -> None:
784
+ """Remove stale migration records from the database"""
785
+ # Load migrations from disk and database
786
+ conn = get_connection()
787
+ loader = MigrationLoader(conn, ignore_no_migrations=True)
788
+ assert loader.disk_migrations is not None
789
+ recorder = MigrationRecorder(conn)
790
+ recorded_migrations = recorder.applied_migrations()
791
+
792
+ # Find all prunable migrations (recorded but not on disk)
793
+ all_prunable = [
794
+ migration
795
+ for migration in recorded_migrations
796
+ if migration not in loader.disk_migrations
797
+ ]
798
+
799
+ if not all_prunable:
800
+ click.echo("No stale migration records found.")
801
+ return
802
+
803
+ # Separate into existing packages vs orphaned packages
804
+ existing_packages = set(loader.migrated_packages)
805
+ prunable_existing: dict[str, list[str]] = {}
806
+ prunable_orphaned: dict[str, list[str]] = {}
807
+
808
+ for migration in all_prunable:
809
+ package, name = migration
810
+ if package in existing_packages:
811
+ if package not in prunable_existing:
812
+ prunable_existing[package] = []
813
+ prunable_existing[package].append(name)
814
+ else:
815
+ if package not in prunable_orphaned:
816
+ prunable_orphaned[package] = []
817
+ prunable_orphaned[package].append(name)
818
+
819
+ # Display what was found
820
+ if prunable_existing:
821
+ click.secho(
822
+ "Stale migration records (from existing packages):",
823
+ fg="yellow",
824
+ bold=True,
825
+ )
826
+ for package in sorted(prunable_existing.keys()):
827
+ click.secho(f" {package}:", fg="yellow")
828
+ for name in sorted(prunable_existing[package]):
829
+ click.echo(f" - {name}")
830
+ click.echo()
831
+
832
+ if prunable_orphaned:
833
+ click.secho(
834
+ "Orphaned migration records (from removed packages):",
835
+ fg="red",
836
+ bold=True,
837
+ )
838
+ for package in sorted(prunable_orphaned.keys()):
839
+ click.secho(f" {package}:", fg="red")
840
+ for name in sorted(prunable_orphaned[package]):
841
+ click.echo(f" - {name}")
842
+ click.echo()
843
+
844
+ total_count = sum(len(migs) for migs in prunable_existing.values()) + sum(
845
+ len(migs) for migs in prunable_orphaned.values()
846
+ )
847
+
848
+ if not yes:
849
+ click.echo(
850
+ f"Found {total_count} stale migration record{'s' if total_count != 1 else ''}."
851
+ )
852
+ click.echo()
853
+
854
+ # Prompt for confirmation if interactive
855
+ if not click.confirm(
856
+ "Do you want to remove these migrations from the database?"
857
+ ):
858
+ return
859
+
860
+ # Actually prune the migrations
861
+ click.secho("Pruning migrations...", bold=True)
862
+
863
+ for package, migration_names in prunable_existing.items():
864
+ for name in sorted(migration_names):
865
+ click.echo(f" Pruning {package}.{name}...", nl=False)
866
+ recorder.record_unapplied(package, name)
867
+ click.echo(" OK")
868
+
869
+ for package, migration_names in prunable_orphaned.items():
870
+ for name in sorted(migration_names):
871
+ click.echo(f" Pruning {package}.{name} (orphaned)...", nl=False)
872
+ recorder.record_unapplied(package, name)
873
+ click.echo(" OK")
874
+
875
+ click.secho(
876
+ f"✓ Removed {total_count} stale migration record{'s' if total_count != 1 else ''}.",
877
+ fg="green",
878
+ )
879
+
880
+
881
+ @cli.command("squash")
882
+ @click.argument("package_label")
883
+ @click.argument("start_migration_name", required=False)
884
+ @click.argument("migration_name")
885
+ @click.option(
886
+ "--no-optimize",
887
+ is_flag=True,
888
+ help="Do not try to optimize the squashed operations.",
889
+ )
890
+ @click.option(
891
+ "--noinput",
892
+ "--no-input",
893
+ "no_input",
894
+ is_flag=True,
895
+ help="Tells Plain to NOT prompt the user for input of any kind.",
896
+ )
897
+ @click.option("--squashed-name", help="Sets the name of the new squashed migration.")
898
+ @click.option(
899
+ "-v",
900
+ "--verbosity",
901
+ type=int,
902
+ default=1,
903
+ help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",
904
+ )
905
+ def squash(
906
+ package_label: str,
907
+ start_migration_name: str | None,
908
+ migration_name: str,
909
+ no_optimize: bool,
910
+ no_input: bool,
911
+ squashed_name: str | None,
912
+ verbosity: int,
913
+ ) -> None:
914
+ """Squash multiple migrations into one"""
915
+ interactive = not no_input
916
+
917
+ def find_migration(
918
+ loader: MigrationLoader, package_label: str, name: str
919
+ ) -> Migration:
920
+ try:
921
+ return loader.get_migration_by_prefix(package_label, name)
922
+ except AmbiguityError:
923
+ raise click.ClickException(
924
+ f"More than one migration matches '{name}' in package '{package_label}'. Please be more specific."
925
+ )
926
+ except KeyError:
927
+ raise click.ClickException(
928
+ f"Cannot find a migration matching '{name}' from package '{package_label}'."
929
+ )
930
+
931
+ # Validate package_label
932
+ try:
933
+ packages_registry.get_package_config(package_label)
934
+ except LookupError as err:
935
+ raise click.ClickException(str(err))
936
+
937
+ # Load the current graph state, check the app and migration they asked for exists
938
+ loader = MigrationLoader(get_connection())
939
+ if package_label not in loader.migrated_packages:
940
+ raise click.ClickException(
941
+ f"Package '{package_label}' does not have migrations (so squashmigrations on it makes no sense)"
942
+ )
943
+
944
+ migration = find_migration(loader, package_label, migration_name)
945
+
946
+ # Work out the list of predecessor migrations
947
+ migrations_to_squash: list[Migration] = []
948
+ for al, mn in loader.graph.forwards_plan((migration.package_label, migration.name)):
949
+ if al != migration.package_label:
950
+ continue
951
+ candidate = loader.get_migration(al, mn)
952
+ if candidate is None:
953
+ raise click.ClickException(f"Migration {mn} in package {al} is missing")
954
+ migrations_to_squash.append(candidate)
955
+
956
+ if start_migration_name:
957
+ start_migration = find_migration(loader, package_label, start_migration_name)
958
+ start = loader.get_migration(
959
+ start_migration.package_label, start_migration.name
960
+ )
961
+ if start is None:
962
+ raise click.ClickException(
963
+ f"Cannot find migration '{start_migration.name}' in package '{package_label}'."
964
+ )
965
+ try:
966
+ start_index = migrations_to_squash.index(start)
967
+ migrations_to_squash = migrations_to_squash[start_index:]
968
+ except ValueError:
969
+ raise click.ClickException(
970
+ f"The migration '{start_migration}' cannot be found. Maybe it comes after "
971
+ f"the migration '{migration}'?\n"
972
+ f"Have a look at:\n"
973
+ f" plain migrations list {package_label}\n"
974
+ f"to debug this issue."
975
+ )
976
+
977
+ # Tell them what we're doing and optionally ask if we should proceed
978
+ if verbosity > 0 or interactive:
979
+ click.secho("Will squash the following migrations:", fg="cyan", bold=True)
980
+ for migration in migrations_to_squash:
981
+ click.echo(f" - {migration.name}")
982
+
983
+ if interactive:
984
+ if not click.confirm("Do you wish to proceed?"):
985
+ return
986
+
987
+ # Load the operations from all those migrations and concat together,
988
+ # along with collecting external dependencies and detecting double-squashing
989
+ operations = []
990
+ dependencies = set()
991
+ # We need to take all dependencies from the first migration in the list
992
+ # as it may be 0002 depending on 0001
993
+ first_migration = True
994
+ for smigration in migrations_to_squash:
995
+ if smigration.replaces:
996
+ raise click.ClickException(
997
+ "You cannot squash squashed migrations! Please transition it to a "
998
+ "normal migration first"
999
+ )
1000
+ operations.extend(smigration.operations)
1001
+ for dependency in smigration.dependencies:
1002
+ if isinstance(dependency, SettingsTuple):
1003
+ dependencies.add(dependency)
1004
+ elif dependency[0] != smigration.package_label or first_migration:
1005
+ dependencies.add(dependency)
1006
+ first_migration = False
1007
+
1008
+ if no_optimize:
1009
+ if verbosity > 0:
1010
+ click.secho("(Skipping optimization.)", fg="yellow")
1011
+ new_operations = operations
1012
+ else:
1013
+ if verbosity > 0:
1014
+ click.secho("Optimizing...", fg="cyan")
1015
+
1016
+ optimizer = MigrationOptimizer()
1017
+ new_operations = optimizer.optimize(operations, migration.package_label)
1018
+
1019
+ if verbosity > 0:
1020
+ if len(new_operations) == len(operations):
1021
+ click.echo(" No optimizations possible.")
1022
+ else:
1023
+ click.echo(
1024
+ f" Optimized from {len(operations)} operations to {len(new_operations)} operations."
1025
+ )
1026
+
1027
+ # Work out the value of replaces (any squashed ones we're re-squashing)
1028
+ # need to feed their replaces into ours
1029
+ replaces: list[tuple[str, str]] = []
1030
+ for migration in migrations_to_squash:
1031
+ if migration.replaces:
1032
+ replaces.extend(migration.replaces)
1033
+ else:
1034
+ replaces.append((migration.package_label, migration.name))
1035
+
1036
+ # Make a new migration with those operations
1037
+ subclass = type(
1038
+ "Migration",
1039
+ (migrations.Migration,),
1040
+ {
1041
+ "dependencies": dependencies,
1042
+ "operations": new_operations,
1043
+ "replaces": replaces,
1044
+ },
1045
+ )
1046
+ if start_migration_name:
1047
+ if squashed_name:
1048
+ # Use the name from --squashed-name
1049
+ prefix, _ = start_migration.name.split("_", 1)
1050
+ name = f"{prefix}_{squashed_name}"
1051
+ else:
1052
+ # Generate a name
1053
+ name = f"{start_migration.name}_squashed_{migration.name}"
1054
+ new_migration = subclass(name, package_label)
1055
+ else:
1056
+ name = f"0001_{'squashed_' + migration.name if not squashed_name else squashed_name}"
1057
+ new_migration = subclass(name, package_label)
1058
+ new_migration.initial = True
1059
+
1060
+ # Write out the new migration file
1061
+ writer = MigrationWriter(new_migration)
1062
+ if os.path.exists(writer.path):
1063
+ raise click.ClickException(
1064
+ f"Migration {new_migration.name} already exists. Use a different name."
1065
+ )
1066
+ with open(writer.path, "w", encoding="utf-8") as fh:
1067
+ fh.write(writer.as_string())
1068
+
1069
+ if verbosity > 0:
1070
+ click.secho(
1071
+ f"Created new squashed migration {writer.path}", fg="green", bold=True
1072
+ )
1073
+ click.echo(
1074
+ " You should commit this migration but leave the old ones in place;\n"
1075
+ " the new migration will be used for new installs. Once you are sure\n"
1076
+ " all instances of the codebase have applied the migrations you squashed,\n"
1077
+ " you can delete them."
1078
+ )
1079
+ if writer.needs_manual_porting:
1080
+ click.secho("Manual porting required", fg="yellow", bold=True)
1081
+ click.echo(
1082
+ " Your migrations contained functions that must be manually copied over,\n"
1083
+ " as we could not safely copy their implementation.\n"
1084
+ " See the comment at the top of the squashed migration for details."
1085
+ )