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,405 @@
|
|
|
1
|
+
"""Literal receipt verification and settings projection; never imports the compiler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import fcntl
|
|
7
|
+
import hashlib
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable, Iterable, Iterator, Mapping
|
|
12
|
+
from contextlib import AbstractContextManager, contextmanager
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from types import MappingProxyType
|
|
16
|
+
|
|
17
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
18
|
+
|
|
19
|
+
from django_compose.snapshot import Entry, Snapshot, app_entries
|
|
20
|
+
from django_compose.snapshot import mode as mode
|
|
21
|
+
from django_compose.sources import SourceResolver
|
|
22
|
+
|
|
23
|
+
GENERATED_SENTINEL = "# django-compose generated output v1"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _validate_output_root(root: Path) -> None:
|
|
27
|
+
if any(parent.is_symlink() for parent in (root, *root.parents)):
|
|
28
|
+
raise ValueError(f"Generated root traverses a symlink: {root}")
|
|
29
|
+
if root.exists() and not root.is_dir():
|
|
30
|
+
raise ValueError(f"Generated root is not a directory: {root}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@contextmanager
|
|
34
|
+
def receipt_lock(
|
|
35
|
+
output_dir: Path | str, *, write: bool = True, required: bool = False, wait: float | None = None
|
|
36
|
+
) -> Iterator[None]:
|
|
37
|
+
"""Serialize mutations; a check never creates a directory or lock file."""
|
|
38
|
+
if wait is not None and (not math.isfinite(wait) or wait < 0):
|
|
39
|
+
raise ValueError("Composition wait must be finite and non-negative")
|
|
40
|
+
root = Path(output_dir)
|
|
41
|
+
_validate_output_root(root)
|
|
42
|
+
path = root / ".compose.lock"
|
|
43
|
+
if path.is_symlink() or (path.exists() and not path.is_file()):
|
|
44
|
+
raise ValueError("Generated lock must be a regular file, not a symlink")
|
|
45
|
+
if not write:
|
|
46
|
+
if path.exists():
|
|
47
|
+
with path.open("rb") as stream:
|
|
48
|
+
os.set_inheritable(stream.fileno(), False)
|
|
49
|
+
fcntl.flock(stream, fcntl.LOCK_SH)
|
|
50
|
+
try:
|
|
51
|
+
yield
|
|
52
|
+
finally:
|
|
53
|
+
fcntl.flock(stream, fcntl.LOCK_UN)
|
|
54
|
+
else:
|
|
55
|
+
if required:
|
|
56
|
+
raise ImproperlyConfigured("Missing generated composition lock; run compose")
|
|
57
|
+
yield
|
|
58
|
+
return
|
|
59
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
with path.open("a+b") as stream:
|
|
61
|
+
os.set_inheritable(stream.fileno(), False)
|
|
62
|
+
if wait is None:
|
|
63
|
+
fcntl.flock(stream, fcntl.LOCK_EX)
|
|
64
|
+
else:
|
|
65
|
+
deadline = time.monotonic() + wait
|
|
66
|
+
while True:
|
|
67
|
+
try:
|
|
68
|
+
fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
69
|
+
break
|
|
70
|
+
except BlockingIOError:
|
|
71
|
+
if time.monotonic() >= deadline:
|
|
72
|
+
raise ImproperlyConfigured(
|
|
73
|
+
f"Timed out after {wait:g}s waiting for composition lock {path}; "
|
|
74
|
+
"another reader or writer holds it"
|
|
75
|
+
) from None
|
|
76
|
+
time.sleep(min(0.05, max(0, deadline - time.monotonic())))
|
|
77
|
+
try:
|
|
78
|
+
yield
|
|
79
|
+
finally:
|
|
80
|
+
fcntl.flock(stream, fcntl.LOCK_UN)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class Receipt:
|
|
85
|
+
HISTORY_LABELS: tuple[str, ...]
|
|
86
|
+
RETIRED_LABELS: tuple[str, ...]
|
|
87
|
+
DIGEST: str
|
|
88
|
+
OWNED_FILES: Mapping[str, str]
|
|
89
|
+
COMPILER: str
|
|
90
|
+
MODEL_LABELS: tuple[str, ...] = ()
|
|
91
|
+
IN_PROGRESS: bool = False
|
|
92
|
+
|
|
93
|
+
def source(self) -> str:
|
|
94
|
+
return (
|
|
95
|
+
GENERATED_SENTINEL
|
|
96
|
+
+ "\n"
|
|
97
|
+
+ "".join(
|
|
98
|
+
f"{name} = {value!r}\n"
|
|
99
|
+
for name, value in (
|
|
100
|
+
("HISTORY_LABELS", self.HISTORY_LABELS),
|
|
101
|
+
("RETIRED_LABELS", self.RETIRED_LABELS),
|
|
102
|
+
("DIGEST", self.DIGEST),
|
|
103
|
+
("OWNED_FILES", dict(sorted(self.OWNED_FILES.items()))),
|
|
104
|
+
("COMPILER", self.COMPILER),
|
|
105
|
+
("MODEL_LABELS", self.MODEL_LABELS),
|
|
106
|
+
("IN_PROGRESS", self.IN_PROGRESS),
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def read_receipt(output_module: Path | str) -> Receipt | None:
|
|
113
|
+
"""Read a dotted output package without executing it or any dotted parent.
|
|
114
|
+
|
|
115
|
+
Native path-based publication uses the same literal parser via Path inputs.
|
|
116
|
+
"""
|
|
117
|
+
root = output_directory(output_module) if isinstance(output_module, str) else output_module
|
|
118
|
+
_validate_output_root(root)
|
|
119
|
+
path = root / "__init__.py"
|
|
120
|
+
if not path.exists():
|
|
121
|
+
return None
|
|
122
|
+
if root.is_symlink() or path.is_symlink() or not path.is_file():
|
|
123
|
+
raise ImproperlyConfigured("Generated receipt must be a regular file, not a symlink")
|
|
124
|
+
try:
|
|
125
|
+
source = path.read_text()
|
|
126
|
+
if not source:
|
|
127
|
+
return None
|
|
128
|
+
if GENERATED_SENTINEL not in source:
|
|
129
|
+
raise ValueError("missing generated-output marker")
|
|
130
|
+
values = {}
|
|
131
|
+
for node in ast.parse(source).body:
|
|
132
|
+
if (
|
|
133
|
+
not isinstance(node, ast.Assign)
|
|
134
|
+
or len(node.targets) != 1
|
|
135
|
+
or not isinstance(node.targets[0], ast.Name)
|
|
136
|
+
):
|
|
137
|
+
raise ValueError("receipt must contain only literal assignments")
|
|
138
|
+
name = node.targets[0].id
|
|
139
|
+
if name in values:
|
|
140
|
+
raise ValueError(f"duplicate receipt constant {name}")
|
|
141
|
+
values[name] = ast.literal_eval(node.value)
|
|
142
|
+
# Accept the former receipt format solely to preserve native history on rebuild.
|
|
143
|
+
values.pop("GENERATION", None)
|
|
144
|
+
receipt = Receipt(**values)
|
|
145
|
+
if not isinstance(receipt.IN_PROGRESS, bool) or not all(
|
|
146
|
+
isinstance(value, str) for value in (receipt.DIGEST, receipt.COMPILER)
|
|
147
|
+
):
|
|
148
|
+
raise ValueError("invalid receipt metadata")
|
|
149
|
+
if not all(
|
|
150
|
+
isinstance(labels, tuple) and len(labels) == len(set(labels))
|
|
151
|
+
for labels in (receipt.HISTORY_LABELS, receipt.RETIRED_LABELS, receipt.MODEL_LABELS)
|
|
152
|
+
):
|
|
153
|
+
raise ValueError("invalid receipt labels")
|
|
154
|
+
if not all(
|
|
155
|
+
isinstance(label, str) and label.isidentifier()
|
|
156
|
+
for label in (*receipt.HISTORY_LABELS, *receipt.RETIRED_LABELS, *receipt.MODEL_LABELS)
|
|
157
|
+
):
|
|
158
|
+
raise ValueError("invalid receipt label")
|
|
159
|
+
if not isinstance(receipt.OWNED_FILES, dict):
|
|
160
|
+
raise ValueError("invalid owned-file index")
|
|
161
|
+
for relative, digest in receipt.OWNED_FILES.items():
|
|
162
|
+
if not isinstance(relative, str):
|
|
163
|
+
raise ValueError("invalid owned file name")
|
|
164
|
+
candidate = Path(relative)
|
|
165
|
+
if (
|
|
166
|
+
candidate.is_absolute()
|
|
167
|
+
or ".." in candidate.parts
|
|
168
|
+
or not candidate.parts
|
|
169
|
+
or candidate == Path("__init__.py")
|
|
170
|
+
or not isinstance(digest, str)
|
|
171
|
+
or len(digest) != 64
|
|
172
|
+
or any(char not in "0123456789abcdef" for char in digest)
|
|
173
|
+
):
|
|
174
|
+
raise ValueError(f"invalid owned file {relative!r}")
|
|
175
|
+
return Receipt(
|
|
176
|
+
tuple(receipt.HISTORY_LABELS),
|
|
177
|
+
tuple(receipt.RETIRED_LABELS),
|
|
178
|
+
receipt.DIGEST,
|
|
179
|
+
MappingProxyType(receipt.OWNED_FILES),
|
|
180
|
+
receipt.COMPILER,
|
|
181
|
+
tuple(receipt.MODEL_LABELS),
|
|
182
|
+
receipt.IN_PROGRESS,
|
|
183
|
+
)
|
|
184
|
+
except (OSError, SyntaxError, ValueError, TypeError) as error:
|
|
185
|
+
raise ImproperlyConfigured(f"Invalid generated receipt: {error}; run compose") from error
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def output_directory(output_module: str) -> Path:
|
|
189
|
+
source = SourceResolver().resolve(output_module)
|
|
190
|
+
if len(source.search_locations) != 1:
|
|
191
|
+
raise ImproperlyConfigured(f"Missing generated package {output_module!r}; run compose")
|
|
192
|
+
root = source.search_locations[0]
|
|
193
|
+
_validate_output_root(root)
|
|
194
|
+
return root
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _verify(snap: Snapshot, root: Path) -> Receipt:
|
|
198
|
+
snap.check_mode()
|
|
199
|
+
receipt = read_receipt(root)
|
|
200
|
+
if (
|
|
201
|
+
receipt is None
|
|
202
|
+
or receipt.IN_PROGRESS
|
|
203
|
+
or receipt.DIGEST != snap.digest()
|
|
204
|
+
or receipt.COMPILER != snap.compiler
|
|
205
|
+
):
|
|
206
|
+
raise ImproperlyConfigured("Generated composition is absent, interrupted or stale; run compose")
|
|
207
|
+
for relative, digest in receipt.OWNED_FILES.items():
|
|
208
|
+
_owned_path(root, Path(relative), digest)
|
|
209
|
+
return receipt
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass
|
|
213
|
+
class _StartupVerification:
|
|
214
|
+
root: Path
|
|
215
|
+
source: bytes
|
|
216
|
+
lock: AbstractContextManager[None]
|
|
217
|
+
receipt: Receipt
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
_startup_verification: _StartupVerification | None = None
|
|
221
|
+
_verified: _StartupVerification | None = None
|
|
222
|
+
_pending_artifacts: tuple[Path, Receipt] | None = None
|
|
223
|
+
_lifetime_lock: AbstractContextManager[None] | None = None
|
|
224
|
+
_build_cleanup: Callable[[], None] | None = None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _register_build(cleanup: Callable[[], None]) -> None:
|
|
228
|
+
"""Register the one active build's cleanup at the process lifecycle owner."""
|
|
229
|
+
global _build_cleanup
|
|
230
|
+
if _build_cleanup is not None and _build_cleanup != cleanup:
|
|
231
|
+
raise ImproperlyConfigured("A composition build is already active")
|
|
232
|
+
_build_cleanup = cleanup
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _publish_build_artifacts(cleanup: Callable[[], None], root: Path, receipt: Receipt) -> None:
|
|
236
|
+
"""Expose the active build's published models artifacts to ready callbacks."""
|
|
237
|
+
global _pending_artifacts
|
|
238
|
+
if _build_cleanup != cleanup:
|
|
239
|
+
raise ImproperlyConfigured("Cannot publish artifacts for an inactive build")
|
|
240
|
+
_pending_artifacts = (root, receipt)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _clear_build(cleanup: Callable[[], None]) -> None:
|
|
244
|
+
"""Clear process build state only when the closing build still owns it."""
|
|
245
|
+
global _build_cleanup, _pending_artifacts
|
|
246
|
+
if _build_cleanup == cleanup:
|
|
247
|
+
_pending_artifacts = None
|
|
248
|
+
_build_cleanup = None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _close_build() -> None:
|
|
252
|
+
"""Release the current failed native build at the launcher boundary."""
|
|
253
|
+
if _build_cleanup is not None:
|
|
254
|
+
_build_cleanup()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _close_startup_verification() -> None:
|
|
258
|
+
global _startup_verification
|
|
259
|
+
session, _startup_verification = _startup_verification, None
|
|
260
|
+
if session is not None:
|
|
261
|
+
session.lock.__exit__(None, None, None)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _finish_startup_verification(*, required: bool = False) -> None:
|
|
265
|
+
global _lifetime_lock, _startup_verification
|
|
266
|
+
session = _startup_verification
|
|
267
|
+
if required and session is None:
|
|
268
|
+
raise ImproperlyConfigured("Startup must verify composition before population; use configure()")
|
|
269
|
+
try:
|
|
270
|
+
if session is not None and (session.root / "__init__.py").read_bytes() != session.source:
|
|
271
|
+
raise ImproperlyConfigured("Generated composition changed during population; run compose")
|
|
272
|
+
if session is not None and os.environ.get("RUN_MAIN") == "true":
|
|
273
|
+
_lifetime_lock = session.lock
|
|
274
|
+
_startup_verification = None
|
|
275
|
+
finally:
|
|
276
|
+
_close_startup_verification()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def verify(snap: Snapshot, output_module: str, *, retain_startup: bool = False) -> Receipt:
|
|
280
|
+
"""Verify under a shared lock, optionally retaining it through final ready.
|
|
281
|
+
|
|
282
|
+
Retained verification belongs to the single native Django startup. A failed
|
|
283
|
+
bare setup is unrecoverable; its process must exit to release resources.
|
|
284
|
+
"""
|
|
285
|
+
global _startup_verification, _verified
|
|
286
|
+
if retain_startup and mode() == "write":
|
|
287
|
+
raise ImproperlyConfigured("Write composition cannot retain a startup reader lock")
|
|
288
|
+
if retain_startup and _startup_verification is not None:
|
|
289
|
+
raise ImproperlyConfigured("Composition startup verification is already active")
|
|
290
|
+
root = output_directory(output_module)
|
|
291
|
+
lock = receipt_lock(root, write=False, required=True)
|
|
292
|
+
lock.__enter__()
|
|
293
|
+
try:
|
|
294
|
+
receipt = _verify(snap, root)
|
|
295
|
+
if retain_startup:
|
|
296
|
+
_startup_verification = _StartupVerification(
|
|
297
|
+
root, (root / "__init__.py").read_bytes(), lock, receipt
|
|
298
|
+
)
|
|
299
|
+
_verified = _startup_verification
|
|
300
|
+
return receipt
|
|
301
|
+
except BaseException:
|
|
302
|
+
lock.__exit__(None, None, None)
|
|
303
|
+
raise
|
|
304
|
+
lock.__exit__(None, None, None)
|
|
305
|
+
return receipt
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def installed_apps(entries: Iterable[str | Entry], receipt: Receipt, output_module: str) -> tuple[str, ...]:
|
|
309
|
+
selected = app_entries(entries)
|
|
310
|
+
return tuple(
|
|
311
|
+
f"{output_module}.{e.label}.apps.GeneratedConfig" if e.label in receipt.MODEL_LABELS else e.entry
|
|
312
|
+
for e in selected
|
|
313
|
+
) + tombstones(receipt, selected, output_module)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def tombstones(
|
|
317
|
+
receipt: Receipt | None, entries: Iterable[str | Entry], output_module: str
|
|
318
|
+
) -> tuple[str, ...]:
|
|
319
|
+
active = {entry.label for entry in app_entries(entries)}
|
|
320
|
+
return (
|
|
321
|
+
tuple(
|
|
322
|
+
f"django_compose.history.History_{label}"
|
|
323
|
+
for label in receipt.HISTORY_LABELS
|
|
324
|
+
if label not in active and label not in receipt.RETIRED_LABELS
|
|
325
|
+
)
|
|
326
|
+
if receipt
|
|
327
|
+
else ()
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def migration_modules(
|
|
332
|
+
authored: Mapping[str, str | None], receipt: Receipt | None, output_module: str
|
|
333
|
+
) -> dict[str, str | None]:
|
|
334
|
+
result: dict[str, str | None] = (
|
|
335
|
+
{
|
|
336
|
+
label: f"{output_module}.{label}.migrations"
|
|
337
|
+
for label in receipt.HISTORY_LABELS
|
|
338
|
+
if label not in receipt.RETIRED_LABELS
|
|
339
|
+
}
|
|
340
|
+
if receipt
|
|
341
|
+
else {}
|
|
342
|
+
)
|
|
343
|
+
result.update(authored)
|
|
344
|
+
return result
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def verified_receipt() -> Receipt:
|
|
348
|
+
"""Return the receipt verified for this process's native startup."""
|
|
349
|
+
if _verified is None:
|
|
350
|
+
raise ImproperlyConfigured("No startup-verified composition")
|
|
351
|
+
return _verified.receipt
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@contextmanager
|
|
355
|
+
def receipt_lease() -> Iterator[Receipt]:
|
|
356
|
+
"""Exclude publication while acting on the exact startup composition."""
|
|
357
|
+
session = _verified
|
|
358
|
+
if session is None:
|
|
359
|
+
raise ImproperlyConfigured("No startup-verified composition")
|
|
360
|
+
with receipt_lock(session.root, write=False, required=True):
|
|
361
|
+
if (session.root / "__init__.py").read_bytes() != session.source:
|
|
362
|
+
raise ImproperlyConfigured("Generated composition changed since startup; restart this process")
|
|
363
|
+
yield session.receipt
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def artifact_path(relative: Path | str) -> Path:
|
|
367
|
+
"""Resolve a declared, receipt-owned artifact and verify its current bytes.
|
|
368
|
+
|
|
369
|
+
During build-ready callbacks models-phase artifacts use the pending receipt.
|
|
370
|
+
Serving callers performing longer operations should also hold receipt_lease().
|
|
371
|
+
"""
|
|
372
|
+
from django.conf import settings
|
|
373
|
+
|
|
374
|
+
path = Path(relative)
|
|
375
|
+
roots = set(getattr(settings, "COMPOSE_EXTERNAL_ARTIFACT_ROOTS", ()))
|
|
376
|
+
if (
|
|
377
|
+
path.is_absolute()
|
|
378
|
+
or len(path.parts) < 2
|
|
379
|
+
or ".." in path.parts
|
|
380
|
+
or "migrations" in path.parts
|
|
381
|
+
or path.parts[0] not in roots
|
|
382
|
+
):
|
|
383
|
+
raise ImproperlyConfigured(f"Undeclared artifact: {relative}")
|
|
384
|
+
if _pending_artifacts is not None:
|
|
385
|
+
root, receipt = _pending_artifacts
|
|
386
|
+
elif _verified is not None:
|
|
387
|
+
root, receipt = _verified.root, _verified.receipt
|
|
388
|
+
else:
|
|
389
|
+
raise ImproperlyConfigured("No startup-verified composition")
|
|
390
|
+
digest = receipt.OWNED_FILES.get(path.as_posix())
|
|
391
|
+
return _owned_path(root, path, digest)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _owned_path(root: Path, relative: Path, digest: str | None) -> Path:
|
|
395
|
+
"""The single current-byte and safe-path check for receipt-owned files."""
|
|
396
|
+
_validate_output_root(root)
|
|
397
|
+
target = root / relative
|
|
398
|
+
if (
|
|
399
|
+
digest is None
|
|
400
|
+
or any((root / part).is_symlink() for part in (*relative.parents, relative))
|
|
401
|
+
or not target.is_file()
|
|
402
|
+
or hashlib.sha256(target.read_bytes()).hexdigest() != digest
|
|
403
|
+
):
|
|
404
|
+
raise ImproperlyConfigured(f"Generated file is missing or altered: {relative}; run compose")
|
|
405
|
+
return target
|