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.
- pydantic_modelable_mypy-0.4.0/LICENSE +28 -0
- pydantic_modelable_mypy-0.4.0/PKG-INFO +77 -0
- pydantic_modelable_mypy-0.4.0/README.md +45 -0
- pydantic_modelable_mypy-0.4.0/pydantic_modelable_mypy/__init__.py +25 -0
- pydantic_modelable_mypy-0.4.0/pydantic_modelable_mypy/plugin.py +547 -0
- pydantic_modelable_mypy-0.4.0/pydantic_modelable_mypy/py.typed +0 -0
- pydantic_modelable_mypy-0.4.0/pydantic_modelable_mypy.egg-info/PKG-INFO +77 -0
- pydantic_modelable_mypy-0.4.0/pydantic_modelable_mypy.egg-info/SOURCES.txt +13 -0
- pydantic_modelable_mypy-0.4.0/pydantic_modelable_mypy.egg-info/dependency_links.txt +1 -0
- pydantic_modelable_mypy-0.4.0/pydantic_modelable_mypy.egg-info/requires.txt +5 -0
- pydantic_modelable_mypy-0.4.0/pydantic_modelable_mypy.egg-info/scm_file_list.json +10 -0
- pydantic_modelable_mypy-0.4.0/pydantic_modelable_mypy.egg-info/scm_version.json +8 -0
- pydantic_modelable_mypy-0.4.0/pydantic_modelable_mypy.egg-info/top_level.txt +1 -0
- pydantic_modelable_mypy-0.4.0/pyproject.toml +54 -0
- pydantic_modelable_mypy-0.4.0/setup.cfg +4 -0
|
@@ -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
|
|
File without changes
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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'
|