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/typing.py
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
"""Optional django-stubs extension for native composed models.
|
|
2
|
+
|
|
3
|
+
Configure ``plugins = django_compose.typing`` and django-stubs' ordinary
|
|
4
|
+
``django_settings_module``. Use an isolated build settings module for analysis.
|
|
5
|
+
The dependency's field, queryset, manager and settings hooks remain authoritative.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from collections.abc import Callable, Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from functools import partial
|
|
15
|
+
from typing import TYPE_CHECKING, Literal
|
|
16
|
+
|
|
17
|
+
from django.core.exceptions import FieldDoesNotExist
|
|
18
|
+
from django.db import models
|
|
19
|
+
from mypy.mro import calculate_mro
|
|
20
|
+
from mypy.nodes import (
|
|
21
|
+
MDEF,
|
|
22
|
+
ClassDef,
|
|
23
|
+
Decorator,
|
|
24
|
+
FuncDef,
|
|
25
|
+
MemberExpr,
|
|
26
|
+
MypyFile,
|
|
27
|
+
OverloadedFuncDef,
|
|
28
|
+
PlaceholderNode,
|
|
29
|
+
SymbolTableNode,
|
|
30
|
+
TypeInfo,
|
|
31
|
+
Var,
|
|
32
|
+
)
|
|
33
|
+
from mypy.options import Options
|
|
34
|
+
from mypy.plugin import AttributeContext, ClassDefContext, DynamicClassDefContext, ReportConfigContext
|
|
35
|
+
from mypy.semanal_shared import PRIORITY_FALLBACKS, has_placeholder
|
|
36
|
+
from mypy.server.trigger import make_trigger
|
|
37
|
+
from mypy.types import CallableType, Instance, Overloaded, TypeType, get_proper_type
|
|
38
|
+
from mypy.types import Type as MypyType
|
|
39
|
+
from mypy.typevars import fill_typevars, fill_typevars_with_any
|
|
40
|
+
from mypy_django_plugin.django.context import DjangoContext
|
|
41
|
+
from mypy_django_plugin.lib import fullnames, helpers
|
|
42
|
+
from mypy_django_plugin.main import NewSemanalDjangoPlugin
|
|
43
|
+
from mypy_django_plugin.transformers.fields import (
|
|
44
|
+
get_field_descriptor_types,
|
|
45
|
+
reparametrize_field_type,
|
|
46
|
+
reparametrize_related_field_type,
|
|
47
|
+
)
|
|
48
|
+
from mypy_django_plugin.transformers.managers import create_new_manager_class_from_from_queryset_method
|
|
49
|
+
from mypy_django_plugin.transformers.manytomany import M2MArguments, M2MThrough, M2MTo
|
|
50
|
+
from mypy_django_plugin.transformers.models import ProcessManyToManyFields, process_model_class
|
|
51
|
+
|
|
52
|
+
from django_compose.addons import Closure
|
|
53
|
+
from django_compose.snapshot import Snapshot
|
|
54
|
+
|
|
55
|
+
if TYPE_CHECKING:
|
|
56
|
+
from mypy.checker import TypeChecker
|
|
57
|
+
from mypy.semanal import SemanticAnalyzer
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@contextmanager
|
|
61
|
+
def _compose_mode() -> Iterator[None]:
|
|
62
|
+
previous = os.environ.get("DJANGO_COMPOSE_MODE")
|
|
63
|
+
os.environ["DJANGO_COMPOSE_MODE"] = "write"
|
|
64
|
+
try:
|
|
65
|
+
yield
|
|
66
|
+
finally:
|
|
67
|
+
if previous is None:
|
|
68
|
+
os.environ.pop("DJANGO_COMPOSE_MODE", None)
|
|
69
|
+
else:
|
|
70
|
+
os.environ["DJANGO_COMPOSE_MODE"] = previous
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class _Member:
|
|
75
|
+
"""The native C3 owner and specialized donor receiver of one member."""
|
|
76
|
+
|
|
77
|
+
owner: str
|
|
78
|
+
receiver: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class CompositionContext(DjangoContext):
|
|
82
|
+
"""Relate authored abstract names to Django's already constructed classes."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, django_settings_module: str) -> None:
|
|
85
|
+
with _compose_mode():
|
|
86
|
+
super().__init__(django_settings_module)
|
|
87
|
+
self.composed: dict[str, type[models.Model]] = {}
|
|
88
|
+
self.static_names: dict[type[models.Model], str] = {}
|
|
89
|
+
self.parents: dict[str, str] = {}
|
|
90
|
+
self.required_ancestors: dict[type[models.Model], type[models.Model]] = {}
|
|
91
|
+
self.fields: dict[str, tuple[str, tuple[str, ...]]] = {}
|
|
92
|
+
self.members: dict[str, dict[str, _Member]] = {}
|
|
93
|
+
self.dependencies: dict[str, set[str]] = {}
|
|
94
|
+
|
|
95
|
+
# The same discovery owner serves builds and optional static analysis.
|
|
96
|
+
from django_compose.build import discover
|
|
97
|
+
|
|
98
|
+
composition = discover(tuple(self.apps_registry.get_app_configs()))
|
|
99
|
+
self.required_ancestors = composition.required_ancestors
|
|
100
|
+
for source in composition.ordered_models:
|
|
101
|
+
name = helpers.get_class_fullname(source)
|
|
102
|
+
concrete = self.apps_registry.get_model(source._meta.app_label, source.__name__)
|
|
103
|
+
self.composed[name] = concrete
|
|
104
|
+
self.static_names[concrete] = name
|
|
105
|
+
parent = composition.parent(source)
|
|
106
|
+
if parent is not None:
|
|
107
|
+
self.parents[name] = helpers.get_class_fullname(parent)
|
|
108
|
+
donors = composition.donors(source)
|
|
109
|
+
for donor in donors:
|
|
110
|
+
donor_name = helpers.get_class_fullname(donor)
|
|
111
|
+
self.composed[donor_name] = concrete
|
|
112
|
+
self.parents[donor_name] = name
|
|
113
|
+
self.fields[donor_name] = (
|
|
114
|
+
name,
|
|
115
|
+
tuple(
|
|
116
|
+
field.name for field in composition.contributed_fields(source) if field.model is donor
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
selected: dict[str, _Member] = {}
|
|
120
|
+
self.members[name] = selected
|
|
121
|
+
donor_ancestors = {base for donor in donors for base in donor.__mro__}
|
|
122
|
+
for owner in concrete.__mro__:
|
|
123
|
+
if owner in source.__mro__ or owner not in donor_ancestors:
|
|
124
|
+
continue
|
|
125
|
+
receiver = next(
|
|
126
|
+
donor for donor in concrete.__mro__ if donor in donors and owner in donor.__mro__
|
|
127
|
+
)
|
|
128
|
+
member = _Member(helpers.get_class_fullname(owner), helpers.get_class_fullname(receiver))
|
|
129
|
+
self.dependencies.setdefault(source.__module__, set()).add(owner.__module__)
|
|
130
|
+
for attribute in vars(owner):
|
|
131
|
+
selected.setdefault(attribute, member)
|
|
132
|
+
for source, parent in self.required_ancestors.items():
|
|
133
|
+
self.parents.setdefault(helpers.get_class_fullname(source), helpers.get_class_fullname(parent))
|
|
134
|
+
|
|
135
|
+
def get_model_fullname(self, model_cls: type[models.Model]) -> str:
|
|
136
|
+
return self.static_names.get(model_cls) or super().get_model_fullname(model_cls)
|
|
137
|
+
|
|
138
|
+
def get_model_class_by_fullname(self, fullname: str) -> type[models.Model] | None:
|
|
139
|
+
return self.composed.get(fullname) or super().get_model_class_by_fullname(fullname)
|
|
140
|
+
|
|
141
|
+
def is_fallback_manager(self, manager: models.Manager[models.Model]) -> bool:
|
|
142
|
+
"""Native implicit managers may inherit a checked parent contract."""
|
|
143
|
+
|
|
144
|
+
return manager.auto_created
|
|
145
|
+
|
|
146
|
+
def get_model_manager(
|
|
147
|
+
self,
|
|
148
|
+
api: TypeChecker | SemanticAnalyzer,
|
|
149
|
+
model_cls: type[models.Model],
|
|
150
|
+
name: Literal["_default_manager", "_base_manager"],
|
|
151
|
+
) -> models.Manager[models.Model] | None:
|
|
152
|
+
current = super().get_model_manager(api, model_cls, name)
|
|
153
|
+
parent = self.required_ancestors.get(model_cls)
|
|
154
|
+
if current is None or parent is None or not self.is_fallback_manager(current):
|
|
155
|
+
return current
|
|
156
|
+
info = self.lookup_model_typeinfo(api, model_cls)
|
|
157
|
+
if info is None:
|
|
158
|
+
return current
|
|
159
|
+
meta = info.names.get("Meta")
|
|
160
|
+
if meta is not None and isinstance(meta.node, TypeInfo) and name[1:] + "_name" in meta.node.names:
|
|
161
|
+
return current
|
|
162
|
+
concrete_parent = self.get_model_class_by_fullname(helpers.get_class_fullname(parent))
|
|
163
|
+
if concrete_parent is None:
|
|
164
|
+
api.fail(f"Unavailable required parent {helpers.get_class_fullname(parent)}", info.defn)
|
|
165
|
+
return current
|
|
166
|
+
selected = super().get_model_manager(api, concrete_parent, name)
|
|
167
|
+
if selected is None:
|
|
168
|
+
return current
|
|
169
|
+
local = model_cls._meta.managers_map.get(selected.name)
|
|
170
|
+
return local if local is not None and not self.is_fallback_manager(local) else selected
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class CompositionPlugin(NewSemanalDjangoPlugin):
|
|
174
|
+
"""Supply composition facts to the native django-stubs semantic hooks."""
|
|
175
|
+
|
|
176
|
+
django_context: CompositionContext
|
|
177
|
+
context_class: type[CompositionContext] = CompositionContext
|
|
178
|
+
|
|
179
|
+
def __init__(self, options: Options) -> None:
|
|
180
|
+
with _compose_mode():
|
|
181
|
+
super().__init__(options)
|
|
182
|
+
self.django_context = self.context_class(self.plugin_config.django_settings_module)
|
|
183
|
+
|
|
184
|
+
def report_config_data(self, ctx: ReportConfigContext) -> dict[str, object]:
|
|
185
|
+
"""Use the composition input owner to invalidate in-memory model caches."""
|
|
186
|
+
|
|
187
|
+
snapshot = getattr(self.django_context.settings, "COMPOSE_SNAPSHOT", None)
|
|
188
|
+
return {
|
|
189
|
+
**super().report_config_data(ctx),
|
|
190
|
+
"compose_snapshot": snapshot.digest() if isinstance(snapshot, Snapshot) else None,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
def get_attribute_hook(self, fullname: str) -> Callable[[AttributeContext], MypyType] | None:
|
|
194
|
+
"""Expose configure's derived facts through native Django settings lookup."""
|
|
195
|
+
|
|
196
|
+
native = super().get_attribute_hook(fullname)
|
|
197
|
+
if fullname.rpartition(".")[0] == fullnames.DUMMY_SETTINGS_BASE_CLASS and native is not None:
|
|
198
|
+
return partial(self._derived_setting, native=native)
|
|
199
|
+
return native
|
|
200
|
+
|
|
201
|
+
def _derived_setting(
|
|
202
|
+
self, ctx: AttributeContext, *, native: Callable[[AttributeContext], MypyType]
|
|
203
|
+
) -> MypyType:
|
|
204
|
+
if not isinstance(ctx.context, MemberExpr):
|
|
205
|
+
return native(ctx)
|
|
206
|
+
name = ctx.context.name
|
|
207
|
+
derived: type[Snapshot] | type[Closure]
|
|
208
|
+
if name == "COMPOSE_SNAPSHOT" and isinstance(
|
|
209
|
+
getattr(self.django_context.settings, name, None), Snapshot
|
|
210
|
+
):
|
|
211
|
+
derived = Snapshot
|
|
212
|
+
elif name == "COMPOSE_CLOSURE" and isinstance(
|
|
213
|
+
getattr(self.django_context.settings, name, None), Closure
|
|
214
|
+
):
|
|
215
|
+
derived = Closure
|
|
216
|
+
else:
|
|
217
|
+
return native(ctx)
|
|
218
|
+
fullname = helpers.get_class_fullname(derived)
|
|
219
|
+
info = helpers.lookup_fully_qualified_typeinfo(helpers.get_typechecker_api(ctx), fullname)
|
|
220
|
+
if info is None:
|
|
221
|
+
ctx.api.fail(f"Unavailable derived setting type {fullname}", ctx.context)
|
|
222
|
+
return ctx.default_attr_type
|
|
223
|
+
return Instance(info, [])
|
|
224
|
+
|
|
225
|
+
def get_additional_deps(self, file: MypyFile) -> list[tuple[int, str, int]]:
|
|
226
|
+
extra = set(self.django_context.dependencies.get(file.fullname, ()))
|
|
227
|
+
modules = {file.fullname}
|
|
228
|
+
modules.update(
|
|
229
|
+
model.__module__
|
|
230
|
+
for name, model in self.django_context.composed.items()
|
|
231
|
+
if name.rpartition(".")[0] == file.fullname
|
|
232
|
+
)
|
|
233
|
+
extra.update(
|
|
234
|
+
module_name
|
|
235
|
+
for module_name in modules
|
|
236
|
+
if any(
|
|
237
|
+
model not in self.django_context.static_names
|
|
238
|
+
for model in self.django_context.model_modules.get(module_name, {}).values()
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
for module_name in modules:
|
|
242
|
+
for model in self.django_context.model_modules.get(module_name, {}).values():
|
|
243
|
+
extra.update(base.__module__ for base in model.__bases__)
|
|
244
|
+
extra.update(type(field).__module__ for field in self.django_context.get_model_fields(model))
|
|
245
|
+
extra.update(type(manager).__module__ for manager in model._meta.managers)
|
|
246
|
+
return super().get_additional_deps(file) + [
|
|
247
|
+
self._new_dependency(module) for module in sorted(extra) if module != file.fullname
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
def get_base_class_hook(self, fullname: str) -> Callable[[ClassDefContext], None] | None:
|
|
251
|
+
native = super().get_base_class_hook(fullname)
|
|
252
|
+
return partial(self._model_contract, native=native) if native else None
|
|
253
|
+
|
|
254
|
+
def _model_contract(self, ctx: ClassDefContext, *, native: Callable[[ClassDefContext], None]) -> None:
|
|
255
|
+
parent_name = self.django_context.parents.get(ctx.cls.fullname)
|
|
256
|
+
if parent_name:
|
|
257
|
+
parent = self.lookup_fully_qualified(parent_name)
|
|
258
|
+
if parent is None or not isinstance(parent.node, TypeInfo) or not parent.node.mro:
|
|
259
|
+
if ctx.api.final_iteration:
|
|
260
|
+
ctx.api.fail(f"Unresolved composition target {parent_name}", ctx.cls)
|
|
261
|
+
else:
|
|
262
|
+
ctx.api.defer()
|
|
263
|
+
return
|
|
264
|
+
if not any(base.type.has_base(parent_name) for base in ctx.cls.info.bases):
|
|
265
|
+
ancestors = {base.fullname for base in parent.node.mro}
|
|
266
|
+
instance = fill_typevars(parent.node)
|
|
267
|
+
assert isinstance(instance, Instance)
|
|
268
|
+
ctx.cls.info.bases = [
|
|
269
|
+
*[base for base in ctx.cls.info.bases if base.type.fullname not in ancestors],
|
|
270
|
+
instance,
|
|
271
|
+
]
|
|
272
|
+
ctx.cls.info.mro = []
|
|
273
|
+
calculate_mro(ctx.cls.info)
|
|
274
|
+
ctx.api.add_plugin_dependency(make_trigger(parent_name))
|
|
275
|
+
native(ctx)
|
|
276
|
+
runtime = self.django_context.get_model_class_by_fullname(ctx.cls.fullname)
|
|
277
|
+
modules = {ctx.cls.info.module_name}
|
|
278
|
+
if runtime is not None:
|
|
279
|
+
modules.add(runtime.__module__)
|
|
280
|
+
for module_name in sorted(modules):
|
|
281
|
+
for _name, model in sorted(self.django_context.model_modules.get(module_name, {}).items()):
|
|
282
|
+
if model not in self.django_context.static_names:
|
|
283
|
+
self.project_model(ctx, model)
|
|
284
|
+
contribution = self.django_context.fields.get(ctx.cls.fullname)
|
|
285
|
+
if contribution:
|
|
286
|
+
target_name, fields = contribution
|
|
287
|
+
target = self.lookup_fully_qualified(target_name)
|
|
288
|
+
assert target is not None and isinstance(target.node, TypeInfo)
|
|
289
|
+
for name in fields:
|
|
290
|
+
symbol = ctx.cls.info.get(name)
|
|
291
|
+
if symbol is None:
|
|
292
|
+
ctx.api.fail(f"Missing contributed field {ctx.cls.fullname}.{name}", ctx.cls)
|
|
293
|
+
else:
|
|
294
|
+
target.node.names[name] = symbol
|
|
295
|
+
ctx.api.add_plugin_dependency(
|
|
296
|
+
make_trigger(f"{ctx.cls.fullname}.{name}"), target=target_name
|
|
297
|
+
)
|
|
298
|
+
ctx.api.add_plugin_dependency(make_trigger(ctx.cls.fullname), target=target_name)
|
|
299
|
+
self._schedule_projection(ctx, target.node)
|
|
300
|
+
if ctx.cls.fullname in self.django_context.members:
|
|
301
|
+
self._schedule_projection(ctx, ctx.cls.info)
|
|
302
|
+
|
|
303
|
+
def project_model(self, ctx: ClassDefContext, model: type[models.Model]) -> TypeInfo | None:
|
|
304
|
+
"""Project a supplied native model lacking a source declaration in memory.
|
|
305
|
+
|
|
306
|
+
Invoke during semantic analysis. Unavailable native base or field types
|
|
307
|
+
defer the calling class, as django-stubs' own model transformer does.
|
|
308
|
+
Authored TypeInfos remain authoritative; repeated calls reuse identity.
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
fullname = helpers.get_class_fullname(model)
|
|
312
|
+
symbol = self.lookup_fully_qualified(fullname)
|
|
313
|
+
info = symbol.node if symbol is not None and isinstance(symbol.node, TypeInfo) else None
|
|
314
|
+
if info is not None and "compose_runtime_model" not in info.metadata:
|
|
315
|
+
return info
|
|
316
|
+
if symbol is not None and isinstance(symbol.node, PlaceholderNode):
|
|
317
|
+
if not ctx.api.final_iteration:
|
|
318
|
+
ctx.api.defer()
|
|
319
|
+
return None
|
|
320
|
+
try:
|
|
321
|
+
api = helpers.get_semanal_api(ctx)
|
|
322
|
+
module = api.modules.get(model.__module__)
|
|
323
|
+
if module is None:
|
|
324
|
+
raise helpers.IncompleteDefnException(model.__module__)
|
|
325
|
+
if info is None and any(
|
|
326
|
+
isinstance(declaration, ClassDef) and declaration.name == model.__name__
|
|
327
|
+
for declaration in module.defs
|
|
328
|
+
):
|
|
329
|
+
return None
|
|
330
|
+
bases = []
|
|
331
|
+
for base in model.__bases__:
|
|
332
|
+
base_name = (
|
|
333
|
+
self.django_context.get_model_fullname(base)
|
|
334
|
+
if issubclass(base, models.Model)
|
|
335
|
+
else helpers.get_class_fullname(base)
|
|
336
|
+
)
|
|
337
|
+
base_symbol = self.lookup_fully_qualified(base_name)
|
|
338
|
+
if (
|
|
339
|
+
base_symbol is None
|
|
340
|
+
or not isinstance(base_symbol.node, TypeInfo)
|
|
341
|
+
or not base_symbol.node.mro
|
|
342
|
+
):
|
|
343
|
+
raise helpers.IncompleteDefnException(base_name)
|
|
344
|
+
instance = fill_typevars_with_any(base_symbol.node)
|
|
345
|
+
assert isinstance(instance, Instance)
|
|
346
|
+
bases.append(instance)
|
|
347
|
+
self.django_context.composed[fullname] = model
|
|
348
|
+
if info is None:
|
|
349
|
+
info = helpers.add_new_class_for_module(module, name=model.__name__, bases=bases)
|
|
350
|
+
info.metadata["compose_runtime_model"] = {}
|
|
351
|
+
helpers.get_django_metadata(info)["is_abstract_model"] = model._meta.abstract
|
|
352
|
+
else:
|
|
353
|
+
info.bases = bases
|
|
354
|
+
info.mro = []
|
|
355
|
+
calculate_mro(info)
|
|
356
|
+
api.enter_class(info)
|
|
357
|
+
try:
|
|
358
|
+
api.setup_self_type()
|
|
359
|
+
finally:
|
|
360
|
+
api.leave_class()
|
|
361
|
+
for base_instance in bases:
|
|
362
|
+
ctx.api.add_plugin_dependency(make_trigger(base_instance.type.fullname), target=info.fullname)
|
|
363
|
+
for field in self.django_context.get_model_fields(model):
|
|
364
|
+
if field.name in info.names or isinstance(field, models.ManyToManyField):
|
|
365
|
+
continue
|
|
366
|
+
inherited = info.get(field.name)
|
|
367
|
+
if inherited is not None and isinstance(inherited.node, Var):
|
|
368
|
+
owner = self.django_context.get_model_class_by_fullname(inherited.node.info.fullname)
|
|
369
|
+
if owner is not None:
|
|
370
|
+
try:
|
|
371
|
+
original = owner._meta.get_field(field.name)
|
|
372
|
+
except FieldDoesNotExist:
|
|
373
|
+
# A static annotation need not declare a Django field.
|
|
374
|
+
original = None
|
|
375
|
+
# Django's abstract-field deepcopy preserves this counter;
|
|
376
|
+
# a newly declared override gets a new one.
|
|
377
|
+
if (
|
|
378
|
+
isinstance(original, models.Field)
|
|
379
|
+
and original.creation_counter == field.creation_counter
|
|
380
|
+
):
|
|
381
|
+
continue
|
|
382
|
+
field_fullname = helpers.get_class_fullname(type(field))
|
|
383
|
+
field_symbol = self.lookup_fully_qualified(field_fullname)
|
|
384
|
+
if (
|
|
385
|
+
field_symbol is None
|
|
386
|
+
or not isinstance(field_symbol.node, TypeInfo)
|
|
387
|
+
or not field_symbol.node.has_base(fullnames.FIELD_FULLNAME)
|
|
388
|
+
):
|
|
389
|
+
raise helpers.IncompleteDefnException(f"{field_fullname}: native Field base unavailable")
|
|
390
|
+
field_type = fill_typevars_with_any(field_symbol.node)
|
|
391
|
+
assert isinstance(field_type, Instance)
|
|
392
|
+
descriptors = get_field_descriptor_types(
|
|
393
|
+
field_symbol.node,
|
|
394
|
+
is_set_nullable=field.null
|
|
395
|
+
or isinstance(field, models.AutoField)
|
|
396
|
+
or (field.primary_key and field.has_default()),
|
|
397
|
+
is_get_nullable=field.null,
|
|
398
|
+
)
|
|
399
|
+
field_type = reparametrize_field_type(field_type, *descriptors)
|
|
400
|
+
if isinstance(field, models.ForeignKey):
|
|
401
|
+
related_model = self.django_context.get_field_related_model_cls(field)
|
|
402
|
+
related = self.django_context.lookup_model_typeinfo(api, related_model)
|
|
403
|
+
concrete_related = helpers.lookup_fully_qualified_typeinfo(
|
|
404
|
+
api, helpers.get_class_fullname(related_model)
|
|
405
|
+
)
|
|
406
|
+
if related is None or concrete_related is None:
|
|
407
|
+
raise helpers.IncompleteDefnException(helpers.get_class_fullname(related_model))
|
|
408
|
+
field_type = reparametrize_related_field_type(
|
|
409
|
+
field_type,
|
|
410
|
+
set_type=Instance(related, []),
|
|
411
|
+
get_type=Instance(concrete_related, []),
|
|
412
|
+
)
|
|
413
|
+
for dependency in (related, concrete_related):
|
|
414
|
+
ctx.api.add_plugin_dependency(make_trigger(dependency.fullname), target=info.fullname)
|
|
415
|
+
helpers.add_new_sym_for_info(info, name=field.name, sym_type=field_type)
|
|
416
|
+
ctx.api.add_plugin_dependency(make_trigger(field_symbol.node.fullname), target=info.fullname)
|
|
417
|
+
model_ctx = ClassDefContext(info.defn, ctx.reason, ctx.api)
|
|
418
|
+
process_model_class(model_ctx, self.django_context)
|
|
419
|
+
# Native discovery needs authored assignments; runtime fields instead
|
|
420
|
+
# supply their resolved facts to the same through/manager builders.
|
|
421
|
+
many_to_many = ProcessManyToManyFields(model_ctx, self.django_context)
|
|
422
|
+
for field in model._meta.many_to_many:
|
|
423
|
+
related_model = self.django_context.get_field_related_model_cls(field)
|
|
424
|
+
related = self.django_context.lookup_model_typeinfo(api, related_model)
|
|
425
|
+
if related is None:
|
|
426
|
+
raise helpers.IncompleteDefnException(helpers.get_class_fullname(related_model))
|
|
427
|
+
related_type = Instance(related, [])
|
|
428
|
+
through_model = field.remote_field.through
|
|
429
|
+
assert through_model is not None and not isinstance(through_model, str)
|
|
430
|
+
through = None
|
|
431
|
+
if not through_model._meta.auto_created:
|
|
432
|
+
through_info = self.django_context.lookup_model_typeinfo(api, through_model)
|
|
433
|
+
if through_info is None:
|
|
434
|
+
raise helpers.IncompleteDefnException(helpers.get_class_fullname(through_model))
|
|
435
|
+
through = M2MThrough(arg=None, model=Instance(through_info, []))
|
|
436
|
+
through_info = many_to_many.create_through_table_class(
|
|
437
|
+
field_name=field.name,
|
|
438
|
+
model_name=through_model.__name__,
|
|
439
|
+
model_fullname=helpers.get_class_fullname(through_model),
|
|
440
|
+
m2m_args=M2MArguments(
|
|
441
|
+
to=M2MTo(arg=info.defn, model=related_type, self=related == info),
|
|
442
|
+
through=through,
|
|
443
|
+
),
|
|
444
|
+
)
|
|
445
|
+
assert through_info is not None
|
|
446
|
+
if through is None:
|
|
447
|
+
many_to_many.add_through_table_managers(through_info)
|
|
448
|
+
helpers.add_new_sym_for_info(
|
|
449
|
+
info,
|
|
450
|
+
name=field.name,
|
|
451
|
+
sym_type=Instance(many_to_many.m2m_field, [related_type, Instance(through_info, [])]),
|
|
452
|
+
)
|
|
453
|
+
many_to_many.create_many_related_manager(Instance(info, []))
|
|
454
|
+
many_to_many.create_many_related_manager(related_type)
|
|
455
|
+
for dependency in (related, through_info):
|
|
456
|
+
ctx.api.add_plugin_dependency(make_trigger(dependency.fullname), target=info.fullname)
|
|
457
|
+
return info
|
|
458
|
+
except helpers.IncompleteDefnException as error:
|
|
459
|
+
if ctx.api.final_iteration:
|
|
460
|
+
ctx.api.fail(f"Unresolved runtime model {fullname}: {error}", ctx.cls)
|
|
461
|
+
else:
|
|
462
|
+
ctx.api.defer()
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
def _schedule_projection(self, ctx: ClassDefContext, target: TypeInfo) -> None:
|
|
466
|
+
for name, member in self.django_context.members.get(target.fullname, {}).items():
|
|
467
|
+
for dependency in (member.owner, f"{member.owner}.{name}", member.receiver):
|
|
468
|
+
ctx.api.add_plugin_dependency(make_trigger(dependency), target=target.fullname)
|
|
469
|
+
helpers.get_semanal_api(ctx).schedule_patch(
|
|
470
|
+
PRIORITY_FALLBACKS,
|
|
471
|
+
partial(self._project_members, target, ctx.api.named_type("builtins.function")),
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
def _project_members(self, target: TypeInfo, function_type: Instance) -> None:
|
|
475
|
+
for name in target.metadata.pop("compose_members", {}):
|
|
476
|
+
symbol = target.names.get(name)
|
|
477
|
+
if symbol and symbol.plugin_generated and isinstance(symbol.node, Var):
|
|
478
|
+
del target.names[name]
|
|
479
|
+
emitted = target.metadata.setdefault("compose_members", {})
|
|
480
|
+
for name, member in self.django_context.members.get(target.fullname, {}).items():
|
|
481
|
+
if target.get(name) is not None:
|
|
482
|
+
continue
|
|
483
|
+
origin = self.lookup_fully_qualified(member.owner)
|
|
484
|
+
receiver = self.lookup_fully_qualified(member.receiver)
|
|
485
|
+
if (
|
|
486
|
+
not origin
|
|
487
|
+
or not receiver
|
|
488
|
+
or not isinstance(origin.node, TypeInfo)
|
|
489
|
+
or not isinstance(receiver.node, TypeInfo)
|
|
490
|
+
):
|
|
491
|
+
continue
|
|
492
|
+
symbol = origin.node.names.get(name)
|
|
493
|
+
declaration = _member_variable(symbol.node) if symbol is not None else None
|
|
494
|
+
if declaration is None or declaration.type is None or has_placeholder(declaration.type):
|
|
495
|
+
continue
|
|
496
|
+
projected = self._specialize_member(
|
|
497
|
+
name, declaration, target, origin.node, receiver.node, function_type
|
|
498
|
+
)
|
|
499
|
+
target.names[name] = SymbolTableNode(MDEF, projected, plugin_generated=True)
|
|
500
|
+
emitted[name] = member.owner
|
|
501
|
+
|
|
502
|
+
@staticmethod
|
|
503
|
+
def _specialize_member(
|
|
504
|
+
name: str,
|
|
505
|
+
declaration: Var,
|
|
506
|
+
target: TypeInfo,
|
|
507
|
+
owner: TypeInfo,
|
|
508
|
+
receiver: TypeInfo,
|
|
509
|
+
function_type: Instance,
|
|
510
|
+
) -> Var:
|
|
511
|
+
# Load this native owner after mypy.types: its standalone import cycle
|
|
512
|
+
# otherwise prevents importing CompositionContext outside mypy's CLI.
|
|
513
|
+
from mypy.expandtype import expand_type, expand_type_by_instance
|
|
514
|
+
from mypy.maptype import map_instance_to_supertype
|
|
515
|
+
|
|
516
|
+
assert declaration.type is not None
|
|
517
|
+
instance = fill_typevars(receiver)
|
|
518
|
+
assert isinstance(instance, Instance)
|
|
519
|
+
specialized = map_instance_to_supertype(instance, owner)
|
|
520
|
+
replacement = fill_typevars(target)
|
|
521
|
+
remapping = {}
|
|
522
|
+
if owner.self_type is not None:
|
|
523
|
+
if target.self_type is None:
|
|
524
|
+
target.self_type = owner.self_type.copy_modified(upper_bound=replacement)
|
|
525
|
+
remapping[owner.self_type.id] = target.self_type
|
|
526
|
+
|
|
527
|
+
def bind(value: MypyType) -> MypyType:
|
|
528
|
+
bound = get_proper_type(expand_type(expand_type_by_instance(value, specialized), remapping))
|
|
529
|
+
if isinstance(bound, Overloaded):
|
|
530
|
+
items = [bind(item) for item in bound.items]
|
|
531
|
+
assert all(isinstance(item, CallableType) for item in items)
|
|
532
|
+
return Overloaded([item for item in items if isinstance(item, CallableType)])
|
|
533
|
+
if isinstance(bound, CallableType):
|
|
534
|
+
arguments = list(bound.arg_types)
|
|
535
|
+
if arguments and not declaration.is_staticmethod:
|
|
536
|
+
first = get_proper_type(arguments[0])
|
|
537
|
+
if isinstance(first, Instance) and first.type is owner:
|
|
538
|
+
arguments[0] = replacement
|
|
539
|
+
elif (
|
|
540
|
+
isinstance(first, TypeType)
|
|
541
|
+
and isinstance(first.item, Instance)
|
|
542
|
+
and first.item.type is owner
|
|
543
|
+
):
|
|
544
|
+
arguments[0] = TypeType(replacement)
|
|
545
|
+
return bound.copy_modified(
|
|
546
|
+
arg_types=arguments,
|
|
547
|
+
variables=[remapping.get(variable.id, variable) for variable in bound.variables],
|
|
548
|
+
fallback=function_type,
|
|
549
|
+
)
|
|
550
|
+
return bound
|
|
551
|
+
|
|
552
|
+
projected = Var(name, bind(declaration.type))
|
|
553
|
+
projected.info = target
|
|
554
|
+
projected._fullname = f"{target.fullname}.{name}"
|
|
555
|
+
for flag in (
|
|
556
|
+
"is_initialized_in_class",
|
|
557
|
+
"is_staticmethod",
|
|
558
|
+
"is_classmethod",
|
|
559
|
+
"is_property",
|
|
560
|
+
"is_settable_property",
|
|
561
|
+
"is_classvar",
|
|
562
|
+
"is_final",
|
|
563
|
+
"is_abstract_var",
|
|
564
|
+
"is_inferred",
|
|
565
|
+
):
|
|
566
|
+
setattr(projected, flag, getattr(declaration, flag))
|
|
567
|
+
if declaration.setter_type is not None:
|
|
568
|
+
setter = bind(declaration.setter_type)
|
|
569
|
+
assert isinstance(setter, CallableType)
|
|
570
|
+
projected.setter_type = setter
|
|
571
|
+
return projected
|
|
572
|
+
|
|
573
|
+
def get_dynamic_class_hook(self, fullname: str) -> Callable[[DynamicClassDefContext], None] | None:
|
|
574
|
+
native = super().get_dynamic_class_hook(fullname)
|
|
575
|
+
if native:
|
|
576
|
+
return native
|
|
577
|
+
owner, _, name = fullname.rpartition(".")
|
|
578
|
+
symbol = self.lookup_fully_qualified(owner)
|
|
579
|
+
if (
|
|
580
|
+
name == "from_queryset"
|
|
581
|
+
and symbol
|
|
582
|
+
and isinstance(symbol.node, PlaceholderNode)
|
|
583
|
+
and symbol.node.becomes_typeinfo
|
|
584
|
+
):
|
|
585
|
+
return partial(
|
|
586
|
+
create_new_manager_class_from_from_queryset_method, django_context=self.django_context
|
|
587
|
+
)
|
|
588
|
+
return None
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _member_variable(node: object) -> Var | None:
|
|
592
|
+
"""Expose analyzed native callable/property metadata as a projected variable."""
|
|
593
|
+
|
|
594
|
+
if isinstance(node, Var):
|
|
595
|
+
return node
|
|
596
|
+
if not isinstance(node, FuncDef | Decorator | OverloadedFuncDef):
|
|
597
|
+
return None
|
|
598
|
+
function = node.func if isinstance(node, Decorator) else node
|
|
599
|
+
declaration = node.var if isinstance(node, Decorator) else Var(function.name)
|
|
600
|
+
signature = declaration.type if isinstance(node, Decorator) else function.type
|
|
601
|
+
setter = declaration.setter_type
|
|
602
|
+
if isinstance(function, OverloadedFuncDef) and function.is_property:
|
|
603
|
+
getter = function.items[0]
|
|
604
|
+
if not isinstance(getter, Decorator):
|
|
605
|
+
return None
|
|
606
|
+
declaration = getter.var
|
|
607
|
+
signature = getter.func.type
|
|
608
|
+
if len(function.items) > 1 and isinstance(function.items[1], Decorator):
|
|
609
|
+
candidate = get_proper_type(function.items[1].func.type)
|
|
610
|
+
setter = candidate if isinstance(candidate, CallableType) else None
|
|
611
|
+
elif signature is None and isinstance(node, Decorator) and not node.decorators:
|
|
612
|
+
signature = function.type
|
|
613
|
+
if signature is None or has_placeholder(signature):
|
|
614
|
+
return None
|
|
615
|
+
result = Var(function.name, signature)
|
|
616
|
+
result.is_initialized_in_class = True
|
|
617
|
+
result.is_inferred = True
|
|
618
|
+
result.is_classmethod = function.is_class
|
|
619
|
+
result.is_staticmethod = function.is_static
|
|
620
|
+
result.is_property = function.is_property
|
|
621
|
+
result.is_classvar = not function.is_property and isinstance(
|
|
622
|
+
get_proper_type(signature), CallableType | Overloaded
|
|
623
|
+
)
|
|
624
|
+
result.is_final = function.is_final or declaration.is_final
|
|
625
|
+
result.is_settable_property = declaration.is_settable_property
|
|
626
|
+
result.setter_type = setter
|
|
627
|
+
return result
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def plugin(version: str) -> type[CompositionPlugin]:
|
|
631
|
+
"""Mypy's standard plugin entrypoint; install the optional typing extra."""
|
|
632
|
+
|
|
633
|
+
return CompositionPlugin
|
django_compose/urls.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Post-setup, opt-in native Django URL composition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
|
|
8
|
+
from django.apps import apps
|
|
9
|
+
from django.core.checks import Error
|
|
10
|
+
from django.urls import URLPattern, URLResolver, include, path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _patterns() -> Iterator[tuple[str, URLResolver]]:
|
|
14
|
+
apps.check_apps_ready()
|
|
15
|
+
if not apps.ready:
|
|
16
|
+
raise RuntimeError("URL composition requires completed django.setup()")
|
|
17
|
+
for config in apps.get_app_configs():
|
|
18
|
+
module = getattr(config, "compose_urls", None)
|
|
19
|
+
if module is not None:
|
|
20
|
+
yield (
|
|
21
|
+
config.name,
|
|
22
|
+
path(
|
|
23
|
+
getattr(config, "compose_url_prefix", ""),
|
|
24
|
+
include(module, namespace=getattr(config, "compose_url_namespace", None)),
|
|
25
|
+
),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _routes(pattern: URLPattern | URLResolver, prefix: str = "") -> Iterator[str]:
|
|
30
|
+
# Compare native effective regular expressions, including nested includes.
|
|
31
|
+
expression = pattern.pattern.regex.pattern.removeprefix("^")
|
|
32
|
+
route = prefix + expression
|
|
33
|
+
if isinstance(pattern, URLResolver):
|
|
34
|
+
for child in pattern.url_patterns:
|
|
35
|
+
yield from _routes(child, route)
|
|
36
|
+
else:
|
|
37
|
+
yield re.sub(r"\(\?P<[^>]+>", "(?:", route)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check_url_collisions(**kwargs: object) -> list[Error]:
|
|
41
|
+
"""Report competing effective routes, regardless of reverse namespace."""
|
|
42
|
+
owners: dict[str, str] = {}
|
|
43
|
+
errors = []
|
|
44
|
+
for owner, resolver in _patterns():
|
|
45
|
+
for route in _routes(resolver):
|
|
46
|
+
previous = owners.get(route)
|
|
47
|
+
owners[route] = owner
|
|
48
|
+
if previous is not None:
|
|
49
|
+
errors.append(
|
|
50
|
+
Error(
|
|
51
|
+
f"URL route {route!r} is claimed by {previous} and {owner}",
|
|
52
|
+
id="django_compose.E001",
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
return errors
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
urlpatterns = [resolver for _, resolver in _patterns()]
|