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
django_compose/conf.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Compose a project's settings before Django populates its app registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Mapping, MutableMapping
|
|
8
|
+
from importlib import import_module
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import MappingProxyType, ModuleType
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import django_yamlconf
|
|
14
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
15
|
+
from django.utils.module_loading import module_has_submodule
|
|
16
|
+
|
|
17
|
+
from django_compose import serving
|
|
18
|
+
from django_compose.addons import Closure, enabled_apps
|
|
19
|
+
from django_compose.autoreload import report_inputs, report_watch_paths
|
|
20
|
+
from django_compose.snapshot import Entry, mode, snapshot, structural_settings
|
|
21
|
+
|
|
22
|
+
_DERIVED = frozenset({"COMPOSE_ROOTS", "COMPOSE_SNAPSHOT", "COMPOSE_CLOSURE"})
|
|
23
|
+
_PROTECTED = _DERIVED | {"INSTALLED_APPS", "COMPOSE_OUTPUT_DIR", "COMPOSE_OUTPUT_MODULE"}
|
|
24
|
+
_ATTRIBUTES = "_YAMLCONF_ATTRIBUTES"
|
|
25
|
+
_ENVIRONMENT = "**ENVIRONMENT**"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def setting_name(key: str) -> str:
|
|
29
|
+
"""Project a native yamlconf key by removing colon, then dotted-key suffixes.
|
|
30
|
+
|
|
31
|
+
This literal root projection does not validate or normalize the setting name.
|
|
32
|
+
"""
|
|
33
|
+
return key.split(":", 1)[0].split(".", 1)[0]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _copy_settings(value: Any) -> Any:
|
|
37
|
+
"""Detach settings containers while retaining opaque native objects."""
|
|
38
|
+
if isinstance(value, Mapping):
|
|
39
|
+
return {key: _copy_settings(item) for key, item in value.items()}
|
|
40
|
+
if isinstance(value, list):
|
|
41
|
+
return [_copy_settings(item) for item in value]
|
|
42
|
+
if isinstance(value, tuple):
|
|
43
|
+
return tuple(_copy_settings(item) for item in value)
|
|
44
|
+
if isinstance(value, set):
|
|
45
|
+
return {_copy_settings(item) for item in value}
|
|
46
|
+
if isinstance(value, frozenset):
|
|
47
|
+
return frozenset(_copy_settings(item) for item in value)
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def expand_attribute_refs(attributes: MutableMapping[str, Any]) -> None:
|
|
52
|
+
"""Expand native yamlconf attributes without destroying reference provenance.
|
|
53
|
+
|
|
54
|
+
Native loaders call this instead of django_yamlconf.expand_attribute_refs,
|
|
55
|
+
then call django_yamlconf.inject_attr and pass that settings namespace to
|
|
56
|
+
configure once. Native grammar and expansion remain django-yamlconf-owned.
|
|
57
|
+
Only evalue fields are updated; value and history containers remain intact.
|
|
58
|
+
|
|
59
|
+
Ordinary upstream expansion mutates collection templates in place. Once lost
|
|
60
|
+
from both value and history, those references cannot be recovered from an
|
|
61
|
+
already-expanded cache. Preserve them here before expansion so configure can
|
|
62
|
+
validate structural inputs against direct and transitive environment sources.
|
|
63
|
+
Upstream logged errors retain their native behavior; a strict loader installs
|
|
64
|
+
its own error handler, as contribution application does.
|
|
65
|
+
"""
|
|
66
|
+
expanded = _copy_settings(attributes)
|
|
67
|
+
django_yamlconf.expand_attribute_refs(expanded)
|
|
68
|
+
for key, info in attributes.items():
|
|
69
|
+
info["evalue"] = expanded[key]["evalue"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _Errors(logging.Handler):
|
|
73
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
74
|
+
raise ImproperlyConfigured(record.getMessage())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _Reference:
|
|
78
|
+
"""Observe native format field use without parsing its grammar."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, value: Any, name: str, used: set[str]) -> None:
|
|
81
|
+
self._value, self._name, self._used = value, name, used
|
|
82
|
+
|
|
83
|
+
def __format__(self, spec: str) -> str:
|
|
84
|
+
self._used.add(self._name)
|
|
85
|
+
return format(self._value, spec)
|
|
86
|
+
|
|
87
|
+
def __str__(self) -> str:
|
|
88
|
+
self._used.add(self._name)
|
|
89
|
+
return str(self._value)
|
|
90
|
+
|
|
91
|
+
def __repr__(self) -> str:
|
|
92
|
+
self._used.add(self._name)
|
|
93
|
+
return repr(self._value)
|
|
94
|
+
|
|
95
|
+
def __getitem__(self, key: Any) -> _Reference:
|
|
96
|
+
self._used.add(self._name)
|
|
97
|
+
return _Reference(self._value[key], f"{self._name}.{key}", self._used)
|
|
98
|
+
|
|
99
|
+
def __getattr__(self, name: str) -> _Reference:
|
|
100
|
+
self._used.add(self._name)
|
|
101
|
+
return _Reference(getattr(self._value, name), f"{self._name}.{name}", self._used)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _Contributions:
|
|
105
|
+
def __init__(self, namespace: MutableMapping[str, Any]) -> None:
|
|
106
|
+
self._namespace = namespace
|
|
107
|
+
self.authored = frozenset(namespace)
|
|
108
|
+
self.scalars: dict[str, str] = {}
|
|
109
|
+
self.previous: frozenset[str] = frozenset()
|
|
110
|
+
self.module = ModuleType(str(namespace.get("__name__", "project.settings")))
|
|
111
|
+
self.module.__dict__.update(namespace)
|
|
112
|
+
attributes = self.module.__dict__.setdefault(_ATTRIBUTES, {})
|
|
113
|
+
self.literal_inputs: set[str] = set()
|
|
114
|
+
for name, value in namespace.items():
|
|
115
|
+
if not name.startswith("_") and name.isupper() and name not in attributes:
|
|
116
|
+
django_yamlconf.add_attr_info(attributes, name, value, self.module.__name__)
|
|
117
|
+
# Project values are reference inputs, not new expansion templates.
|
|
118
|
+
marker = f"{name}:raw"
|
|
119
|
+
if marker not in attributes:
|
|
120
|
+
django_yamlconf.add_attr_info(attributes, marker, True, self.module.__name__)
|
|
121
|
+
self.literal_inputs.add(name)
|
|
122
|
+
|
|
123
|
+
def merge(self, values: Mapping[str, Any], owner: str, *, derived: bool = False) -> set[str]:
|
|
124
|
+
accepted = {}
|
|
125
|
+
declared = set()
|
|
126
|
+
for key, value in values.items():
|
|
127
|
+
if (
|
|
128
|
+
not isinstance(key, str)
|
|
129
|
+
or setting_name(key).startswith("_")
|
|
130
|
+
or not setting_name(key).isidentifier()
|
|
131
|
+
or not setting_name(key).isupper()
|
|
132
|
+
):
|
|
133
|
+
raise ImproperlyConfigured(f"{owner} contributed invalid setting name {key!r}")
|
|
134
|
+
name = setting_name(key)
|
|
135
|
+
if name in _PROTECTED or name.startswith("COMPOSE_OUTPUT_"):
|
|
136
|
+
raise ImproperlyConfigured(f"{owner} must not contribute {name}")
|
|
137
|
+
declared.add(name)
|
|
138
|
+
plain = key == name
|
|
139
|
+
if plain and not isinstance(value, (Mapping, list, tuple, set, frozenset)):
|
|
140
|
+
previous = self.scalars.setdefault(name, owner)
|
|
141
|
+
if previous != owner:
|
|
142
|
+
raise ImproperlyConfigured(
|
|
143
|
+
f"{name} has competing scalar defaults from {previous} and {owner}"
|
|
144
|
+
)
|
|
145
|
+
if plain and (
|
|
146
|
+
name in self.authored or (name in self._namespace and (not derived or name in self.previous))
|
|
147
|
+
):
|
|
148
|
+
continue
|
|
149
|
+
if "." in key.split(":", 1)[0]:
|
|
150
|
+
self.module.__dict__.setdefault(name, {})
|
|
151
|
+
accepted[key] = value
|
|
152
|
+
self.apply(accepted, owner)
|
|
153
|
+
return declared
|
|
154
|
+
|
|
155
|
+
def apply(self, values: Mapping[str, Any], owner: str) -> None:
|
|
156
|
+
if not values:
|
|
157
|
+
return
|
|
158
|
+
handler = _Errors(logging.ERROR)
|
|
159
|
+
logger = logging.getLogger("django_yamlconf")
|
|
160
|
+
logger.addHandler(handler)
|
|
161
|
+
try:
|
|
162
|
+
attributes = vars(self.module)[_ATTRIBUTES]
|
|
163
|
+
for key, value in values.items():
|
|
164
|
+
name = setting_name(key)
|
|
165
|
+
if name in self.literal_inputs:
|
|
166
|
+
del attributes[f"{name}:raw"]
|
|
167
|
+
self.literal_inputs.remove(name)
|
|
168
|
+
django_yamlconf.set_attr_value(attributes, self.module, owner, key, _copy_settings(value))
|
|
169
|
+
expand_attribute_refs(attributes)
|
|
170
|
+
django_yamlconf.inject_attr(attributes, self.module)
|
|
171
|
+
finally:
|
|
172
|
+
logger.removeHandler(handler)
|
|
173
|
+
self._namespace.update(
|
|
174
|
+
{
|
|
175
|
+
name: value
|
|
176
|
+
for name, value in vars(self.module).items()
|
|
177
|
+
if name == _ATTRIBUTES or (not name.startswith("_") and name.isupper())
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def environment(self, names: set[str]) -> None:
|
|
182
|
+
prefixes = self._namespace.get("COMPOSE_ENV_PREFIXES", ())
|
|
183
|
+
if not isinstance(prefixes, (list, tuple)) or not all(isinstance(p, str) for p in prefixes):
|
|
184
|
+
raise ImproperlyConfigured("COMPOSE_ENV_PREFIXES must be a list or tuple of strings")
|
|
185
|
+
values = {
|
|
186
|
+
name: os.environ[name]
|
|
187
|
+
for name in sorted(names)
|
|
188
|
+
if name.startswith(tuple(prefixes)) and name in os.environ
|
|
189
|
+
}
|
|
190
|
+
for name in values:
|
|
191
|
+
if name in structural_settings(self._namespace) or name in _PROTECTED:
|
|
192
|
+
raise ImproperlyConfigured(f"Structural setting {name} must not come from the environment")
|
|
193
|
+
self.apply(values, _ENVIRONMENT)
|
|
194
|
+
|
|
195
|
+
def check_environment(self) -> None:
|
|
196
|
+
attributes = vars(self.module)[_ATTRIBUTES]
|
|
197
|
+
structural = structural_settings(self._namespace) | _DERIVED
|
|
198
|
+
|
|
199
|
+
def inspect(key: str, visited: set[str]) -> str | None:
|
|
200
|
+
if key in visited or key not in attributes:
|
|
201
|
+
return None
|
|
202
|
+
visited.add(key)
|
|
203
|
+
info = attributes[key]
|
|
204
|
+
if _ENVIRONMENT in (info["source"], *(source for _, source in info["history"])):
|
|
205
|
+
return key
|
|
206
|
+
if attributes.get(f"{key}:raw", {}).get("value"):
|
|
207
|
+
return None
|
|
208
|
+
used: set[str] = set()
|
|
209
|
+
wrt = {name: _Reference(data["evalue"], name, used) for name, data in attributes.items()}
|
|
210
|
+
for value in (info["value"], *(value for value, _ in info["history"])):
|
|
211
|
+
django_yamlconf.expand_attr_helper(_copy_settings(value), wrt=wrt)
|
|
212
|
+
for reference in sorted(used):
|
|
213
|
+
source = inspect(reference, visited)
|
|
214
|
+
if source is not None:
|
|
215
|
+
return source
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
for key in attributes:
|
|
219
|
+
if setting_name(key) in structural:
|
|
220
|
+
source = inspect(key, set())
|
|
221
|
+
if source is not None:
|
|
222
|
+
raise ImproperlyConfigured(
|
|
223
|
+
f"Structural setting {setting_name(key)} must not come from "
|
|
224
|
+
f"the environment ({source})"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def app(self, entry: Entry) -> None:
|
|
228
|
+
if not module_has_submodule(import_module(entry.name), "autoconfig"):
|
|
229
|
+
return
|
|
230
|
+
module = import_module(f"{entry.name}.autoconfig")
|
|
231
|
+
owner = module.__name__
|
|
232
|
+
values = getattr(module, "SETTINGS", {})
|
|
233
|
+
if not isinstance(values, Mapping):
|
|
234
|
+
raise ImproperlyConfigured(f"{owner}.SETTINGS must be a mapping")
|
|
235
|
+
self.previous = frozenset(self._namespace)
|
|
236
|
+
names = self.merge(values, owner)
|
|
237
|
+
self.environment(names)
|
|
238
|
+
callback = getattr(module, "settings", None)
|
|
239
|
+
if callback is not None:
|
|
240
|
+
if not callable(callback):
|
|
241
|
+
raise ImproperlyConfigured(f"{owner}.settings must be callable")
|
|
242
|
+
effective = {
|
|
243
|
+
name: value
|
|
244
|
+
for name, value in self._namespace.items()
|
|
245
|
+
if not name.startswith("_") and name.isupper()
|
|
246
|
+
}
|
|
247
|
+
result = callback(MappingProxyType(_copy_settings(effective)))
|
|
248
|
+
if not isinstance(result, Mapping):
|
|
249
|
+
raise ImproperlyConfigured(f"{owner}.settings() must return a mapping")
|
|
250
|
+
self.merge(result, owner, derived=True)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def apply_app_settings(namespace: MutableMapping[str, Any], closure: Closure) -> None:
|
|
254
|
+
"""Apply a resolved app selection's native settings contributions once.
|
|
255
|
+
|
|
256
|
+
Pass authored settings and a Closure selected by addons.enabled_apps.
|
|
257
|
+
The same closure is exposed as COMPOSE_CLOSURE before callbacks; its app
|
|
258
|
+
order, selected native configs, yamlconf merging, authored precedence and
|
|
259
|
+
environment rules are retained. This phase requires no composition output
|
|
260
|
+
or mode, creates no snapshot or receipt, and leaves INSTALLED_APPS intact.
|
|
261
|
+
"""
|
|
262
|
+
namespace["COMPOSE_CLOSURE"] = closure
|
|
263
|
+
contributions = _Contributions(namespace)
|
|
264
|
+
for entry in closure.app_entries:
|
|
265
|
+
contributions.app(entry)
|
|
266
|
+
contributions.check_environment()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def configure(namespace: MutableMapping[str, Any]) -> None:
|
|
270
|
+
"""Resolve apps, apply contributions, freeze inputs and project startup settings.
|
|
271
|
+
|
|
272
|
+
Call once at the end of the project's settings module, after deployment and
|
|
273
|
+
compiler inputs have been authored. Derived composition settings are reserved.
|
|
274
|
+
|
|
275
|
+
App autoconfig modules declare SETTINGS mappings and optional
|
|
276
|
+
settings(namespace) mapping callbacks. Plain defaults preserve project values;
|
|
277
|
+
competing scalar defaults fail. Native yamlconf directives merge explicitly.
|
|
278
|
+
Declared COMPOSE_ENV_PREFIXES overlay static declarations before callbacks,
|
|
279
|
+
which see effective settings without an ambient environment fallback. Callback
|
|
280
|
+
results can refine their app's defaults but cannot replace authored values.
|
|
281
|
+
Structural compiler inputs may never originate in the environment.
|
|
282
|
+
"""
|
|
283
|
+
selected_mode = mode()
|
|
284
|
+
for name in _DERIVED:
|
|
285
|
+
if name in namespace:
|
|
286
|
+
raise ImproperlyConfigured(f"{name} is derived and must not be authored")
|
|
287
|
+
if not namespace.get("COMPOSE_OUTPUT_DIR"):
|
|
288
|
+
raise ImproperlyConfigured("COMPOSE_OUTPUT_DIR is required")
|
|
289
|
+
report_inputs(files=(Path(namespace["COMPOSE_OUTPUT_DIR"]) / "__init__.py",))
|
|
290
|
+
report_watch_paths(namespace.get("COMPOSE_WATCH_PATHS", ()))
|
|
291
|
+
roots = tuple(namespace.get("INSTALLED_APPS", ()))
|
|
292
|
+
closure = enabled_apps(roots, group=namespace.get("COMPOSE_ADDON_GROUP"))
|
|
293
|
+
apply_app_settings(namespace, closure)
|
|
294
|
+
report_watch_paths(namespace.get("COMPOSE_WATCH_PATHS", ()))
|
|
295
|
+
namespace["COMPOSE_ROOTS"] = roots
|
|
296
|
+
snap = snapshot(closure.app_entries, namespace=namespace, dependencies=closure.dependencies)
|
|
297
|
+
namespace["COMPOSE_SNAPSHOT"] = snap
|
|
298
|
+
output_module = snap.settings["COMPOSE_OUTPUT_MODULE"]
|
|
299
|
+
namespace["COMPOSE_OUTPUT_MODULE"] = output_module
|
|
300
|
+
try:
|
|
301
|
+
receipt = (
|
|
302
|
+
serving.read_receipt(Path(namespace["COMPOSE_OUTPUT_DIR"]))
|
|
303
|
+
if selected_mode == "write"
|
|
304
|
+
else serving.verify(snap, output_module, retain_startup=True)
|
|
305
|
+
)
|
|
306
|
+
if selected_mode is not None:
|
|
307
|
+
installed = closure.entries + serving.tombstones(receipt, closure.app_entries, output_module)
|
|
308
|
+
else:
|
|
309
|
+
assert receipt is not None
|
|
310
|
+
installed = serving.installed_apps(closure.app_entries, receipt, output_module)
|
|
311
|
+
namespace["MIGRATION_MODULES"] = serving.migration_modules(
|
|
312
|
+
namespace.get("MIGRATION_MODULES", {}), receipt, output_module
|
|
313
|
+
)
|
|
314
|
+
namespace["INSTALLED_APPS"] = (
|
|
315
|
+
*installed,
|
|
316
|
+
"django_compose.apps.BuildComposeConfig"
|
|
317
|
+
if selected_mode
|
|
318
|
+
else "django_compose.apps.ComposeConfig",
|
|
319
|
+
)
|
|
320
|
+
except BaseException:
|
|
321
|
+
serving._close_startup_verification()
|
|
322
|
+
raise
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Phase-2 structural integration protocols and literal model declarations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import keyword
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Literal, Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
from django.apps.registry import Apps
|
|
12
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
13
|
+
from django.db.models.options import DEFAULT_NAMES
|
|
14
|
+
from django.utils.module_loading import import_string
|
|
15
|
+
|
|
16
|
+
from django_compose.models import Composable
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from django.db import models
|
|
20
|
+
|
|
21
|
+
from django_compose.model_composition import ModelComposition
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
type DeclarationValue = (
|
|
25
|
+
None
|
|
26
|
+
| bool
|
|
27
|
+
| int
|
|
28
|
+
| str
|
|
29
|
+
| list[DeclarationValue]
|
|
30
|
+
| tuple[DeclarationValue, ...]
|
|
31
|
+
| dict[str, DeclarationValue]
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class ModelDeclaration:
|
|
37
|
+
"""Literal feature attributes and custom Meta names projected onto concrete models."""
|
|
38
|
+
|
|
39
|
+
attributes: Mapping[str, DeclarationValue] = field(default_factory=dict)
|
|
40
|
+
meta_options: Mapping[str, DeclarationValue] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@runtime_checkable
|
|
44
|
+
class ModelDeclarationHook(Protocol):
|
|
45
|
+
"""Project capability-owned literal attributes and custom Meta options."""
|
|
46
|
+
|
|
47
|
+
def model_declaration(
|
|
48
|
+
self, composition: ModelComposition, source: type[models.Model]
|
|
49
|
+
) -> ModelDeclaration: ...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@runtime_checkable
|
|
53
|
+
class MigrationPolicy(Protocol):
|
|
54
|
+
"""Supply capability-owned exemptions to destructive-change checks."""
|
|
55
|
+
|
|
56
|
+
def retired_primary_keys(self, model: type[models.Model], alias: str) -> Iterable[Any]: ...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ModelsView(Protocol):
|
|
60
|
+
"""Restricted phase-2 registry access, before Django declares models ready."""
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def composition(self) -> ModelComposition: ...
|
|
64
|
+
|
|
65
|
+
def get_model(self, label: str, name: str) -> type[models.Model]: ...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@runtime_checkable
|
|
69
|
+
class ArtifactHook(Protocol):
|
|
70
|
+
"""Render capability files beneath one explicitly declared output subtree."""
|
|
71
|
+
|
|
72
|
+
external_root: str
|
|
73
|
+
phase: Literal["models", "ready"]
|
|
74
|
+
|
|
75
|
+
def render(self, composition: ModelComposition, models: ModelsView | Apps) -> Mapping[Path, bytes]: ...
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def resolve_integrations(paths: Iterable[str]) -> tuple[object, ...]:
|
|
79
|
+
"""Instantiate explicit dotted classes once, preserving authored order.
|
|
80
|
+
|
|
81
|
+
Called only in phase 2; integration modules may freely import source models.
|
|
82
|
+
A class implements one or more independent protocols without inheritance.
|
|
83
|
+
"""
|
|
84
|
+
if isinstance(paths, str) or not isinstance(paths, (list, tuple)):
|
|
85
|
+
raise ImproperlyConfigured("COMPOSE_INTEGRATIONS must be a list or tuple of dotted paths")
|
|
86
|
+
resolved: list[object] = []
|
|
87
|
+
classes: set[type] = set()
|
|
88
|
+
for path in paths:
|
|
89
|
+
if not isinstance(path, str):
|
|
90
|
+
raise ImproperlyConfigured("COMPOSE_INTEGRATIONS requires dotted class paths")
|
|
91
|
+
candidate = import_string(path)
|
|
92
|
+
if not isinstance(candidate, type):
|
|
93
|
+
raise ImproperlyConfigured(f"{path} must name an integration class")
|
|
94
|
+
if candidate in classes:
|
|
95
|
+
raise ImproperlyConfigured(f"Duplicate integration {path!r}")
|
|
96
|
+
classes.add(candidate)
|
|
97
|
+
instance = candidate()
|
|
98
|
+
if not isinstance(instance, ModelDeclarationHook | MigrationPolicy | ArtifactHook):
|
|
99
|
+
raise ImproperlyConfigured(f"{path} implements no composition integration protocol")
|
|
100
|
+
if isinstance(instance, ArtifactHook) and instance.phase not in ("models", "ready"):
|
|
101
|
+
raise ImproperlyConfigured(f"{path} has invalid artifact phase {instance.phase!r}")
|
|
102
|
+
resolved.append(instance)
|
|
103
|
+
return tuple(resolved)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def render_literal(value: DeclarationValue) -> str:
|
|
107
|
+
"""Render a closed, deterministic Python literal grammar."""
|
|
108
|
+
if value is None or type(value) in (bool, int, str):
|
|
109
|
+
return repr(value)
|
|
110
|
+
if isinstance(value, (list, tuple)) and type(value) in (list, tuple):
|
|
111
|
+
body = ", ".join(render_literal(item) for item in value)
|
|
112
|
+
return (
|
|
113
|
+
"[" + body + "]" if type(value) is list else "(" + body + ("," if len(value) == 1 else "") + ")"
|
|
114
|
+
)
|
|
115
|
+
if type(value) is dict and all(type(key) is str for key in value):
|
|
116
|
+
return "{" + ", ".join(f"{key!r}: {render_literal(value[key])}" for key in sorted(value)) + "}"
|
|
117
|
+
raise ImproperlyConfigured(f"Build declarations require literal values, got {type(value).__name__}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def model_declaration(
|
|
121
|
+
composition: ModelComposition, source: type[models.Model], integrations: tuple[ModelDeclarationHook, ...]
|
|
122
|
+
) -> ModelDeclaration:
|
|
123
|
+
"""Merge disjoint feature declarations after reserving native structural names."""
|
|
124
|
+
reserved = {"Meta", "compose_contributed_field_origins"}
|
|
125
|
+
reserved.update(name for name in vars(Composable) if name.startswith("compose_"))
|
|
126
|
+
current: type[models.Model] | None = source
|
|
127
|
+
while current is not None:
|
|
128
|
+
for contributor in (current, *composition.donors(current)):
|
|
129
|
+
for native in (
|
|
130
|
+
*contributor._meta.local_fields,
|
|
131
|
+
*contributor._meta.local_many_to_many,
|
|
132
|
+
*contributor._meta.private_fields,
|
|
133
|
+
):
|
|
134
|
+
reserved.update((native.name, native.attname))
|
|
135
|
+
reserved.update(manager.name for manager in contributor._meta.managers)
|
|
136
|
+
reserved.update(
|
|
137
|
+
name
|
|
138
|
+
for base in contributor.__mro__
|
|
139
|
+
for name, value in vars(base).items()
|
|
140
|
+
if callable(value) or isinstance(value, (property, staticmethod, classmethod))
|
|
141
|
+
)
|
|
142
|
+
current = composition.parent(current)
|
|
143
|
+
attributes: dict[str, DeclarationValue] = {}
|
|
144
|
+
options: dict[str, DeclarationValue] = {}
|
|
145
|
+
for integration in integrations:
|
|
146
|
+
declaration = integration.model_declaration(composition, source)
|
|
147
|
+
if not isinstance(declaration, ModelDeclaration):
|
|
148
|
+
raise ImproperlyConfigured("model_declaration() must return ModelDeclaration")
|
|
149
|
+
for output, values, blocked in (
|
|
150
|
+
(attributes, declaration.attributes, reserved),
|
|
151
|
+
(options, declaration.meta_options, set(DEFAULT_NAMES)),
|
|
152
|
+
):
|
|
153
|
+
for name, value in values.items():
|
|
154
|
+
if (
|
|
155
|
+
not isinstance(name, str)
|
|
156
|
+
or not name.isidentifier()
|
|
157
|
+
or keyword.iskeyword(name)
|
|
158
|
+
or name.startswith("_")
|
|
159
|
+
or name in blocked
|
|
160
|
+
):
|
|
161
|
+
raise ImproperlyConfigured(f"{source._meta.label}: cannot declare {name!r}")
|
|
162
|
+
if name in output:
|
|
163
|
+
raise ImproperlyConfigured(
|
|
164
|
+
f"{source._meta.label}: competing build declarations for {name!r}"
|
|
165
|
+
)
|
|
166
|
+
render_literal(value)
|
|
167
|
+
output[name] = value
|
|
168
|
+
return ModelDeclaration(attributes, options)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Receipt-declared history identities without importing stale generated models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from django.apps import AppConfig as _AppConfig
|
|
6
|
+
from django.conf import settings as _settings
|
|
7
|
+
from django.core.exceptions import ImproperlyConfigured as _ImproperlyConfigured
|
|
8
|
+
|
|
9
|
+
from django_compose.serving import read_receipt as _read_receipt
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def __getattr__(attribute: str) -> type[_AppConfig]:
|
|
13
|
+
if not attribute.startswith("History_"):
|
|
14
|
+
raise AttributeError(attribute)
|
|
15
|
+
label = attribute.removeprefix("History_")
|
|
16
|
+
receipt = _read_receipt(_settings.COMPOSE_OUTPUT_MODULE)
|
|
17
|
+
if receipt is None or label not in receipt.HISTORY_LABELS or label in receipt.RETIRED_LABELS:
|
|
18
|
+
raise AttributeError(attribute)
|
|
19
|
+
|
|
20
|
+
def import_models(self: _AppConfig) -> None:
|
|
21
|
+
if self.apps is None:
|
|
22
|
+
raise _ImproperlyConfigured("History config requires an app registry")
|
|
23
|
+
self.models = self.apps.all_models[self.label]
|
|
24
|
+
self.models_module = None
|
|
25
|
+
|
|
26
|
+
return type(
|
|
27
|
+
attribute,
|
|
28
|
+
(_AppConfig,),
|
|
29
|
+
{
|
|
30
|
+
"__module__": __name__,
|
|
31
|
+
"name": f"{_settings.COMPOSE_OUTPUT_MODULE}.{label}",
|
|
32
|
+
"label": label,
|
|
33
|
+
"default": False,
|
|
34
|
+
"import_models": import_models,
|
|
35
|
+
},
|
|
36
|
+
)
|