modmex 1.0.0__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.
- modmex/__init__.py +15 -0
- modmex/base_model.py +166 -0
- modmex/datetime_parser.py +239 -0
- modmex/errors.py +16 -0
- modmex/fields.py +57 -0
- modmex/serialization.py +123 -0
- modmex/validation.py +339 -0
- modmex-1.0.0.dist-info/LICENSE +21 -0
- modmex-1.0.0.dist-info/METADATA +407 -0
- modmex-1.0.0.dist-info/RECORD +11 -0
- modmex-1.0.0.dist-info/WHEEL +4 -0
modmex/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .base_model import (
|
|
2
|
+
BaseModel,
|
|
3
|
+
field_validator,
|
|
4
|
+
model_validator,
|
|
5
|
+
)
|
|
6
|
+
from .errors import ValidationError
|
|
7
|
+
from .fields import Field
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"BaseModel",
|
|
11
|
+
"Field",
|
|
12
|
+
"ValidationError",
|
|
13
|
+
"field_validator",
|
|
14
|
+
"model_validator",
|
|
15
|
+
]
|
modmex/base_model.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Dataclass-backed model base with validation and serialization helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, fields
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
import orjson
|
|
10
|
+
|
|
11
|
+
from .fields import should_exclude_field
|
|
12
|
+
from .serialization import ExcludeSpec, TypeSerializers, custom_serializer, normalize_exclude, serialize_value
|
|
13
|
+
from .validation import validate_model_fields
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def field_validator(field_name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
17
|
+
"""Decorate a method that validates or transforms one field."""
|
|
18
|
+
|
|
19
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
20
|
+
func._validator_field = field_name # type: ignore[attr-defined]
|
|
21
|
+
return func
|
|
22
|
+
|
|
23
|
+
return decorator
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def model_validator(mode: str = "before") -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
27
|
+
"""Decorate a method that validates or transforms the full model."""
|
|
28
|
+
|
|
29
|
+
if mode not in {"before", "after"}:
|
|
30
|
+
raise ValueError("model validator mode must be 'before' or 'after'")
|
|
31
|
+
|
|
32
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
33
|
+
func._validator_mode = mode # type: ignore[attr-defined]
|
|
34
|
+
return func
|
|
35
|
+
|
|
36
|
+
return decorator
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BaseModelMeta(type):
|
|
40
|
+
def __new__(mcls, name: str, bases: tuple[type, ...], namespace: dict[str, Any]) -> type:
|
|
41
|
+
model_cls = super().__new__(mcls, name, bases, namespace)
|
|
42
|
+
dataclass(model_cls, kw_only=True)
|
|
43
|
+
|
|
44
|
+
original_init = model_cls.__init__
|
|
45
|
+
|
|
46
|
+
def new_init(self: Any, *args: Any, **kwargs: Any) -> None:
|
|
47
|
+
valid_fields = {field.name for field in fields(model_cls)}
|
|
48
|
+
filtered_kwargs = {key: value for key, value in kwargs.items() if key in valid_fields}
|
|
49
|
+
original_init(self, *args, **filtered_kwargs)
|
|
50
|
+
|
|
51
|
+
model_cls.__init__ = new_init
|
|
52
|
+
return model_cls
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class BaseModel(metaclass=BaseModelMeta):
|
|
56
|
+
"""Base class for lightweight validated models."""
|
|
57
|
+
|
|
58
|
+
def __post_init__(self) -> None:
|
|
59
|
+
self._validate_model_before()
|
|
60
|
+
self._validate_types()
|
|
61
|
+
self._validate_fields()
|
|
62
|
+
self._validate_model_after()
|
|
63
|
+
|
|
64
|
+
def _validate_types(self) -> None:
|
|
65
|
+
validate_model_fields(self)
|
|
66
|
+
|
|
67
|
+
def _validate_model_before(self) -> None:
|
|
68
|
+
self._run_model_validators("before")
|
|
69
|
+
|
|
70
|
+
def _validate_model_after(self) -> None:
|
|
71
|
+
self._run_model_validators("after")
|
|
72
|
+
|
|
73
|
+
def _run_model_validators(self, mode: str) -> None:
|
|
74
|
+
for attr_name in dir(self):
|
|
75
|
+
if isinstance(getattr(type(self), attr_name, None), property):
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
attr = getattr(self, attr_name)
|
|
79
|
+
if callable(attr) and getattr(attr, "_validator_mode", None) == mode:
|
|
80
|
+
updated_values = attr(self.__dict__.copy())
|
|
81
|
+
if updated_values is not None:
|
|
82
|
+
self.__dict__.update(updated_values)
|
|
83
|
+
|
|
84
|
+
def _validate_fields(self) -> None:
|
|
85
|
+
for attr_name in dir(self):
|
|
86
|
+
if isinstance(getattr(type(self), attr_name, None), property):
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
attr = getattr(self, attr_name)
|
|
90
|
+
field_name = getattr(attr, "_validator_field", None)
|
|
91
|
+
if callable(attr) and field_name:
|
|
92
|
+
setattr(self, field_name, attr(getattr(self, field_name, None)))
|
|
93
|
+
|
|
94
|
+
def _append_properties(
|
|
95
|
+
self,
|
|
96
|
+
data: dict[str, Any],
|
|
97
|
+
exclude: Mapping[str, Any],
|
|
98
|
+
profile: str | None,
|
|
99
|
+
type_serializers: TypeSerializers,
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
for attr_name in dir(self):
|
|
102
|
+
if attr_name in exclude:
|
|
103
|
+
continue
|
|
104
|
+
if isinstance(getattr(type(self), attr_name, None), property):
|
|
105
|
+
data[attr_name] = serialize_value(
|
|
106
|
+
getattr(self, attr_name),
|
|
107
|
+
exclude=None,
|
|
108
|
+
profile=profile,
|
|
109
|
+
type_serializers=type_serializers,
|
|
110
|
+
)
|
|
111
|
+
return data
|
|
112
|
+
|
|
113
|
+
def _serialize(
|
|
114
|
+
self,
|
|
115
|
+
exclude: ExcludeSpec = None,
|
|
116
|
+
profile: str | None = None,
|
|
117
|
+
include_excluded: bool = False,
|
|
118
|
+
type_serializers: TypeSerializers = None,
|
|
119
|
+
) -> dict[str, Any]:
|
|
120
|
+
exclude_map = normalize_exclude(exclude)
|
|
121
|
+
result = {
|
|
122
|
+
field.name: serialize_value(
|
|
123
|
+
getattr(self, field.name),
|
|
124
|
+
exclude=exclude_map.get(field.name),
|
|
125
|
+
profile=profile,
|
|
126
|
+
include_excluded=include_excluded,
|
|
127
|
+
type_serializers=type_serializers,
|
|
128
|
+
)
|
|
129
|
+
for field in fields(self)
|
|
130
|
+
if not should_exclude_field(field, exclude_map.get(field.name), profile, include_excluded)
|
|
131
|
+
}
|
|
132
|
+
return self._append_properties(result, exclude_map, profile, type_serializers)
|
|
133
|
+
|
|
134
|
+
def model_dump(
|
|
135
|
+
self,
|
|
136
|
+
*,
|
|
137
|
+
exclude: ExcludeSpec = None,
|
|
138
|
+
profile: str | None = None,
|
|
139
|
+
include_excluded: bool = False,
|
|
140
|
+
type_serializers: TypeSerializers = None,
|
|
141
|
+
) -> dict[str, Any]:
|
|
142
|
+
return self._serialize(
|
|
143
|
+
exclude=exclude,
|
|
144
|
+
profile=profile,
|
|
145
|
+
include_excluded=include_excluded,
|
|
146
|
+
type_serializers=type_serializers,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def model_dump_json(
|
|
150
|
+
self,
|
|
151
|
+
*,
|
|
152
|
+
exclude: ExcludeSpec = None,
|
|
153
|
+
profile: str | None = None,
|
|
154
|
+
include_excluded: bool = False,
|
|
155
|
+
type_serializers: TypeSerializers = None,
|
|
156
|
+
) -> str:
|
|
157
|
+
return orjson.dumps(
|
|
158
|
+
self.model_dump(
|
|
159
|
+
exclude=exclude,
|
|
160
|
+
profile=profile,
|
|
161
|
+
include_excluded=include_excluded,
|
|
162
|
+
type_serializers=type_serializers,
|
|
163
|
+
),
|
|
164
|
+
option=orjson.OPT_PASSTHROUGH_DATETIME,
|
|
165
|
+
default=custom_serializer,
|
|
166
|
+
).decode("utf-8")
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Parse date, time, datetime, and duration values."""
|
|
2
|
+
import re
|
|
3
|
+
from datetime import date, datetime, time, timedelta, timezone
|
|
4
|
+
from typing import Optional, Union
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
date_expr = r'(?P<year>\d{4})-(?P<month>\d{1,2})-(?P<day>\d{1,2})'
|
|
8
|
+
time_expr = (
|
|
9
|
+
r'(?P<hour>\d{1,2}):(?P<minute>\d{1,2})'
|
|
10
|
+
r'(?::(?P<second>\d{1,2})(?:\.(?P<microsecond>\d{1,6})\d{0,6})?)?'
|
|
11
|
+
r'(?P<tzinfo>Z|[+-]\d{2}(?::?\d{2})?)?$'
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
date_re = re.compile(f'{date_expr}$')
|
|
15
|
+
time_re = re.compile(time_expr)
|
|
16
|
+
datetime_re = re.compile(f'{date_expr}[T ]{time_expr}')
|
|
17
|
+
|
|
18
|
+
standard_duration_re = re.compile(
|
|
19
|
+
r'^'
|
|
20
|
+
r'(?:(?P<days>-?\d+) (days?, )?)?'
|
|
21
|
+
r'((?:(?P<hours>-?\d+):)(?=\d+:\d+))?'
|
|
22
|
+
r'(?:(?P<minutes>-?\d+):)?'
|
|
23
|
+
r'(?P<seconds>-?\d+)'
|
|
24
|
+
r'(?:\.(?P<microseconds>\d{1,6})\d{0,6})?'
|
|
25
|
+
r'$'
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Support the sections of ISO 8601 date representation that are accepted by timedelta
|
|
29
|
+
iso8601_duration_re = re.compile(
|
|
30
|
+
r'^(?P<sign>[-+]?)'
|
|
31
|
+
r'P'
|
|
32
|
+
r'(?:(?P<days>\d+(.\d+)?)D)?'
|
|
33
|
+
r'(?:T'
|
|
34
|
+
r'(?:(?P<hours>\d+(.\d+)?)H)?'
|
|
35
|
+
r'(?:(?P<minutes>\d+(.\d+)?)M)?'
|
|
36
|
+
r'(?:(?P<seconds>\d+(.\d+)?)S)?'
|
|
37
|
+
r')?'
|
|
38
|
+
r'$'
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
EPOCH = datetime(1970, 1, 1)
|
|
42
|
+
# if greater than this, the number is in ms, if less than or equal it's in seconds
|
|
43
|
+
# (in seconds this is 11th October 2603, in ms it's 20th August 1970)
|
|
44
|
+
MS_WATERSHED = int(2e10)
|
|
45
|
+
# slightly more than datetime.max in ns - (datetime.max - EPOCH).total_seconds() * 1e9
|
|
46
|
+
MAX_NUMBER = int(3e20)
|
|
47
|
+
StrBytesIntFloat = Union[str, bytes, int, float]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_numeric(value: StrBytesIntFloat, native_expected_type: str) -> Union[None, int, float]:
|
|
51
|
+
if isinstance(value, (int, float)):
|
|
52
|
+
return value
|
|
53
|
+
try:
|
|
54
|
+
return float(value)
|
|
55
|
+
except ValueError:
|
|
56
|
+
return None
|
|
57
|
+
except TypeError:
|
|
58
|
+
raise TypeError(f'invalid type; expected {native_expected_type}, string, bytes, int or float')
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def from_unix_seconds(seconds: Union[int, float]) -> datetime:
|
|
62
|
+
if seconds > MAX_NUMBER:
|
|
63
|
+
return datetime.max
|
|
64
|
+
elif seconds < -MAX_NUMBER:
|
|
65
|
+
return datetime.min
|
|
66
|
+
|
|
67
|
+
while abs(seconds) > MS_WATERSHED:
|
|
68
|
+
seconds /= 1000
|
|
69
|
+
dt = EPOCH + timedelta(seconds=seconds)
|
|
70
|
+
return dt.replace(tzinfo=timezone.utc)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _parse_timezone(value: Optional[str]) -> timezone | None:
|
|
74
|
+
if value == 'Z':
|
|
75
|
+
return timezone.utc
|
|
76
|
+
elif value is not None:
|
|
77
|
+
offset_mins = int(value[-2:]) if len(value) > 3 else 0
|
|
78
|
+
offset = 60 * int(value[1:3]) + offset_mins
|
|
79
|
+
if value[0] == '-':
|
|
80
|
+
offset = -offset
|
|
81
|
+
try:
|
|
82
|
+
return timezone(timedelta(minutes=offset))
|
|
83
|
+
except ValueError as exc:
|
|
84
|
+
raise ValueError("invalid timezone") from exc
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_date(value: Union[date, StrBytesIntFloat]) -> date:
|
|
89
|
+
"""
|
|
90
|
+
Parse a date/int/float/string and return a datetime.date.
|
|
91
|
+
|
|
92
|
+
Raise ValueError if the input is well formatted but not a valid date.
|
|
93
|
+
Raise ValueError if the input isn't well formatted.
|
|
94
|
+
"""
|
|
95
|
+
if isinstance(value, date):
|
|
96
|
+
if isinstance(value, datetime):
|
|
97
|
+
return value.date()
|
|
98
|
+
else:
|
|
99
|
+
return value
|
|
100
|
+
|
|
101
|
+
number = get_numeric(value, 'date')
|
|
102
|
+
if number is not None:
|
|
103
|
+
return from_unix_seconds(number).date()
|
|
104
|
+
|
|
105
|
+
if isinstance(value, bytes):
|
|
106
|
+
value = value.decode()
|
|
107
|
+
|
|
108
|
+
match = date_re.match(value) # type: ignore
|
|
109
|
+
if match is None:
|
|
110
|
+
# Fallback: try parsing as a datetime string and extract the date part
|
|
111
|
+
dt_match = datetime_re.match(value)
|
|
112
|
+
if dt_match is None:
|
|
113
|
+
raise ValueError("invalid date")
|
|
114
|
+
try:
|
|
115
|
+
# Validate that the time and timezone sections are also valid.
|
|
116
|
+
return parse_datetime(value).date()
|
|
117
|
+
except ValueError as exc:
|
|
118
|
+
raise ValueError("invalid date") from exc
|
|
119
|
+
|
|
120
|
+
kw = {k: int(v) for k, v in match.groupdict().items()}
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
return date(**kw)
|
|
124
|
+
except ValueError as exc:
|
|
125
|
+
raise ValueError("invalid date") from exc
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def parse_time(value: Union[time, StrBytesIntFloat]) -> time:
|
|
129
|
+
"""
|
|
130
|
+
Parse a time/string and return a datetime.time.
|
|
131
|
+
|
|
132
|
+
Raise ValueError if the input is well formatted but not a valid time.
|
|
133
|
+
Raise ValueError if the input isn't well formatted, in particular if it contains an offset.
|
|
134
|
+
"""
|
|
135
|
+
if isinstance(value, time):
|
|
136
|
+
return value
|
|
137
|
+
|
|
138
|
+
number = get_numeric(value, 'time')
|
|
139
|
+
if number is not None:
|
|
140
|
+
if number >= 86400:
|
|
141
|
+
# doesn't make sense since the time time loop back around to 0
|
|
142
|
+
raise ValueError("invalid time")
|
|
143
|
+
return (datetime.min + timedelta(seconds=number)).time()
|
|
144
|
+
|
|
145
|
+
if isinstance(value, bytes):
|
|
146
|
+
value = value.decode()
|
|
147
|
+
|
|
148
|
+
match = time_re.match(value) # type: ignore
|
|
149
|
+
if match is None:
|
|
150
|
+
raise ValueError("invalid time")
|
|
151
|
+
|
|
152
|
+
kw = match.groupdict()
|
|
153
|
+
if kw['microsecond']:
|
|
154
|
+
kw['microsecond'] = kw['microsecond'].ljust(6, '0')
|
|
155
|
+
|
|
156
|
+
tzinfo = _parse_timezone(kw.pop('tzinfo'))
|
|
157
|
+
kw_: dict[str, Union[None, int, timezone]] = {k: int(v) for k, v in kw.items() if v is not None}
|
|
158
|
+
kw_['tzinfo'] = tzinfo
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
return time(**kw_) # type: ignore
|
|
162
|
+
except ValueError as exc:
|
|
163
|
+
raise ValueError("invalid time") from exc
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime:
|
|
167
|
+
"""
|
|
168
|
+
Parse a datetime/int/float/string and return a datetime.datetime.
|
|
169
|
+
|
|
170
|
+
This function supports time zone offsets. When the input contains one,
|
|
171
|
+
the output uses a timezone with a fixed offset from UTC.
|
|
172
|
+
|
|
173
|
+
Raise ValueError if the input is well formatted but not a valid datetime.
|
|
174
|
+
Raise ValueError if the input isn't well formatted.
|
|
175
|
+
"""
|
|
176
|
+
if isinstance(value, datetime):
|
|
177
|
+
return value
|
|
178
|
+
|
|
179
|
+
number = get_numeric(value, 'datetime')
|
|
180
|
+
if number is not None:
|
|
181
|
+
return from_unix_seconds(number)
|
|
182
|
+
|
|
183
|
+
if isinstance(value, bytes):
|
|
184
|
+
value = value.decode()
|
|
185
|
+
|
|
186
|
+
match = datetime_re.match(value) # type: ignore
|
|
187
|
+
if match is None:
|
|
188
|
+
raise ValueError("invalid datetime")
|
|
189
|
+
|
|
190
|
+
kw = match.groupdict()
|
|
191
|
+
if kw['microsecond']:
|
|
192
|
+
kw['microsecond'] = kw['microsecond'].ljust(6, '0')
|
|
193
|
+
|
|
194
|
+
tzinfo = _parse_timezone(kw.pop('tzinfo'))
|
|
195
|
+
kw_: dict[str, Union[None, int, timezone]] = {k: int(v) for k, v in kw.items() if v is not None}
|
|
196
|
+
kw_['tzinfo'] = tzinfo
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
return datetime(**kw_) # type: ignore
|
|
200
|
+
except ValueError as exc:
|
|
201
|
+
raise ValueError("invalid datetime") from exc
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def parse_duration(value: StrBytesIntFloat) -> timedelta:
|
|
205
|
+
"""
|
|
206
|
+
Parse a duration int/float/string and return a datetime.timedelta.
|
|
207
|
+
|
|
208
|
+
The preferred format for durations in Django is '%d %H:%M:%S.%f'.
|
|
209
|
+
|
|
210
|
+
Also supports ISO 8601 representation.
|
|
211
|
+
"""
|
|
212
|
+
if isinstance(value, timedelta):
|
|
213
|
+
return value
|
|
214
|
+
|
|
215
|
+
if isinstance(value, (int, float)):
|
|
216
|
+
# below code requires a string
|
|
217
|
+
value = f'{value:f}'
|
|
218
|
+
elif isinstance(value, bytes):
|
|
219
|
+
value = value.decode()
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
match = standard_duration_re.match(value) or iso8601_duration_re.match(value)
|
|
223
|
+
except TypeError:
|
|
224
|
+
raise TypeError('invalid type; expected timedelta, string, bytes, int or float')
|
|
225
|
+
|
|
226
|
+
if not match:
|
|
227
|
+
raise ValueError("invalid duration")
|
|
228
|
+
|
|
229
|
+
kw = match.groupdict()
|
|
230
|
+
sign = -1 if kw.pop('sign', '+') == '-' else 1
|
|
231
|
+
if kw.get('microseconds'):
|
|
232
|
+
kw['microseconds'] = kw['microseconds'].ljust(6, '0')
|
|
233
|
+
|
|
234
|
+
if kw.get('seconds') and kw.get('microseconds') and kw['seconds'].startswith('-'):
|
|
235
|
+
kw['microseconds'] = '-' + kw['microseconds']
|
|
236
|
+
|
|
237
|
+
kw_ = {k: float(v) for k, v in kw.items() if v is not None}
|
|
238
|
+
|
|
239
|
+
return sign * timedelta(**kw_)
|
modmex/errors.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
|
|
2
|
+
"""Exception types raised by modmex."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ValidationError(Exception):
|
|
6
|
+
def __init__(self, errors: list[dict], message: str = "Validation errors occurred"):
|
|
7
|
+
super().__init__(message)
|
|
8
|
+
self.errors = errors
|
|
9
|
+
|
|
10
|
+
def _format_error_message(self) -> str:
|
|
11
|
+
return "\n".join(
|
|
12
|
+
f"Error at {'.'.join(map(str, error['loc']))}: {error['msg']}" for error in self.errors
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
def __str__(self) -> str:
|
|
16
|
+
return self._format_error_message()
|
modmex/fields.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Field helpers for dataclass-backed models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from dataclasses import MISSING, Field as DataclassField
|
|
7
|
+
from dataclasses import field as dataclass_field
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
_EXCLUDE = "__modmex_exclude__"
|
|
11
|
+
_EXCLUDE_FROM = "__modmex_exclude_from__"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def Field(
|
|
15
|
+
default: Any = MISSING,
|
|
16
|
+
*,
|
|
17
|
+
default_factory: Callable[[], Any] | Any = MISSING,
|
|
18
|
+
exclude: bool = False,
|
|
19
|
+
exclude_from: Iterable[str] | None = None,
|
|
20
|
+
metadata: Mapping[str, Any] | None = None,
|
|
21
|
+
**kwargs: Any,
|
|
22
|
+
) -> Any:
|
|
23
|
+
"""Create a dataclass field with modmex serialization options."""
|
|
24
|
+
field_metadata = dict(metadata or {})
|
|
25
|
+
field_metadata[_EXCLUDE] = exclude
|
|
26
|
+
field_metadata[_EXCLUDE_FROM] = _normalize_profiles(exclude_from)
|
|
27
|
+
|
|
28
|
+
if default is not MISSING and default_factory is not MISSING:
|
|
29
|
+
raise ValueError("cannot specify both default and default_factory")
|
|
30
|
+
if default is not MISSING:
|
|
31
|
+
return dataclass_field(default=default, metadata=field_metadata, **kwargs)
|
|
32
|
+
if default_factory is not MISSING:
|
|
33
|
+
return dataclass_field(default_factory=default_factory, metadata=field_metadata, **kwargs)
|
|
34
|
+
return dataclass_field(metadata=field_metadata, **kwargs)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def should_exclude_field(
|
|
38
|
+
field: DataclassField[Any],
|
|
39
|
+
explicit_exclude: Any,
|
|
40
|
+
profile: str | None,
|
|
41
|
+
include_excluded: bool,
|
|
42
|
+
) -> bool:
|
|
43
|
+
if explicit_exclude is True:
|
|
44
|
+
return True
|
|
45
|
+
if include_excluded:
|
|
46
|
+
return False
|
|
47
|
+
if field.metadata.get(_EXCLUDE, False):
|
|
48
|
+
return True
|
|
49
|
+
return profile is not None and profile in field.metadata.get(_EXCLUDE_FROM, frozenset())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _normalize_profiles(profiles: Iterable[str] | None) -> frozenset[str]:
|
|
53
|
+
if profiles is None:
|
|
54
|
+
return frozenset()
|
|
55
|
+
if isinstance(profiles, str):
|
|
56
|
+
return frozenset({profiles})
|
|
57
|
+
return frozenset(profiles)
|
modmex/serialization.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Serialization helpers for model dumps and JSON encoding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from datetime import date, datetime, time, timedelta
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
ExcludeSpec = str | Iterable[str] | Mapping[str, Any] | None
|
|
12
|
+
TypeSerializer = Callable[[Any], Any]
|
|
13
|
+
TypeSerializers = Mapping[type[Any], TypeSerializer] | None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _serialize_by_type(value: Any, type_serializers: TypeSerializers) -> Any:
|
|
17
|
+
if not type_serializers:
|
|
18
|
+
return value
|
|
19
|
+
|
|
20
|
+
for expected_type, serializer in type_serializers.items():
|
|
21
|
+
if isinstance(value, expected_type):
|
|
22
|
+
return serializer(value)
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def normalize_exclude(exclude: ExcludeSpec) -> dict[str, Any]:
|
|
27
|
+
if exclude is None:
|
|
28
|
+
return {}
|
|
29
|
+
if isinstance(exclude, str):
|
|
30
|
+
return {exclude: True}
|
|
31
|
+
if isinstance(exclude, Mapping):
|
|
32
|
+
return dict(exclude)
|
|
33
|
+
return {field_name: True for field_name in exclude}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def excludes_entire_value(exclude: Any) -> bool:
|
|
37
|
+
return exclude is True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def serialize_value(
|
|
41
|
+
value: Any,
|
|
42
|
+
exclude: ExcludeSpec = None,
|
|
43
|
+
profile: str | None = None,
|
|
44
|
+
include_excluded: bool = False,
|
|
45
|
+
type_serializers: TypeSerializers = None,
|
|
46
|
+
) -> Any:
|
|
47
|
+
value = _serialize_by_type(value, type_serializers)
|
|
48
|
+
|
|
49
|
+
if hasattr(value, "model_dump") and callable(value.model_dump):
|
|
50
|
+
nested_exclude = None if exclude is True else exclude
|
|
51
|
+
return value.model_dump(
|
|
52
|
+
exclude=nested_exclude,
|
|
53
|
+
profile=profile,
|
|
54
|
+
include_excluded=include_excluded,
|
|
55
|
+
type_serializers=type_serializers,
|
|
56
|
+
)
|
|
57
|
+
if isinstance(value, list):
|
|
58
|
+
nested_exclude = None if exclude is True else exclude
|
|
59
|
+
return [
|
|
60
|
+
serialize_value(
|
|
61
|
+
item,
|
|
62
|
+
exclude=nested_exclude,
|
|
63
|
+
profile=profile,
|
|
64
|
+
include_excluded=include_excluded,
|
|
65
|
+
type_serializers=type_serializers,
|
|
66
|
+
)
|
|
67
|
+
for item in value
|
|
68
|
+
]
|
|
69
|
+
if isinstance(value, tuple):
|
|
70
|
+
nested_exclude = None if exclude is True else exclude
|
|
71
|
+
return tuple(
|
|
72
|
+
serialize_value(
|
|
73
|
+
item,
|
|
74
|
+
exclude=nested_exclude,
|
|
75
|
+
profile=profile,
|
|
76
|
+
include_excluded=include_excluded,
|
|
77
|
+
type_serializers=type_serializers,
|
|
78
|
+
)
|
|
79
|
+
for item in value
|
|
80
|
+
)
|
|
81
|
+
if isinstance(value, dict):
|
|
82
|
+
exclude_map = normalize_exclude(exclude)
|
|
83
|
+
return {
|
|
84
|
+
key: serialize_value(
|
|
85
|
+
item,
|
|
86
|
+
exclude=exclude_map.get(key),
|
|
87
|
+
profile=profile,
|
|
88
|
+
include_excluded=include_excluded,
|
|
89
|
+
type_serializers=type_serializers,
|
|
90
|
+
)
|
|
91
|
+
for key, item in value.items()
|
|
92
|
+
if not excludes_entire_value(exclude_map.get(key))
|
|
93
|
+
}
|
|
94
|
+
if isinstance(value, datetime):
|
|
95
|
+
return value.isoformat()
|
|
96
|
+
if isinstance(value, date):
|
|
97
|
+
return value.isoformat()
|
|
98
|
+
if isinstance(value, time):
|
|
99
|
+
return value.isoformat()
|
|
100
|
+
if isinstance(value, timedelta):
|
|
101
|
+
return value.total_seconds()
|
|
102
|
+
if isinstance(value, Enum):
|
|
103
|
+
return value.value
|
|
104
|
+
if isinstance(value, Decimal):
|
|
105
|
+
return float(value)
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def custom_serializer(obj: Any) -> Any:
|
|
110
|
+
"""Serialize values that orjson does not handle by default."""
|
|
111
|
+
if isinstance(obj, Enum):
|
|
112
|
+
return obj.value
|
|
113
|
+
if isinstance(obj, datetime):
|
|
114
|
+
return obj.isoformat()
|
|
115
|
+
if isinstance(obj, date):
|
|
116
|
+
return obj.isoformat()
|
|
117
|
+
if isinstance(obj, time):
|
|
118
|
+
return obj.isoformat()
|
|
119
|
+
if isinstance(obj, timedelta):
|
|
120
|
+
return obj.total_seconds()
|
|
121
|
+
if isinstance(obj, Decimal):
|
|
122
|
+
return float(obj)
|
|
123
|
+
raise TypeError(f"Type {type(obj)} not serializable")
|
modmex/validation.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Type coercion and validation helpers for dataclass-backed models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import sys
|
|
7
|
+
import types
|
|
8
|
+
import typing
|
|
9
|
+
from collections.abc import Callable as CallableABC
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from datetime import date, datetime, time, timedelta
|
|
12
|
+
from decimal import Decimal, DecimalException
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Any, Callable, Literal, get_args, get_origin
|
|
15
|
+
|
|
16
|
+
from .datetime_parser import parse_date, parse_datetime, parse_duration, parse_time
|
|
17
|
+
from .errors import ValidationError
|
|
18
|
+
|
|
19
|
+
GlobalNS_T = dict[str, Any]
|
|
20
|
+
Loc = list[str | int]
|
|
21
|
+
|
|
22
|
+
NoneType = type(None)
|
|
23
|
+
_NONE_TYPES: tuple[Any, ...] = (None, NoneType, Literal[None])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_none_type(tp: Any) -> bool:
|
|
27
|
+
"""Return whether ``tp`` represents ``None`` in a type annotation."""
|
|
28
|
+
return tp in _NONE_TYPES
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def str_validator(value: Any) -> str:
|
|
32
|
+
if isinstance(value, str):
|
|
33
|
+
return value
|
|
34
|
+
if isinstance(value, Enum):
|
|
35
|
+
return str(value.value)
|
|
36
|
+
if isinstance(value, (float, int, Decimal)):
|
|
37
|
+
return str(value)
|
|
38
|
+
if isinstance(value, (bytes, bytearray)):
|
|
39
|
+
return value.decode()
|
|
40
|
+
raise ValueError("invalid str")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def int_validator(value: Any) -> int:
|
|
44
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
45
|
+
return value
|
|
46
|
+
try:
|
|
47
|
+
return int(value)
|
|
48
|
+
except (TypeError, ValueError, OverflowError) as exc:
|
|
49
|
+
raise ValueError(f"'{value}' is not a valid integer") from exc
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
BOOL_FALSE = {0, "0", "off", "f", "false", "n", "no"}
|
|
53
|
+
BOOL_TRUE = {1, "1", "on", "t", "true", "y", "yes"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def bool_validator(value: Any) -> bool:
|
|
57
|
+
if value is True or value is False:
|
|
58
|
+
return value
|
|
59
|
+
if isinstance(value, bytes):
|
|
60
|
+
value = value.decode()
|
|
61
|
+
if isinstance(value, str):
|
|
62
|
+
value = value.lower()
|
|
63
|
+
try:
|
|
64
|
+
if value in BOOL_TRUE:
|
|
65
|
+
return True
|
|
66
|
+
if value in BOOL_FALSE:
|
|
67
|
+
return False
|
|
68
|
+
except TypeError as exc:
|
|
69
|
+
raise ValueError("invalid bool") from exc
|
|
70
|
+
raise ValueError("invalid bool")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def float_validator(value: Any) -> float:
|
|
74
|
+
if isinstance(value, float):
|
|
75
|
+
return value
|
|
76
|
+
try:
|
|
77
|
+
return float(value)
|
|
78
|
+
except (TypeError, ValueError) as exc:
|
|
79
|
+
raise ValueError("invalid float") from exc
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def decimal_validator(value: Any) -> Decimal:
|
|
83
|
+
if isinstance(value, Decimal):
|
|
84
|
+
return value
|
|
85
|
+
if isinstance(value, (bytes, bytearray)):
|
|
86
|
+
value = value.decode()
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
decimal_value = Decimal(str(value).strip())
|
|
90
|
+
except DecimalException as exc:
|
|
91
|
+
raise ValueError("invalid decimal") from exc
|
|
92
|
+
|
|
93
|
+
if not decimal_value.is_finite():
|
|
94
|
+
raise ValueError("decimal is not finite")
|
|
95
|
+
return decimal_value
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def callable_validator(value: Any) -> Callable[..., Any]:
|
|
99
|
+
if callable(value):
|
|
100
|
+
return value
|
|
101
|
+
raise ValueError("invalid callable")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_VALIDATORS: dict[Any, Callable[[Any], Any]] = {
|
|
105
|
+
str: str_validator,
|
|
106
|
+
int: int_validator,
|
|
107
|
+
bool: bool_validator,
|
|
108
|
+
float: float_validator,
|
|
109
|
+
datetime: parse_datetime,
|
|
110
|
+
date: parse_date,
|
|
111
|
+
time: parse_time,
|
|
112
|
+
timedelta: parse_duration,
|
|
113
|
+
Decimal: decimal_validator,
|
|
114
|
+
Callable: callable_validator,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _error(loc: Loc, message: str, error_type: str = "value_error") -> ValidationError:
|
|
119
|
+
return ValidationError(errors=[{"loc": loc, "msg": message, "type": error_type}])
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _merge_nested_errors(loc: Loc, exc: ValidationError) -> ValidationError:
|
|
123
|
+
return ValidationError(
|
|
124
|
+
errors=[
|
|
125
|
+
{
|
|
126
|
+
"loc": loc + list(error.get("loc", [])),
|
|
127
|
+
"msg": error.get("msg", str(error)),
|
|
128
|
+
"type": error.get("type", "type_error"),
|
|
129
|
+
}
|
|
130
|
+
for error in exc.errors
|
|
131
|
+
]
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _evaluate_forward_reference(ref_type: typing.ForwardRef, globalns: GlobalNS_T) -> Any:
|
|
136
|
+
if sys.version_info < (3, 9):
|
|
137
|
+
return ref_type._evaluate(globalns, None)
|
|
138
|
+
return ref_type._evaluate(globalns, None, recursive_guard=set())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _validate_simple_type(expected_type: type[Any], value: Any, loc: Loc) -> Any:
|
|
142
|
+
if expected_type in _VALIDATORS:
|
|
143
|
+
try:
|
|
144
|
+
return _VALIDATORS[expected_type](value)
|
|
145
|
+
except ValueError as exc:
|
|
146
|
+
raise _error(loc, str(exc)) from exc
|
|
147
|
+
|
|
148
|
+
if expected_type is Any:
|
|
149
|
+
return value
|
|
150
|
+
|
|
151
|
+
if isinstance(expected_type, type) and issubclass(expected_type, Enum):
|
|
152
|
+
if isinstance(value, expected_type):
|
|
153
|
+
return value
|
|
154
|
+
try:
|
|
155
|
+
return expected_type(value)
|
|
156
|
+
except ValueError as exc:
|
|
157
|
+
raise _error(loc, str(exc)) from exc
|
|
158
|
+
|
|
159
|
+
if isinstance(value, expected_type):
|
|
160
|
+
return value
|
|
161
|
+
|
|
162
|
+
if dataclasses.is_dataclass(expected_type) and isinstance(value, Mapping):
|
|
163
|
+
try:
|
|
164
|
+
return expected_type(**value)
|
|
165
|
+
except ValidationError as exc:
|
|
166
|
+
raise _merge_nested_errors(loc, exc) from exc
|
|
167
|
+
except TypeError as exc:
|
|
168
|
+
raise _error(loc, str(exc), "type_error") from exc
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
return expected_type(value)
|
|
172
|
+
except ValueError as exc:
|
|
173
|
+
raise _error(loc, str(exc)) from exc
|
|
174
|
+
except TypeError as exc:
|
|
175
|
+
raise _error(loc, f"Error instantiating {expected_type.__name__}: {exc}", "type_error") from exc
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _validate_list(expected_type: Any, value: Any, strict: bool, globalns: GlobalNS_T, loc: Loc) -> list[Any]:
|
|
179
|
+
if not isinstance(value, list):
|
|
180
|
+
raise _error(loc, "must be a list", "type_error.list")
|
|
181
|
+
|
|
182
|
+
item_type = _first_arg(expected_type, Any)
|
|
183
|
+
return [
|
|
184
|
+
_validate_types(item_type, item, strict, globalns, loc + [index])
|
|
185
|
+
for index, item in enumerate(value)
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _validate_tuple(expected_type: Any, value: Any, strict: bool, globalns: GlobalNS_T, loc: Loc) -> tuple[Any, ...]:
|
|
190
|
+
if not isinstance(value, tuple):
|
|
191
|
+
raise _error(loc, "must be a tuple", "type_error.tuple")
|
|
192
|
+
|
|
193
|
+
args = get_args(expected_type)
|
|
194
|
+
if not args:
|
|
195
|
+
return value
|
|
196
|
+
if len(args) == 2 and args[1] is Ellipsis:
|
|
197
|
+
return tuple(
|
|
198
|
+
_validate_types(args[0], item, strict, globalns, loc + [index])
|
|
199
|
+
for index, item in enumerate(value)
|
|
200
|
+
)
|
|
201
|
+
if len(args) != len(value):
|
|
202
|
+
raise _error(loc, f"expected {len(args)} items, received {len(value)}", "value_error.tuple.length")
|
|
203
|
+
return tuple(
|
|
204
|
+
_validate_types(item_type, item, strict, globalns, loc + [index])
|
|
205
|
+
for index, (item_type, item) in enumerate(zip(args, value))
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _validate_dict(expected_type: Any, value: Any, strict: bool, globalns: GlobalNS_T, loc: Loc) -> dict[Any, Any]:
|
|
210
|
+
if not isinstance(value, dict):
|
|
211
|
+
raise _error(loc, "must be a dict", "type_error.dict")
|
|
212
|
+
|
|
213
|
+
key_type, value_type = _dict_args(expected_type)
|
|
214
|
+
return {
|
|
215
|
+
_validate_types(key_type, key, strict, globalns, loc + [key]): _validate_types(
|
|
216
|
+
value_type,
|
|
217
|
+
item,
|
|
218
|
+
strict,
|
|
219
|
+
globalns,
|
|
220
|
+
loc + [key],
|
|
221
|
+
)
|
|
222
|
+
for key, item in value.items()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _validate_set(expected_type: Any, value: Any, strict: bool, globalns: GlobalNS_T, loc: Loc) -> set[Any]:
|
|
227
|
+
if not isinstance(value, set):
|
|
228
|
+
raise _error(loc, "must be a set", "type_error.set")
|
|
229
|
+
|
|
230
|
+
item_type = _first_arg(expected_type, Any)
|
|
231
|
+
return {
|
|
232
|
+
_validate_types(item_type, item, strict, globalns, loc + [index])
|
|
233
|
+
for index, item in enumerate(value)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _validate_literal(expected_type: Any, value: Any, loc: Loc) -> Any:
|
|
238
|
+
allowed = get_args(expected_type)
|
|
239
|
+
if value not in allowed:
|
|
240
|
+
values = ", ".join(map(str, allowed))
|
|
241
|
+
raise _error(loc, f"must be one of [{values}] but received {value}", "value_error.literal")
|
|
242
|
+
return value
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _validate_union(expected_type: Any, value: Any, strict: bool, globalns: GlobalNS_T, loc: Loc) -> Any:
|
|
246
|
+
errors: list[dict[str, Any]] = []
|
|
247
|
+
for item_type in get_args(expected_type):
|
|
248
|
+
try:
|
|
249
|
+
return _validate_types(item_type, value, strict, globalns, loc)
|
|
250
|
+
except ValidationError as exc:
|
|
251
|
+
errors.extend(exc.errors)
|
|
252
|
+
|
|
253
|
+
if errors:
|
|
254
|
+
raise ValidationError(errors=errors)
|
|
255
|
+
raise _error(loc, f"must be an instance of {expected_type}, but received {value}", "type_error.union")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _first_arg(expected_type: Any, default: Any) -> Any:
|
|
259
|
+
args = get_args(expected_type)
|
|
260
|
+
return args[0] if args else default
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _dict_args(expected_type: Any) -> tuple[Any, Any]:
|
|
264
|
+
args = get_args(expected_type)
|
|
265
|
+
if len(args) == 2:
|
|
266
|
+
return args
|
|
267
|
+
return Any, Any
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _validate_types(expected_type: Any, value: Any, strict: bool, globalns: GlobalNS_T, loc: Loc | None = None) -> Any:
|
|
271
|
+
loc = loc or []
|
|
272
|
+
|
|
273
|
+
if isinstance(expected_type, str):
|
|
274
|
+
expected_type = typing.ForwardRef(expected_type)
|
|
275
|
+
|
|
276
|
+
if isinstance(expected_type, typing.ForwardRef):
|
|
277
|
+
expected_type = _evaluate_forward_reference(expected_type, globalns)
|
|
278
|
+
|
|
279
|
+
if is_none_type(expected_type):
|
|
280
|
+
if value is None:
|
|
281
|
+
return None
|
|
282
|
+
raise _error(loc, f"{value} is not a valid none value", "value_error.none")
|
|
283
|
+
|
|
284
|
+
origin = get_origin(expected_type)
|
|
285
|
+
if origin is None:
|
|
286
|
+
if isinstance(expected_type, type):
|
|
287
|
+
return _validate_simple_type(expected_type, value, loc)
|
|
288
|
+
if expected_type is Any:
|
|
289
|
+
return value
|
|
290
|
+
if strict:
|
|
291
|
+
raise RuntimeError(f"Unknown type of {expected_type}")
|
|
292
|
+
return value
|
|
293
|
+
|
|
294
|
+
if origin is list:
|
|
295
|
+
return _validate_list(expected_type, value, strict, globalns, loc)
|
|
296
|
+
if origin is tuple:
|
|
297
|
+
return _validate_tuple(expected_type, value, strict, globalns, loc)
|
|
298
|
+
if origin is dict:
|
|
299
|
+
return _validate_dict(expected_type, value, strict, globalns, loc)
|
|
300
|
+
if origin is set:
|
|
301
|
+
return _validate_set(expected_type, value, strict, globalns, loc)
|
|
302
|
+
if origin is frozenset:
|
|
303
|
+
if not isinstance(value, frozenset):
|
|
304
|
+
raise _error(loc, "must be a frozenset", "type_error.frozenset")
|
|
305
|
+
return frozenset(_validate_set(expected_type, set(value), strict, globalns, loc))
|
|
306
|
+
if origin is Literal:
|
|
307
|
+
return _validate_literal(expected_type, value, loc)
|
|
308
|
+
if origin in (typing.Union, types.UnionType):
|
|
309
|
+
return _validate_union(expected_type, value, strict, globalns, loc)
|
|
310
|
+
if origin in (Callable, CallableABC):
|
|
311
|
+
return callable_validator(value)
|
|
312
|
+
|
|
313
|
+
if strict:
|
|
314
|
+
raise RuntimeError(f"Unknown type of {expected_type}")
|
|
315
|
+
return value
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def validate_model_fields(target: Any, strict: bool = False) -> None:
|
|
319
|
+
"""Coerce and validate all dataclass fields on ``target`` in place."""
|
|
320
|
+
globalns = sys.modules[target.__module__].__dict__.copy()
|
|
321
|
+
try:
|
|
322
|
+
type_hints = typing.get_type_hints(target.__class__, globalns=globalns, include_extras=True)
|
|
323
|
+
except Exception:
|
|
324
|
+
type_hints = {}
|
|
325
|
+
errors: list[dict[str, Any]] = []
|
|
326
|
+
|
|
327
|
+
for field in dataclasses.fields(target):
|
|
328
|
+
value = getattr(target, field.name)
|
|
329
|
+
expected_type = type_hints.get(field.name, field.type)
|
|
330
|
+
try:
|
|
331
|
+
validated = _validate_types(expected_type, value, strict, globalns, loc=[field.name])
|
|
332
|
+
setattr(target, field.name, validated)
|
|
333
|
+
except ValidationError as exc:
|
|
334
|
+
errors.extend(exc.errors)
|
|
335
|
+
except Exception as exc:
|
|
336
|
+
errors.append({"loc": [field.name], "msg": str(exc), "type": "unexpected_error"})
|
|
337
|
+
|
|
338
|
+
if errors:
|
|
339
|
+
raise ValidationError(errors=errors)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 modmex
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: modmex
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Lightweight Python models built on dataclasses with validation, serialization, and type-safe data mapping
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: clandro89@gmail.com
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Requires-Dist: orjson (>=3.11.9,<4.0.0)
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# modmex
|
|
18
|
+
|
|
19
|
+
Lightweight Python models built on dataclasses with validation, serialization, and type-safe data mapping.
|
|
20
|
+
|
|
21
|
+
[](https://github.com/modmex/modmex/actions/workflows/ci.yml)
|
|
22
|
+
[](https://codecov.io/gh/modmex/modmex)
|
|
23
|
+
[](https://pypi.org/project/modmex/)
|
|
24
|
+
[](https://pypi.org/project/modmex/)
|
|
25
|
+
[](https://github.com/modmex/modmex/blob/main/LICENSE)
|
|
26
|
+
|
|
27
|
+
modmex gives you a small but powerful toolkit for:
|
|
28
|
+
|
|
29
|
+
- Typed models with minimal boilerplate.
|
|
30
|
+
- Automatic coercion and validation at initialization time.
|
|
31
|
+
- Recursive serialization to Python primitives and JSON.
|
|
32
|
+
- Per-field and per-model validation hooks.
|
|
33
|
+
- Type-based custom serializers to adapt output for different consumers.
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
## Why modmex
|
|
37
|
+
|
|
38
|
+
If you want stricter models than plain dataclasses, but without the weight of a large framework, modmex is designed for that middle ground.
|
|
39
|
+
|
|
40
|
+
It focuses on:
|
|
41
|
+
|
|
42
|
+
- Simplicity: small API surface.
|
|
43
|
+
- Predictability: explicit model lifecycle.
|
|
44
|
+
- Flexibility: configurable serialization without changing your model definitions.
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
With pip:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install modmex
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
With Poetry:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
poetry add modmex
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
## Quick Start
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from decimal import Decimal
|
|
68
|
+
|
|
69
|
+
from modmex import BaseModel, Field
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class User(BaseModel):
|
|
73
|
+
id: int
|
|
74
|
+
name: str
|
|
75
|
+
balance: Decimal = Decimal("0")
|
|
76
|
+
password: str = Field("", exclude=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
user = User(id="1", name=123, balance="10.50")
|
|
80
|
+
|
|
81
|
+
# Type coercion happens during initialization.
|
|
82
|
+
assert user.id == 1
|
|
83
|
+
assert user.name == "123"
|
|
84
|
+
assert user.balance == Decimal("10.50")
|
|
85
|
+
|
|
86
|
+
# model_dump returns primitive/serializable values.
|
|
87
|
+
assert user.model_dump() == {
|
|
88
|
+
"id": 1,
|
|
89
|
+
"name": "123",
|
|
90
|
+
"balance": 10.5,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# model_dump_json returns a JSON string.
|
|
94
|
+
assert user.model_dump_json() == '{"id":1,"name":"123","balance":10.5}'
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Field Configuration
|
|
98
|
+
|
|
99
|
+
Use `Field(...)` to add serialization metadata to a model field.
|
|
100
|
+
|
|
101
|
+
Main options:
|
|
102
|
+
|
|
103
|
+
- `exclude=True`
|
|
104
|
+
- Always excludes this field from `model_dump` and `model_dump_json`.
|
|
105
|
+
- `exclude_from={"profile_name"}`
|
|
106
|
+
- Excludes this field only for selected serialization profiles.
|
|
107
|
+
|
|
108
|
+
Example:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from modmex import BaseModel, Field
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Session(BaseModel):
|
|
115
|
+
id: str
|
|
116
|
+
secret: str = Field("", exclude_from={"public"})
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class User(BaseModel):
|
|
120
|
+
id: int
|
|
121
|
+
private_note: str = Field("x", exclude=True)
|
|
122
|
+
sessions: list[Session] = Field(default_factory=list)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
user = User(id=1, sessions=[Session(id="s1", secret="abc")])
|
|
126
|
+
|
|
127
|
+
assert user.model_dump(profile="public") == {
|
|
128
|
+
"id": 1,
|
|
129
|
+
"sessions": [{"id": "s1"}],
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Tip:
|
|
134
|
+
|
|
135
|
+
- Use `exclude=True` for values that should never leave the model.
|
|
136
|
+
- Use `exclude_from={...}` when omission depends on the output profile.
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
## Everyday Usage
|
|
141
|
+
|
|
142
|
+
### 1) Parse and normalize input data
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from modmex import BaseModel
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class Product(BaseModel):
|
|
149
|
+
id: int
|
|
150
|
+
name: str
|
|
151
|
+
active: bool
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
product = Product(id="10", name=123, active="true")
|
|
155
|
+
|
|
156
|
+
assert product.id == 10
|
|
157
|
+
assert product.name == "123"
|
|
158
|
+
assert product.active is True
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### 2) Work with nested models
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from modmex import BaseModel
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class Address(BaseModel):
|
|
168
|
+
zipcode: int
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class User(BaseModel):
|
|
172
|
+
id: int
|
|
173
|
+
address: Address
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
user = User(id="1", address={"zipcode": "90210"})
|
|
177
|
+
assert user.address.zipcode == 90210
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### 3) Prepare different payloads from the same model
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
api_payload = account.model_dump(profile="public")
|
|
184
|
+
internal_payload = account.model_dump()
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### 4) Build JSON directly
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
json_payload = account.model_dump_json(profile="public")
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
## Validators
|
|
195
|
+
|
|
196
|
+
### Field validators
|
|
197
|
+
|
|
198
|
+
Use `@field_validator("field_name")` to transform or validate a single field.
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
from modmex import BaseModel, field_validator
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class Product(BaseModel):
|
|
205
|
+
name: str
|
|
206
|
+
|
|
207
|
+
@field_validator("name")
|
|
208
|
+
def normalize_name(self, value: str) -> str:
|
|
209
|
+
return value.strip().title()
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
### Model validators
|
|
214
|
+
|
|
215
|
+
Use `@model_validator(mode="before" | "after")` to work with full model state.
|
|
216
|
+
|
|
217
|
+
- `before`: runs before type coercion.
|
|
218
|
+
- `after`: runs after field-level validation.
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
from modmex import BaseModel, model_validator
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class Product(BaseModel):
|
|
225
|
+
name: str
|
|
226
|
+
slug: str = ""
|
|
227
|
+
|
|
228
|
+
@model_validator(mode="before")
|
|
229
|
+
def build_slug(self, values: dict) -> dict:
|
|
230
|
+
values["slug"] = values["name"].lower().replace(" ", "-")
|
|
231
|
+
return values
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
## Serialization
|
|
236
|
+
|
|
237
|
+
### `model_dump(...)`
|
|
238
|
+
|
|
239
|
+
Use `model_dump` when you need a dictionary payload.
|
|
240
|
+
|
|
241
|
+
Most common options:
|
|
242
|
+
|
|
243
|
+
- `exclude={...}` to omit fields for a specific call.
|
|
244
|
+
- `profile="..."` to apply `exclude_from` rules.
|
|
245
|
+
- `include_excluded=True` to force metadata-excluded fields into the payload.
|
|
246
|
+
- `type_serializers={...}` to control how specific Python types are represented.
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
### `model_dump_json(...)`
|
|
250
|
+
|
|
251
|
+
Use `model_dump_json` when you need a JSON string output.
|
|
252
|
+
|
|
253
|
+
It supports the same practical options as `model_dump` (`exclude`, `profile`, `include_excluded`, `type_serializers`).
|
|
254
|
+
|
|
255
|
+
## Omitting Fields During Serialization
|
|
256
|
+
|
|
257
|
+
Use this feature when the same model must produce different payloads depending on where the data is going.
|
|
258
|
+
|
|
259
|
+
- `exclude_from` defines where a field should be omitted.
|
|
260
|
+
- `profile` selects which omission rules to apply in a specific dump call.
|
|
261
|
+
|
|
262
|
+
### What each option does
|
|
263
|
+
|
|
264
|
+
- `exclude_from={"public"}`
|
|
265
|
+
- Omit this field when serializing with `profile="public"`.
|
|
266
|
+
- `profile="public"`
|
|
267
|
+
- Apply all field rules tagged for `public` during serialization.
|
|
268
|
+
|
|
269
|
+
### Common pattern
|
|
270
|
+
|
|
271
|
+
You may want one shape for API responses and another for internal flows (logs, queues, exports, persistence payloads, etc.).
|
|
272
|
+
|
|
273
|
+
- API payload (`profile="public"`): hide internal fields.
|
|
274
|
+
- Internal payload (no profile, or another profile): keep those fields.
|
|
275
|
+
|
|
276
|
+
### Example
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
from modmex import BaseModel, Field
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class Account(BaseModel):
|
|
283
|
+
id: int
|
|
284
|
+
email: str = Field("", exclude_from={"public"})
|
|
285
|
+
internal_note: str = Field("", exclude=True)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
account = Account(id=1, email="a@x.com", internal_note="secret")
|
|
289
|
+
|
|
290
|
+
# No profile: only always-excluded fields are removed.
|
|
291
|
+
assert account.model_dump() == {
|
|
292
|
+
"id": 1,
|
|
293
|
+
"email": "a@x.com",
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
# public profile: profile-based exclusions are applied.
|
|
297
|
+
assert account.model_dump(profile="public") == {
|
|
298
|
+
"id": 1,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
# include_excluded=True: ignore Field exclusion metadata.
|
|
302
|
+
assert account.model_dump(profile="public", include_excluded=True) == {
|
|
303
|
+
"id": 1,
|
|
304
|
+
"email": "a@x.com",
|
|
305
|
+
"internal_note": "secret",
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
# Dynamic omission for one call (without metadata changes).
|
|
309
|
+
assert account.model_dump(exclude={"email"}) == {
|
|
310
|
+
"id": 1,
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
## Type-Based Custom Serializers
|
|
316
|
+
|
|
317
|
+
You can override serialization behavior by type with `type_serializers`.
|
|
318
|
+
|
|
319
|
+
Shape:
|
|
320
|
+
|
|
321
|
+
```python
|
|
322
|
+
type_serializers = {
|
|
323
|
+
SomeType: serializer_function,
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Keep Decimal values as Decimal in `model_dump`
|
|
328
|
+
|
|
329
|
+
```python
|
|
330
|
+
from decimal import Decimal
|
|
331
|
+
|
|
332
|
+
dumped = model.model_dump(
|
|
333
|
+
type_serializers={
|
|
334
|
+
Decimal: lambda value: value,
|
|
335
|
+
}
|
|
336
|
+
)
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### Convert float to Decimal for a specific output contract
|
|
340
|
+
|
|
341
|
+
```python
|
|
342
|
+
from decimal import Decimal
|
|
343
|
+
|
|
344
|
+
from modmex import BaseModel
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class Price(BaseModel):
|
|
348
|
+
amount: float
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
p = Price(amount=10.25)
|
|
352
|
+
dumped = p.model_dump(
|
|
353
|
+
type_serializers={
|
|
354
|
+
float: lambda value: Decimal(str(value)),
|
|
355
|
+
}
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
assert dumped["amount"] == Decimal("10.25")
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Emit Decimal as string in JSON
|
|
362
|
+
|
|
363
|
+
```python
|
|
364
|
+
from decimal import Decimal
|
|
365
|
+
|
|
366
|
+
dumped_json = model.model_dump_json(
|
|
367
|
+
type_serializers={
|
|
368
|
+
Decimal: lambda value: str(value),
|
|
369
|
+
}
|
|
370
|
+
)
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Note: some client libraries expect `Decimal` instead of `float` values (for example, common `boto3` workflows). Type serializers let you adapt output contracts cleanly, without hard-coding backend-specific behavior into your models.
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
## Error Handling
|
|
380
|
+
|
|
381
|
+
Validation issues raise `ValidationError`.
|
|
382
|
+
|
|
383
|
+
Each error includes:
|
|
384
|
+
|
|
385
|
+
- `loc`: location path (supports nested structures).
|
|
386
|
+
- `msg`: human-readable message.
|
|
387
|
+
- `type`: error category.
|
|
388
|
+
|
|
389
|
+
Example locations:
|
|
390
|
+
|
|
391
|
+
- `["address", "zipcode"]`
|
|
392
|
+
- `["tags", 1]`
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
## Practical Usage Pattern
|
|
396
|
+
|
|
397
|
+
Use this rule of thumb:
|
|
398
|
+
|
|
399
|
+
- Keep rich Python types in the in-memory model instance.
|
|
400
|
+
- Use `model_dump` / `model_dump_json` to produce transport-friendly payloads.
|
|
401
|
+
- Use `type_serializers` when a specific consumer requires a different type format.
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
## Compatibility
|
|
405
|
+
|
|
406
|
+
- Python 3.10+
|
|
407
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
modmex/__init__.py,sha256=iOrjDJLI4VToonWBCb99KAwO-ATRg2Q2mr6Xbm85n5Y,261
|
|
2
|
+
modmex/base_model.py,sha256=Ry_px8JJZFoqB3XBYyqR0SrI9vrGscvMRSeZ9t-YpS0,5787
|
|
3
|
+
modmex/datetime_parser.py,sha256=flZYy3YwKiKQuPJ-hyTKewDKjdxy_906Krsu0sBM5y0,7568
|
|
4
|
+
modmex/errors.py,sha256=0P_oG4BAc4ITD11a0iFjvtNbYW5hxm51Y_bgJaTelLs,485
|
|
5
|
+
modmex/fields.py,sha256=5UFVDYagLGpY8vjMDWT5E1G3BidAjhyxAJh_vbYzwOk,1916
|
|
6
|
+
modmex/serialization.py,sha256=NfRp179z3pVnnbd9fmaeLGtKWSEPIyD3hhOJVLjMFCI,3894
|
|
7
|
+
modmex/validation.py,sha256=FjX1M3QHCmEAmqoFFxDSDwKJ6R6qfrCNLF0Az_S7P4A,11368
|
|
8
|
+
modmex-1.0.0.dist-info/LICENSE,sha256=_C2TDTOsYeJvE4vn9VB51laKvleBKbdNnn96wJVtXhQ,1063
|
|
9
|
+
modmex-1.0.0.dist-info/METADATA,sha256=qHwH9Ri_c2YqkW19Os6Wk8TO7Io8aqS82MGORKyXj5g,9280
|
|
10
|
+
modmex-1.0.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
11
|
+
modmex-1.0.0.dist-info/RECORD,,
|