django-compose 0.1.0.dev1__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 (39) hide show
  1. django_compose/__init__.py +0 -0
  2. django_compose/__main__.py +5 -0
  3. django_compose/addons.py +205 -0
  4. django_compose/apps.py +103 -0
  5. django_compose/artifacts.py +224 -0
  6. django_compose/asgi.py +110 -0
  7. django_compose/autoreload.py +110 -0
  8. django_compose/build.py +282 -0
  9. django_compose/checks.py +11 -0
  10. django_compose/cli.py +12 -0
  11. django_compose/conf.py +322 -0
  12. django_compose/declarations.py +168 -0
  13. django_compose/history.py +36 -0
  14. django_compose/management/__init__.py +221 -0
  15. django_compose/management/commands/__init__.py +1 -0
  16. django_compose/management/commands/check_migrations.py +57 -0
  17. django_compose/management/commands/compose.py +42 -0
  18. django_compose/management/commands/makemigrations.py +136 -0
  19. django_compose/management/commands/migrate.py +18 -0
  20. django_compose/management/commands/reset_migration_history.py +34 -0
  21. django_compose/management/commands/retire_history.py +24 -0
  22. django_compose/migration_imports.py +474 -0
  23. django_compose/migrations.py +843 -0
  24. django_compose/model_composition.py +292 -0
  25. django_compose/models.py +65 -0
  26. django_compose/py.typed +0 -0
  27. django_compose/rendering.py +92 -0
  28. django_compose/serving.py +405 -0
  29. django_compose/snapshot.py +419 -0
  30. django_compose/sources.py +147 -0
  31. django_compose/typing.py +633 -0
  32. django_compose/urls.py +58 -0
  33. django_compose-0.1.0.dev1.dist-info/METADATA +101 -0
  34. django_compose-0.1.0.dev1.dist-info/RECORD +39 -0
  35. django_compose-0.1.0.dev1.dist-info/WHEEL +4 -0
  36. django_compose-0.1.0.dev1.dist-info/entry_points.txt +2 -0
  37. django_compose-0.1.0.dev1.dist-info/licenses/COPYING +674 -0
  38. django_compose-0.1.0.dev1.dist-info/licenses/LICENSE +165 -0
  39. django_compose-0.1.0.dev1.dist-info/licenses/NOTICE +10 -0
@@ -0,0 +1,843 @@
1
+ """Bind reusable transitions to durable native Django migration history.
2
+
3
+ Planning uses an offline MigrationLoader. Only explicit materialization writes
4
+ history; Django remains responsible for migration state, execution and recording.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import hashlib
11
+ import importlib
12
+ import importlib.util
13
+ import os
14
+ import sys
15
+ import tempfile
16
+ from collections.abc import Callable, Iterable, Iterator, Mapping
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Any, cast
20
+
21
+ from django.apps import apps as global_apps
22
+ from django.apps.registry import Apps
23
+ from django.conf import settings
24
+ from django.core.exceptions import ImproperlyConfigured
25
+ from django.core.management.base import CommandError
26
+ from django.db import DEFAULT_DB_ALIAS, DatabaseError, connections, router, transaction
27
+ from django.db.migrations import Migration
28
+ from django.db.migrations.autodetector import MigrationAutodetector
29
+ from django.db.migrations.executor import MigrationExecutor
30
+ from django.db.migrations.graph import MigrationGraph
31
+ from django.db.migrations.loader import MigrationLoader
32
+ from django.db.migrations.operations.fields import RemoveField
33
+ from django.db.migrations.operations.models import DeleteModel
34
+ from django.db.migrations.recorder import MigrationRecorder
35
+ from django.db.migrations.state import ProjectState
36
+ from django.db.migrations.writer import MigrationWriter
37
+ from django.db.models.expressions import DatabaseDefault
38
+ from django.utils.module_loading import module_dir
39
+
40
+ from django_compose.declarations import MigrationPolicy, resolve_integrations
41
+ from django_compose.management import require_serving_mode as require_serving_mode
42
+ from django_compose.migration_imports import HistoricalImport as HistoricalImport
43
+ from django_compose.migration_imports import historical_imports as historical_imports
44
+ from django_compose.serving import read_receipt
45
+ from django_compose.snapshot import transition_declarations
46
+
47
+ Node = tuple[str, str]
48
+ MATERIALIZED_FOOTER = "# DJANGO-COMPOSE MATERIALIZED MIGRATION - DO NOT EDIT"
49
+ _LEGACY_FOOTER = b"# ANGEE MATERIALIZED MIGRATION - DO NOT EDIT"
50
+ _SEAL_PREFIX = b"Migration.compose_materialization_sha256 = "
51
+
52
+
53
+ def history_only_labels() -> frozenset[str]:
54
+ """Receipt-retained labels absent from the frozen selected source apps.
55
+
56
+ A live app may legitimately have no models module. Native apps without a
57
+ composition snapshot have no compiler-declared history-only membership.
58
+ """
59
+ snap = getattr(settings, "COMPOSE_SNAPSHOT", None)
60
+ if snap is None:
61
+ return frozenset()
62
+ receipt = read_receipt(Path(settings.COMPOSE_OUTPUT_DIR))
63
+ if receipt is None:
64
+ return frozenset()
65
+ active = {entry.label for entry in snap.entries}
66
+ return frozenset(receipt.HISTORY_LABELS) - active - frozenset(receipt.RETIRED_LABELS)
67
+
68
+
69
+ def _preserve_history(
70
+ before: ProjectState, after: ProjectState, purge: frozenset[str] = frozenset()
71
+ ) -> ProjectState:
72
+ retained = history_only_labels() - purge
73
+ for key, model in before.models.items():
74
+ if key[0] in retained:
75
+ after.models[key] = model.clone()
76
+ return after
77
+
78
+
79
+ def effective_states(
80
+ loader: MigrationLoader, registry: Apps, *, purge: frozenset[str] = frozenset()
81
+ ) -> tuple[ProjectState, ProjectState]:
82
+ """Compare active models while retaining disabled labels' historical state.
83
+
84
+ Receipt retention and frozen source selection identify history-only labels.
85
+ Only explicit purge compares their durable history with an empty target state.
86
+ """
87
+ before = loader.project_state()
88
+ after = _preserve_history(before, ProjectState.from_apps(registry), purge)
89
+ return before, after
90
+
91
+
92
+ def _atomic(path: Path, content: bytes, *, exclusive: bool = False) -> None:
93
+ """Replace one file after its complete contents are on disk."""
94
+ descriptor, temporary = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
95
+ try:
96
+ with os.fdopen(descriptor, "wb") as stream:
97
+ stream.write(content)
98
+ if exclusive:
99
+ os.link(temporary, path)
100
+ else:
101
+ os.replace(temporary, path)
102
+ finally:
103
+ Path(temporary).unlink(missing_ok=True)
104
+
105
+
106
+ def _directory(label: str, *, missing: bool = False) -> Path:
107
+ package, _ = MigrationLoader.migrations_module(label)
108
+ if package is None:
109
+ raise ImproperlyConfigured(f"Migrations are disabled for app {label!r}")
110
+ try:
111
+ module = importlib.import_module(package)
112
+ if not hasattr(module, "__path__"):
113
+ raise ValueError("Migrations must be a Python package")
114
+ return Path(module_dir(module))
115
+ except ModuleNotFoundError as error:
116
+ if (
117
+ not missing
118
+ or error.name is None
119
+ or not (package == error.name or package.startswith(error.name + "."))
120
+ ):
121
+ raise ImproperlyConfigured(f"Migration package {package!r} must be importable") from error
122
+ parent, _, name = package.rpartition(".")
123
+ config = global_apps.get_app_config(label)
124
+ if parent == config.name:
125
+ return Path(config.path) / name
126
+ try:
127
+ parent_module = importlib.import_module(parent)
128
+ if not hasattr(parent_module, "__path__"):
129
+ raise ValueError("Migration parent must be a Python package")
130
+ return Path(module_dir(parent_module)) / name
131
+ except (ImportError, ValueError) as parent_error:
132
+ raise ImproperlyConfigured(
133
+ f"Initialize importable migration parent package {parent!r}"
134
+ ) from parent_error
135
+ except (ImportError, ValueError) as error:
136
+ raise ImproperlyConfigured(f"Migration package {package!r} must be an importable package") from error
137
+
138
+
139
+ def _node(raw: Any, origin: str, kind: str) -> Node:
140
+ if not isinstance(raw, (list, tuple)) or len(raw) != 2 or not all(isinstance(x, str) for x in raw):
141
+ raise RuntimeError(f"{origin}: invalid Django {kind} {raw!r}")
142
+ return raw[0], raw[1]
143
+
144
+
145
+ def _metadata(migration: Migration, name: str) -> Any:
146
+ """Read only the two historical Angee spellings needed for adoption."""
147
+ return getattr(migration, f"compose_{name}", getattr(migration, f"angee_{name}", None))
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class TransitionPlan:
152
+ """A source snapshot with host-resolved native graph edges."""
153
+
154
+ origin: str
155
+ app_label: str
156
+ name: str
157
+ source_path: Path
158
+ source: str
159
+ output_path: Path
160
+ source_sha256: str
161
+ dependencies: tuple[Node, ...]
162
+ run_before: tuple[Node, ...]
163
+ source_package: str
164
+
165
+
166
+ @dataclass(frozen=True)
167
+ class _Source:
168
+ origin: str
169
+ migration: Migration
170
+ guard: Callable[[ProjectState], bool]
171
+ path: Path
172
+ text: str
173
+ digest: str
174
+ package: str
175
+
176
+
177
+ class _BindingLoader(MigrationLoader):
178
+ """Supply pending native Migration objects at Django's disk-loading hook."""
179
+
180
+ def __init__(self, pending: Mapping[Node, _Source]) -> None:
181
+ self.pending = pending
182
+ super().__init__(None, ignore_no_migrations=True)
183
+
184
+ def load_disk(self) -> None:
185
+ super().load_disk()
186
+ for key, source in self.pending.items():
187
+ self.disk_migrations[key] = source.migration
188
+ self.migrated_apps.add(key[0])
189
+ self.unmigrated_apps.discard(key[0])
190
+ self.disk_migrations = dict(sorted(self.disk_migrations.items()))
191
+
192
+ def add_internal_dependencies(self, key: Node, migration: Migration) -> None:
193
+ if key in self.pending:
194
+ cast(list[Node], migration.dependencies)[:] = [
195
+ edge
196
+ for edge in migration.dependencies
197
+ if edge[0] != key[0] or self.check_key(edge, key[0]) is not None
198
+ ]
199
+ super().add_internal_dependencies(key, migration)
200
+
201
+ def add_external_dependencies(self, key: Node, migration: Migration) -> None:
202
+ if key in self.pending:
203
+ for attribute in ("dependencies", "run_before"):
204
+ frozen = []
205
+ for edge in getattr(migration, attribute):
206
+ resolved = self.check_key(edge, key[0])
207
+ if resolved is None and edge[0] != key[0] and edge[1] == "__latest__":
208
+ raise RuntimeError(f"{self.pending[key].origin}: unresolved __latest__ {edge!r}")
209
+ if resolved is not None and resolved not in frozen:
210
+ frozen.append(resolved)
211
+ setattr(migration, attribute, frozen)
212
+ super().add_external_dependencies(key, migration)
213
+
214
+
215
+ class TransitionBinding:
216
+ """Bind declared migrations to host leaves without connecting to a database.
217
+
218
+ Origins and snapshots are immutable, including after declaration removal.
219
+ A false guard never establishes that a data operation has run. Completed
220
+ transitions must remain in native history, including native squash chains.
221
+ """
222
+
223
+ def __init__(
224
+ self,
225
+ *,
226
+ labels: Iterable[str],
227
+ declarations: Iterable[Mapping[str, str]] | None = None,
228
+ ) -> None:
229
+ self.labels = frozenset(labels)
230
+ self.declarations = transition_declarations(
231
+ getattr(getattr(settings, "COMPOSE_SNAPSHOT", None), "transition_records", ())
232
+ if declarations is None
233
+ else declarations
234
+ )
235
+
236
+ def _history(self, loader: MigrationLoader) -> dict[str, tuple[Node, Migration]]:
237
+ history = {}
238
+ for key, migration in sorted(loader.disk_migrations.items()):
239
+ origin = _metadata(migration, "origin")
240
+ if origin is None:
241
+ continue
242
+ if not isinstance(origin, str) or not origin or origin in history:
243
+ raise RuntimeError(f"invalid or duplicate materialized origin {origin!r}")
244
+ path = _directory(key[0]) / f"{key[1]}.py"
245
+ data = path.read_bytes()
246
+ digest = _metadata(migration, "source_sha256")
247
+ canonical = hasattr(migration, "compose_origin")
248
+ body_digest = getattr(migration, "compose_body_sha256", digest) if canonical else digest
249
+ marker = MATERIALIZED_FOOTER.encode() if canonical else _LEGACY_FOOTER
250
+ body, separator, _ = data.partition(marker)
251
+ if (
252
+ not separator
253
+ or not isinstance(digest, str)
254
+ or not isinstance(body_digest, str)
255
+ or hashlib.sha256(body).hexdigest() != body_digest
256
+ ):
257
+ raise RuntimeError(f"{origin}: materialized body digest changed or footer is missing")
258
+ if canonical:
259
+ payload, separator, seal = data.rpartition(_SEAL_PREFIX)
260
+ expected = repr(hashlib.sha256(payload).hexdigest()).encode() + b"\n"
261
+ if not separator or seal != expected:
262
+ raise RuntimeError(f"{origin}: immutable binding metadata digest changed")
263
+ history[origin] = (key, migration)
264
+ return history
265
+
266
+ def _pending(self, loader: MigrationLoader) -> dict[Node, _Source]:
267
+ history = self._history(loader)
268
+ origins: set[str] = set()
269
+ pending: dict[Node, _Source] = {}
270
+ replaced = {key for migration in loader.replacements.values() for key in migration.replaces}
271
+ for declaration in self.declarations:
272
+ name, label = declaration["name"], declaration["app_label"]
273
+ declaring_app = declaration["declaring_app"]
274
+ origin = f"{declaring_app}:{name}"
275
+ if origin in origins:
276
+ raise RuntimeError(f"duplicate declared origin {origin}")
277
+ origins.add(origin)
278
+ if label not in self.labels:
279
+ raise RuntimeError(f"{origin}: unknown runtime migration target {label!r}")
280
+ module_name = declaration["module"]
281
+ conventional = declaring_app + ".migrations"
282
+ if module_name == conventional or module_name.startswith(conventional + "."):
283
+ raise RuntimeError(f"{origin}: source must live outside the conventional migrations package")
284
+ try:
285
+ module = importlib.import_module(module_name)
286
+ except Exception as error:
287
+ raise RuntimeError(f"{origin}: cannot import source migration {module_name!r}") from error
288
+ path = Path(module.__file__ or "")
289
+ if path.suffix != ".py":
290
+ raise RuntimeError(f"{origin}: migration requires Python source")
291
+ data = path.read_bytes()
292
+ digest = hashlib.sha256(data).hexdigest()
293
+ if origin in history:
294
+ key, migration = history[origin]
295
+ if key[0] != label or _metadata(migration, "source_sha256") != digest:
296
+ raise RuntimeError(f"{origin}: source digest or target changed after materialization")
297
+ continue
298
+ key = label, name
299
+ if key in pending or key in loader.disk_migrations:
300
+ raise RuntimeError(f"{origin}: duplicate native migration node {key!r}")
301
+ if key in replaced:
302
+ raise RuntimeError(f"{origin}: replacement requires the complete immutable original chain")
303
+ cls = getattr(module, "Migration", None)
304
+ guard = getattr(module, "applies", None)
305
+ if not isinstance(cls, type) or not issubclass(cls, Migration) or not callable(guard):
306
+ raise RuntimeError(f"{origin}: source requires native Migration and applies(project_state)")
307
+ migration = cls(name, label)
308
+ if migration.replaces:
309
+ raise RuntimeError(f"{origin}: declare native replacements in the host migration package")
310
+ cast(list[Node], migration.dependencies)[:] = [
311
+ _node(edge, origin, "dependency") for edge in migration.dependencies
312
+ ]
313
+ cast(list[Node], migration.run_before)[:] = [
314
+ _node(edge, origin, "run_before") for edge in migration.run_before
315
+ ]
316
+ leaves = loader.graph.leaf_nodes(label)
317
+ if len(leaves) == 1 and leaves[0] not in migration.dependencies:
318
+ cast(list[Node], migration.dependencies).append(leaves[0])
319
+ package = (module.__spec__.parent if module.__spec__ else None) or module_name.rpartition(".")[0]
320
+ pending[key] = _Source(origin, migration, guard, path, data.decode("utf-8"), digest, package)
321
+ return pending
322
+
323
+ def plan(
324
+ self,
325
+ *,
326
+ apps: Apps | None = None,
327
+ defer_drop_check: bool = False,
328
+ app_labels: Iterable[str] | None = None,
329
+ allow_missing_packages: bool = False,
330
+ ) -> tuple[TransitionPlan, ...]:
331
+ """Validate the complete graph and selected prerequisite closure, without writes."""
332
+ existing = MigrationLoader(None, ignore_no_migrations=True)
333
+ pending = self._pending(existing)
334
+ try:
335
+ loader = _BindingLoader(pending)
336
+ except Exception as error:
337
+ raise RuntimeError(f"authored migration graph is invalid: {error}") from error
338
+ selected = None if app_labels is None else set(app_labels)
339
+ ordered = dict.fromkeys(
340
+ key
341
+ for leaf in loader.graph.leaf_nodes()
342
+ if selected is None or leaf[0] in selected
343
+ for key in loader.graph.forwards_plan(leaf)
344
+ )
345
+ relevant = {key[0] for key in ordered}
346
+ conflicts = {
347
+ label: names
348
+ for label, names in loader.detect_conflicts().items()
349
+ if selected is None or label in relevant or label in selected
350
+ }
351
+ if conflicts:
352
+ raise RuntimeError(f"multiple migration leaves: {conflicts}")
353
+ result, destinations = [], set()
354
+ for key in ordered:
355
+ if key not in pending:
356
+ continue
357
+ source = pending[key]
358
+ state = loader.project_state(nodes=[key], at_end=False)
359
+ try:
360
+ applies = source.guard(state.clone())
361
+ except Exception as error:
362
+ raise RuntimeError(f"{source.origin}: applies(project_state) failed") from error
363
+ if not isinstance(applies, bool):
364
+ raise RuntimeError(f"{source.origin}: applies must return bool")
365
+ if not applies:
366
+ raise RuntimeError(
367
+ f"{source.origin}: false guard cannot prove a data transition completed; "
368
+ "retain native history or a complete replacement baseline"
369
+ )
370
+ try:
371
+ source.migration.mutate_state(state)
372
+ except Exception as error:
373
+ raise RuntimeError(f"{source.origin}: invalid migration state transition") from error
374
+ path = _directory(key[0], missing=allow_missing_packages) / f"{key[1]}.py"
375
+ if path.exists() or path.resolve() in destinations:
376
+ raise RuntimeError(f"{source.origin}: migration destination already exists: {path}")
377
+ destinations.add(path.resolve())
378
+ result.append(
379
+ TransitionPlan(
380
+ source.origin,
381
+ key[0],
382
+ key[1],
383
+ source.path,
384
+ source.text,
385
+ path,
386
+ source.digest,
387
+ tuple(source.migration.dependencies),
388
+ tuple(source.migration.run_before),
389
+ source.package,
390
+ )
391
+ )
392
+ if apps is not None and not defer_drop_check:
393
+ # This check compares state only. DropGuardAutodetector's row proofs
394
+ # belong exclusively to explicit native authoring and migrate.
395
+ if selected is not None:
396
+ loader = _BindingLoader({key: pending[key] for key in ordered if key in pending})
397
+ before, after = effective_states(loader, apps)
398
+ changes = MigrationAutodetector(before, after).changes(
399
+ graph=loader.graph, trim_to_apps=None if selected is None else selected | relevant
400
+ )
401
+ _shared_table_drops(before, after, changes)
402
+ return tuple(result)
403
+
404
+ def check(self, app_labels: Iterable[str] | None = None) -> None:
405
+ """Refuse unmaterialized declarations without creating packages or history."""
406
+ pending = self.plan(app_labels=app_labels)
407
+ if pending:
408
+ raise RuntimeError("pending authored runtime migration " + ", ".join(p.origin for p in pending))
409
+
410
+ def check_native_history(self, apps: Apps) -> None:
411
+ """Require model-clean history and bound transitions without database access.
412
+
413
+ Compose check calls this after population. Disabled native migration
414
+ mappings remain authoritative, and tombstones retain historical state.
415
+ """
416
+ self.check()
417
+ enabled = {label for label in self.labels if MigrationLoader.migrations_module(label)[0] is not None}
418
+ if not enabled:
419
+ return
420
+ loader = MigrationLoader(None, ignore_no_migrations=True)
421
+ before, after = effective_states(loader, apps)
422
+ changes = MigrationAutodetector(before, after).changes(
423
+ graph=loader.graph,
424
+ trim_to_apps=enabled,
425
+ convert_apps=enabled,
426
+ )
427
+ if changes:
428
+ operations = ", ".join(
429
+ f"{label}: {operation.__class__.__name__}"
430
+ for label, migrations in sorted(changes.items())
431
+ for migration in migrations
432
+ for operation in migration.operations
433
+ )
434
+ raise RuntimeError(f"native migration history does not match composed models: {operations}")
435
+
436
+ @staticmethod
437
+ def _render(plan: TransitionPlan) -> bytes:
438
+ if not plan.source.endswith("\n"):
439
+ raise RuntimeError(f"{plan.origin}: source migration must end with a newline")
440
+ # Keep native relative imports anchored to the author package. Module
441
+ # docstrings and future imports must precede the injected assignment.
442
+ header = None
443
+ for index, node in enumerate(ast.parse(plan.source).body):
444
+ if (
445
+ index == 0
446
+ and isinstance(node, ast.Expr)
447
+ and isinstance(node.value, ast.Constant)
448
+ and isinstance(node.value.value, str)
449
+ ) or (isinstance(node, ast.ImportFrom) and node.module == "__future__"):
450
+ header = node
451
+ else:
452
+ break
453
+ original = plan.source.encode()
454
+ if header is None:
455
+ body = f"__package__ = {plan.source_package!r}\n".encode() + original
456
+ else:
457
+ lines = original.splitlines(keepends=True)
458
+ offset = sum(map(len, lines[: cast(int, header.end_lineno) - 1])) + cast(
459
+ int, header.end_col_offset
460
+ )
461
+ body = original[:offset] + f"; __package__ = {plan.source_package!r}".encode() + original[offset:]
462
+ body_digest = hashlib.sha256(body).hexdigest()
463
+ payload = (
464
+ body.decode()
465
+ + MATERIALIZED_FOOTER
466
+ + "\n"
467
+ + f"Migration.dependencies = {list(plan.dependencies)!r}\n"
468
+ + f"Migration.run_before = {list(plan.run_before)!r}\n"
469
+ + f"Migration.compose_origin = {plan.origin!r}\n"
470
+ + f"Migration.compose_source_sha256 = {plan.source_sha256!r}\n"
471
+ + f"Migration.compose_body_sha256 = {body_digest!r}\n"
472
+ ).encode()
473
+ return payload + _SEAL_PREFIX + repr(hashlib.sha256(payload).hexdigest()).encode() + b"\n"
474
+
475
+ def materialize(self, apps: Apps, app_labels: Iterable[str] | None = None) -> tuple[Path, ...]:
476
+ """Snapshot all validated sources at the explicit native authoring boundary."""
477
+ plans = self.plan(apps=apps, app_labels=app_labels, allow_missing_packages=True)
478
+ rendered = [(plan, self._render(plan)) for plan in plans]
479
+ for label in dict.fromkeys(plan.app_label for plan in plans):
480
+ directory = Path(MigrationWriter(Migration("_package", label)).basedir)
481
+ directory.mkdir(parents=True, exist_ok=True)
482
+ if not (directory / "__init__.py").exists():
483
+ _atomic(directory / "__init__.py", b"", exclusive=True)
484
+ written = []
485
+ try:
486
+ for plan, content in rendered:
487
+ if plan.output_path.exists():
488
+ raise RuntimeError(f"Migration destination appeared during authoring: {plan.output_path}")
489
+ _atomic(plan.output_path, content, exclusive=True)
490
+ written.append(plan.output_path)
491
+ importlib.invalidate_caches()
492
+ self.plan(apps=apps, app_labels=app_labels)
493
+ except BaseException:
494
+ for path in written:
495
+ path.unlink(missing_ok=True)
496
+ _forget_history({plan.app_label for plan in plans})
497
+ raise
498
+ return tuple(written)
499
+
500
+
501
+ def _shared_table_drops(
502
+ before: ProjectState,
503
+ after: ProjectState,
504
+ changes: dict[str, list[Migration]],
505
+ ) -> None:
506
+ owners: dict[str, set[str]] = {}
507
+ for model in after.apps.get_models(include_swapped=True):
508
+ if model._meta.managed and not model._meta.proxy:
509
+ owners.setdefault(model._meta.db_table, set()).add(model._meta.label_lower)
510
+ for label, migrations in sorted(changes.items()):
511
+ for migration in migrations:
512
+ for operation in migration.operations:
513
+ if isinstance(operation, DeleteModel):
514
+ model = before.apps.get_model(label, operation.name)
515
+ remaining = owners.get(model._meta.db_table, set()) - {model._meta.label_lower}
516
+ if model._meta.managed and not model._meta.proxy and remaining:
517
+ raise CommandError(
518
+ f"unsafe autodetected model deletion: table {model._meta.db_table!r} "
519
+ f"is still owned by {', '.join(sorted(remaining))}"
520
+ )
521
+
522
+
523
+ class DropGuardAutodetector(MigrationAutodetector):
524
+ """Require every routed database to prove autodetected dropped storage empty.
525
+
526
+ Null columns are empty at NULL; non-null columns use their native default.
527
+ A database default cannot prove emptiness. Explicit through models and
528
+ generated columns own no independently dropped storage. Integrations may
529
+ explicitly retire rows of a deleted table; other rows remain protected.
530
+ """
531
+
532
+ def __init__(self, from_state: ProjectState, to_state: ProjectState, questioner: Any = None) -> None:
533
+ to_state = _preserve_history(from_state, to_state.clone())
534
+ super().__init__(from_state, to_state, questioner)
535
+
536
+ def changes(
537
+ self,
538
+ graph: MigrationGraph,
539
+ trim_to_apps: set[str] | None = None,
540
+ convert_apps: set[str] | None = None,
541
+ migration_name: str | None = None,
542
+ ) -> dict[str, list[Migration]]:
543
+ changes = super().changes(graph, trim_to_apps, convert_apps, migration_name)
544
+ _shared_table_drops(self.from_state, self.to_state, changes)
545
+ refusals: list[str] = []
546
+ rows: Callable[[str], Any]
547
+ for label, migrations in sorted(changes.items()):
548
+ for migration in migrations:
549
+ for operation in migration.operations:
550
+ if isinstance(operation, RemoveField):
551
+ model = self.from_state.apps.get_model(label, operation.model_name)
552
+ field = model._meta.get_field(operation.name)
553
+ if field.many_to_many:
554
+ through = field.remote_field.through
555
+ if not through._meta.auto_created:
556
+ continue
557
+ table, column = through._meta.db_table, None
558
+
559
+ def through_rows(alias: str, through: Any = through) -> Any:
560
+ return through._base_manager.all()
561
+
562
+ rows = through_rows
563
+ elif field.concrete and not field.generated:
564
+ table, column = model._meta.db_table, field.column
565
+ empty = None if field.null else field.get_default()
566
+ query = model._base_manager.all()
567
+ if empty is None:
568
+ query = query.exclude(**{f"{field.attname}__isnull": True})
569
+ elif not isinstance(empty, DatabaseDefault):
570
+ query = query.exclude(**{field.attname: empty})
571
+
572
+ def column_rows(alias: str, query: Any = query) -> Any:
573
+ return query
574
+
575
+ rows = column_rows
576
+ else:
577
+ continue
578
+ refusals.extend(self._holding(model, rows, table, column))
579
+ elif isinstance(operation, DeleteModel):
580
+ model = self.from_state.apps.get_model(label, operation.name)
581
+ if model._meta.proxy or not model._meta.managed or model._meta.swapped:
582
+ continue
583
+ integrations = tuple(
584
+ integration
585
+ for integration in resolve_integrations(
586
+ getattr(settings, "COMPOSE_INTEGRATIONS", ())
587
+ )
588
+ if isinstance(integration, MigrationPolicy)
589
+ )
590
+
591
+ def remaining_rows(
592
+ alias: str,
593
+ model: Any = model,
594
+ integrations: tuple[MigrationPolicy, ...] = integrations,
595
+ ) -> Any:
596
+ retired = {
597
+ pk
598
+ for integration in integrations
599
+ for pk in integration.retired_primary_keys(model, alias)
600
+ }
601
+ return model._base_manager.exclude(pk__in=retired)
602
+
603
+ refusals.extend(self._holding(model, remaining_rows, model._meta.db_table, None))
604
+ if refusals:
605
+ raise CommandError(
606
+ "unsafe autodetected drop: "
607
+ + "; ".join(refusals)
608
+ + ". Declare a reviewed cutover migration before native makemigrations."
609
+ )
610
+ return changes
611
+
612
+ @staticmethod
613
+ def _holding(
614
+ model: type[Any],
615
+ rows: Callable[[str], Any],
616
+ table: str,
617
+ column: str | None,
618
+ ) -> Iterator[str]:
619
+ for alias in sorted(connections):
620
+ if not router.allow_migrate_model(alias, model):
621
+ continue
622
+ connection = connections[alias]
623
+ try:
624
+ with connection.cursor() as cursor:
625
+ present = table in connection.introspection.table_names(cursor)
626
+ if present and column is not None:
627
+ present = column in {
628
+ f.name for f in connection.introspection.get_table_description(cursor, table)
629
+ }
630
+ count = rows(alias).using(alias).count() if present else 0
631
+ except (DatabaseError, ImproperlyConfigured) as error:
632
+ yield (
633
+ f"{model._meta.label_lower}: cannot prove {table}.{column or '*'} "
634
+ f"empty on {alias!r} ({error})"
635
+ )
636
+ continue
637
+ if count:
638
+ yield (
639
+ f"{model._meta.label_lower}: {table}.{column or '*'} "
640
+ f"holds data in {count} rows on {alias!r}"
641
+ )
642
+
643
+
644
+ def _forget_history(labels: Iterable[str]) -> None:
645
+ prefixes = [MigrationLoader.migrations_module(label)[0] for label in labels]
646
+ for name in tuple(sys.modules):
647
+ if any(prefix and (name == prefix or name.startswith(prefix + ".")) for prefix in prefixes):
648
+ sys.modules.pop(name, None)
649
+ for label in labels:
650
+ for bytecode in (_directory(label) / "__pycache__").glob("*.pyc"):
651
+ bytecode.unlink()
652
+ importlib.invalidate_caches()
653
+
654
+
655
+ def reset_migration_history(
656
+ labels: Iterable[str],
657
+ *,
658
+ database: str = DEFAULT_DB_ALIAS,
659
+ ) -> tuple[tuple[str, ...], int, int]:
660
+ """Consolidate explicitly selected fully applied, model-clean native history.
661
+
662
+ Refuse cross-scope dependents and histories routed to another database.
663
+ Recorder changes are transactional and every original file is restored if
664
+ authoring, consistency validation, or the recorder transaction fails.
665
+ """
666
+ labels = tuple(sorted(set(labels)))
667
+ if not labels:
668
+ return labels, 0, 0
669
+ for label in labels:
670
+ global_apps.get_app_config(label)
671
+ connection = connections[database]
672
+ if not connection.features.supports_transactions:
673
+ raise RuntimeError("history reset requires transactional recorder support")
674
+ offline = MigrationLoader(None, ignore_no_migrations=True)
675
+ for alias in _history_aliases(offline, set(labels)):
676
+ if alias != database:
677
+ raise RuntimeError(
678
+ f"selected history is also routed to database {alias!r}; coordinate its cutoff first"
679
+ )
680
+ executor = MigrationExecutor(connection)
681
+ loader = executor.loader
682
+ loader.check_consistent_history(connection)
683
+ if executor.migration_plan(loader.graph.leaf_nodes()):
684
+ raise RuntimeError("history reset requires a fully applied migration graph")
685
+ _check_dependents(loader, set(labels))
686
+ before, after = effective_states(loader, global_apps)
687
+ if MigrationAutodetector(before, after).changes(
688
+ graph=loader.graph, trim_to_apps=set(labels), convert_apps=set(labels)
689
+ ):
690
+ raise RuntimeError("history reset requires a model-clean native graph")
691
+ directories = {label: _directory(label) for label in labels}
692
+ if len({path.resolve() for path in directories.values()}) != len(directories):
693
+ raise RuntimeError("history reset requires distinct native migration packages")
694
+ baseline = {path for directory in directories.values() for path in directory.glob("*.py")}
695
+ original = {
696
+ directories[label] / f"{name}.py": (directories[label] / f"{name}.py").read_bytes()
697
+ for label, name in loader.disk_migrations
698
+ if label in directories
699
+ }
700
+ try:
701
+ for path in original:
702
+ path.unlink()
703
+ _forget_history(labels)
704
+ fresh_loader = MigrationLoader(None, ignore_no_migrations=True)
705
+ fresh_before, fresh_after = effective_states(fresh_loader, global_apps)
706
+ for key, model in after.models.items():
707
+ if key[0] in labels:
708
+ fresh_after.models[key] = model.clone()
709
+ changes = MigrationAutodetector(fresh_before, fresh_after).changes(
710
+ graph=fresh_loader.graph, trim_to_apps=set(labels), convert_apps=set(labels)
711
+ )
712
+ for migrations in changes.values():
713
+ for migration in migrations:
714
+ writer = MigrationWriter(migration, include_header=False)
715
+ _atomic(Path(writer.path), writer.as_string().encode(), exclusive=True)
716
+ _forget_history(labels)
717
+ TransitionBinding(labels=(config.label for config in global_apps.get_app_configs())).check(
718
+ app_labels=labels
719
+ )
720
+ fresh = MigrationExecutor(connection).loader
721
+ nodes = {key for key in fresh.graph.nodes if key[0] in labels}
722
+ recorder = MigrationRecorder(connection)
723
+ with transaction.atomic(using=database):
724
+ applied = set(recorder.applied_migrations())
725
+ for label, name in sorted(nodes - applied):
726
+ recorder.record_applied(label, name)
727
+ for label, name in sorted(applied - nodes):
728
+ if label in labels:
729
+ recorder.record_unapplied(label, name)
730
+ fresh.check_consistent_history(connection)
731
+ count = recorder.migration_qs.filter(app__in=labels).count()
732
+ except BaseException:
733
+ for directory in directories.values():
734
+ for path in directory.glob("*.py"):
735
+ if path not in baseline:
736
+ path.unlink()
737
+ for path, content in original.items():
738
+ _atomic(path, content)
739
+ _forget_history(labels)
740
+ raise
741
+ return labels, len(original), count
742
+
743
+
744
+ def _history_aliases(loader: MigrationLoader, labels: set[str]) -> tuple[str, ...]:
745
+ """Union routes for every native historical model, including deleted models.
746
+
747
+ Intermediate operation states matter: a model may be created and deleted
748
+ within one migration, or change routing identity before a later deletion.
749
+ Native operations own those state transitions; leaf state alone loses them.
750
+ """
751
+ aliases: set[str] = set()
752
+ model_labels: set[str] = set()
753
+
754
+ def include(state: ProjectState) -> None:
755
+ for model in state.apps.get_models():
756
+ if model._meta.app_label in labels:
757
+ model_labels.add(model._meta.app_label)
758
+ aliases.update(alias for alias in connections if router.allow_migrate_model(alias, model))
759
+
760
+ for node, migration in sorted(loader.graph.nodes.items()):
761
+ if node[0] not in labels:
762
+ continue
763
+ state = loader.project_state(nodes=[node], at_end=False)
764
+ include(state)
765
+ for operation in migration.operations:
766
+ operation.state_forwards(node[0], state)
767
+ include(state)
768
+ aliases.update(
769
+ alias
770
+ for alias in connections
771
+ if any(router.allow_migrate(alias, label) for label in labels - model_labels)
772
+ )
773
+ return tuple(sorted(aliases))
774
+
775
+
776
+ def _check_dependents(loader: MigrationLoader, labels: set[str]) -> None:
777
+ for key in sorted(loader.graph.nodes):
778
+ if key[0] not in labels and any(node[0] in labels for node in loader.graph.forwards_plan(key)):
779
+ raise RuntimeError(
780
+ f"cross-app dependent retained migration {key!r} depends on retiring history; "
781
+ "include its app in the cutoff scope, or squash or rebase dependents first"
782
+ )
783
+
784
+
785
+ def _fully_applied(labels: set[str], aliases: Iterable[str]) -> None:
786
+ for alias in aliases:
787
+ connection = connections[alias]
788
+ executor = MigrationExecutor(connection)
789
+ loader = executor.loader
790
+ loader.check_consistent_history(connection)
791
+ targets = [node for node in loader.graph.leaf_nodes() if node[0] in labels]
792
+ if executor.migration_plan(targets):
793
+ raise RuntimeError(f"history retirement requires fully applied history on database {alias!r}")
794
+
795
+
796
+ def retire_history(label: str, *, purge: bool = False) -> None:
797
+ """Retire a disabled label only after all routed configured databases agree.
798
+
799
+ Purge authors a native final migration, applies it on every routed alias, and
800
+ then runs the same retirement proof. A failed application leaves durable
801
+ history for retry; it never records retirement prematurely.
802
+ """
803
+ from django_compose.artifacts import receipt_lock, update_receipt
804
+
805
+ require_serving_mode()
806
+ output = Path(settings.COMPOSE_OUTPUT_DIR)
807
+ with receipt_lock(output):
808
+ receipt = read_receipt(output)
809
+ if receipt is None:
810
+ raise RuntimeError("History retirement requires a composed receipt")
811
+ if label in receipt.RETIRED_LABELS:
812
+ return
813
+ if label not in receipt.HISTORY_LABELS:
814
+ raise RuntimeError(f"Unknown history label {label!r}")
815
+ global_apps.get_app_config(label)
816
+ if label not in history_only_labels():
817
+ raise RuntimeError(f"{label!r} must be history-only before retirement")
818
+ loader = MigrationLoader(None, ignore_no_migrations=True)
819
+ _check_dependents(loader, {label})
820
+ aliases = _history_aliases(loader, {label})
821
+ if purge:
822
+ before, after = effective_states(loader, global_apps, purge=frozenset({label}))
823
+ changes = MigrationAutodetector(before, after).changes(
824
+ graph=loader.graph, trim_to_apps={label}, migration_name="compose_purge"
825
+ )
826
+ for migration in changes.get(label, ()):
827
+ writer = MigrationWriter(migration, include_header=False)
828
+ path = Path(writer.path)
829
+ if path.exists():
830
+ raise RuntimeError(f"Refusing to overwrite native purge migration {path}")
831
+ _atomic(path, writer.as_string().encode(), exclusive=True)
832
+ _forget_history({label})
833
+ for alias in aliases:
834
+ executor = MigrationExecutor(connections[alias])
835
+ executor.loader.check_consistent_history(connections[alias])
836
+ executor.migrate(executor.loader.graph.leaf_nodes(label))
837
+ _fully_applied({label}, aliases)
838
+ loader = MigrationLoader(None, ignore_no_migrations=True)
839
+ _check_dependents(loader, {label})
840
+ before, after = effective_states(loader, global_apps)
841
+ if MigrationAutodetector(before, after).changes(graph=loader.graph, trim_to_apps={label}):
842
+ raise RuntimeError("History retirement requires model-clean history")
843
+ update_receipt(output, retired_labels=tuple(sorted({*receipt.RETIRED_LABELS, label})), locked=True)