Coverage for muutils/json_serialize/serializable_field.py: 44%
45 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-28 17:24 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-28 17:24 +0000
1"""extends `dataclasses.Field` for use with `SerializableDataclass`
3In particular, instead of using `dataclasses.field`, use `serializable_field` to define fields in a `SerializableDataclass`.
4You provide information on how the field should be serialized and loaded (as well as anything that goes into `dataclasses.field`)
5when you define the field, and the `SerializableDataclass` will automatically use those functions.
7"""
9from __future__ import annotations
11import dataclasses
12import sys
13import types
14from typing import Any, Callable, Optional, Union, overload, TypeVar
17# pylint: disable=bad-mcs-classmethod-argument, too-many-arguments, protected-access
20class SerializableField(dataclasses.Field):
21 """extension of `dataclasses.Field` with additional serialization properties"""
23 __slots__ = (
24 # from dataclasses.Field.__slots__
25 "name",
26 "type",
27 "default",
28 "default_factory",
29 "repr",
30 "hash",
31 "init",
32 "compare",
33 "doc",
34 "metadata",
35 "kw_only",
36 "_field_type", # Private: not to be used by user code.
37 # new ones
38 "serialize",
39 "serialization_fn",
40 "loading_fn",
41 "deserialize_fn", # new alternative to loading_fn
42 "assert_type",
43 "custom_typecheck_fn",
44 )
46 def __init__(
47 self,
48 default: Union[Any, dataclasses._MISSING_TYPE] = dataclasses.MISSING,
49 default_factory: Union[
50 Callable[[], Any], dataclasses._MISSING_TYPE
51 ] = dataclasses.MISSING,
52 init: bool = True,
53 repr: bool = True,
54 hash: Optional[bool] = None,
55 compare: bool = True,
56 doc: str | None = None,
57 # TODO: add field for custom comparator (such as serializing)
58 metadata: Optional[types.MappingProxyType] = None,
59 kw_only: Union[bool, dataclasses._MISSING_TYPE] = dataclasses.MISSING,
60 serialize: bool = True,
61 serialization_fn: Optional[Callable[[Any], Any]] = None,
62 loading_fn: Optional[Callable[[Any], Any]] = None,
63 deserialize_fn: Optional[Callable[[Any], Any]] = None,
64 assert_type: bool = True,
65 custom_typecheck_fn: Optional[Callable[[type], bool]] = None,
66 ):
67 # TODO: should we do this check, or assume the user knows what they are doing?
68 if init and not serialize:
69 raise ValueError("Cannot have init=True and serialize=False")
71 # need to assemble kwargs in this hacky way so as not to upset type checking
72 super_kwargs: dict[str, Any] = dict(
73 default=default,
74 default_factory=default_factory,
75 init=init,
76 repr=repr,
77 hash=hash,
78 compare=compare,
79 kw_only=kw_only,
80 )
82 if metadata is not None:
83 super_kwargs["metadata"] = metadata
84 else:
85 super_kwargs["metadata"] = types.MappingProxyType({})
87 # only pass `doc` to super if python >=3.14
88 if sys.version_info >= (3, 14):
89 super_kwargs["doc"] = doc
91 # special check, kw_only is not supported in python <3.9 and `dataclasses.MISSING` is truthy
92 if sys.version_info < (3, 10):
93 if super_kwargs["kw_only"] == True: # noqa: E712
94 raise ValueError("kw_only is not supported in python >=3.9")
95 else:
96 del super_kwargs["kw_only"]
98 # actually init the super class
99 super().__init__(**super_kwargs) # type: ignore[call-arg]
101 # init doc if python <3.14
102 if sys.version_info < (3, 14):
103 self.doc: str | None = doc
105 # now init the new fields
106 self.serialize: bool = serialize
107 self.serialization_fn: Optional[Callable[[Any], Any]] = serialization_fn
109 if loading_fn is not None and deserialize_fn is not None:
110 raise ValueError(
111 "Cannot pass both loading_fn and deserialize_fn, pass only one. ",
112 "`loading_fn` is the older interface and takes the dict of the class, ",
113 "`deserialize_fn` is the new interface and takes only the field's value.",
114 )
115 self.loading_fn: Optional[Callable[[Any], Any]] = loading_fn
116 self.deserialize_fn: Optional[Callable[[Any], Any]] = deserialize_fn
118 self.assert_type: bool = assert_type
119 self.custom_typecheck_fn: Optional[Callable[[type], bool]] = custom_typecheck_fn
121 @classmethod
122 def from_Field(cls, field: dataclasses.Field) -> "SerializableField":
123 """copy all values from a `dataclasses.Field` to new `SerializableField`"""
124 return cls(
125 default=field.default,
126 default_factory=field.default_factory,
127 init=field.init,
128 repr=field.repr,
129 hash=field.hash,
130 compare=field.compare,
131 doc=getattr(field, "doc", None), # `doc` added in python <3.14
132 metadata=field.metadata,
133 kw_only=getattr(field, "kw_only", dataclasses.MISSING), # for python <3.9
134 serialize=field.repr, # serialize if it's going to be repr'd
135 serialization_fn=None,
136 loading_fn=None,
137 deserialize_fn=None,
138 )
141Sfield_T = TypeVar("Sfield_T")
144@overload
145def serializable_field( # only `default_factory` is provided
146 *_args,
147 default_factory: Callable[[], Sfield_T],
148 default: dataclasses._MISSING_TYPE = dataclasses.MISSING,
149 init: bool = True,
150 repr: bool = True,
151 hash: Optional[bool] = None,
152 compare: bool = True,
153 doc: str | None = None,
154 metadata: Optional[types.MappingProxyType] = None,
155 kw_only: Union[bool, dataclasses._MISSING_TYPE] = dataclasses.MISSING,
156 serialize: bool = True,
157 serialization_fn: Optional[Callable[[Any], Any]] = None,
158 deserialize_fn: Optional[Callable[[Any], Any]] = None,
159 assert_type: bool = True,
160 custom_typecheck_fn: Optional[Callable[[type], bool]] = None,
161 **kwargs: Any,
162) -> Sfield_T: ...
163@overload
164def serializable_field( # only `default` is provided
165 *_args,
166 default: Sfield_T,
167 default_factory: dataclasses._MISSING_TYPE = dataclasses.MISSING,
168 init: bool = True,
169 repr: bool = True,
170 hash: Optional[bool] = None,
171 compare: bool = True,
172 doc: str | None = None,
173 metadata: Optional[types.MappingProxyType] = None,
174 kw_only: Union[bool, dataclasses._MISSING_TYPE] = dataclasses.MISSING,
175 serialize: bool = True,
176 serialization_fn: Optional[Callable[[Any], Any]] = None,
177 deserialize_fn: Optional[Callable[[Any], Any]] = None,
178 assert_type: bool = True,
179 custom_typecheck_fn: Optional[Callable[[type], bool]] = None,
180 **kwargs: Any,
181) -> Sfield_T: ...
182@overload
183def serializable_field( # both `default` and `default_factory` are MISSING
184 *_args,
185 default: dataclasses._MISSING_TYPE = dataclasses.MISSING,
186 default_factory: dataclasses._MISSING_TYPE = dataclasses.MISSING,
187 init: bool = True,
188 repr: bool = True,
189 hash: Optional[bool] = None,
190 compare: bool = True,
191 doc: str | None = None,
192 metadata: Optional[types.MappingProxyType] = None,
193 kw_only: Union[bool, dataclasses._MISSING_TYPE] = dataclasses.MISSING,
194 serialize: bool = True,
195 serialization_fn: Optional[Callable[[Any], Any]] = None,
196 deserialize_fn: Optional[Callable[[Any], Any]] = None,
197 assert_type: bool = True,
198 custom_typecheck_fn: Optional[Callable[[type], bool]] = None,
199 **kwargs: Any,
200) -> Any: ...
201def serializable_field( # general implementation
202 *_args,
203 default: Union[Any, dataclasses._MISSING_TYPE] = dataclasses.MISSING,
204 default_factory: Union[Any, dataclasses._MISSING_TYPE] = dataclasses.MISSING,
205 init: bool = True,
206 repr: bool = True,
207 hash: Optional[bool] = None,
208 compare: bool = True,
209 doc: str | None = None,
210 metadata: Optional[types.MappingProxyType] = None,
211 kw_only: Union[bool, dataclasses._MISSING_TYPE] = dataclasses.MISSING,
212 serialize: bool = True,
213 serialization_fn: Optional[Callable[[Any], Any]] = None,
214 deserialize_fn: Optional[Callable[[Any], Any]] = None,
215 assert_type: bool = True,
216 custom_typecheck_fn: Optional[Callable[[type], bool]] = None,
217 **kwargs: Any,
218) -> Any:
219 """Create a new `SerializableField`
221 ```
222 default: Sfield_T | dataclasses._MISSING_TYPE = dataclasses.MISSING,
223 default_factory: Callable[[], Sfield_T]
224 | dataclasses._MISSING_TYPE = dataclasses.MISSING,
225 init: bool = True,
226 repr: bool = True,
227 hash: Optional[bool] = None,
228 compare: bool = True,
229 doc: str | None = None, # new in python 3.14. can alternately pass `description` to match pydantic, but this is discouraged
230 metadata: types.MappingProxyType | None = None,
231 kw_only: bool | dataclasses._MISSING_TYPE = dataclasses.MISSING,
232 # ----------------------------------------------------------------------
233 # new in `SerializableField`, not in `dataclasses.Field`
234 serialize: bool = True,
235 serialization_fn: Optional[Callable[[Any], Any]] = None,
236 loading_fn: Optional[Callable[[Any], Any]] = None,
237 deserialize_fn: Optional[Callable[[Any], Any]] = None,
238 assert_type: bool = True,
239 custom_typecheck_fn: Optional[Callable[[type], bool]] = None,
240 ```
242 # new Parameters:
243 - `serialize`: whether to serialize this field when serializing the class'
244 - `serialization_fn`: function taking the instance of the field and returning a serializable object. If not provided, will iterate through the `SerializerHandler`s defined in `muutils.json_serialize.json_serialize`
245 - `loading_fn`: function taking the serialized object and returning the instance of the field. If not provided, will take object as-is.
246 - `deserialize_fn`: new alternative to `loading_fn`. takes only the field's value, not the whole class. if both `loading_fn` and `deserialize_fn` are provided, an error will be raised.
247 - `assert_type`: whether to assert the type of the field when loading. if `False`, will not check the type of the field.
248 - `custom_typecheck_fn`: function taking the type of the field and returning whether the type itself is valid. if not provided, will use the default type checking.
250 # Gotchas:
251 - `loading_fn` takes the dict of the **class**, not the field. if you wanted a `loading_fn` that does nothing, you'd write:
253 ```python
254 class MyClass:
255 my_field: int = serializable_field(
256 serialization_fn=lambda x: str(x),
257 loading_fn=lambda x["my_field"]: int(x)
258 )
259 ```
261 using `deserialize_fn` instead:
263 ```python
264 class MyClass:
265 my_field: int = serializable_field(
266 serialization_fn=lambda x: str(x),
267 deserialize_fn=lambda x: int(x)
268 )
269 ```
271 In the above code, `my_field` is an int but will be serialized as a string.
273 note that if not using ZANJ, and you have a class inside a container, you MUST provide
274 `serialization_fn` and `loading_fn` to serialize and load the container.
275 ZANJ will automatically do this for you.
277 # TODO: `custom_value_check_fn`: function taking the value of the field and returning whether the value itself is valid. if not provided, any value is valid as long as it passes the type test
278 """
279 assert len(_args) == 0, f"unexpected positional arguments: {_args}"
281 if "description" in kwargs:
282 import warnings
284 warnings.warn(
285 "`description` is deprecated, use `doc` instead",
286 DeprecationWarning,
287 )
288 if doc is not None:
289 err_msg: str = f"cannot pass both `doc` and `description`: {doc=}, {kwargs['description']=}"
290 raise ValueError(err_msg)
291 doc = kwargs.pop("description")
293 return SerializableField(
294 default=default,
295 default_factory=default_factory,
296 init=init,
297 repr=repr,
298 hash=hash,
299 compare=compare,
300 metadata=metadata,
301 kw_only=kw_only,
302 serialize=serialize,
303 serialization_fn=serialization_fn,
304 deserialize_fn=deserialize_fn,
305 assert_type=assert_type,
306 custom_typecheck_fn=custom_typecheck_fn,
307 **kwargs,
308 )