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
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
+ )