pytypehint 0.0.1__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.
- pytypehint/__init__.py +58 -0
- pytypehint/atoms.py +220 -0
- pytypehint/bridge.py +309 -0
- pytypehint/errors.py +56 -0
- pytypehint/py.typed +0 -0
- pytypehint/shapes.py +586 -0
- pytypehint/signature.py +29 -0
- pytypehint/structure.py +351 -0
- pytypehint/utils.py +20 -0
- pytypehint/validation.py +10 -0
- pytypehint-0.0.1.dist-info/METADATA +112 -0
- pytypehint-0.0.1.dist-info/RECORD +14 -0
- pytypehint-0.0.1.dist-info/WHEEL +4 -0
- pytypehint-0.0.1.dist-info/licenses/LICENSE +21 -0
pytypehint/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from pytypehint.bridge import struct_of, signature_of
|
|
2
|
+
from pytypehint.atoms import (
|
|
3
|
+
# limit atoms
|
|
4
|
+
Min, Max, Choices, MultipleOf, Pattern, IsPathFile,
|
|
5
|
+
# notation atoms
|
|
6
|
+
Label, Description, Placeholder, Step, Slider, IsPassword, Rows, Extra,
|
|
7
|
+
OptionalToggle,
|
|
8
|
+
)
|
|
9
|
+
from pytypehint.errors import SchemaTypeError, SchemaValueError
|
|
10
|
+
from pytypehint.structure import Struct, Field
|
|
11
|
+
from pytypehint.signature import Signature
|
|
12
|
+
from pytypehint.shapes import (
|
|
13
|
+
Shape, Int, Float, Str, Bool, Date, Time, List, NoneShape, EnumShape,
|
|
14
|
+
)
|
|
15
|
+
from pytypehint.utils import MISSING
|
|
16
|
+
|
|
17
|
+
__version__ = "0.0.1"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"struct_of",
|
|
21
|
+
"signature_of",
|
|
22
|
+
# limit atoms
|
|
23
|
+
"Min",
|
|
24
|
+
"Max",
|
|
25
|
+
"Choices",
|
|
26
|
+
"MultipleOf",
|
|
27
|
+
"Pattern",
|
|
28
|
+
"IsPathFile",
|
|
29
|
+
# notation atoms
|
|
30
|
+
"Label",
|
|
31
|
+
"Description",
|
|
32
|
+
"Placeholder",
|
|
33
|
+
"Step",
|
|
34
|
+
"Slider",
|
|
35
|
+
"IsPassword",
|
|
36
|
+
"Rows",
|
|
37
|
+
"Extra",
|
|
38
|
+
"OptionalToggle",
|
|
39
|
+
# errors
|
|
40
|
+
"SchemaTypeError",
|
|
41
|
+
"SchemaValueError",
|
|
42
|
+
# compiled schema, for inspection
|
|
43
|
+
"Struct",
|
|
44
|
+
"Field",
|
|
45
|
+
"Signature",
|
|
46
|
+
# shapes
|
|
47
|
+
"Shape",
|
|
48
|
+
"Int",
|
|
49
|
+
"Float",
|
|
50
|
+
"Str",
|
|
51
|
+
"Bool",
|
|
52
|
+
"Date",
|
|
53
|
+
"Time",
|
|
54
|
+
"List",
|
|
55
|
+
"NoneShape",
|
|
56
|
+
"EnumShape",
|
|
57
|
+
"MISSING",
|
|
58
|
+
]
|
pytypehint/atoms.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from datetime import date, time
|
|
4
|
+
|
|
5
|
+
from pytypehint.utils import type_name
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_ORDERED = (int, float, date, time)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Min:
|
|
13
|
+
value: int | float | date | time
|
|
14
|
+
exclusive: bool = field(default=False, kw_only=True)
|
|
15
|
+
|
|
16
|
+
def __post_init__(self):
|
|
17
|
+
name = type_name(self)
|
|
18
|
+
if type(self.value) not in _ORDERED:
|
|
19
|
+
raise TypeError(f"{name}.value must be orderable (int, float, date or time), got {type(self.value).__name__}")
|
|
20
|
+
|
|
21
|
+
if type(self.exclusive) is not bool:
|
|
22
|
+
raise TypeError(f"{name}.exclusive must be bool, got {type(self.exclusive).__name__}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Max:
|
|
27
|
+
value: int | float | date | time
|
|
28
|
+
exclusive: bool = field(default=False, kw_only=True)
|
|
29
|
+
|
|
30
|
+
def __post_init__(self):
|
|
31
|
+
name = type_name(self)
|
|
32
|
+
if type(self.value) not in _ORDERED:
|
|
33
|
+
raise TypeError(f"{name}.value must be orderable (int, float, date or time), got {type(self.value).__name__}")
|
|
34
|
+
|
|
35
|
+
if type(self.exclusive) is not bool:
|
|
36
|
+
raise TypeError(f"{name}.exclusive must be bool, got {type(self.exclusive).__name__}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Label:
|
|
41
|
+
value: str
|
|
42
|
+
|
|
43
|
+
def __post_init__(self):
|
|
44
|
+
name = type_name(self)
|
|
45
|
+
if type(self.value) is not str:
|
|
46
|
+
raise TypeError(f"{name}.value must be str, got {type(self.value).__name__}")
|
|
47
|
+
|
|
48
|
+
if not self.value:
|
|
49
|
+
raise ValueError(f"{name}.value must not be empty")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Description:
|
|
54
|
+
value: str
|
|
55
|
+
|
|
56
|
+
def __post_init__(self):
|
|
57
|
+
name = type_name(self)
|
|
58
|
+
if type(self.value) is not str:
|
|
59
|
+
raise TypeError(f"{name}.value must be str, got {type(self.value).__name__}")
|
|
60
|
+
|
|
61
|
+
if not self.value:
|
|
62
|
+
raise ValueError(f"{name}.value must not be empty")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Step:
|
|
67
|
+
value: int | float
|
|
68
|
+
|
|
69
|
+
def __post_init__(self):
|
|
70
|
+
name = type_name(self)
|
|
71
|
+
if type(self.value) not in (int, float):
|
|
72
|
+
raise TypeError(f"{name}.value must be a number, got {type(self.value).__name__}")
|
|
73
|
+
|
|
74
|
+
if self.value <= 0:
|
|
75
|
+
raise ValueError(f"{name}.value must be > 0, got {self.value}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, kw_only=True)
|
|
79
|
+
class Slider:
|
|
80
|
+
show_value: bool = True
|
|
81
|
+
|
|
82
|
+
def __post_init__(self):
|
|
83
|
+
if type(self.show_value) is not bool:
|
|
84
|
+
raise TypeError(f"{type_name(self)}.show_value must be bool, got {type(self.show_value).__name__}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class Placeholder:
|
|
89
|
+
value: str
|
|
90
|
+
|
|
91
|
+
def __post_init__(self):
|
|
92
|
+
name = type_name(self)
|
|
93
|
+
if type(self.value) is not str:
|
|
94
|
+
raise TypeError(f"{name}.value must be str, got {type(self.value).__name__}")
|
|
95
|
+
|
|
96
|
+
if not self.value:
|
|
97
|
+
raise ValueError(f"{name}.value must not be empty")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class Extra:
|
|
102
|
+
value: str
|
|
103
|
+
|
|
104
|
+
def __post_init__(self):
|
|
105
|
+
name = type_name(self)
|
|
106
|
+
if type(self.value) is not str:
|
|
107
|
+
raise TypeError(f"{name}.value must be str, got {type(self.value).__name__}")
|
|
108
|
+
|
|
109
|
+
if not self.value:
|
|
110
|
+
raise ValueError(f"{name}.value must not be empty")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, kw_only=True)
|
|
114
|
+
class Choices:
|
|
115
|
+
values: tuple[int | float | str | date | time, ...]
|
|
116
|
+
|
|
117
|
+
def __post_init__(self):
|
|
118
|
+
name = type_name(self)
|
|
119
|
+
if type(self.values) is not tuple:
|
|
120
|
+
raise TypeError(f"{name}.values must be tuple, got {type(self.values).__name__}")
|
|
121
|
+
|
|
122
|
+
if not self.values:
|
|
123
|
+
raise ValueError(f"{name}.values must not be empty")
|
|
124
|
+
|
|
125
|
+
# Type belongs to the key because Python equates 1, 1.0 and True.
|
|
126
|
+
keys = [(type(v), v) for v in self.values]
|
|
127
|
+
try:
|
|
128
|
+
if len(keys) != len(set(keys)):
|
|
129
|
+
raise ValueError(f"{name}.values must not repeat")
|
|
130
|
+
except TypeError:
|
|
131
|
+
raise TypeError(f"{name}.values must be hashable") from None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class MultipleOf:
|
|
136
|
+
value: int
|
|
137
|
+
|
|
138
|
+
def __post_init__(self):
|
|
139
|
+
name = type_name(self)
|
|
140
|
+
if type(self.value) is not int:
|
|
141
|
+
raise TypeError(f"{name}.value must be int, got {type(self.value).__name__}")
|
|
142
|
+
|
|
143
|
+
if self.value <= 0:
|
|
144
|
+
raise ValueError(f"{name}.value must be > 0, got {self.value}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class Pattern:
|
|
149
|
+
value: str
|
|
150
|
+
message: str | None = field(default=None, kw_only=True)
|
|
151
|
+
|
|
152
|
+
def __post_init__(self):
|
|
153
|
+
name = type_name(self)
|
|
154
|
+
if type(self.value) is not str:
|
|
155
|
+
raise TypeError(f"{name}.value must be str, got {type(self.value).__name__}")
|
|
156
|
+
|
|
157
|
+
if not self.value:
|
|
158
|
+
raise ValueError(f"{name}.value must not be empty")
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
re.compile(self.value)
|
|
162
|
+
except re.error as e:
|
|
163
|
+
raise ValueError(f"{name}.value is not a valid regex: {e}") from e
|
|
164
|
+
|
|
165
|
+
if self.message is not None:
|
|
166
|
+
if type(self.message) is not str:
|
|
167
|
+
raise TypeError(f"{name}.message must be str, got {type(self.message).__name__}")
|
|
168
|
+
|
|
169
|
+
if not self.message:
|
|
170
|
+
raise ValueError(f"{name}.message must not be empty")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True, kw_only=True)
|
|
174
|
+
class IsPassword:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(frozen=True)
|
|
179
|
+
class Rows:
|
|
180
|
+
value: int
|
|
181
|
+
|
|
182
|
+
def __post_init__(self):
|
|
183
|
+
name = type_name(self)
|
|
184
|
+
if type(self.value) is not int:
|
|
185
|
+
raise TypeError(f"{name}.value must be int, got {type(self.value).__name__}")
|
|
186
|
+
|
|
187
|
+
if self.value <= 0:
|
|
188
|
+
raise ValueError(f"{name}.value must be > 0, got {self.value}")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass(frozen=True, kw_only=True)
|
|
192
|
+
class IsPathFile:
|
|
193
|
+
extensions: tuple[str, ...] = ()
|
|
194
|
+
|
|
195
|
+
def __post_init__(self):
|
|
196
|
+
name = type_name(self)
|
|
197
|
+
if type(self.extensions) is not tuple:
|
|
198
|
+
raise TypeError(f"{name}.extensions must be tuple, got {type(self.extensions).__name__}")
|
|
199
|
+
|
|
200
|
+
for e in self.extensions:
|
|
201
|
+
if type(e) is not str:
|
|
202
|
+
raise TypeError(f"{name}.extensions: expected str, got {type(e).__name__}")
|
|
203
|
+
|
|
204
|
+
if not e.startswith("."):
|
|
205
|
+
raise ValueError(f"{name}.extensions: {e!r} must start with '.'")
|
|
206
|
+
|
|
207
|
+
if e != e.lower():
|
|
208
|
+
raise ValueError(f"{name}.extensions: {e!r} must be lowercase")
|
|
209
|
+
|
|
210
|
+
if len(self.extensions) != len(set(self.extensions)):
|
|
211
|
+
raise ValueError(f"{name}.extensions must not repeat")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass(frozen=True)
|
|
215
|
+
class OptionalToggle:
|
|
216
|
+
enabled: bool
|
|
217
|
+
|
|
218
|
+
def __post_init__(self):
|
|
219
|
+
if type(self.enabled) is not bool:
|
|
220
|
+
raise TypeError(f"{type_name(self)}.enabled must be bool, got {type(self.enabled).__name__}")
|
pytypehint/bridge.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import types
|
|
3
|
+
from dataclasses import (
|
|
4
|
+
MISSING as _DC_MISSING, Field as _DcField, InitVar as _InitVar,
|
|
5
|
+
fields as dc_fields, is_dataclass,
|
|
6
|
+
)
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Annotated, Literal, Union, get_args, get_origin, get_type_hints
|
|
9
|
+
|
|
10
|
+
from pytypehint import atoms
|
|
11
|
+
from pytypehint.atoms import Choices, Description, Label, OptionalToggle
|
|
12
|
+
from pytypehint.shapes import Bool, Date, EnumShape, Float, Int, List, NoneShape, Shape, Str, Time
|
|
13
|
+
from pytypehint.signature import Signature
|
|
14
|
+
from pytypehint.structure import Field, Struct, _Factory, _certify
|
|
15
|
+
from pytypehint.utils import MISSING
|
|
16
|
+
|
|
17
|
+
_ATOM_CLASSES = {v for v in vars(atoms).values()
|
|
18
|
+
if isinstance(v, type) and is_dataclass(v)
|
|
19
|
+
and v.__module__ == atoms.__name__}
|
|
20
|
+
_FIELD_ATOMS = (Label, Description, OptionalToggle)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _atom_type(hint):
|
|
24
|
+
if get_origin(hint) in (Union, types.UnionType):
|
|
25
|
+
hint = next((a for a in get_args(hint) if a is not type(None)), None)
|
|
26
|
+
return hint if hint in _ATOM_CLASSES else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _atoms_of(shape_cls):
|
|
30
|
+
hints = get_type_hints(shape_cls)
|
|
31
|
+
return {a: f.name for f in dc_fields(shape_cls)
|
|
32
|
+
if (a := _atom_type(hints[f.name])) is not None}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_VOCABULARY = {cls.pytype: (cls, _atoms_of(cls))
|
|
36
|
+
for cls in (Int, Float, Bool, Str, Date, Time,
|
|
37
|
+
NoneShape, List)}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _kwargs_of(meta, kind, table):
|
|
41
|
+
kwargs = {}
|
|
42
|
+
for m in meta:
|
|
43
|
+
name = table.get(type(m))
|
|
44
|
+
if name is None:
|
|
45
|
+
raise TypeError(f"unsupported metadata for {kind}: {m!r}")
|
|
46
|
+
kwargs[name] = m
|
|
47
|
+
return kwargs
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _split_annotated(hint):
|
|
51
|
+
if get_origin(hint) is Annotated:
|
|
52
|
+
base, *meta = get_args(hint)
|
|
53
|
+
return base, tuple(meta)
|
|
54
|
+
return hint, ()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _options_of(hint):
|
|
58
|
+
if get_origin(hint) in (Union, types.UnionType):
|
|
59
|
+
return get_args(hint)
|
|
60
|
+
return (hint,)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _hint_label(hint) -> str:
|
|
64
|
+
return hint.__name__ if isinstance(hint, type) else str(hint).replace("typing.", "")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Two hints can read as different options and still compile to one pytype:
|
|
68
|
+
# Literal['a'] and str both become Str. A list item routes by its exact runtime
|
|
69
|
+
# type, so such a pair is unroutable. List.item reports the collision by shape,
|
|
70
|
+
# which cannot name the hints the author actually wrote — this can.
|
|
71
|
+
def _reject_colliding_items(raw_options, item_options) -> None:
|
|
72
|
+
seen: dict[type, object] = {}
|
|
73
|
+
for hint, shape in zip(raw_options, item_options):
|
|
74
|
+
if shape.pytype in seen:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"list items: {_hint_label(seen[shape.pytype])} and {_hint_label(hint)} "
|
|
77
|
+
f"both compile to {shape.pytype.__name__} — merge them into one option, "
|
|
78
|
+
f"or give each variant a dataclass and route with $type")
|
|
79
|
+
seen[shape.pytype] = hint
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _shape_of(opt, cache: dict) -> Shape:
|
|
83
|
+
base, meta = _split_annotated(opt)
|
|
84
|
+
|
|
85
|
+
if base is None:
|
|
86
|
+
base = type(None)
|
|
87
|
+
|
|
88
|
+
if base is list:
|
|
89
|
+
raise TypeError("list requires an item type: list[X]")
|
|
90
|
+
|
|
91
|
+
if get_origin(base) is list:
|
|
92
|
+
(item_hint,) = get_args(base)
|
|
93
|
+
item_base, item_meta = _split_annotated(item_hint)
|
|
94
|
+
if any(isinstance(m, _FIELD_ATOMS) for m in item_meta):
|
|
95
|
+
raise TypeError("field atoms cannot apply to list items")
|
|
96
|
+
raw_options = _options_of(item_base)
|
|
97
|
+
if len(raw_options) > 1 and item_meta:
|
|
98
|
+
raise TypeError(
|
|
99
|
+
"metadata on a union of multiple types must go per option: "
|
|
100
|
+
"Annotated[int, Min(0)] | str")
|
|
101
|
+
item_options = ((_shape_of(item_hint, cache),) if len(raw_options) == 1
|
|
102
|
+
else tuple(_shape_of(o, cache) for o in raw_options))
|
|
103
|
+
if len(raw_options) > 1:
|
|
104
|
+
_reject_colliding_items(raw_options, item_options)
|
|
105
|
+
return List(item=item_options,
|
|
106
|
+
**_kwargs_of(meta, "list", _VOCABULARY[list][1]))
|
|
107
|
+
|
|
108
|
+
if get_origin(base) is Literal:
|
|
109
|
+
values = get_args(base)
|
|
110
|
+
for v in values:
|
|
111
|
+
if type(v) is float:
|
|
112
|
+
raise TypeError("Literal values must be int or str, got float — "
|
|
113
|
+
"for float choices use Annotated[float, Choices(...)]")
|
|
114
|
+
if type(v) not in (int, str):
|
|
115
|
+
raise TypeError(f"Literal values must be int or str, got {type(v).__name__}")
|
|
116
|
+
if len({type(v) for v in values}) > 1:
|
|
117
|
+
raise TypeError("Literal values must all be the same type")
|
|
118
|
+
if any(type(m) is Choices for m in meta):
|
|
119
|
+
raise TypeError("Literal already defines its choices")
|
|
120
|
+
return _shape_of(Annotated[tuple([type(values[0]),
|
|
121
|
+
Choices(values=values), *meta])], cache)
|
|
122
|
+
|
|
123
|
+
if base in _VOCABULARY:
|
|
124
|
+
shape_cls, table = _VOCABULARY[base]
|
|
125
|
+
return shape_cls(**_kwargs_of(meta, base.__name__, table))
|
|
126
|
+
|
|
127
|
+
if isinstance(base, type) and issubclass(base, Enum):
|
|
128
|
+
_kwargs_of(meta, "enum", {})
|
|
129
|
+
return EnumShape(cls=base)
|
|
130
|
+
|
|
131
|
+
if isinstance(base, type) and is_dataclass(base):
|
|
132
|
+
_kwargs_of(meta, "dataclass", {})
|
|
133
|
+
return _struct_of_class(base, cache)
|
|
134
|
+
|
|
135
|
+
raise TypeError(f"unsupported type: {base!r}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _field_atoms_of(meta):
|
|
139
|
+
field_atoms = {}
|
|
140
|
+
type_meta = []
|
|
141
|
+
for m in meta:
|
|
142
|
+
if isinstance(m, _FIELD_ATOMS):
|
|
143
|
+
field_atoms[type(m)] = m
|
|
144
|
+
else:
|
|
145
|
+
type_meta.append(m)
|
|
146
|
+
return field_atoms, type_meta
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _field_of(name: str, hint, default, cache: dict) -> Field:
|
|
150
|
+
base_hint, meta = _split_annotated(hint)
|
|
151
|
+
|
|
152
|
+
outer_atoms, type_meta = _field_atoms_of(meta)
|
|
153
|
+
|
|
154
|
+
options = _options_of(base_hint)
|
|
155
|
+
|
|
156
|
+
if type_meta:
|
|
157
|
+
# None expresses optionality; type metadata targets the single real option.
|
|
158
|
+
real_opts = [o for o in options if o is not type(None) and o is not None]
|
|
159
|
+
if len(real_opts) == 0:
|
|
160
|
+
raise TypeError(f"{name}: metadata on None: None is optionality, not a type")
|
|
161
|
+
if len(real_opts) > 1:
|
|
162
|
+
raise TypeError(
|
|
163
|
+
f"{name}: metadata on a union of multiple types must go per option: "
|
|
164
|
+
f"Annotated[int, Min(0)] | str")
|
|
165
|
+
# Preserve the user's union-option order.
|
|
166
|
+
options = tuple(
|
|
167
|
+
Annotated[tuple([o, *type_meta])]
|
|
168
|
+
if o is not type(None) and o is not None else o
|
|
169
|
+
for o in options)
|
|
170
|
+
|
|
171
|
+
# typing flattens Annotated aliases; hoist their field atoms before compiling.
|
|
172
|
+
hoisted: dict[type, object] = {}
|
|
173
|
+
stripped = []
|
|
174
|
+
for opt in options:
|
|
175
|
+
opt_base, opt_meta = _split_annotated(opt)
|
|
176
|
+
opt_atoms, opt_type_meta = _field_atoms_of(opt_meta)
|
|
177
|
+
for atom_type, atom in opt_atoms.items():
|
|
178
|
+
if atom_type in outer_atoms:
|
|
179
|
+
continue
|
|
180
|
+
existing = hoisted.get(atom_type)
|
|
181
|
+
if existing is not None and existing != atom:
|
|
182
|
+
raise TypeError(
|
|
183
|
+
f"{name}: conflicting {atom_type.__name__.lower()}s across union options: "
|
|
184
|
+
f"{getattr(existing, 'value', existing)!r} vs {getattr(atom, 'value', atom)!r}")
|
|
185
|
+
hoisted[atom_type] = atom
|
|
186
|
+
stripped.append(
|
|
187
|
+
Annotated[tuple([opt_base, *opt_type_meta])] if opt_type_meta else opt_base)
|
|
188
|
+
|
|
189
|
+
field_atoms = {**hoisted, **outer_atoms}
|
|
190
|
+
|
|
191
|
+
return Field(name=name, shape=tuple(_shape_of(o, cache) for o in stripped),
|
|
192
|
+
default=default,
|
|
193
|
+
label=field_atoms.get(Label),
|
|
194
|
+
description=field_atoms.get(Description),
|
|
195
|
+
optional_toggle=field_atoms.get(OptionalToggle))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _default_of(f):
|
|
199
|
+
if f.default is not _DC_MISSING:
|
|
200
|
+
return f.default
|
|
201
|
+
if f.default_factory is not _DC_MISSING:
|
|
202
|
+
# The factory is the recipe and runs for each missing-key serving.
|
|
203
|
+
return _Factory(f.default_factory)
|
|
204
|
+
return MISSING
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _struct_of_class(cls: type, cache: dict) -> Struct:
|
|
208
|
+
if cls in cache:
|
|
209
|
+
return cache[cls]
|
|
210
|
+
|
|
211
|
+
struct = Struct.__new__(Struct)
|
|
212
|
+
cache[cls] = struct
|
|
213
|
+
object.__setattr__(struct, "cls", cls)
|
|
214
|
+
|
|
215
|
+
hints = get_type_hints(cls, include_extras=True)
|
|
216
|
+
|
|
217
|
+
# InitVar enters construction but is absent from the instance, so no Field can represent it.
|
|
218
|
+
# The resolved hint carries that fact itself; ClassVar resolves to ClassVar, not InitVar.
|
|
219
|
+
initvars = sorted(n for n, h in hints.items() if isinstance(h, _InitVar))
|
|
220
|
+
if initvars:
|
|
221
|
+
raise TypeError(f"{initvars[0]}: InitVar fields are not supported")
|
|
222
|
+
|
|
223
|
+
flds = []
|
|
224
|
+
for f in dc_fields(cls):
|
|
225
|
+
if not f.init:
|
|
226
|
+
raise TypeError(f"{f.name}: init=False fields are not supported")
|
|
227
|
+
raw = _default_of(f)
|
|
228
|
+
fld = _field_of(f.name, hints[f.name], raw, cache)
|
|
229
|
+
flds.append(fld)
|
|
230
|
+
|
|
231
|
+
object.__setattr__(struct, "fields", tuple(flds))
|
|
232
|
+
struct.__post_init__()
|
|
233
|
+
|
|
234
|
+
return struct
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _validate_cache(cache: dict) -> None:
|
|
238
|
+
# Recursive fields certify after the complete shape graph exists.
|
|
239
|
+
for struct in cache.values():
|
|
240
|
+
for f in struct.fields:
|
|
241
|
+
if not f._deferred:
|
|
242
|
+
continue
|
|
243
|
+
pytypes = [s.pytype for s in f.shape]
|
|
244
|
+
if len(pytypes) != len(set(pytypes)):
|
|
245
|
+
raise ValueError(f"Field {f.name!r}: duplicate option types in shape")
|
|
246
|
+
object.__setattr__(f, "default", _certify(f))
|
|
247
|
+
object.__setattr__(f, "_deferred", False)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def struct_of(obj) -> Struct:
|
|
251
|
+
if is_dataclass(obj):
|
|
252
|
+
if not isinstance(obj, type):
|
|
253
|
+
raise TypeError(f"expected a dataclass type, got an instance of {type(obj).__name__}")
|
|
254
|
+
cache: dict[type, Struct] = {}
|
|
255
|
+
root = _struct_of_class(obj, cache)
|
|
256
|
+
_validate_cache(cache)
|
|
257
|
+
return root
|
|
258
|
+
|
|
259
|
+
if isinstance(obj, type):
|
|
260
|
+
raise TypeError(
|
|
261
|
+
f"{obj.__name__} is not a dataclass — add @dataclass; "
|
|
262
|
+
f"pytypehint reads standard dataclasses, it doesn't replace them")
|
|
263
|
+
|
|
264
|
+
raise TypeError(f"expected a dataclass type, got {obj!r}")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def signature_of(fn) -> Signature:
|
|
268
|
+
if not inspect.isfunction(fn):
|
|
269
|
+
raise TypeError(
|
|
270
|
+
f"expected a plain function, got {fn!r} — bound methods, partials and "
|
|
271
|
+
f"callable objects are not supported: wrap the call in a plain function "
|
|
272
|
+
f"(def run(q: str): return service.search(q))")
|
|
273
|
+
|
|
274
|
+
if fn.__name__ == "<lambda>":
|
|
275
|
+
raise TypeError("lambdas have no usable name; use a named function")
|
|
276
|
+
|
|
277
|
+
hints = get_type_hints(fn, include_extras=True)
|
|
278
|
+
cache: dict[type, Struct] = {}
|
|
279
|
+
params = []
|
|
280
|
+
|
|
281
|
+
for i, (n, p) in enumerate(inspect.signature(fn).parameters.items()):
|
|
282
|
+
if p.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
283
|
+
raise TypeError(f"{n}: variadic parameters (*args/**kwargs) are not supported")
|
|
284
|
+
|
|
285
|
+
if p.kind is inspect.Parameter.POSITIONAL_ONLY:
|
|
286
|
+
raise TypeError(f"{n}: positional-only parameters are not supported")
|
|
287
|
+
|
|
288
|
+
# An unhinted leading self/cls identifies an unbound method.
|
|
289
|
+
if i == 0 and n in ("self", "cls") and n not in hints:
|
|
290
|
+
raise TypeError(
|
|
291
|
+
f"{n}: looks like an unbound method — pytypehint takes "
|
|
292
|
+
f"plain functions; wrap the call (def run(q: str): return "
|
|
293
|
+
f"service.search(q))")
|
|
294
|
+
|
|
295
|
+
if n not in hints:
|
|
296
|
+
raise TypeError(f"{n}: missing type hint")
|
|
297
|
+
|
|
298
|
+
default = MISSING if p.default is inspect.Parameter.empty else p.default
|
|
299
|
+
|
|
300
|
+
if type(default) is _DcField:
|
|
301
|
+
raise TypeError(
|
|
302
|
+
f"{n}: field() is dataclass syntax; in functions write the default "
|
|
303
|
+
f"directly — it is fresh through the schema")
|
|
304
|
+
|
|
305
|
+
params.append(_field_of(n, hints[n], default, cache))
|
|
306
|
+
|
|
307
|
+
_validate_cache(cache)
|
|
308
|
+
sig = Signature(name=fn.__name__, doc=fn.__doc__, params=tuple(params))
|
|
309
|
+
return sig
|
pytypehint/errors.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Structured validation errors.
|
|
2
|
+
|
|
3
|
+
Every validation failure carries the coordinate where it happened as data, not
|
|
4
|
+
only inside its message. `path` walks from the root of the supplied input to the
|
|
5
|
+
value that failed; `leaf` is the failure itself, with no path attached. `str()`
|
|
6
|
+
renders the two into the exact line the schema has always produced, so wrappers
|
|
7
|
+
can keep matching on text or switch to the structure.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _render(path, leaf: str) -> str:
|
|
12
|
+
# Integers are list indexes and render as "[0]"; everything else is a key.
|
|
13
|
+
return "".join(f"[{s}]: " if type(s) is int else f"{s}: " for s in path) + leaf
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# BaseException.__reduce__ would rebuild from `args`, which holds the rendered
|
|
17
|
+
# line — reconstruction would land on leaf=<whole line>, path=(), and only the
|
|
18
|
+
# trailing state dict would put it right. Rebuild from the real arguments
|
|
19
|
+
# instead, and keep the state so add_note() and wrapper attributes survive too.
|
|
20
|
+
def _reduce(error):
|
|
21
|
+
return (type(error), (error.leaf, error.path), error.__dict__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SchemaTypeError(TypeError):
|
|
25
|
+
"""A value had the wrong type. Subclasses TypeError; existing handlers still catch it."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, leaf: str, path: tuple = ()):
|
|
28
|
+
self.leaf = leaf
|
|
29
|
+
self.path: tuple = tuple(path)
|
|
30
|
+
# A single arg keeps str(error) equal to the rendered line.
|
|
31
|
+
super().__init__(_render(self.path, self.leaf))
|
|
32
|
+
|
|
33
|
+
def __reduce__(self):
|
|
34
|
+
return _reduce(self)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SchemaValueError(ValueError):
|
|
38
|
+
"""A value had the right type but broke a constraint. Subclasses ValueError."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, leaf: str, path: tuple = ()):
|
|
41
|
+
self.leaf = leaf
|
|
42
|
+
self.path: tuple = tuple(path)
|
|
43
|
+
super().__init__(_render(self.path, self.leaf))
|
|
44
|
+
|
|
45
|
+
def __reduce__(self):
|
|
46
|
+
return _reduce(self)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _prefixed(error: Exception, path: tuple) -> Exception:
|
|
50
|
+
"""Re-raise `error` one level out, with `path` prepended and its leaf intact."""
|
|
51
|
+
if isinstance(error, (SchemaTypeError, SchemaValueError)):
|
|
52
|
+
return type(error)(error.leaf, (*path, *error.path))
|
|
53
|
+
# A foreign TypeError/ValueError — a user factory or __post_init__ — has no
|
|
54
|
+
# structure to preserve, so its whole message becomes the leaf.
|
|
55
|
+
cls = SchemaTypeError if isinstance(error, TypeError) else SchemaValueError
|
|
56
|
+
return cls(str(error), path)
|
pytypehint/py.typed
ADDED
|
File without changes
|