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 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)
@@ -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
+ [![CI](https://img.shields.io/github/actions/workflow/status/modmex/modmex/ci.yml?branch=main&logo=github&label=CI)](https://github.com/modmex/modmex/actions/workflows/ci.yml)
22
+ [![Coverage](https://img.shields.io/codecov/c/github/modmex/modmex?label=coverage)](https://codecov.io/gh/modmex/modmex)
23
+ [![PyPI](https://img.shields.io/pypi/v/modmex.svg)](https://pypi.org/project/modmex/)
24
+ [![Python Versions](https://img.shields.io/pypi/pyversions/modmex.svg)](https://pypi.org/project/modmex/)
25
+ [![License](https://img.shields.io/github/license/modmex/modmex.svg)](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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.1.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any