pydantic-modelable-mypy 0.4.0__tar.gz

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.
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2025, David Pineau
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: pydantic-modelable-mypy
3
+ Version: 0.4.0
4
+ Summary: A mypy plugin teaching the type-checker about pydantic-modelable runtime model extensions
5
+ Author-email: David Pineau <dav.pineau@gmail.com>
6
+ License-Expression: BSD-3-Clause
7
+ Project-URL: Homepage, https://joacchim.github.io/pydantic-modelable/
8
+ Project-URL: Source, https://github.com/Joacchim/pydantic-modelable
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Programming Language :: Python
11
+ Classifier: Programming Language :: Python :: Implementation :: CPython
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Intended Audience :: Developers
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Framework :: Pydantic :: 2
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: pydantic-modelable
28
+ Requires-Dist: mypy>=1.11
29
+ Provides-Extra: test
30
+ Requires-Dist: pytest; extra == "test"
31
+ Dynamic: license-file
32
+
33
+ # pydantic-modelable-mypy
34
+
35
+ A [mypy](https://mypy-lang.org/) plugin that teaches the type-checker about the
36
+ model extensions `pydantic_modelable` performs at runtime.
37
+
38
+ ## Why
39
+
40
+ `pydantic_modelable` lets a class decorate a *different*, pre-existing model to
41
+ add fields, union members or enum values to it. A decorator cannot re-type a
42
+ third class in the standard type system, so `mypy --strict` reports the added
43
+ fields as unknown attributes. This plugin observes those decorators and injects
44
+ the corresponding members into the target model during analysis.
45
+
46
+ ## Usage
47
+
48
+ Install alongside `pydantic-modelable` and enable the plugin in your mypy
49
+ configuration:
50
+
51
+ ```ini
52
+ [mypy]
53
+ plugins = pydantic_modelable_mypy.plugin
54
+ ```
55
+
56
+ ## What it covers
57
+
58
+ - `as_attribute` — the injected field is known for reads and construction.
59
+ - `extends_union` — the field is typed as the discriminated union of the base's
60
+ subtypes (reads and construction), including subtypes defined in other
61
+ modules, on full and incremental runs.
62
+ - `extends_enum` — the discriminator values injected as enum members are
63
+ resolved by name (`Palette.red`).
64
+ - `ModelableForwarder` — decorators applied through a forwarder
65
+ (`@Fwd.as_attribute(...)`) are resolved through the `forwards_to` chain
66
+ (including chained forwarders) to the target `Modelable` and handled as above.
67
+
68
+ ## Limitation (`extends_enum` member names)
69
+
70
+ Enum member access on an unknown name offers no plugin hook, so injected member
71
+ names can only be resolved from subtypes discovered during analysis. This is
72
+ reliable on **full (non-incremental) runs** (e.g. CI with `mypy` fresh, or
73
+ `--no-incremental`). On **incremental / daemon** runs, subtypes served from
74
+ cache are not re-analysed, so member-name access (`Palette.red`) may report an
75
+ unknown attribute until a clean run. Everything else `ModelableStrEnum` provides
76
+ — iteration, membership, construction, `.value` — works in all modes, as does
77
+ all of `as_attribute` and `extends_union`.
@@ -0,0 +1,45 @@
1
+ # pydantic-modelable-mypy
2
+
3
+ A [mypy](https://mypy-lang.org/) plugin that teaches the type-checker about the
4
+ model extensions `pydantic_modelable` performs at runtime.
5
+
6
+ ## Why
7
+
8
+ `pydantic_modelable` lets a class decorate a *different*, pre-existing model to
9
+ add fields, union members or enum values to it. A decorator cannot re-type a
10
+ third class in the standard type system, so `mypy --strict` reports the added
11
+ fields as unknown attributes. This plugin observes those decorators and injects
12
+ the corresponding members into the target model during analysis.
13
+
14
+ ## Usage
15
+
16
+ Install alongside `pydantic-modelable` and enable the plugin in your mypy
17
+ configuration:
18
+
19
+ ```ini
20
+ [mypy]
21
+ plugins = pydantic_modelable_mypy.plugin
22
+ ```
23
+
24
+ ## What it covers
25
+
26
+ - `as_attribute` — the injected field is known for reads and construction.
27
+ - `extends_union` — the field is typed as the discriminated union of the base's
28
+ subtypes (reads and construction), including subtypes defined in other
29
+ modules, on full and incremental runs.
30
+ - `extends_enum` — the discriminator values injected as enum members are
31
+ resolved by name (`Palette.red`).
32
+ - `ModelableForwarder` — decorators applied through a forwarder
33
+ (`@Fwd.as_attribute(...)`) are resolved through the `forwards_to` chain
34
+ (including chained forwarders) to the target `Modelable` and handled as above.
35
+
36
+ ## Limitation (`extends_enum` member names)
37
+
38
+ Enum member access on an unknown name offers no plugin hook, so injected member
39
+ names can only be resolved from subtypes discovered during analysis. This is
40
+ reliable on **full (non-incremental) runs** (e.g. CI with `mypy` fresh, or
41
+ `--no-incremental`). On **incremental / daemon** runs, subtypes served from
42
+ cache are not re-analysed, so member-name access (`Palette.red`) may report an
43
+ unknown attribute until a clean run. Everything else `ModelableStrEnum` provides
44
+ — iteration, membership, construction, `.value` — works in all modes, as does
45
+ all of `as_attribute` and `extends_union`.
@@ -0,0 +1,25 @@
1
+ """A mypy plugin for pydantic-modelable.
2
+
3
+ `pydantic_modelable` extends pydantic models at runtime: decorating a class
4
+ with `Modelable.as_attribute`, `extends_union` or `extends_enum` mutates a
5
+ *different*, pre-existing model. The static type system cannot express such a
6
+ cross-class mutation, so `mypy --strict` reports the extended fields as unknown
7
+ attributes.
8
+
9
+ This package ships a mypy plugin that observes those decorators during analysis
10
+ and teaches the type-checker about the fields they add to the target model.
11
+
12
+ Enable it in your mypy configuration:
13
+
14
+ ```ini
15
+ [mypy]
16
+ plugins = pydantic_modelable_mypy.plugin
17
+ ```
18
+ """
19
+
20
+ from .plugin import ModelablePlugin, plugin
21
+
22
+ __all__ = [
23
+ 'ModelablePlugin',
24
+ 'plugin',
25
+ ]
@@ -0,0 +1,547 @@
1
+ """The mypy plugin entrypoint for pydantic-modelable.
2
+
3
+ `pydantic_modelable`'s registration decorators mutate a *different*, pre-existing
4
+ model at runtime, which the static type system cannot express. This plugin
5
+ observes those decorators during semantic analysis and injects the corresponding
6
+ members into the target model's `TypeInfo`, so `mypy --strict` sees the extended
7
+ model as its runtime shape.
8
+
9
+ Capabilities are added one decorator at a time; each is backed by a test that
10
+ first reproduces the error it fixes.
11
+ """
12
+
13
+ from collections.abc import Callable
14
+
15
+ from mypy.nodes import (
16
+ ARG_NAMED,
17
+ ARG_NAMED_OPT,
18
+ ARG_STAR2,
19
+ CallExpr,
20
+ Expression,
21
+ MemberExpr,
22
+ NameExpr,
23
+ RefExpr,
24
+ StrExpr,
25
+ TypeInfo,
26
+ Var,
27
+ )
28
+ from mypy.options import Options
29
+ from mypy.plugin import (
30
+ AttributeContext,
31
+ ClassDefContext,
32
+ FunctionSigContext,
33
+ Plugin,
34
+ SemanticAnalyzerPluginInterface,
35
+ )
36
+ from mypy.plugins.common import add_attribute_to_class
37
+ from mypy.types import (
38
+ CallableType,
39
+ FunctionLike,
40
+ Instance,
41
+ LiteralType,
42
+ NoneType,
43
+ ProperType,
44
+ Type,
45
+ UnionType,
46
+ get_proper_type,
47
+ )
48
+
49
+ # The base every extensible model derives from; used to filter our decorators
50
+ # apart from any unrelated method that happens to be named `as_attribute`.
51
+ MODELABLE_FULLNAME = 'pydantic_modelable.model.Modelable'
52
+ # The registration proxy: decorators invoked on a subclass are delegated to its
53
+ # `forwards_to` target (a `Modelable` or a further forwarder).
54
+ FORWARDER_FULLNAME = 'pydantic_modelable.forwarder.ModelableForwarder'
55
+
56
+ _AS_ATTRIBUTE = 'as_attribute'
57
+ _EXTENDS_UNION = 'extends_union'
58
+ _EXTENDS_ENUM = 'extends_enum'
59
+
60
+ # Parameters of `Modelable.as_attribute`, after the bound `cls`, in order.
61
+ _AS_ATTRIBUTE_PARAMS = ('attr_name', 'optional', 'default_factory')
62
+
63
+ # Metadata persisted in mypy's cache, so the information survives incremental /
64
+ # daemon runs where the module that recorded it is not re-analysed.
65
+ _METADATA_KEY = 'pydantic_modelable'
66
+ # On a target `TypeInfo`: {attr_name: required} injected by `as_attribute`.
67
+ _INJECTED_ATTRS = 'injected_attrs'
68
+ # On a container `TypeInfo`: {attr_name: base_fullname} for `extends_union`.
69
+ _UNION_FIELDS = 'union_fields'
70
+ # On an extensible base `TypeInfo`: the discriminator field name.
71
+ _DISCRIMINATOR = 'discriminator'
72
+ # On a `ModelableForwarder` `TypeInfo`: the `forwards_to` target fullname.
73
+ _FORWARDS_TO = 'forwards_to'
74
+
75
+
76
+ class ModelablePlugin(Plugin):
77
+ """Teach mypy about the models `pydantic_modelable` extends at runtime."""
78
+
79
+ def __init__(self, options: Options) -> None:
80
+ """Initialise the per-run caches."""
81
+ super().__init__(options)
82
+ # Subtypes discovered during semantic analysis (`get_base_class_hook`).
83
+ # Reliable for a full run — where module trees may be freed before the
84
+ # check phase, defeating the module scan — but empty for classes loaded
85
+ # from cache on an incremental run.
86
+ self._union_subtypes: dict[str, list[str]] = {}
87
+ # Same-run supplement to the container's persisted `union_fields`
88
+ # metadata: covers containers decorated this run before their cache
89
+ # exists. Keyed by 'Container.attr' fullname -> base fullname.
90
+ self._union_fields: dict[str, str] = {}
91
+ # Extensible base fullname -> its discriminator field name, captured
92
+ # from the base's `discriminator=` class keyword.
93
+ self._discriminators: dict[str, str] = {}
94
+ # Extensible base fullname -> the extensible enum TypeInfos registered
95
+ # onto it. Subtypes analysed after the enum push their discriminator
96
+ # values into these (the enum's module is analysed before its subtypes',
97
+ # so the enum TypeInfo already exists when a subtype is seen).
98
+ self._enum_bases: dict[str, list[TypeInfo]] = {}
99
+ # ModelableForwarder subclass fullname -> its `forwards_to` target
100
+ # fullname (a Modelable or a further forwarder).
101
+ self._forwards_to: dict[str, str] = {}
102
+
103
+ def get_class_decorator_hook_2(self, fullname: str) -> Callable[[ClassDefContext], bool] | None:
104
+ """Dispatch class-decorator analysis for the registration decorators.
105
+
106
+ mypy keys this on the decorator's fullname. The decorators are
107
+ classmethods inherited from `Modelable` (or `ModelableForwarder`, which
108
+ delegates), so the trailing component identifies them regardless of the
109
+ receiving subclass; the receiver is resolved (through any forwarder
110
+ chain) to the target `Modelable` at handling time.
111
+ """
112
+ name = fullname.rsplit('.', 1)[-1]
113
+ if name == _AS_ATTRIBUTE:
114
+ return self._as_attribute_hook
115
+ if name == _EXTENDS_UNION:
116
+ return self._record_union_field
117
+ if name == _EXTENDS_ENUM:
118
+ return self._extend_enum_hook
119
+ return None
120
+
121
+ def get_base_class_hook(self, fullname: str) -> Callable[[ClassDefContext], None] | None:
122
+ """Record subclasses of an extensible `Modelable` base during analysis.
123
+
124
+ mypy keys this on the base's fullname, which may be a re-export path
125
+ (e.g. `pydantic_modelable.Modelable`), so we resolve it to a `TypeInfo`
126
+ and compare its canonical fullname. When the base is `Modelable` itself,
127
+ the class being defined is an extensible base and we capture its
128
+ discriminator for `extends_enum`. When the base is a proper `Modelable`
129
+ subclass, the class is one of its union alternatives; recording it here
130
+ keeps full runs reliable, since the check-time module scan cannot see
131
+ trees mypy has freed.
132
+ """
133
+ symbol = self.lookup_fully_qualified(fullname)
134
+ if symbol is None or not isinstance(symbol.node, TypeInfo):
135
+ return None
136
+ base_info = symbol.node
137
+ if base_info.fullname == MODELABLE_FULLNAME:
138
+ return self._capture_discriminator
139
+ if base_info.fullname == FORWARDER_FULLNAME:
140
+ return self._capture_forwards_to
141
+ if not base_info.has_base(MODELABLE_FULLNAME):
142
+ return None
143
+ base_fullname = base_info.fullname
144
+
145
+ def _register(ctx: ClassDefContext) -> None:
146
+ subtypes = self._union_subtypes.setdefault(base_fullname, [])
147
+ subtype = ctx.cls.info.fullname
148
+ if subtype not in subtypes:
149
+ subtypes.append(subtype)
150
+ # Push this subtype's discriminator values into any enum already
151
+ # registered for the base (covers subtypes — incl. cross-module —
152
+ # defined after the enum).
153
+ discriminator = self._discriminator_of(base_fullname)
154
+ if discriminator is not None:
155
+ for enum_info in self._enum_bases.get(base_fullname, []):
156
+ self._add_enum_members(ctx.api, enum_info, ctx.cls.info, discriminator)
157
+
158
+ return _register
159
+
160
+ def get_attribute_hook(self, fullname: str) -> Callable[[AttributeContext], Type] | None:
161
+ """Type an `extends_union` field as the union of its base's subtypes."""
162
+ base_fullname = self._union_field_base(fullname)
163
+ if base_fullname is None:
164
+ return None
165
+
166
+ def _typed(ctx: AttributeContext) -> Type:
167
+ union = self._build_union(base_fullname)
168
+ return union if union is not None else ctx.default_attr_type
169
+
170
+ return _typed
171
+
172
+ def get_function_signature_hook(self, fullname: str) -> Callable[[FunctionSigContext], FunctionLike] | None:
173
+ """Adjust constructor signatures of extended models.
174
+
175
+ Two adjustments, both keyed on the callee (constructor) fullname:
176
+
177
+ - `as_attribute` injects fields whose keyword the synthesized pydantic
178
+ `__init__` does not know about; we append them.
179
+ - `extends_union` rewrites a declared field into a discriminated union;
180
+ we narrow that field's constructor keyword to the union so a bare
181
+ base instance is rejected, matching runtime validation.
182
+
183
+ Offered only for `Modelable` subclasses or classes carrying union
184
+ fields, so unrelated callables are left untouched.
185
+ """
186
+ symbol = self.lookup_fully_qualified(fullname)
187
+ if symbol is None or not isinstance(symbol.node, TypeInfo):
188
+ return None
189
+ if symbol.node.has_base(MODELABLE_FULLNAME) or self._union_fields_of(symbol.node):
190
+ return self._constructor_sig_hook
191
+ return None
192
+
193
+ def _record_union_field(self, ctx: ClassDefContext) -> bool:
194
+ """Record that `@Base.extends_union('attr')` makes `attr` a discriminated union.
195
+
196
+ The field keeps its declared type; the union is resolved lazily at use
197
+ (read site and constructor) once every subtype is known. The mapping is
198
+ stored on the container's own (persisted) metadata plus the same-run
199
+ registry.
200
+ """
201
+ reason = ctx.reason
202
+ receiver = _receiver_type(reason, _EXTENDS_UNION)
203
+ if receiver is None:
204
+ return False
205
+ base = self._resolve_modelable(receiver)
206
+ if base is None:
207
+ return True
208
+ assert isinstance(reason, CallExpr)
209
+ attr_name: str | None = None
210
+ for name, arg in zip(reason.arg_names, reason.args, strict=True):
211
+ if isinstance(arg, StrExpr) and name in (None, 'attr_name'):
212
+ attr_name = arg.value
213
+ break
214
+ if attr_name is None:
215
+ return True
216
+ container = ctx.cls.info
217
+ container.metadata.setdefault(_METADATA_KEY, {}).setdefault(_UNION_FIELDS, {})[attr_name] = base.fullname
218
+ self._union_fields[f'{container.fullname}.{attr_name}'] = base.fullname
219
+ return True
220
+
221
+ def _as_attribute_hook(self, ctx: ClassDefContext) -> bool:
222
+ """Inject the field `as_attribute` adds onto its target model.
223
+
224
+ Returns whether analysis is complete: if the receiver is not resolvable
225
+ yet, we return `False` so mypy re-runs us on a later pass. The receiver
226
+ may be a `ModelableForwarder`, in which case it is resolved through its
227
+ `forwards_to` chain to the target `Modelable`.
228
+ """
229
+ receiver = _receiver_type(ctx.reason, _AS_ATTRIBUTE)
230
+ if receiver is None:
231
+ return False
232
+ target = self._resolve_modelable(receiver)
233
+ if target is None:
234
+ return True
235
+
236
+ reason = ctx.reason
237
+ assert isinstance(reason, CallExpr)
238
+ bound = _bind_arguments(reason)
239
+ attr_name_expr = bound.get('attr_name')
240
+ if not isinstance(attr_name_expr, StrExpr):
241
+ return True
242
+ attr_name = attr_name_expr.value
243
+
244
+ # `optional=True` widens the field type; it does not supply a default. A
245
+ # field is required at construction unless a `default_factory` is given.
246
+ optional = _is_true(bound.get('optional'))
247
+ factory = bound.get('default_factory')
248
+ required = factory is None or _is_none(factory)
249
+
250
+ attr_type: ProperType = Instance(ctx.cls.info, [])
251
+ if optional:
252
+ attr_type = UnionType([attr_type, NoneType()])
253
+
254
+ add_attribute_to_class(ctx.api, target.defn, attr_name, attr_type)
255
+ _record_injected(target, attr_name, required=required)
256
+ return True
257
+
258
+ def _capture_discriminator(self, ctx: ClassDefContext) -> None:
259
+ """Record the `discriminator=` keyword of an extensible base being defined."""
260
+ discriminator = ctx.cls.keywords.get(_DISCRIMINATOR)
261
+ if not isinstance(discriminator, StrExpr):
262
+ return
263
+ base = ctx.cls.info
264
+ self._discriminators[base.fullname] = discriminator.value
265
+ base.metadata.setdefault(_METADATA_KEY, {})[_DISCRIMINATOR] = discriminator.value
266
+
267
+ def _capture_forwards_to(self, ctx: ClassDefContext) -> None:
268
+ """Record the `forwards_to=` target of a `ModelableForwarder` being defined."""
269
+ target = ctx.cls.keywords.get(_FORWARDS_TO)
270
+ if not isinstance(target, RefExpr) or not isinstance(target.node, TypeInfo):
271
+ return
272
+ forwarder = ctx.cls.info
273
+ self._forwards_to[forwarder.fullname] = target.node.fullname
274
+ forwarder.metadata.setdefault(_METADATA_KEY, {})[_FORWARDS_TO] = target.node.fullname
275
+
276
+ def _forwards_to_of(self, forwarder_fullname: str) -> str | None:
277
+ """Return a forwarder's `forwards_to` target fullname, or None."""
278
+ cached = self._forwards_to.get(forwarder_fullname)
279
+ if cached is not None:
280
+ return cached
281
+ symbol = self.lookup_fully_qualified(forwarder_fullname)
282
+ if symbol is None or not isinstance(symbol.node, TypeInfo):
283
+ return None
284
+ value: str | None = symbol.node.metadata.get(_METADATA_KEY, {}).get(_FORWARDS_TO)
285
+ return value
286
+
287
+ def _resolve_modelable(self, info: TypeInfo, depth: int = 0) -> TypeInfo | None:
288
+ """Resolve a decorator receiver to its target `Modelable`.
289
+
290
+ A `Modelable` subclass resolves to itself; a `ModelableForwarder` is
291
+ followed through its `forwards_to` chain (bounded, to guard cycles) to
292
+ the target `Modelable`. Anything else yields None.
293
+ """
294
+ if info.has_base(MODELABLE_FULLNAME):
295
+ return info
296
+ if depth < 16 and info.has_base(FORWARDER_FULLNAME):
297
+ target = self._forwards_to_of(info.fullname)
298
+ if target is not None:
299
+ symbol = self.lookup_fully_qualified(target)
300
+ if symbol is not None and isinstance(symbol.node, TypeInfo):
301
+ return self._resolve_modelable(symbol.node, depth + 1)
302
+ return None
303
+
304
+ def _discriminator_of(self, base_fullname: str) -> str | None:
305
+ """Return the discriminator field name of an extensible base, or None."""
306
+ cached = self._discriminators.get(base_fullname)
307
+ if cached is not None:
308
+ return cached
309
+ symbol = self.lookup_fully_qualified(base_fullname)
310
+ if symbol is None or not isinstance(symbol.node, TypeInfo):
311
+ return None
312
+ value: str | None = symbol.node.metadata.get(_METADATA_KEY, {}).get(_DISCRIMINATOR)
313
+ return value
314
+
315
+ def _extend_enum_hook(self, ctx: ClassDefContext) -> bool:
316
+ """Inject the discriminator literals of a base's subtypes as enum members.
317
+
318
+ `@Base.extends_enum` adds, at runtime, one member per discriminator value
319
+ of every `Base` subtype. We mirror that on the decorated enum's TypeInfo
320
+ so member access (`Species.one`) resolves.
321
+ """
322
+ receiver = _receiver_type(ctx.reason, _EXTENDS_ENUM)
323
+ if receiver is None:
324
+ return False
325
+ base = self._resolve_modelable(receiver)
326
+ if base is None:
327
+ return True
328
+ discriminator = self._discriminator_of(base.fullname)
329
+ if discriminator is None:
330
+ return True
331
+ enum_info = ctx.cls.info
332
+ # Register so subtypes analysed later push their values into this enum.
333
+ enums = self._enum_bases.setdefault(base.fullname, [])
334
+ if enum_info not in enums:
335
+ enums.append(enum_info)
336
+ # Inject members for subtypes already known (defined before the enum, or
337
+ # loaded from cache on an incremental run).
338
+ for subtype_fullname in self._subtypes_of(base.fullname):
339
+ symbol = self.lookup_fully_qualified(subtype_fullname)
340
+ if symbol is not None and isinstance(symbol.node, TypeInfo):
341
+ self._add_enum_members(ctx.api, enum_info, symbol.node, discriminator)
342
+ return True
343
+
344
+ def _add_enum_members(
345
+ self,
346
+ api: SemanticAnalyzerPluginInterface,
347
+ enum_info: TypeInfo,
348
+ subtype: TypeInfo,
349
+ discriminator: str,
350
+ ) -> None:
351
+ """Add `subtype`'s discriminator literal(s) as members of `enum_info`."""
352
+ field = subtype.get(discriminator)
353
+ if field is None or not isinstance(field.node, Var) or field.node.type is None:
354
+ return
355
+ for value in _literal_str_values(field.node.type):
356
+ if value not in enum_info.names:
357
+ add_attribute_to_class(api, enum_info.defn, value, Instance(enum_info, []))
358
+
359
+ def _subtypes_of(self, base_fullname: str) -> list[str]:
360
+ """Direct subclasses of an extensible base: registry + module-graph scan.
361
+
362
+ The semantic-analysis registry (`_union_subtypes`) covers classes
363
+ analysed this run; the module-graph scan covers classes loaded from
364
+ cache on incremental runs (where semanal, hence the registry, is
365
+ skipped). Merged, de-duplicated by fullname, registry order first.
366
+
367
+ Deliberately not memoised: an early call (e.g. during incremental SCC
368
+ processing, before the registry is complete) would otherwise cache an
369
+ incomplete result. Only invoked for genuine union-field accesses.
370
+ """
371
+ ordered: list[str] = []
372
+ seen: set[str] = set()
373
+ for name in self._union_subtypes.get(base_fullname, []):
374
+ if name not in seen:
375
+ seen.add(name)
376
+ ordered.append(name)
377
+ for module in (self._modules or {}).values():
378
+ for symbol in module.names.values():
379
+ node = symbol.node
380
+ if (
381
+ isinstance(node, TypeInfo)
382
+ and node.fullname not in seen
383
+ and any(base.type.fullname == base_fullname for base in node.bases)
384
+ ):
385
+ seen.add(node.fullname)
386
+ ordered.append(node.fullname)
387
+ return ordered
388
+
389
+ def _union_field_base(self, attr_fullname: str) -> str | None:
390
+ """Return the extensible base for a `Container.attr` fullname, or None."""
391
+ base = self._union_fields.get(attr_fullname)
392
+ if base is not None:
393
+ return base
394
+ cls_fullname, _, attr = attr_fullname.rpartition('.')
395
+ if not attr:
396
+ return None
397
+ symbol = self.lookup_fully_qualified(cls_fullname)
398
+ if symbol is None or not isinstance(symbol.node, TypeInfo):
399
+ return None
400
+ fields: dict[str, str] = symbol.node.metadata.get(_METADATA_KEY, {}).get(_UNION_FIELDS, {})
401
+ return fields.get(attr)
402
+
403
+ def _union_fields_of(self, info: TypeInfo) -> dict[str, str]:
404
+ """Return the `extends_union` fields declared on `info`: {attr: base_fullname}."""
405
+ fields: dict[str, str] = dict(info.metadata.get(_METADATA_KEY, {}).get(_UNION_FIELDS, {}))
406
+ prefix = f'{info.fullname}.'
407
+ for key, base in self._union_fields.items():
408
+ if key.startswith(prefix):
409
+ fields[key[len(prefix):]] = base
410
+ return fields
411
+
412
+ def _build_union(self, base_fullname: str) -> Type | None:
413
+ """Build the discriminated union of a base's subtypes, or None if none resolve."""
414
+ instances: list[Instance] = []
415
+ for subtype in self._subtypes_of(base_fullname):
416
+ symbol = self.lookup_fully_qualified(subtype)
417
+ if symbol is not None and isinstance(symbol.node, TypeInfo):
418
+ instances.append(Instance(symbol.node, []))
419
+ if not instances:
420
+ return None
421
+ return UnionType.make_union(instances)
422
+
423
+ def _constructor_sig_hook(self, ctx: FunctionSigContext) -> FunctionLike:
424
+ """Narrow `extends_union` fields and append `as_attribute` fields on a constructor."""
425
+ signature = ctx.default_signature
426
+ if not isinstance(signature, CallableType):
427
+ return signature
428
+ ret_type = get_proper_type(signature.ret_type)
429
+ if not isinstance(ret_type, Instance):
430
+ return signature
431
+ info = ret_type.type
432
+
433
+ arg_types = list(signature.arg_types)
434
+ arg_kinds = list(signature.arg_kinds)
435
+ arg_names = list(signature.arg_names)
436
+
437
+ # extends_union: narrow the declared field's keyword to the union.
438
+ for attr, base_fullname in self._union_fields_of(info).items():
439
+ if attr not in arg_names:
440
+ continue
441
+ union = self._build_union(base_fullname)
442
+ if union is not None:
443
+ arg_types[arg_names.index(attr)] = union
444
+
445
+ # as_attribute: append injected fields, unless the constructor already
446
+ # accepts arbitrary keywords (inserting named args after `**kwargs` would
447
+ # be ill-formed).
448
+ if ARG_STAR2 not in arg_kinds:
449
+ existing = set(arg_names)
450
+ for name, (typ, required) in _injected_fields(info).items():
451
+ if name in existing:
452
+ continue
453
+ arg_types.append(typ)
454
+ arg_kinds.append(ARG_NAMED if required else ARG_NAMED_OPT)
455
+ arg_names.append(name)
456
+
457
+ return signature.copy_modified(arg_types=arg_types, arg_kinds=arg_kinds, arg_names=arg_names)
458
+
459
+
460
+ def _record_injected(info: TypeInfo, attr_name: str, *, required: bool) -> None:
461
+ """Note, in persisted metadata, the field we injected onto `info`."""
462
+ data = info.metadata.setdefault(_METADATA_KEY, {})
463
+ attrs = data.setdefault(_INJECTED_ATTRS, {})
464
+ attrs[attr_name] = required
465
+
466
+
467
+ def _injected_fields(info: TypeInfo) -> dict[str, tuple[Type, bool]]:
468
+ """Fields injected onto `info` (or a base), mapping name -> (type, required)."""
469
+ fields: dict[str, tuple[Type, bool]] = {}
470
+ for base in info.mro:
471
+ attrs = base.metadata.get(_METADATA_KEY, {}).get(_INJECTED_ATTRS, {})
472
+ for name, required in attrs.items():
473
+ symbol = info.get(name)
474
+ if symbol is not None and isinstance(symbol.node, Var) and symbol.node.type is not None:
475
+ fields[name] = (symbol.node.type, required)
476
+ return fields
477
+
478
+
479
+ def _receiver_type(reason: Expression, method: str) -> TypeInfo | None:
480
+ """Return the class a `@Receiver.method(...)` / `@Receiver.method` decorator is bound to.
481
+
482
+ That is the object the decorator method is invoked on (a `Modelable` or a
483
+ `ModelableForwarder`), not the decorated class. Handles both the call form
484
+ (`as_attribute`, `extends_union`) and the bare form (`extends_enum`).
485
+ """
486
+ callee = reason.callee if isinstance(reason, CallExpr) else reason
487
+ if not isinstance(callee, MemberExpr) or callee.name != method:
488
+ return None
489
+ receiver = callee.expr
490
+ if isinstance(receiver, RefExpr) and isinstance(receiver.node, TypeInfo):
491
+ return receiver.node
492
+ return None
493
+
494
+
495
+ def _bind_arguments(reason: CallExpr) -> dict[str, Expression]:
496
+ """Map a decorator call's positional/keyword args to parameter names.
497
+
498
+ `as_attribute` is called as a bound classmethod (`Shelter.as_attribute(...)`),
499
+ so positional arguments align with `_AS_ATTRIBUTE_PARAMS` directly.
500
+ """
501
+ bound: dict[str, Expression] = {}
502
+ position = 0
503
+ for name, arg in zip(reason.arg_names, reason.args, strict=True):
504
+ if name is None:
505
+ if position < len(_AS_ATTRIBUTE_PARAMS):
506
+ bound[_AS_ATTRIBUTE_PARAMS[position]] = arg
507
+ position += 1
508
+ else:
509
+ bound[name] = arg
510
+ return bound
511
+
512
+
513
+ def _literal_str_values(typ: Type) -> list[str]:
514
+ """Extract the string values of a `Literal[...]` type (a union yields several)."""
515
+ proper = get_proper_type(typ)
516
+ if isinstance(proper, UnionType):
517
+ values: list[str] = []
518
+ for item in proper.items:
519
+ values.extend(_literal_str_values(item))
520
+ return values
521
+ if isinstance(proper, LiteralType) and isinstance(proper.value, str):
522
+ return [proper.value]
523
+ # A field declared as a value (`mtype: Literal['one'] = 'one'`) may present
524
+ # as its fallback instance carrying the literal in `last_known_value`.
525
+ if isinstance(proper, Instance) and proper.last_known_value is not None:
526
+ return _literal_str_values(proper.last_known_value)
527
+ return []
528
+
529
+
530
+ def _is_true(expr: object) -> bool:
531
+ """Whether a call argument is the literal `True`."""
532
+ return isinstance(expr, NameExpr) and expr.fullname == 'builtins.True'
533
+
534
+
535
+ def _is_none(expr: object) -> bool:
536
+ """Whether a call argument is the literal `None`."""
537
+ return isinstance(expr, NameExpr) and expr.fullname == 'builtins.None'
538
+
539
+
540
+ def plugin(version: str) -> type[Plugin]:
541
+ """Return the plugin class for mypy to instantiate.
542
+
543
+ This is the entrypoint mypy looks up from the `plugins` configuration key.
544
+ The `version` argument is mypy's own version string, which a plugin may use
545
+ to guard against incompatible internal APIs.
546
+ """
547
+ return ModelablePlugin
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: pydantic-modelable-mypy
3
+ Version: 0.4.0
4
+ Summary: A mypy plugin teaching the type-checker about pydantic-modelable runtime model extensions
5
+ Author-email: David Pineau <dav.pineau@gmail.com>
6
+ License-Expression: BSD-3-Clause
7
+ Project-URL: Homepage, https://joacchim.github.io/pydantic-modelable/
8
+ Project-URL: Source, https://github.com/Joacchim/pydantic-modelable
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Programming Language :: Python
11
+ Classifier: Programming Language :: Python :: Implementation :: CPython
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Intended Audience :: Developers
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Framework :: Pydantic :: 2
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: pydantic-modelable
28
+ Requires-Dist: mypy>=1.11
29
+ Provides-Extra: test
30
+ Requires-Dist: pytest; extra == "test"
31
+ Dynamic: license-file
32
+
33
+ # pydantic-modelable-mypy
34
+
35
+ A [mypy](https://mypy-lang.org/) plugin that teaches the type-checker about the
36
+ model extensions `pydantic_modelable` performs at runtime.
37
+
38
+ ## Why
39
+
40
+ `pydantic_modelable` lets a class decorate a *different*, pre-existing model to
41
+ add fields, union members or enum values to it. A decorator cannot re-type a
42
+ third class in the standard type system, so `mypy --strict` reports the added
43
+ fields as unknown attributes. This plugin observes those decorators and injects
44
+ the corresponding members into the target model during analysis.
45
+
46
+ ## Usage
47
+
48
+ Install alongside `pydantic-modelable` and enable the plugin in your mypy
49
+ configuration:
50
+
51
+ ```ini
52
+ [mypy]
53
+ plugins = pydantic_modelable_mypy.plugin
54
+ ```
55
+
56
+ ## What it covers
57
+
58
+ - `as_attribute` — the injected field is known for reads and construction.
59
+ - `extends_union` — the field is typed as the discriminated union of the base's
60
+ subtypes (reads and construction), including subtypes defined in other
61
+ modules, on full and incremental runs.
62
+ - `extends_enum` — the discriminator values injected as enum members are
63
+ resolved by name (`Palette.red`).
64
+ - `ModelableForwarder` — decorators applied through a forwarder
65
+ (`@Fwd.as_attribute(...)`) are resolved through the `forwards_to` chain
66
+ (including chained forwarders) to the target `Modelable` and handled as above.
67
+
68
+ ## Limitation (`extends_enum` member names)
69
+
70
+ Enum member access on an unknown name offers no plugin hook, so injected member
71
+ names can only be resolved from subtypes discovered during analysis. This is
72
+ reliable on **full (non-incremental) runs** (e.g. CI with `mypy` fresh, or
73
+ `--no-incremental`). On **incremental / daemon** runs, subtypes served from
74
+ cache are not re-analysed, so member-name access (`Palette.red`) may report an
75
+ unknown attribute until a clean run. Everything else `ModelableStrEnum` provides
76
+ — iteration, membership, construction, `.value` — works in all modes, as does
77
+ all of `as_attribute` and `extends_union`.
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ pydantic_modelable_mypy/__init__.py
5
+ pydantic_modelable_mypy/plugin.py
6
+ pydantic_modelable_mypy/py.typed
7
+ pydantic_modelable_mypy.egg-info/PKG-INFO
8
+ pydantic_modelable_mypy.egg-info/SOURCES.txt
9
+ pydantic_modelable_mypy.egg-info/dependency_links.txt
10
+ pydantic_modelable_mypy.egg-info/requires.txt
11
+ pydantic_modelable_mypy.egg-info/scm_file_list.json
12
+ pydantic_modelable_mypy.egg-info/scm_version.json
13
+ pydantic_modelable_mypy.egg-info/top_level.txt
@@ -0,0 +1,5 @@
1
+ pydantic-modelable
2
+ mypy>=1.11
3
+
4
+ [test]
5
+ pytest
@@ -0,0 +1,10 @@
1
+ {
2
+ "files": [
3
+ "README.md",
4
+ "LICENSE",
5
+ "pyproject.toml",
6
+ "pydantic_modelable_mypy/py.typed",
7
+ "pydantic_modelable_mypy/__init__.py",
8
+ "pydantic_modelable_mypy/plugin.py"
9
+ ]
10
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.4.0",
3
+ "distance": 0,
4
+ "node": "g3f5cc8cdfca05a5086c59ce848d358615a003804",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-07-09"
8
+ }
@@ -0,0 +1 @@
1
+ pydantic_modelable_mypy
@@ -0,0 +1,54 @@
1
+ [build-system]
2
+ requires = ['setuptools', 'setuptools_scm']
3
+ build-backend = 'setuptools.build_meta'
4
+
5
+ [tool.setuptools_scm]
6
+ # The git repository (and its tags) live one level up: this subproject shares
7
+ # the parent's version so the plugin always tracks the runtime library it
8
+ # supports.
9
+ root = '..'
10
+
11
+ [tool.setuptools.packages.find]
12
+ include = ['pydantic_modelable_mypy*']
13
+
14
+ [project]
15
+ name = 'pydantic-modelable-mypy'
16
+ description = 'A mypy plugin teaching the type-checker about pydantic-modelable runtime model extensions'
17
+ authors = [
18
+ {name = 'David Pineau', email = 'dav.pineau@gmail.com'},
19
+ ]
20
+ license = 'BSD-3-Clause'
21
+ license-files = ['LICENSE']
22
+ readme = 'README.md'
23
+ classifiers = [
24
+ 'Development Status :: 3 - Alpha',
25
+ 'Programming Language :: Python',
26
+ 'Programming Language :: Python :: Implementation :: CPython',
27
+ 'Programming Language :: Python :: 3',
28
+ 'Programming Language :: Python :: 3 :: Only',
29
+ 'Programming Language :: Python :: 3.10',
30
+ 'Programming Language :: Python :: 3.11',
31
+ 'Programming Language :: Python :: 3.12',
32
+ 'Programming Language :: Python :: 3.13',
33
+ 'Programming Language :: Python :: 3.14',
34
+ 'Intended Audience :: Developers',
35
+ 'Operating System :: OS Independent',
36
+ 'Topic :: Software Development :: Libraries :: Python Modules',
37
+ 'Framework :: Pydantic :: 2',
38
+ 'Typing :: Typed',
39
+ ]
40
+ requires-python = '>=3.10'
41
+ dependencies = [
42
+ "pydantic-modelable",
43
+ "mypy>=1.11",
44
+ ]
45
+ dynamic = ['version']
46
+
47
+ [project.optional-dependencies]
48
+ test = [
49
+ "pytest",
50
+ ]
51
+
52
+ [project.urls]
53
+ Homepage = 'https://joacchim.github.io/pydantic-modelable/'
54
+ Source = 'https://github.com/Joacchim/pydantic-modelable'
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+