plain.models 0.51.1__tar.gz → 0.53.0__tar.gz

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 (138) hide show
  1. {plain_models-0.51.1 → plain_models-0.53.0}/PKG-INFO +1 -1
  2. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/CHANGELOG.md +25 -0
  3. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/base/creation.py +1 -5
  4. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/base/schema.py +11 -18
  5. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/cli.py +214 -196
  6. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/migrations/executor.py +8 -8
  7. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/migrations/loader.py +5 -22
  8. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/migrations/migration.py +23 -17
  9. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/migrations/recorder.py +2 -1
  10. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/preflight.py +80 -0
  11. {plain_models-0.51.1 → plain_models-0.53.0}/pyproject.toml +1 -1
  12. {plain_models-0.51.1 → plain_models-0.53.0}/.gitignore +0 -0
  13. {plain_models-0.51.1 → plain_models-0.53.0}/LICENSE +0 -0
  14. {plain_models-0.51.1 → plain_models-0.53.0}/README.md +0 -0
  15. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/AGENTS.md +0 -0
  16. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/README.md +0 -0
  17. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/__init__.py +0 -0
  18. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/aggregates.py +0 -0
  19. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/__init__.py +0 -0
  20. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/base/__init__.py +0 -0
  21. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/base/base.py +0 -0
  22. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/base/client.py +0 -0
  23. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/base/features.py +0 -0
  24. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/base/introspection.py +0 -0
  25. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/base/operations.py +0 -0
  26. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/base/validation.py +0 -0
  27. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/ddl_references.py +0 -0
  28. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/mysql/__init__.py +0 -0
  29. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/mysql/base.py +0 -0
  30. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/mysql/client.py +0 -0
  31. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/mysql/compiler.py +0 -0
  32. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/mysql/creation.py +0 -0
  33. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/mysql/features.py +0 -0
  34. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/mysql/introspection.py +0 -0
  35. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/mysql/operations.py +0 -0
  36. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/mysql/schema.py +0 -0
  37. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/mysql/validation.py +0 -0
  38. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/postgresql/__init__.py +0 -0
  39. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/postgresql/base.py +0 -0
  40. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/postgresql/client.py +0 -0
  41. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/postgresql/creation.py +0 -0
  42. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/postgresql/features.py +0 -0
  43. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/postgresql/introspection.py +0 -0
  44. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/postgresql/operations.py +0 -0
  45. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/postgresql/schema.py +0 -0
  46. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/sqlite3/__init__.py +0 -0
  47. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/sqlite3/_functions.py +0 -0
  48. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/sqlite3/base.py +0 -0
  49. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/sqlite3/client.py +0 -0
  50. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/sqlite3/creation.py +0 -0
  51. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/sqlite3/features.py +0 -0
  52. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/sqlite3/introspection.py +0 -0
  53. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/sqlite3/operations.py +0 -0
  54. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/sqlite3/schema.py +0 -0
  55. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backends/utils.py +0 -0
  56. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backups/__init__.py +0 -0
  57. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backups/cli.py +0 -0
  58. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backups/clients.py +0 -0
  59. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/backups/core.py +0 -0
  60. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/base.py +0 -0
  61. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/config.py +0 -0
  62. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/connections.py +0 -0
  63. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/constants.py +0 -0
  64. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/constraints.py +0 -0
  65. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/database_url.py +0 -0
  66. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/db.py +0 -0
  67. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/default_settings.py +0 -0
  68. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/deletion.py +0 -0
  69. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/entrypoints.py +0 -0
  70. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/enums.py +0 -0
  71. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/exceptions.py +0 -0
  72. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/expressions.py +0 -0
  73. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/fields/__init__.py +0 -0
  74. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/fields/json.py +0 -0
  75. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/fields/mixins.py +0 -0
  76. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/fields/related.py +0 -0
  77. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/fields/related_descriptors.py +0 -0
  78. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/fields/related_lookups.py +0 -0
  79. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/fields/related_managers.py +0 -0
  80. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/fields/reverse_related.py +0 -0
  81. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/forms.py +0 -0
  82. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/functions/__init__.py +0 -0
  83. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/functions/comparison.py +0 -0
  84. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/functions/datetime.py +0 -0
  85. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/functions/math.py +0 -0
  86. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/functions/mixins.py +0 -0
  87. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/functions/text.py +0 -0
  88. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/functions/window.py +0 -0
  89. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/indexes.py +0 -0
  90. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/lookups.py +0 -0
  91. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/meta.py +0 -0
  92. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/migrations/__init__.py +0 -0
  93. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/migrations/autodetector.py +0 -0
  94. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/migrations/exceptions.py +0 -0
  95. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/migrations/graph.py +0 -0
  96. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/migrations/operations/__init__.py +0 -0
  97. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/migrations/operations/base.py +0 -0
  98. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/migrations/operations/fields.py +0 -0
  99. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/migrations/operations/models.py +0 -0
  100. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/migrations/operations/special.py +0 -0
  101. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/migrations/optimizer.py +0 -0
  102. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/migrations/questioner.py +0 -0
  103. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/migrations/serializer.py +0 -0
  104. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/migrations/state.py +0 -0
  105. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/migrations/utils.py +0 -0
  106. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/migrations/writer.py +0 -0
  107. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/options.py +0 -0
  108. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/otel.py +0 -0
  109. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/query.py +0 -0
  110. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/query_utils.py +0 -0
  111. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/registry.py +0 -0
  112. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/sql/__init__.py +0 -0
  113. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/sql/compiler.py +0 -0
  114. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/sql/constants.py +0 -0
  115. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/sql/datastructures.py +0 -0
  116. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/sql/query.py +0 -0
  117. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/sql/subqueries.py +0 -0
  118. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/sql/where.py +0 -0
  119. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/test/__init__.py +0 -0
  120. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/test/pytest.py +0 -0
  121. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/test/utils.py +0 -0
  122. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/transaction.py +0 -0
  123. {plain_models-0.51.1 → plain_models-0.53.0}/plain/models/utils.py +0 -0
  124. {plain_models-0.51.1 → plain_models-0.53.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  125. {plain_models-0.51.1 → plain_models-0.53.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  126. {plain_models-0.51.1 → plain_models-0.53.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  127. {plain_models-0.51.1 → plain_models-0.53.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  128. {plain_models-0.51.1 → plain_models-0.53.0}/tests/app/examples/migrations/__init__.py +0 -0
  129. {plain_models-0.51.1 → plain_models-0.53.0}/tests/app/examples/models.py +0 -0
  130. {plain_models-0.51.1 → plain_models-0.53.0}/tests/app/settings.py +0 -0
  131. {plain_models-0.51.1 → plain_models-0.53.0}/tests/app/urls.py +0 -0
  132. {plain_models-0.51.1 → plain_models-0.53.0}/tests/test_database_url.py +0 -0
  133. {plain_models-0.51.1 → plain_models-0.53.0}/tests/test_delete_behaviors.py +0 -0
  134. {plain_models-0.51.1 → plain_models-0.53.0}/tests/test_exceptions.py +0 -0
  135. {plain_models-0.51.1 → plain_models-0.53.0}/tests/test_manager_assignment.py +0 -0
  136. {plain_models-0.51.1 → plain_models-0.53.0}/tests/test_models.py +0 -0
  137. {plain_models-0.51.1 → plain_models-0.53.0}/tests/test_related_descriptors.py +0 -0
  138. {plain_models-0.51.1 → plain_models-0.53.0}/tests/test_related_manager_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.models
3
- Version: 0.51.1
3
+ Version: 0.53.0
4
4
  Summary: Model your data and store it in a database.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE
@@ -1,5 +1,30 @@
1
1
  # plain-models changelog
2
2
 
3
+ ## [0.53.0](https://github.com/dropseed/plain/releases/plain-models@0.53.0) (2025-10-12)
4
+
5
+ ### What's changed
6
+
7
+ - Added new `plain models prune-migrations` command to identify and remove stale migration records from the database ([998aa49](https://github.com/dropseed/plain/commit/998aa49140))
8
+ - The `--prune` option has been removed from `plain migrate` command in favor of the dedicated `prune-migrations` command ([998aa49](https://github.com/dropseed/plain/commit/998aa49140))
9
+ - Added new preflight check `models.prunable_migrations` that warns about stale migration records in the database ([9b43617](https://github.com/dropseed/plain/commit/9b4361765c))
10
+ - The `show-migrations` command no longer displays prunable migrations in its output ([998aa49](https://github.com/dropseed/plain/commit/998aa49140))
11
+
12
+ ### Upgrade instructions
13
+
14
+ - Replace any usage of `plain migrate --prune` with the new `plain models prune-migrations` command
15
+
16
+ ## [0.52.0](https://github.com/dropseed/plain/releases/plain-models@0.52.0) (2025-10-10)
17
+
18
+ ### What's changed
19
+
20
+ - The `plain migrate` command now shows detailed operation descriptions and SQL statements for each migration step, replacing the previous verbosity levels with a cleaner `--quiet` flag ([d6b041bd24](https://github.com/dropseed/plain/commit/d6b041bd24))
21
+ - Migration output format has been improved to display each operation's description and the actual SQL being executed, making it easier to understand what changes are being made to the database ([d6b041bd24](https://github.com/dropseed/plain/commit/d6b041bd24))
22
+ - The `-v/--verbosity` option has been removed from `plain migrate` in favor of the simpler `--quiet` flag for suppressing output ([d6b041bd24](https://github.com/dropseed/plain/commit/d6b041bd24))
23
+
24
+ ### Upgrade instructions
25
+
26
+ - Replace any usage of `-v` or `--verbosity` flags in `plain migrate` commands with `--quiet` if you want to suppress migration output
27
+
3
28
  ## [0.51.1](https://github.com/dropseed/plain/releases/plain-models@0.51.1) (2025-10-08)
4
29
 
5
30
  ### What's changed
@@ -52,9 +52,6 @@ class BaseDatabaseCreation:
52
52
  settings.DATABASE["NAME"] = test_database_name
53
53
  self.connection.settings_dict["NAME"] = test_database_name
54
54
 
55
- # We report migrate messages at one level lower than that
56
- # requested. This ensures we don't get flooded with messages during
57
- # testing (unless you really ask to be flooded).
58
55
  migrate.callback(
59
56
  package_label=None,
60
57
  migration_name=None,
@@ -62,10 +59,9 @@ class BaseDatabaseCreation:
62
59
  plan=False,
63
60
  check_unapplied=False,
64
61
  backup=False,
65
- prune=False,
66
62
  no_input=True,
67
- verbosity=max(verbosity - 1, 0),
68
63
  atomic_batch=False, # No need for atomic batch when creating test database
64
+ quiet=verbosity < 2, # Show migration output when verbosity is 2+
69
65
  )
70
66
 
71
67
  # Ensure a connection for the side effect of initializing the test database.
@@ -159,19 +159,16 @@ class BaseDatabaseSchemaEditor:
159
159
  def __init__(
160
160
  self,
161
161
  connection: BaseDatabaseWrapper,
162
- collect_sql: bool = False,
163
162
  atomic: bool = True,
164
163
  ):
165
164
  self.connection = connection
166
- self.collect_sql = collect_sql
167
- if self.collect_sql:
168
- self.collected_sql: list[str] = []
169
165
  self.atomic_migration = self.connection.features.can_rollback_ddl and atomic
170
166
 
171
167
  # State-managing methods
172
168
 
173
169
  def __enter__(self) -> BaseDatabaseSchemaEditor:
174
170
  self.deferred_sql: list[Any] = []
171
+ self.executed_sql: list[str] = []
175
172
  if self.atomic_migration:
176
173
  self.atomic = atomic()
177
174
  self.atomic.__enter__()
@@ -190,11 +187,8 @@ class BaseDatabaseSchemaEditor:
190
187
  self, sql: str | Statement, params: tuple[Any, ...] | list[Any] | None = ()
191
188
  ) -> None:
192
189
  """Execute the given SQL statement, with optional parameters."""
193
- # Don't perform the transactional DDL check if SQL is being collected
194
- # as it's not going to be executed anyway.
195
190
  if (
196
- not self.collect_sql
197
- and self.connection.in_atomic_block
191
+ self.connection.in_atomic_block
198
192
  and not self.connection.features.can_rollback_ddl
199
193
  ):
200
194
  raise TransactionManagementError(
@@ -207,17 +201,16 @@ class BaseDatabaseSchemaEditor:
207
201
  logger.debug(
208
202
  "%s; (params %r)", sql, params, extra={"params": params, "sql": sql}
209
203
  )
210
- if self.collect_sql:
211
- ending = "" if sql.rstrip().endswith(";") else ";"
212
- if params is not None:
213
- self.collected_sql.append(
214
- (sql % tuple(map(self.quote_value, params))) + ending
215
- )
216
- else:
217
- self.collected_sql.append(sql + ending)
204
+
205
+ # Track executed SQL for display in migration output
206
+ # Store the SQL for display (interpolate params for readability)
207
+ if params:
208
+ self.executed_sql.append(sql % tuple(map(self.quote_value, params)))
218
209
  else:
219
- with self.connection.cursor() as cursor:
220
- cursor.execute(sql, params)
210
+ self.executed_sql.append(sql)
211
+
212
+ with self.connection.cursor() as cursor:
213
+ cursor.execute(sql, params)
221
214
 
222
215
  def quote_name(self, name: str) -> str:
223
216
  return self.connection.ops.quote_name(name)
@@ -15,7 +15,7 @@ from plain.utils.text import Truncator
15
15
 
16
16
  from . import migrations
17
17
  from .backups.cli import cli as backups_cli
18
- from .backups.cli import create_backup
18
+ from .backups.core import DatabaseBackups
19
19
  from .db import OperationalError
20
20
  from .db import db_connection as _db_connection
21
21
  from .migrations.autodetector import MigrationAutodetector
@@ -34,6 +34,7 @@ from .registry import models_registry
34
34
 
35
35
  if TYPE_CHECKING:
36
36
  from .backends.base.base import BaseDatabaseWrapper
37
+ from .migrations.operations.base import Operation
37
38
 
38
39
  db_connection = cast("BaseDatabaseWrapper", _db_connection)
39
40
  else:
@@ -365,11 +366,6 @@ def makemigrations(
365
366
  default=None,
366
367
  help="Explicitly enable/disable pre-migration backups.",
367
368
  )
368
- @click.option(
369
- "--prune",
370
- is_flag=True,
371
- help="Delete nonexistent migrations from the plainmigrations table.",
372
- )
373
369
  @click.option(
374
370
  "--no-input",
375
371
  "--noinput",
@@ -377,18 +373,16 @@ def makemigrations(
377
373
  is_flag=True,
378
374
  help="Tells Plain to NOT prompt the user for input of any kind.",
379
375
  )
380
- @click.option(
381
- "-v",
382
- "--verbosity",
383
- type=int,
384
- default=1,
385
- help="Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output",
386
- )
387
376
  @click.option(
388
377
  "--atomic-batch/--no-atomic-batch",
389
378
  default=None,
390
379
  help="Run migrations in a single transaction (auto-detected by default)",
391
380
  )
381
+ @click.option(
382
+ "--quiet",
383
+ is_flag=True,
384
+ help="Suppress migration output (used for test database creation).",
385
+ )
392
386
  def migrate(
393
387
  package_label: str | None,
394
388
  migration_name: str | None,
@@ -396,28 +390,43 @@ def migrate(
396
390
  plan: bool,
397
391
  check_unapplied: bool,
398
392
  backup: bool | None,
399
- prune: bool,
400
393
  no_input: bool,
401
- verbosity: int,
402
394
  atomic_batch: bool | None,
395
+ quiet: bool,
403
396
  ) -> None:
404
397
  """Updates database schema. Manages both packages with migrations and those without."""
405
398
 
406
399
  def migration_progress_callback(
407
- action: str, migration: Migration | None = None, fake: bool = False
400
+ action: str,
401
+ *,
402
+ migration: Migration | None = None,
403
+ fake: bool = False,
404
+ operation: Operation | None = None,
405
+ sql_statements: list[str] | None = None,
408
406
  ) -> None:
409
- if verbosity >= 1:
410
- if action == "apply_start":
411
- click.echo(f" Applying {migration}...", nl=False)
412
- elif action == "apply_success":
413
- if fake:
414
- click.echo(click.style(" FAKED", fg="green"))
415
- else:
416
- click.echo(click.style(" OK", fg="green"))
417
- elif action == "render_start":
418
- click.echo(" Rendering model states...", nl=False)
419
- elif action == "render_success":
420
- click.echo(click.style(" DONE", fg="green"))
407
+ if quiet:
408
+ return
409
+
410
+ if action == "apply_start":
411
+ click.echo() # Always add newline between migrations
412
+ if fake:
413
+ click.secho(f"{migration} (faked)", fg="cyan")
414
+ else:
415
+ click.secho(f"{migration}", fg="cyan")
416
+ elif action == "apply_success":
417
+ pass # Already shown via operations
418
+ elif action == "operation_start":
419
+ click.echo(f" {operation.describe()}", nl=False)
420
+ click.secho("... ", dim=True, nl=False)
421
+ elif action == "operation_success":
422
+ # Show SQL statements (no OK needed, SQL implies success)
423
+ if sql_statements:
424
+ click.echo() # newline after "..."
425
+ for sql in sql_statements:
426
+ click.secho(f" {sql}", dim=True)
427
+ else:
428
+ # No SQL: just add a newline
429
+ click.echo()
421
430
 
422
431
  def describe_operation(operation: Any) -> tuple[str, bool]:
423
432
  """Return a string that describes a migration operation for --plan."""
@@ -504,74 +513,22 @@ def migrate(
504
513
  else:
505
514
  targets = list(executor.loader.graph.leaf_nodes())
506
515
 
507
- if prune:
508
- if not package_label:
509
- raise click.ClickException(
510
- "Migrations can be pruned only when a package is specified."
511
- )
512
- if verbosity > 0:
513
- click.secho("Pruning migrations:", fg="cyan")
514
- to_prune = set(executor.loader.applied_migrations) - set( # type: ignore[arg-type]
515
- executor.loader.disk_migrations # type: ignore[arg-type]
516
- )
517
- squashed_migrations_with_deleted_replaced_migrations = [
518
- migration_key
519
- for migration_key, migration_obj in executor.loader.replacements.items()
520
- if any(replaced in to_prune for replaced in migration_obj.replaces)
521
- ]
522
- if squashed_migrations_with_deleted_replaced_migrations:
523
- click.echo(
524
- click.style(
525
- " Cannot use --prune because the following squashed "
526
- "migrations have their 'replaces' attributes and may not "
527
- "be recorded as applied:",
528
- fg="yellow",
529
- )
530
- )
531
- for migration in squashed_migrations_with_deleted_replaced_migrations:
532
- package, name = migration
533
- click.echo(f" {package}.{name}")
534
- click.echo(
535
- click.style(
536
- " Re-run `plain migrate` if they are not marked as "
537
- "applied, and remove 'replaces' attributes in their "
538
- "Migration classes.",
539
- fg="yellow",
540
- )
541
- )
542
- else:
543
- to_prune = sorted(
544
- migration for migration in to_prune if migration[0] == package_label
545
- )
546
- if to_prune:
547
- for migration in to_prune:
548
- package, name = migration
549
- if verbosity > 0:
550
- click.echo(
551
- click.style(f" Pruning {package}.{name}", fg="yellow"),
552
- nl=False,
553
- )
554
- executor.recorder.record_unapplied(package, name)
555
- if verbosity > 0:
556
- click.echo(click.style(" OK", fg="green"))
557
- elif verbosity > 0:
558
- click.echo(" No migrations to prune.")
559
-
560
516
  migration_plan = executor.migration_plan(targets)
561
517
 
562
518
  if plan:
563
- click.secho("Planned operations:", fg="cyan")
564
- if not migration_plan:
565
- click.echo(" No planned migration operations.")
566
- else:
567
- for migration in migration_plan:
568
- click.secho(str(migration), fg="cyan")
569
- for operation in migration.operations:
570
- message, is_error = describe_operation(operation)
571
- if is_error:
572
- click.secho(" " + message, fg="yellow")
573
- else:
574
- click.echo(" " + message)
519
+ if not quiet:
520
+ click.secho("Planned operations:", fg="cyan")
521
+ if not migration_plan:
522
+ click.echo(" No planned migration operations.")
523
+ else:
524
+ for migration in migration_plan:
525
+ click.secho(str(migration), fg="cyan")
526
+ for operation in migration.operations:
527
+ message, is_error = describe_operation(operation)
528
+ if is_error:
529
+ click.secho(" " + message, fg="yellow")
530
+ else:
531
+ click.echo(" " + message)
575
532
  if check_unapplied:
576
533
  sys.exit(1)
577
534
  return
@@ -581,33 +538,24 @@ def migrate(
581
538
  sys.exit(1)
582
539
  return
583
540
 
584
- if prune:
585
- return
586
-
587
541
  # Print some useful info
588
- if verbosity >= 1:
589
- click.secho("Operations to perform:", fg="cyan")
590
-
542
+ if not quiet:
591
543
  if target_package_labels_only:
592
- click.secho(
593
- " Apply all migrations: "
594
- + (", ".join(sorted({a for a, n in targets})) or "(none)"),
595
- fg="yellow",
596
- )
544
+ packages = ", ".join(sorted({a for a, n in targets})) or "(none)"
545
+ click.secho("Packages: ", bold=True, nl=False)
546
+ click.secho(packages, dim=True)
547
+ click.echo() # Add newline after packages
597
548
  else:
598
- click.secho(
599
- f" Target specific migration: {targets[0][1]}, from {targets[0][0]}",
600
- fg="yellow",
601
- )
549
+ click.secho("Target: ", bold=True, nl=False)
550
+ click.secho(f"{targets[0][1]} from {targets[0][0]}", dim=True)
551
+ click.echo() # Add newline after target
602
552
 
603
553
  pre_migrate_state = executor._create_project_state(with_applied_migrations=True)
604
554
 
605
- # sql = executor.loader.collect_sql(migration_plan)
606
- # pprint(sql)
607
-
608
555
  if migration_plan:
609
556
  # Determine whether to use atomic batch
610
557
  use_atomic_batch = False
558
+ atomic_batch_message = None
611
559
  if len(migration_plan) > 1:
612
560
  # Check database capabilities
613
561
  can_rollback_ddl = db_connection.features.can_rollback_ddl
@@ -632,58 +580,62 @@ def migrate(
632
580
  f"--atomic-batch requested but these migrations have atomic=False: {names}"
633
581
  )
634
582
  use_atomic_batch = True
635
- if verbosity >= 1:
636
- click.echo(
637
- f" Running {len(migration_plan)} migrations in atomic batch (all-or-nothing)"
638
- )
583
+ atomic_batch_message = (
584
+ f"Running {len(migration_plan)} migrations in atomic batch"
585
+ )
639
586
  elif atomic_batch is False:
640
587
  # User explicitly disabled atomic batch
641
588
  use_atomic_batch = False
642
- if verbosity >= 1:
643
- click.echo(f" Running {len(migration_plan)} migrations separately")
589
+ if len(migration_plan) > 1:
590
+ atomic_batch_message = (
591
+ f"Running {len(migration_plan)} migrations separately"
592
+ )
644
593
  else:
645
594
  # Auto-detect (atomic_batch is None)
646
595
  if can_rollback_ddl and not non_atomic_migrations:
647
596
  use_atomic_batch = True
648
- if verbosity >= 1:
649
- click.echo(
650
- f" Running {len(migration_plan)} migrations in atomic batch (all-or-nothing)"
651
- )
597
+ atomic_batch_message = (
598
+ f"Running {len(migration_plan)} migrations in atomic batch"
599
+ )
652
600
  else:
653
601
  use_atomic_batch = False
654
- if verbosity >= 1:
602
+ if len(migration_plan) > 1:
655
603
  if not can_rollback_ddl:
656
- click.echo(
657
- f" Running {len(migration_plan)} migrations separately ({db_connection.vendor} doesn't support batch transactions)"
658
- )
604
+ atomic_batch_message = f"Running {len(migration_plan)} migrations separately ({db_connection.vendor} doesn't support batch)"
659
605
  elif non_atomic_migrations:
660
- click.echo(
661
- f" Running {len(migration_plan)} migrations separately (some migrations have atomic=False)"
662
- )
606
+ atomic_batch_message = f"Running {len(migration_plan)} migrations separately (some have atomic=False)"
663
607
  else:
664
- click.echo(
665
- f" Running {len(migration_plan)} migrations separately"
608
+ atomic_batch_message = (
609
+ f"Running {len(migration_plan)} migrations separately"
666
610
  )
667
611
 
668
612
  if backup or (backup is None and settings.DEBUG):
669
613
  backup_name = f"migrate_{time.strftime('%Y%m%d_%H%M%S')}"
670
- click.secho(
671
- f"Creating backup before applying migrations: {backup_name}",
672
- bold=True,
673
- )
674
- # Can't use ctx.invoke because this is called by the test db creation currently,
675
- # which doesn't have a context.
676
- create_backup.callback(
677
- backup_name=backup_name,
678
- pg_dump=os.environ.get(
679
- "PG_DUMP", "pg_dump"
680
- ), # Have to pass this in manually
614
+ if not quiet:
615
+ click.secho("Creating backup: ", bold=True, nl=False)
616
+ click.secho(f"{backup_name}", dim=True, nl=False)
617
+ click.secho("... ", dim=True, nl=False)
618
+
619
+ backups_handler = DatabaseBackups()
620
+ backups_handler.create(
621
+ backup_name,
622
+ pg_dump=os.environ.get("PG_DUMP", "pg_dump"),
681
623
  )
682
- print()
683
624
 
684
- if verbosity >= 1:
685
- click.secho("Running migrations:", fg="cyan")
625
+ if not quiet:
626
+ click.echo(click.style("OK", fg="green"))
627
+ click.echo() # Add blank line after backup output
628
+ else:
629
+ if not quiet:
630
+ click.echo() # Add blank line after packages/target info
686
631
 
632
+ if not quiet:
633
+ if atomic_batch_message:
634
+ click.secho(
635
+ f"Applying migrations ({atomic_batch_message.lower()}):", bold=True
636
+ )
637
+ else:
638
+ click.secho("Applying migrations:", bold=True)
687
639
  post_migrate_state = executor.migrate(
688
640
  targets,
689
641
  plan=migration_plan,
@@ -712,31 +664,24 @@ def migrate(
712
664
  ]
713
665
  )
714
666
 
715
- elif verbosity >= 1:
716
- click.echo(" No migrations to apply.")
717
- # If there's changes that aren't in migrations yet, tell them
718
- # how to fix it.
719
- autodetector = MigrationAutodetector(
720
- executor.loader.project_state(),
721
- ProjectState.from_models_registry(models_registry),
722
- )
723
- changes = autodetector.changes(graph=executor.loader.graph)
724
- if changes:
725
- click.echo(
726
- click.style(
727
- f" Your models in package(s): {', '.join(repr(package) for package in sorted(changes))} "
728
- "have changes that are not yet reflected in a migration, and so won't be applied.",
729
- fg="yellow",
730
- )
667
+ else:
668
+ if not quiet:
669
+ click.echo("No migrations to apply.")
670
+ # If there's changes that aren't in migrations yet, tell them
671
+ # how to fix it.
672
+ autodetector = MigrationAutodetector(
673
+ executor.loader.project_state(),
674
+ ProjectState.from_models_registry(models_registry),
731
675
  )
732
- click.echo(
733
- click.style(
734
- " Run `plain makemigrations` to make new "
735
- "migrations, and then re-run `plain migrate` to "
736
- "apply them.",
737
- fg="yellow",
676
+ changes = autodetector.changes(graph=executor.loader.graph)
677
+ if changes:
678
+ packages = ", ".join(sorted(changes))
679
+ click.echo(
680
+ f"Your models have changes that are not yet reflected in migrations ({packages})."
681
+ )
682
+ click.echo(
683
+ "Run 'plain makemigrations' to create migrations for these changes."
738
684
  )
739
- )
740
685
 
741
686
 
742
687
  @cli.command()
@@ -819,35 +764,6 @@ def show_migrations(
819
764
  if not shown:
820
765
  click.secho(" (no migrations)", fg="red")
821
766
 
822
- # Find recorded migrations that aren't in the graph (prunable)
823
- prunable_migrations = [
824
- migration
825
- for migration in recorded_migrations
826
- if (
827
- migration not in loader.disk_migrations # type: ignore[operator]
828
- and (not package_names_list or migration[0] in package_names_list)
829
- )
830
- ]
831
-
832
- if prunable_migrations:
833
- click.echo()
834
- click.secho(
835
- "Recorded migrations not in migration files (candidates for pruning):",
836
- fg="yellow",
837
- bold=True,
838
- )
839
- prunable_by_package = {}
840
- for migration in prunable_migrations:
841
- package, name = migration
842
- if package not in prunable_by_package:
843
- prunable_by_package[package] = []
844
- prunable_by_package[package].append(name)
845
-
846
- for package in sorted(prunable_by_package.keys()):
847
- click.secho(f" {package}:", fg="yellow")
848
- for name in sorted(prunable_by_package[package]):
849
- click.echo(f" - {name}")
850
-
851
767
  def show_plan(db_connection: Any, package_names: tuple[str, ...]) -> None:
852
768
  """
853
769
  Show all known migrations (or only those of the specified package_names)
@@ -900,6 +816,108 @@ def show_migrations(
900
816
  show_list(db_connection, package_labels)
901
817
 
902
818
 
819
+ @cli.command()
820
+ @click.option(
821
+ "--yes",
822
+ is_flag=True,
823
+ help="Skip confirmation prompt (for non-interactive use).",
824
+ )
825
+ def prune_migrations(yes: bool) -> None:
826
+ """Show and optionally remove stale migration records from the database."""
827
+ # Load migrations from disk and database
828
+ loader = MigrationLoader(db_connection, ignore_no_migrations=True)
829
+ recorder = MigrationRecorder(db_connection)
830
+ recorded_migrations = recorder.applied_migrations()
831
+
832
+ # Find all prunable migrations (recorded but not on disk)
833
+ all_prunable = [
834
+ migration
835
+ for migration in recorded_migrations
836
+ if migration not in loader.disk_migrations # type: ignore[operator]
837
+ ]
838
+
839
+ if not all_prunable:
840
+ click.echo("No stale migration records found.")
841
+ return
842
+
843
+ # Separate into existing packages vs orphaned packages
844
+ existing_packages = set(loader.migrated_packages)
845
+ prunable_existing: dict[str, list[str]] = {}
846
+ prunable_orphaned: dict[str, list[str]] = {}
847
+
848
+ for migration in all_prunable:
849
+ package, name = migration
850
+ if package in existing_packages:
851
+ if package not in prunable_existing:
852
+ prunable_existing[package] = []
853
+ prunable_existing[package].append(name)
854
+ else:
855
+ if package not in prunable_orphaned:
856
+ prunable_orphaned[package] = []
857
+ prunable_orphaned[package].append(name)
858
+
859
+ # Display what was found
860
+ if prunable_existing:
861
+ click.secho(
862
+ "Stale migration records (from existing packages):",
863
+ fg="yellow",
864
+ bold=True,
865
+ )
866
+ for package in sorted(prunable_existing.keys()):
867
+ click.secho(f" {package}:", fg="yellow")
868
+ for name in sorted(prunable_existing[package]):
869
+ click.echo(f" - {name}")
870
+ click.echo()
871
+
872
+ if prunable_orphaned:
873
+ click.secho(
874
+ "Orphaned migration records (from removed packages):",
875
+ fg="red",
876
+ bold=True,
877
+ )
878
+ for package in sorted(prunable_orphaned.keys()):
879
+ click.secho(f" {package}:", fg="red")
880
+ for name in sorted(prunable_orphaned[package]):
881
+ click.echo(f" - {name}")
882
+ click.echo()
883
+
884
+ total_count = sum(len(migs) for migs in prunable_existing.values()) + sum(
885
+ len(migs) for migs in prunable_orphaned.values()
886
+ )
887
+
888
+ if not yes:
889
+ click.echo(
890
+ f"Found {total_count} stale migration record{'s' if total_count != 1 else ''}."
891
+ )
892
+ click.echo()
893
+
894
+ # Prompt for confirmation if interactive
895
+ if not click.confirm(
896
+ "Do you want to remove these migrations from the database?"
897
+ ):
898
+ return
899
+
900
+ # Actually prune the migrations
901
+ click.secho("Pruning migrations...", bold=True)
902
+
903
+ for package, migration_names in prunable_existing.items():
904
+ for name in sorted(migration_names):
905
+ click.echo(f" Pruning {package}.{name}...", nl=False)
906
+ recorder.record_unapplied(package, name)
907
+ click.echo(" OK")
908
+
909
+ for package, migration_names in prunable_orphaned.items():
910
+ for name in sorted(migration_names):
911
+ click.echo(f" Pruning {package}.{name} (orphaned)...", nl=False)
912
+ recorder.record_unapplied(package, name)
913
+ click.echo(" OK")
914
+
915
+ click.secho(
916
+ f"✓ Removed {total_count} stale migration record{'s' if total_count != 1 else ''}.",
917
+ fg="green",
918
+ )
919
+
920
+
903
921
  @cli.command()
904
922
  @click.argument("package_label")
905
923
  @click.argument("start_migration_name", required=False)