django-compose 0.1.0.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. django_compose/__init__.py +0 -0
  2. django_compose/__main__.py +5 -0
  3. django_compose/addons.py +205 -0
  4. django_compose/apps.py +103 -0
  5. django_compose/artifacts.py +224 -0
  6. django_compose/asgi.py +110 -0
  7. django_compose/autoreload.py +110 -0
  8. django_compose/build.py +282 -0
  9. django_compose/checks.py +11 -0
  10. django_compose/cli.py +12 -0
  11. django_compose/conf.py +322 -0
  12. django_compose/declarations.py +168 -0
  13. django_compose/history.py +36 -0
  14. django_compose/management/__init__.py +221 -0
  15. django_compose/management/commands/__init__.py +1 -0
  16. django_compose/management/commands/check_migrations.py +57 -0
  17. django_compose/management/commands/compose.py +42 -0
  18. django_compose/management/commands/makemigrations.py +136 -0
  19. django_compose/management/commands/migrate.py +18 -0
  20. django_compose/management/commands/reset_migration_history.py +34 -0
  21. django_compose/management/commands/retire_history.py +24 -0
  22. django_compose/migration_imports.py +474 -0
  23. django_compose/migrations.py +843 -0
  24. django_compose/model_composition.py +292 -0
  25. django_compose/models.py +65 -0
  26. django_compose/py.typed +0 -0
  27. django_compose/rendering.py +92 -0
  28. django_compose/serving.py +405 -0
  29. django_compose/snapshot.py +419 -0
  30. django_compose/sources.py +147 -0
  31. django_compose/typing.py +633 -0
  32. django_compose/urls.py +58 -0
  33. django_compose-0.1.0.dev1.dist-info/METADATA +101 -0
  34. django_compose-0.1.0.dev1.dist-info/RECORD +39 -0
  35. django_compose-0.1.0.dev1.dist-info/WHEEL +4 -0
  36. django_compose-0.1.0.dev1.dist-info/entry_points.txt +2 -0
  37. django_compose-0.1.0.dev1.dist-info/licenses/COPYING +674 -0
  38. django_compose-0.1.0.dev1.dist-info/licenses/LICENSE +165 -0
  39. django_compose-0.1.0.dev1.dist-info/licenses/NOTICE +10 -0
@@ -0,0 +1,292 @@
1
+ """Discover native abstract declarations and validate additive composition edges."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import keyword
7
+ import sys
8
+ from collections.abc import Iterable, Mapping
9
+ from graphlib import CycleError, TopologicalSorter
10
+ from typing import Any
11
+
12
+ from django.apps import AppConfig
13
+ from django.core.exceptions import ImproperlyConfigured
14
+ from django.db import models
15
+
16
+ from django_compose.models import Composable
17
+
18
+
19
+ def _ordered(graph: Mapping[str, Iterable[str]], description: str) -> tuple[str, ...]:
20
+ try:
21
+ return tuple(TopologicalSorter(graph).static_order())
22
+ except CycleError as error:
23
+ raise ImproperlyConfigured(f"Cyclic {description}: {' -> '.join(error.args[1])}") from error
24
+
25
+
26
+ def _fields(model: type[models.Model]) -> tuple[models.Field[Any, Any], ...]:
27
+ return (*model._meta.local_fields, *model._meta.local_many_to_many, *model._meta.private_fields)
28
+
29
+
30
+ class ModelComposition:
31
+ """Validated native sources, donors, parent chains, and contributor provenance."""
32
+
33
+ def __init__(
34
+ self,
35
+ sources_by_label: dict[str, tuple[type[models.Model], ...]],
36
+ extensions: dict[str, tuple[type[models.Model], ...]],
37
+ model_owners: dict[type[models.Model], str] | None = None,
38
+ ) -> None:
39
+ self.sources_by_label = {
40
+ label: tuple(sorted(sources, key=lambda item: item.__name__))
41
+ for label, sources in sorted(sources_by_label.items())
42
+ }
43
+ self.extensions = extensions
44
+ self._owners = model_owners or {}
45
+ self.models_by_label: dict[str, type[models.Model]] = {}
46
+ for label, sources in self.sources_by_label.items():
47
+ if not label.isidentifier() or keyword.iskeyword(label):
48
+ raise ImproperlyConfigured(f"Invalid generated app label: {label!r}")
49
+ for source in sources:
50
+ if not source.__name__.isidentifier() or keyword.iskeyword(source.__name__):
51
+ raise ImproperlyConfigured(f"Invalid generated model name: {source.__name__!r}")
52
+ key = source._meta.label_lower
53
+ if key in self.models_by_label:
54
+ raise ImproperlyConfigured(f"Duplicate source model label {key!r}")
55
+ self.models_by_label[key] = source
56
+ for target, donors in extensions.items():
57
+ if target not in self.models_by_label:
58
+ raise ImproperlyConfigured(
59
+ f"{', '.join(donor._meta.label for donor in donors)} extends unknown model {target!r}"
60
+ )
61
+ if any(donor not in self._owners for donor in donors):
62
+ raise ImproperlyConfigured("Addon ownership is required for extension donors")
63
+ graph = {}
64
+ for key, source in sorted(self.models_by_label.items()):
65
+ parent_ref = vars(source).get("compose_inherits")
66
+ if parent_ref is not None and parent_ref not in self.models_by_label:
67
+ raise ImproperlyConfigured(f"{source._meta.label} inherits unknown model {parent_ref!r}")
68
+ graph[key] = () if parent_ref is None else (parent_ref,)
69
+ order = _ordered(graph, "concrete model parents")
70
+ modules: dict[str, set[str]] = {label: set() for label in self.sources_by_label}
71
+ for key, dependencies in graph.items():
72
+ own_label = self.models_by_label[key]._meta.app_label
73
+ for target in dependencies:
74
+ parent_label = self.models_by_label[target]._meta.app_label
75
+ if parent_label != own_label:
76
+ modules[own_label].add(parent_label)
77
+ _ordered({label: tuple(sorted(edges)) for label, edges in modules.items()}, "generated model modules")
78
+ self._parents = {
79
+ self.models_by_label[key]: self.models_by_label[edges[0]] if edges else None
80
+ for key, edges in graph.items()
81
+ }
82
+ self.ordered_models = tuple(self.models_by_label[key] for key in order)
83
+ self.labels = tuple(self.sources_by_label)
84
+ for model in (*self.ordered_models, *(donor for donors in extensions.values() for donor in donors)):
85
+ module = sys.modules.get(model.__module__)
86
+ if module is None or getattr(module, model.__name__, None) is not model:
87
+ raise ImproperlyConfigured(
88
+ f"Source model {model.__module__}.{model.__name__} is not importable"
89
+ )
90
+ self.required_ancestors = self._requirements()
91
+ self._contributions: dict[
92
+ type[models.Model], tuple[tuple[models.Field[Any, Any], type[models.Model] | None], ...]
93
+ ] = {}
94
+ self._validate_additions()
95
+ self._constraints = {source: self._resolve_constraints(source) for source in self.ordered_models}
96
+
97
+ @classmethod
98
+ def discover(cls, configs: Iterable[AppConfig]) -> ModelComposition:
99
+ """Use native models_module exports, including custom import_models hooks."""
100
+ configs = tuple(configs)
101
+ sources: dict[str, list[type[models.Model]]] = {}
102
+ donors: dict[str, list[type[models.Model]]] = {}
103
+ owners: dict[type[models.Model], str] = {}
104
+ for config in configs:
105
+ if config.models_module is None:
106
+ continue
107
+ seen = set()
108
+ for model in vars(config.models_module).values():
109
+ if (
110
+ not inspect.isclass(model)
111
+ or not issubclass(model, models.Model)
112
+ or model is models.Model
113
+ or model in seen
114
+ or not (model.__module__ == config.name or model.__module__.startswith(config.name + "."))
115
+ ):
116
+ continue
117
+ seen.add(model)
118
+ if not issubclass(model, Composable):
119
+ continue
120
+ own = vars(model)
121
+ marked = any(name.startswith("compose_") for name in own)
122
+ if not model._meta.abstract:
123
+ if marked:
124
+ raise ImproperlyConfigured(
125
+ f"{model._meta.label}: composition markers require abstract models"
126
+ )
127
+ continue
128
+ concrete = own.get("compose_concrete", False)
129
+ if type(concrete) is not bool:
130
+ raise ImproperlyConfigured(f"{model._meta.label}.compose_concrete must be a boolean")
131
+ target = own.get("compose_extends")
132
+ if not concrete and target is None:
133
+ continue
134
+ if model._meta.app_label != config.label:
135
+ raise ImproperlyConfigured(f"{model._meta.label}: expected app_label {config.label!r}")
136
+ if concrete:
137
+ sources.setdefault(config.label, []).append(model)
138
+ else:
139
+ assert target is not None
140
+ donors.setdefault(target, []).append(model)
141
+ owners[model] = config.name
142
+ return cls(
143
+ {key: tuple(value) for key, value in sources.items()},
144
+ {key: tuple(value) for key, value in donors.items()},
145
+ owners,
146
+ )
147
+
148
+ def parent(self, source: type[models.Model]) -> type[models.Model] | None:
149
+ """Return the immediate concrete parent, or None for a root identity."""
150
+ return self._parents[source]
151
+
152
+ def donors(self, source: type[models.Model]) -> tuple[type[models.Model], ...]:
153
+ """Return additive donors in native enabled-app and declaration order."""
154
+ return self.extensions.get(source._meta.label_lower, ())
155
+
156
+ def contributed_fields(self, source: type[models.Model]) -> tuple[models.Field[Any, Any], ...]:
157
+ """Return the native fields added by donors, excluding source-owned ancestry."""
158
+ return tuple(native for native, _ in self._contributions.get(source, ()))
159
+
160
+ def contributed_field_origins(self, source: type[models.Model]) -> tuple[tuple[str, str], ...]:
161
+ """Return sorted (field name, declaring app name) pairs for owned additions."""
162
+ return tuple(
163
+ (native.name, self._owners[owner])
164
+ for native, owner in self._contributions.get(source, ())
165
+ if owner is not None
166
+ )
167
+
168
+ def constraint_sources(self, source: type[models.Model]) -> tuple[tuple[type[models.Model], int], ...]:
169
+ """Return one native declaration per constraint, preserving shared ancestry."""
170
+ return self._constraints[source]
171
+
172
+ def _resolve_constraints(self, source: type[models.Model]) -> tuple[tuple[type[models.Model], int], ...]:
173
+ selected = []
174
+ seen: set[int] = set()
175
+ names: set[str] = set()
176
+ for contributor in (source, *self.donors(source)):
177
+ for index, constraint in enumerate(contributor._meta.constraints):
178
+ if id(constraint) in seen:
179
+ continue
180
+ # Django clones constraints when interpolating native concrete
181
+ # names. Its private helper is not exposed by django-stubs.
182
+ name = source._meta._format_names([constraint])[0].name # type: ignore[attr-defined]
183
+ if name in names:
184
+ raise ImproperlyConfigured(f"{source._meta.label} composes constraint {name!r} twice")
185
+ seen.add(id(constraint))
186
+ names.add(name)
187
+ selected.append((contributor, index))
188
+ return tuple(selected)
189
+
190
+ def _requirements(self) -> dict[type[models.Model], type[models.Model]]:
191
+ required = {}
192
+ for source in self.ordered_models:
193
+ ancestors = set()
194
+ parent = self.parent(source)
195
+ while parent is not None:
196
+ ancestors.add(parent)
197
+ parent = self.parent(parent)
198
+ for contributor in (*self.donors(source), source):
199
+ for declaration in contributor.__mro__:
200
+ if not issubclass(declaration, models.Model):
201
+ continue
202
+ reference = vars(declaration).get("compose_demands")
203
+ if reference is None:
204
+ continue
205
+ target = self.models_by_label.get(reference)
206
+ if target is None or target not in ancestors:
207
+ raise ImproperlyConfigured(
208
+ f"{source._meta.label} demands ancestor {reference!r} "
209
+ f"through {declaration._meta.label}"
210
+ )
211
+ required[declaration] = target
212
+ return required
213
+
214
+ def _validate_additions(self) -> None:
215
+ inherited_fields: dict[type[models.Model], dict[str, models.Field[Any, Any]]] = {}
216
+ for source in self.ordered_models:
217
+ parent = self.parent(source)
218
+ inherited = inherited_fields[parent] if parent else {}
219
+ fields: dict[str, tuple[models.Field[Any, Any], type[models.Model] | None]] = {}
220
+ managers: dict[str, models.Manager[models.Model]] = {}
221
+ inherited_names = {
222
+ name for native in inherited.values() for name in (native.name, native.attname)
223
+ }
224
+ inherited_columns = {
225
+ native.column
226
+ for native in inherited.values()
227
+ if native.concrete and native.column is not None
228
+ }
229
+ local_names: dict[str, models.Field[Any, Any]] = {}
230
+ local_columns: dict[str, models.Field[Any, Any]] = {}
231
+ for contributor in (*self.donors(source), source):
232
+ if contributor is not source:
233
+ authored = vars(contributor.Meta)
234
+ forbidden = (set(authored) & set(models.options.DEFAULT_NAMES)) - {
235
+ "abstract",
236
+ "app_label",
237
+ "constraints",
238
+ }
239
+ if forbidden:
240
+ raise ImproperlyConfigured(
241
+ f"{contributor._meta.label}: donor Meta authors unsupported options "
242
+ f"{', '.join(sorted(forbidden))}"
243
+ )
244
+ for native in _fields(contributor):
245
+ if (
246
+ native.name in inherited_names
247
+ or native.attname in inherited_names
248
+ or native.concrete
249
+ and native.column is not None
250
+ and native.column in inherited_columns
251
+ ):
252
+ raise ImproperlyConfigured(
253
+ f"{source._meta.label} redeclares parent field {native.name!r}"
254
+ )
255
+ for local_name in (native.name, native.attname):
256
+ existing = local_names.setdefault(local_name, native)
257
+ if existing.creation_counter != native.creation_counter:
258
+ raise ImproperlyConfigured(
259
+ f"{source._meta.label} composes field attribute {local_name!r} twice"
260
+ )
261
+ if native.concrete and native.column is not None:
262
+ existing = local_columns.setdefault(native.column, native)
263
+ if existing.creation_counter != native.creation_counter:
264
+ raise ImproperlyConfigured(
265
+ f"{source._meta.label} composes database column {native.column!r} twice"
266
+ )
267
+ previous = fields.get(native.name)
268
+ if previous is None:
269
+ fields[native.name] = (native, contributor)
270
+ elif previous[0] is not native:
271
+ fields[native.name] = (previous[0], source if contributor is source else None)
272
+ for manager in contributor._meta.managers:
273
+ if manager.auto_created:
274
+ continue
275
+ previous_manager = managers.setdefault(manager.name, manager)
276
+ if (
277
+ contributor is not source
278
+ and previous_manager.creation_counter != manager.creation_counter
279
+ ):
280
+ raise ImproperlyConfigured(
281
+ f"{source._meta.label} composes manager {manager.name!r} twice"
282
+ )
283
+ for manager_name in managers.keys() & local_names.keys():
284
+ raise ImproperlyConfigured(
285
+ f"{source._meta.label} composes field and manager {manager_name!r} twice"
286
+ )
287
+ inherited_fields[source] = {**inherited, **{name: item[0] for name, item in fields.items()}}
288
+ self._contributions[source] = tuple(
289
+ (native, owner)
290
+ for _, (native, owner) in sorted(fields.items())
291
+ if owner is not source and (native.concrete or native.many_to_many)
292
+ )
@@ -0,0 +1,65 @@
1
+ """Native abstract model declarations for generated concrete composition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import keyword
6
+ from typing import Any, ClassVar
7
+
8
+ from django.core.exceptions import ImproperlyConfigured
9
+ from django.db import models
10
+ from django.db.models.utils import make_model_tuple
11
+
12
+ _UNSET: Any = object()
13
+
14
+
15
+ class Composable(models.Model):
16
+ """Declare composition through class keywords, retaining Django's Meta rules.
17
+
18
+ ``concrete``, ``extends`` and ``inherits`` are mutually exclusive and
19
+ apply only to the class declaring them. ``inherits`` implies concrete.
20
+ ``demands`` applies through the complete MRO and combines with any
21
+ declaration. Every source and mixin must explicitly remain abstract using
22
+ native Meta. Factories may pass the same keywords to ``type()``.
23
+ """
24
+
25
+ compose_concrete: ClassVar[bool] = False
26
+ compose_extends: ClassVar[str | None] = None
27
+ compose_inherits: ClassVar[str | None] = None
28
+ compose_demands: ClassVar[str | None] = None
29
+
30
+ class Meta:
31
+ abstract = True
32
+
33
+ def __init_subclass__(
34
+ cls,
35
+ *,
36
+ concrete: bool = _UNSET,
37
+ extends: str | None = None,
38
+ inherits: str | None = None,
39
+ demands: str | None = None,
40
+ **kwargs: Any,
41
+ ) -> None:
42
+ super().__init_subclass__(**kwargs)
43
+ identity = f"{cls.__module__}.{cls.__name__}"
44
+ if concrete is not _UNSET and type(concrete) is not bool:
45
+ raise ImproperlyConfigured(f"{identity}: concrete must be a boolean")
46
+ if sum((concrete is not _UNSET, extends is not None, inherits is not None)) > 1:
47
+ raise ImproperlyConfigured(f"{identity}: concrete, extends and inherits are mutually exclusive")
48
+ if concrete is True or inherits is not None:
49
+ cls.compose_concrete = True
50
+ for name, value in (
51
+ ("extends", extends),
52
+ ("inherits", inherits),
53
+ ("demands", demands),
54
+ ):
55
+ if value is None:
56
+ continue
57
+ if isinstance(value, str):
58
+ try:
59
+ label, model = make_model_tuple(value)
60
+ if label.isidentifier() and not keyword.iskeyword(label) and model.isidentifier():
61
+ setattr(cls, f"compose_{name}", f"{label}.{model}")
62
+ continue
63
+ except ValueError:
64
+ pass
65
+ raise ImproperlyConfigured(f"{identity}: {name} requires an 'app_label.Model' reference")
File without changes
@@ -0,0 +1,92 @@
1
+ """Emit ordinary subclasses while Django retains field and inheritance ownership."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ from django.db import models
8
+
9
+ from django_compose.declarations import ModelDeclaration, render_literal
10
+ from django_compose.model_composition import ModelComposition
11
+
12
+
13
+ def render_models(
14
+ composition: ModelComposition,
15
+ label: str,
16
+ *,
17
+ runtime_module: str = "runtime",
18
+ declarations: Mapping[type[models.Model], ModelDeclaration] | None = None,
19
+ ) -> str:
20
+ """Render donor, source, concrete-parent bases and additive literal declarations."""
21
+ used_names = {source.__name__ for source in composition.sources_by_label.get(label, ())}
22
+
23
+ def alias(preferred: str) -> str:
24
+ selected = preferred
25
+ while selected in used_names:
26
+ selected += "_"
27
+ used_names.add(selected)
28
+ return selected
29
+
30
+ classvar = alias("_ComposeClassVar")
31
+ imports = {f"from typing import ClassVar as {classvar}"}
32
+ classes = []
33
+ for source in composition.ordered_models:
34
+ if source._meta.app_label != label:
35
+ continue
36
+ name = source.__name__
37
+ abstract = alias(f"_Source{name}")
38
+ imports.add(f"from {source.__module__} import {name} as {abstract}")
39
+ bases = []
40
+ contributor_aliases = {source: abstract}
41
+ for index, donor in enumerate(composition.donors(source)):
42
+ donor_alias = alias(f"_Donor{name}{index}")
43
+ imports.add(f"from {donor.__module__} import {donor.__name__} as {donor_alias}")
44
+ bases.append(donor_alias)
45
+ contributor_aliases[donor] = donor_alias
46
+ bases.append(abstract)
47
+ parent = composition.parent(source)
48
+ if parent is not None:
49
+ parent_name = parent.__name__
50
+ if parent._meta.app_label != label:
51
+ parent_name = alias(f"_Parent{name}")
52
+ imports.add(
53
+ f"from {runtime_module}.{parent._meta.app_label}.models "
54
+ f"import {parent.__name__} as {parent_name}"
55
+ )
56
+ bases.append(parent_name)
57
+ declaration = (declarations or {}).get(source, ModelDeclaration())
58
+ body = [
59
+ f"class {name}({', '.join(bases)}):",
60
+ f' """Concrete {name} composed from native abstract models."""',
61
+ ]
62
+ body.extend(
63
+ f" {key} = {render_literal(value)}" for key, value in sorted(declaration.attributes.items())
64
+ )
65
+ body.extend(
66
+ [
67
+ f" compose_contributed_field_origins: {classvar}[tuple[tuple[str, str], ...]] = "
68
+ + repr(composition.contributed_field_origins(source)),
69
+ "",
70
+ f" class Meta({abstract}.Meta):",
71
+ " abstract = False",
72
+ f" app_label = {label!r}",
73
+ ]
74
+ )
75
+ if constraint_sources := composition.constraint_sources(source):
76
+ constraints = ", ".join(
77
+ f"{contributor_aliases[contributor]}._meta.constraints[{index}]"
78
+ for contributor, index in constraint_sources
79
+ )
80
+ body.append(f" constraints = [{constraints}]")
81
+ body.extend(
82
+ f" {key} = {render_literal(value)}"
83
+ for key, value in sorted(declaration.meta_options.items())
84
+ )
85
+ classes.append("\n".join(body))
86
+ return (
87
+ '"""Prebuilt concrete Django models."""\n\nfrom __future__ import annotations\n\n'
88
+ + "\n".join(sorted(imports))
89
+ + "\n\n"
90
+ + "\n\n\n".join(classes)
91
+ + "\n"
92
+ )