Coverage for src/dynapydantic/subclass_tracking_model.py: 99%

130 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-06 00:44 +0000

1"""Base class for dynamic pydantic models""" 

2 

3import dataclasses 

4import functools 

5import json 

6import typing as ty 

7 

8import pydantic 

9from pydantic import ( 

10 BaseModel, 

11 GetCoreSchemaHandler, 

12 GetJsonSchemaHandler, 

13 PydanticInvalidForJsonSchema, 

14) 

15from pydantic.json_schema import JsonSchemaValue 

16from pydantic_core import PydanticCustomError, core_schema 

17 

18from .exceptions import ConfigurationError, Error 

19from .tracking_group import TrackingGroup 

20from .union_mode import UnionRealization 

21from .version_check import pydantic_ge 

22 

23# Names of kwargs to __pydantic_init_subclass__. This has to be manually kept 

24# in sync to avoid a call to inspect.signature on the registration hot path. 

25_STM_INIT_SUBCLASS_KWARGS = ("exclude_from_union", "union_realization") 

26 

27 

28class SubclassTrackingModel(pydantic.BaseModel): 

29 """Subclass-tracking BaseModel 

30 

31 This will inject a [`TrackingGroup`][dynapydantic.TrackingGroup] into your 

32 class and automate the registration of subclasses. 

33 

34 Similar to `BaseModel`, `SubclassTrackingModel` can take arguments in the 

35 class declaration. Arguments from `BaseModel` will be forwarded. 

36 Additionally, any fields from `TrackingGroup` will be forwarded to the 

37 internal `TrackingGroup` instance. The following additional arguments are 

38 supported: 

39 

40 1. `exclude_from_union`: This flag is intended to be used with descendents 

41 of `SubclassTrackingModel`. If `True`, this subclass will be omitted 

42 from tracking. The default for this flag is `True` for direct 

43 descendents of `SubclassTrackingModel` and `False` otherwise. 

44 2. `union_realization`: When the union should be realized. See 

45 [`UnionRealization`][dynapydantic.UnionRealization] for more details 

46 on the various options. The default is to realize unions at model 

47 construction time. 

48 """ 

49 

50 def __init_subclass__(cls, *args, **kwargs) -> None: 

51 """Subclass hook""" 

52 # Intercept any kwargs that are intended for TrackingGroup or 

53 # __pydantic_init_subclass__ 

54 super().__init_subclass__( 

55 *args, 

56 **{ 

57 k: v 

58 for k, v in kwargs.items() 

59 if k not in TrackingGroup.model_fields 

60 and k not in _STM_INIT_SUBCLASS_KWARGS 

61 }, 

62 ) 

63 

64 @classmethod 

65 def __pydantic_init_subclass__( 

66 cls, 

67 *args, 

68 exclude_from_union: bool | None = None, 

69 union_realization: str | UnionRealization | None = None, 

70 **kwargs, 

71 ) -> None: 

72 """Pydantic subclass hook""" 

73 # Forward along any unexpected arguments that were not intended 

74 # for TrackingGroup. 

75 super().__pydantic_init_subclass__( 

76 *args, 

77 **{k: v for k, v in kwargs.items() if k not in TrackingGroup.model_fields}, 

78 ) 

79 

80 # Initialize the tracking group 

81 cls.__DYNAPYDANTIC__: ty.ClassVar[TrackingGroup] = _init_tracking_group( 

82 cls, **kwargs 

83 ) 

84 

85 # Initialize our SubclassTrackingModel-specific config 

86 cls.__DYNAPYDANTIC_STM_CONFIG__: ty.ClassVar[_StmConfig] = _StmConfig.create( 

87 cls, 

88 exclude_from_union=exclude_from_union, 

89 union_realization=union_realization, 

90 inherited=getattr(cls, "__DYNAPYDANTIC_STM_CONFIG__", None), 

91 ) 

92 

93 # If we are going to be tracked, walk the entire MRO (to support 

94 # multi-level tree) and register ourselves with each one. 

95 if not cls.__DYNAPYDANTIC_STM_CONFIG__.exclude_from_union: 

96 for base in cls.__mro__: 

97 if ( 

98 issubclass(base, SubclassTrackingModel) 

99 and base is not SubclassTrackingModel 

100 and not _is_uninstantiated_generic(base) 

101 ): 

102 base.__DYNAPYDANTIC__.register_model(cls) 

103 

104 

105def _init_tracking_group( 

106 cls: type[SubclassTrackingModel], 

107 **kwargs, 

108) -> TrackingGroup: 

109 """Initialize the tracking model embedded in this model""" 

110 # If the user already defined one, use it 

111 if isinstance((tc := getattr(cls, "tracking_config", None)), TrackingGroup): 

112 return tc 

113 

114 # Otherwise, we need to make it. We can inherit arguments from our 

115 # parent class(es) if they have TrackingGroup's and then allow any 

116 # kwargs directly passed here to override. 

117 if isinstance(parent_tg := getattr(cls, "__DYNAPYDANTIC__", None), TrackingGroup): 

118 tg_kwargs: dict[str, ty.Any] = { 

119 "union_mode": parent_tg.union_mode, 

120 "plugin_entry_point": parent_tg.plugin_entry_point, 

121 } | kwargs 

122 if "discriminator_field" in kwargs: 

123 tg_kwargs.pop("union_mode", None) 

124 else: 

125 tg_kwargs = kwargs 

126 tg_kwargs.setdefault("name", f"{cls.__name__}-subclasses") 

127 

128 try: 

129 return TrackingGroup(**tg_kwargs) 

130 except pydantic.ValidationError as e: 

131 msg = ( 

132 "SubclassTrackingModel subclasses must either have a " 

133 "tracking_config: ClassVar[dynapydantic.TrackingGroup] " 

134 "member or pass kwargs sufficient to construct a " 

135 "dynapydantic.TrackingGroup in the class declaration. " 

136 "The latter approach produced the following " 

137 f"ValidationError:\n{e}" 

138 ) 

139 raise ConfigurationError(msg) from e 

140 

141 

142@dataclasses.dataclass(frozen=True) 

143class _StmConfig: 

144 """Config for SubclassTrackingModel""" 

145 

146 union_realization: UnionRealization 

147 exclude_from_union: bool 

148 

149 @classmethod 

150 def create( 

151 cls, 

152 model_t: type[SubclassTrackingModel], 

153 *, 

154 exclude_from_union: bool | None, 

155 union_realization: str | UnionRealization | None = None, 

156 inherited: "_StmConfig | None" = None, 

157 ) -> "_StmConfig": 

158 """Create this model from the user's specified keyword arguments""" 

159 # Figure out the union realization time. Prefer direct argument, then 

160 # inherited value, then default of model construction time. 

161 if union_realization is None: 

162 union_realization = ( 

163 inherited.union_realization 

164 if inherited is not None 

165 else UnionRealization.MODEL_CONSTRUCTION 

166 ) 

167 elif not isinstance(union_realization, UnionRealization): 

168 try: 

169 union_realization = UnionRealization(union_realization) 

170 except (ValueError, TypeError) as e: 

171 msg = f"invalid union_realization: {e}" 

172 raise ConfigurationError(msg) from e 

173 

174 if exclude_from_union is None: 

175 exclude_from_union = _exclude_from_union_default(model_t) 

176 

177 return cls( 

178 union_realization=union_realization, 

179 exclude_from_union=exclude_from_union, 

180 ) 

181 

182 

183def _exclude_from_union_default(model_t: type[SubclassTrackingModel]) -> bool: 

184 """Determine the default value for exclude_from_union""" 

185 # In general, this shall default to False. It will default to True if: 

186 # 1. We are direct descendent of SubclassTrackingModel. This is 

187 # because direct descendents tend to be the abstract base classes. 

188 if SubclassTrackingModel in model_t.__bases__: 

189 return True 

190 

191 # 2. We are a generic class with a TypeVar argument (non-concrete). 

192 if _is_uninstantiated_generic(model_t): 

193 return True 

194 

195 # 3. We are a concrete generic class and our origin is a direct 

196 # descendent of SubclassTrackingModel. Combined case of 1 and 2. A 

197 # concrete generic that is not a direct descendent is the same as any 

198 # other class in the middle of an inheritance tree. 

199 generic_origin = model_t.__pydantic_generic_metadata__["origin"] 

200 if generic_origin is None: 

201 return False 

202 return SubclassTrackingModel in generic_origin.__bases__ 

203 

204 

205def _is_uninstantiated_generic(model_t: type[SubclassTrackingModel]) -> bool: 

206 """Determine if this a generic model with uninstantiated args""" 

207 generic_args = model_t.__pydantic_generic_metadata__["parameters"] 

208 return any(isinstance(arg, ty.TypeVar) for arg in generic_args) 

209 

210 

211_UNSET = object() 

212 

213 

214class ValidationTimeAdapter: 

215 """Pydantic type adapter for a dynapydantic-tracked field 

216 

217 This adapter returns a validator that evaluates the union at validation time 

218 """ 

219 

220 @staticmethod 

221 def __get_pydantic_core_schema__( 

222 source_type: type[SubclassTrackingModel], 

223 _handler: GetCoreSchemaHandler, 

224 ) -> core_schema.CoreSchema: 

225 """Get the pydantic schema for this type""" 

226 

227 def _validate( 

228 value: ty.Any, # noqa: ANN401 

229 info: core_schema.ValidationInfo, 

230 *, 

231 strict: bool, 

232 ) -> ty.Any: # noqa: ANN401 

233 try: 

234 adapter = source_type.__DYNAPYDANTIC__.type_adapter 

235 except Error as e: 

236 err_t = "dynapydantic_error" 

237 raise PydanticCustomError(err_t, "{e}", {"e": str(e)}) from e 

238 

239 kwargs = _validation_kwargs(info) 

240 kwargs["strict"] = strict 

241 if info.mode == "json": 

242 # Field validators receive JSON after the enclosing document has 

243 # already been decoded. Re-encode the field so the nested 

244 # adapter can apply JSON-specific strict-validation behavior. 

245 # https://github.com/pydantic/pydantic/issues/11154 

246 kwargs.pop("from_attributes", None) 

247 try: 

248 value_j = json.dumps(value) 

249 # Since the object came from a JSON load, this shouldn't ever 

250 # occur, but just being overly defensive. 

251 except (TypeError, ValueError, OverflowError) as e: 

252 err_t = "json_reencode_failure" 

253 msg = "JSON re-encoding failed: {e}" 

254 raise PydanticCustomError(err_t, msg, {"e": str(e)}) from e 

255 

256 return adapter.validate_json(value_j, **kwargs) 

257 return adapter.validate_python(value, **kwargs) 

258 

259 def _serialize( 

260 value: BaseModel, 

261 info: core_schema.SerializationInfo, 

262 ) -> dict[str, ty.Any]: 

263 # These arguments we're going to attempt but not require 

264 soft_args = ( 

265 # These were added after 2.0 (we pin >= 2) 

266 "context", 

267 "exclude_computed_fields", 

268 "serialize_as_any", 

269 "polymorphic_serialization", 

270 ) 

271 args: dict[str, ty.Any] = { 

272 # SerializationInfo doesn't expose warnings, so we have to 

273 # pick one option 

274 "warnings": False, 

275 } 

276 for arg in soft_args: 

277 if (v := getattr(info, arg, _UNSET)) is not _UNSET: 277 ↛ 276line 277 didn't jump to line 276 because the condition on line 277 was always true

278 args[arg] = v 

279 

280 return value.model_dump( 

281 mode=info.mode, 

282 # Pydantic's types on SerializationInfo's include/exclude don't 

283 # match up with the corresponding parameter types on model_dump 

284 include=info.include, # type: ignore[bad-argument-type] 

285 exclude=info.exclude, # type: ignore[bad-argument-type] 

286 by_alias=info.by_alias, 

287 exclude_unset=info.exclude_unset, 

288 exclude_defaults=info.exclude_defaults, 

289 exclude_none=info.exclude_none, 

290 round_trip=info.round_trip, 

291 **args, 

292 ) 

293 

294 _validate_lax = functools.partial(_validate, strict=False) 

295 _validate_strict = functools.partial(_validate, strict=True) 

296 

297 serialization = core_schema.plain_serializer_function_ser_schema( 

298 _serialize, 

299 info_arg=True, 

300 when_used="unless-none", 

301 return_schema=core_schema.dict_schema( 

302 core_schema.str_schema(), core_schema.any_schema() 

303 ), 

304 ) 

305 metadata = {"dynapydantic_source_type": source_type} 

306 return core_schema.lax_or_strict_schema( 

307 core_schema.with_info_plain_validator_function(_validate_lax), 

308 core_schema.with_info_plain_validator_function(_validate_strict), 

309 metadata=metadata, 

310 serialization=serialization, 

311 ) 

312 

313 @staticmethod 

314 def __get_pydantic_json_schema__( 

315 schema: core_schema.CoreSchema, 

316 handler: GetJsonSchemaHandler, 

317 ) -> JsonSchemaValue: 

318 """Lazily build the JSON schema from the currently registered subclasses 

319 

320 Runs whenever JSON schema generation actually happens (e.g. a call to 

321 `model_json_schema()`), not when the core schema was first built. 

322 Reflects whatever subclasses are registered with the `TrackingGroup` 

323 at the time of the call. 

324 

325 Parameters 

326 ---------- 

327 schema 

328 Schema for the field type 

329 handler 

330 JSON schema handler to convert core_schemas to JSON schemas 

331 

332 Returns 

333 ------- 

334 JsonSchemaValue 

335 JSON schema for the field 

336 

337 Raises 

338 ------ 

339 pydantic.errors.PydanticInvalidForJsonSchema 

340 If the JSON schema was unable to be generated. 

341 """ 

342 try: 

343 source_type = schema["metadata"]["dynapydantic_source_type"] 

344 except KeyError as e: 

345 msg = "Missing dynapydantic schema metadata." 

346 raise PydanticInvalidForJsonSchema(msg) from e 

347 

348 try: 

349 union_schema = source_type.__DYNAPYDANTIC__.type_adapter.core_schema 

350 except Error as e: 

351 msg = str(e) 

352 raise PydanticInvalidForJsonSchema(msg) from e 

353 

354 return handler(union_schema) 

355 

356 

357def _validation_kwargs( 

358 info: core_schema.ValidationInfo, 

359) -> dict[str, ty.Any]: 

360 """Extract keyword arguments for TypeAdapter.validate_python from info.""" 

361 kwargs: dict[str, ty.Any] = {} 

362 

363 if (ctx := getattr(info, "context", None)) is not None: 

364 kwargs["context"] = ctx 

365 

366 if (config := getattr(info, "config", None)) is not None: 

367 # .validate() didn't support extra until 2.12 

368 if ( 

369 pydantic_ge((2, 12, 0)) 

370 and (val := config.get("extra_fields_behavior")) is not None 

371 ): 

372 kwargs["extra"] = val 

373 

374 if pydantic_ge((2, 11, 0)): 374 ↛ 380line 374 didn't jump to line 380 because the condition on line 374 was always true

375 if (val := config.get("validate_by_alias")) is not None: 

376 kwargs["by_alias"] = val 

377 if (val := config.get("validate_by_name")) is not None: 

378 kwargs["by_name"] = val 

379 

380 if (val := config.get("from_attributes")) is not None: 

381 kwargs["from_attributes"] = val 

382 

383 return kwargs