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.
- django_compose/__init__.py +0 -0
- django_compose/__main__.py +5 -0
- django_compose/addons.py +205 -0
- django_compose/apps.py +103 -0
- django_compose/artifacts.py +224 -0
- django_compose/asgi.py +110 -0
- django_compose/autoreload.py +110 -0
- django_compose/build.py +282 -0
- django_compose/checks.py +11 -0
- django_compose/cli.py +12 -0
- django_compose/conf.py +322 -0
- django_compose/declarations.py +168 -0
- django_compose/history.py +36 -0
- django_compose/management/__init__.py +221 -0
- django_compose/management/commands/__init__.py +1 -0
- django_compose/management/commands/check_migrations.py +57 -0
- django_compose/management/commands/compose.py +42 -0
- django_compose/management/commands/makemigrations.py +136 -0
- django_compose/management/commands/migrate.py +18 -0
- django_compose/management/commands/reset_migration_history.py +34 -0
- django_compose/management/commands/retire_history.py +24 -0
- django_compose/migration_imports.py +474 -0
- django_compose/migrations.py +843 -0
- django_compose/model_composition.py +292 -0
- django_compose/models.py +65 -0
- django_compose/py.typed +0 -0
- django_compose/rendering.py +92 -0
- django_compose/serving.py +405 -0
- django_compose/snapshot.py +419 -0
- django_compose/sources.py +147 -0
- django_compose/typing.py +633 -0
- django_compose/urls.py +58 -0
- django_compose-0.1.0.dev1.dist-info/METADATA +101 -0
- django_compose-0.1.0.dev1.dist-info/RECORD +39 -0
- django_compose-0.1.0.dev1.dist-info/WHEEL +4 -0
- django_compose-0.1.0.dev1.dist-info/entry_points.txt +2 -0
- django_compose-0.1.0.dev1.dist-info/licenses/COPYING +674 -0
- django_compose-0.1.0.dev1.dist-info/licenses/LICENSE +165 -0
- 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)
|