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

1"""extends `dataclasses.Field` for use with `SerializableDataclass` 

2 

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. 

6 

7""" 

8 

9from __future__ import annotations 

10 

11import dataclasses 

12import sys 

13import types 

14from typing import Any, Callable, Optional, Union, overload, TypeVar 

15 

16 

17# pylint: disable=bad-mcs-classmethod-argument, too-many-arguments, protected-access 

18 

19 

20class SerializableField(dataclasses.Field): 

21 """extension of `dataclasses.Field` with additional serialization properties""" 

22 

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 ) 

45 

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") 

70 

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 ) 

81 

82 if metadata is not None: 

83 super_kwargs["metadata"] = metadata 

84 else: 

85 super_kwargs["metadata"] = types.MappingProxyType({}) 

86 

87 # only pass `doc` to super if python >=3.14 

88 if sys.version_info >= (3, 14): 

89 super_kwargs["doc"] = doc 

90 

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"] 

97 

98 # actually init the super class 

99 super().__init__(**super_kwargs) # type: ignore[call-arg] 

100 

101 # init doc if python <3.14 

102 if sys.version_info < (3, 14): 

103 self.doc: str | None = doc 

104 

105 # now init the new fields 

106 self.serialize: bool = serialize 

107 self.serialization_fn: Optional[Callable[[Any], Any]] = serialization_fn 

108 

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 

117 

118 self.assert_type: bool = assert_type 

119 self.custom_typecheck_fn: Optional[Callable[[type], bool]] = custom_typecheck_fn 

120 

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 ) 

139 

140 

141Sfield_T = TypeVar("Sfield_T") 

142 

143 

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` 

220 

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 ``` 

241 

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. 

249 

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: 

252 

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 ``` 

260 

261 using `deserialize_fn` instead: 

262 

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 ``` 

270 

271 In the above code, `my_field` is an int but will be serialized as a string. 

272 

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. 

276 

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}" 

280 

281 if "description" in kwargs: 

282 import warnings 

283 

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") 

292 

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 )