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,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()]