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

125 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-12 02:14 +0000

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

2 

3import dataclasses 

4import functools 

5import inspect 

6import json 

7import typing as ty 

8 

9import pydantic 

10from pydantic import ( 

11 BaseModel, 

12 GetCoreSchemaHandler, 

13 GetJsonSchemaHandler, 

14 PydanticInvalidForJsonSchema, 

15) 

16from pydantic.json_schema import JsonSchemaValue 

17from pydantic_core import PydanticCustomError, core_schema 

18 

19from .exceptions import ConfigurationError, Error 

20from .tracking_group import TrackingGroup 

21from .union_mode import UnionRealization 

22 

23 

24class SubclassTrackingModel(pydantic.BaseModel): 

25 """Subclass-tracking BaseModel 

26 

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

28 class and automate the registration of subclasses. 

29 

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

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

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

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

34 supported: 

35 

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

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

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

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

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

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

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

43 construction time. 

44 """ 

45 

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

47 """Subclass hook""" 

48 # Intercept any kwargs that are intended for TrackingGroup or 

49 # __pydantic_init_subclass__ 

50 sig = inspect.signature(SubclassTrackingModel.__pydantic_init_subclass__) 

51 super().__init_subclass__( 

52 *args, 

53 **{ 

54 k: v 

55 for k, v in kwargs.items() 

56 if k not in TrackingGroup.model_fields and k not in sig.parameters 

57 }, 

58 ) 

59 

60 @classmethod 

61 def __pydantic_init_subclass__( 

62 cls, 

63 *args, 

64 exclude_from_union: bool | None = None, 

65 union_realization: str | UnionRealization | None = None, 

66 **kwargs, 

67 ) -> None: 

68 """Pydantic subclass hook""" 

69 # Forward along any unexpected arguments that were not intended 

70 # for TrackingGroup. 

71 super().__pydantic_init_subclass__( 

72 *args, 

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

74 ) 

75 

76 # Initialize the tracking group 

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

78 cls, **kwargs 

79 ) 

80 

81 # Initialize our SubclassTrackingModel-specific config 

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

83 cls, 

84 exclude_from_union=exclude_from_union, 

85 union_realization=union_realization, 

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

87 ) 

88 

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

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

91 if not cls.__DYNAPYDANTIC_STM_CONFIG__.exclude_from_union: 

92 for base in cls.__mro__: 

93 if ( 

94 issubclass(base, SubclassTrackingModel) 

95 and base is not SubclassTrackingModel 

96 and not _is_uninstantiated_generic(base) 

97 ): 

98 base.__DYNAPYDANTIC__.register_model(cls) 

99 

100 

101def _init_tracking_group( 

102 cls: type[SubclassTrackingModel], 

103 **kwargs, 

104) -> TrackingGroup: 

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

106 # If the user already defined one, use it 

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

108 return tc 

109 

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

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

112 # kwargs directly passed here to override. 

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

114 tg_kwargs = parent_tg.model_dump( 

115 exclude={ 

116 "name", 

117 "models", 

118 "discriminator_field", 

119 "discriminator_value_generator", 

120 } 

121 ) 

122 tg_kwargs |= kwargs 

123 if "discriminator_field" in kwargs: 

124 tg_kwargs.pop("union_mode", None) 

125 else: 

126 tg_kwargs = kwargs 

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

128 

129 try: 

130 return TrackingGroup(**tg_kwargs) 

131 except pydantic.ValidationError as e: 

132 msg = ( 

133 "SubclassTrackingModel subclasses must either have a " 

134 "tracking_config: ClassVar[dynapydantic.TrackingGroup] " 

135 "member or pass kwargs sufficient to construct a " 

136 "dynapydantic.TrackingGroup in the class declaration. " 

137 "The latter approach produced the following " 

138 f"ValidationError:\n{e}" 

139 ) 

140 raise ConfigurationError(msg) from e 

141 

142 

143@dataclasses.dataclass(frozen=True) 

144class _StmConfig: 

145 """Config for SubclassTrackingModel""" 

146 

147 union_realization: UnionRealization 

148 exclude_from_union: bool 

149 

150 @classmethod 

151 def create( 

152 cls, 

153 model_t: type[SubclassTrackingModel], 

154 *, 

155 exclude_from_union: bool | None, 

156 union_realization: str | UnionRealization | None = None, 

157 inherited: "_StmConfig | None" = None, 

158 ) -> "_StmConfig": 

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

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

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

162 if union_realization is None: 

163 union_realization = ( 

164 inherited.union_realization 

165 if inherited is not None 

166 else UnionRealization.MODEL_CONSTRUCTION 

167 ) 

168 elif not isinstance(union_realization, UnionRealization): 

169 try: 

170 union_realization = UnionRealization(union_realization) 

171 except (ValueError, TypeError) as e: 

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

173 raise ConfigurationError(msg) from e 

174 

175 if exclude_from_union is None: 

176 exclude_from_union = _exclude_from_union_default(model_t) 

177 

178 return cls( 

179 union_realization=union_realization, 

180 exclude_from_union=exclude_from_union, 

181 ) 

182 

183 

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

185 """Determine the default value for exclude_from_union""" 

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

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

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

189 if SubclassTrackingModel in model_t.__bases__: 

190 return True 

191 

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

193 if _is_uninstantiated_generic(model_t): 

194 return True 

195 

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

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

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

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

200 generic_origin = model_t.__pydantic_generic_metadata__["origin"] 

201 if generic_origin is None: 

202 return False 

203 return SubclassTrackingModel in generic_origin.__bases__ 

204 

205 

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

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

208 generic_args = model_t.__pydantic_generic_metadata__["parameters"] 

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

210 

211 

212_UNSET = object() 

213 

214 

215class ValidationTimeAdapter: 

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

217 

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

219 """ 

220 

221 @staticmethod 

222 def __get_pydantic_core_schema__( 

223 source_type: type[SubclassTrackingModel], 

224 _handler: GetCoreSchemaHandler, 

225 ) -> core_schema.CoreSchema: 

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

227 

228 def _validate( 

229 value: ty.Any, # noqa: ANN401 

230 info: core_schema.ValidationInfo, 

231 *, 

232 strict: bool, 

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

234 try: 

235 adapter = source_type.__DYNAPYDANTIC__.type_adapter 

236 except Error as e: 

237 err_t = "dynapydantic_error" 

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

239 

240 kwargs = _validation_kwargs(info) 

241 kwargs["strict"] = strict 

242 if info.mode == "json": 

243 # Field validators receive JSON after the enclosing document has 

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

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

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

247 kwargs.pop("from_attributes", None) 

248 try: 

249 value_j = json.dumps(value) 

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

251 # occur, but just being overly defensive. 

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

253 err_t = "json_reencode_failure" 

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

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

256 

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

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

259 

260 def _serialize( 

261 value: BaseModel, 

262 info: core_schema.SerializationInfo, 

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

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

265 soft_args = ( 

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

267 "context", 

268 "exclude_computed_fields", 

269 "serialize_as_any", 

270 "polymorphic_serialization", 

271 ) 

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

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

274 # pick one option 

275 "warnings": False, 

276 } 

277 for arg in soft_args: 

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

279 args[arg] = v 

280 

281 return value.model_dump( 

282 mode=info.mode, 

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

284 # match up with the corresponding parameter types on model_dump 

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

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

287 by_alias=info.by_alias, 

288 exclude_unset=info.exclude_unset, 

289 exclude_defaults=info.exclude_defaults, 

290 exclude_none=info.exclude_none, 

291 round_trip=info.round_trip, 

292 **args, 

293 ) 

294 

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

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

297 

298 serialization = core_schema.plain_serializer_function_ser_schema( 

299 _serialize, 

300 info_arg=True, 

301 when_used="unless-none", 

302 return_schema=core_schema.dict_schema( 

303 core_schema.str_schema(), core_schema.any_schema() 

304 ), 

305 ) 

306 metadata = {"dynapydantic_source_type": source_type} 

307 return core_schema.lax_or_strict_schema( 

308 core_schema.with_info_plain_validator_function(_validate_lax), 

309 core_schema.with_info_plain_validator_function(_validate_strict), 

310 metadata=metadata, 

311 serialization=serialization, 

312 ) 

313 

314 @staticmethod 

315 def __get_pydantic_json_schema__( 

316 schema: core_schema.CoreSchema, 

317 handler: GetJsonSchemaHandler, 

318 ) -> JsonSchemaValue: 

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

320 

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

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

323 Reflects whatever subclasses are registered with the `TrackingGroup` 

324 at the time of the call. 

325 

326 Parameters 

327 ---------- 

328 schema 

329 Schema for the field type 

330 handler 

331 JSON schema handler to convert core_schemas to JSON schemas 

332 

333 Returns 

334 ------- 

335 JsonSchemaValue 

336 JSON schema for the field 

337 

338 Raises 

339 ------ 

340 pydantic.errors.PydanticInvalidForJsonSchema 

341 If the JSON schema was unable to be generated. 

342 """ 

343 try: 

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

345 except KeyError as e: 

346 msg = "Missing dynapydantic schema metadata." 

347 raise PydanticInvalidForJsonSchema(msg) from e 

348 

349 try: 

350 union_schema = source_type.__DYNAPYDANTIC__.type_adapter.core_schema 

351 except Error as e: 

352 msg = str(e) 

353 raise PydanticInvalidForJsonSchema(msg) from e 

354 

355 return handler(union_schema) 

356 

357 

358def _validation_kwargs( 

359 info: core_schema.ValidationInfo, 

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

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

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

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

364 kwargs["context"] = ctx 

365 if (config := getattr(info, "config", None)) is not None: 365 ↛ 374line 365 didn't jump to line 374 because the condition on line 365 was always true

366 for src, dst in ( 

367 ("extra_fields_behavior", "extra"), 

368 ("from_attributes", "from_attributes"), 

369 ("validate_by_alias", "by_alias"), 

370 ("validate_by_name", "by_name"), 

371 ): 

372 if (val := config.get(src)) is not None: 

373 kwargs[dst] = val 

374 return kwargs