Coverage for src/dynapydantic/tracking_group.py: 100%

145 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 contextlib 

4import typing as ty 

5import warnings 

6 

7import pydantic 

8import pydantic.fields 

9import pydantic_core 

10 

11from .exceptions import ( 

12 AmbiguousDiscriminatorValueError, 

13 NoRegisteredTypesError, 

14 RegistrationError, 

15) 

16from .union_mode import DiscriminatedConfig, UnionMode 

17 

18 

19def _inject_discriminator_field( 

20 cls: type[pydantic.BaseModel], 

21 disc_field: str, 

22 value: str, 

23) -> pydantic.fields.FieldInfo: 

24 """Injects the discriminator field into the given model 

25 

26 Parameters 

27 ---------- 

28 cls 

29 The BaseModel subclass 

30 disc_field 

31 Name of the discriminator field 

32 value 

33 Value of the discriminator field 

34 """ 

35 if hasattr(cls, disc_field): 

36 msg = ( 

37 f'Cannot inject discriminator field "{disc_field}" into ' 

38 f"{cls.__name__}: an attribute with that name already exists. " 

39 "Rename either the attribute or the discriminator_field to avoid " 

40 "the conflict." 

41 ) 

42 raise RegistrationError(msg) 

43 

44 cls.model_fields[disc_field] = pydantic.fields.FieldInfo( 

45 default=value, 

46 annotation=ty.Literal[value], # type: ignore[not-a-type] 

47 frozen=True, 

48 ) 

49 with contextlib.suppress(pydantic.errors.PydanticUndefinedAnnotation): 

50 cls.model_rebuild(force=True) 

51 return cls.model_fields[disc_field] 

52 

53 

54class TrackingGroup(pydantic.BaseModel): 

55 """Tracker for pydantic models""" 

56 

57 name: str = pydantic.Field( 

58 description=( 

59 "Name of the tracking group. This is for human display, so it " 

60 "doesn't technically need to be globally unique, but it should be " 

61 "meaningfully named, as it will be used in error messages." 

62 ), 

63 ) 

64 union_mode: UnionMode | None = pydantic.Field( 

65 None, 

66 description=( 

67 "Union validation strategy. Pass a DiscriminatedConfig instance " 

68 'or one of the plain strings "smart" or "left_to_right". You can ' 

69 "also just pass the fields for DiscriminatedConfig to this " 

70 "model and they will be forwarded." 

71 ), 

72 ) 

73 discriminator_field: str | None = pydantic.Field( 

74 None, 

75 description=( 

76 "Name of the discriminator field. NOTE: This field is " 

77 "here as an alias for union_mode.discriminator_field. Passing " 

78 "both a discriminator_field and a union_mode will result in an " 

79 "error." 

80 ), 

81 ) 

82 discriminator_value_generator: ty.Callable[[type], str] | None = pydantic.Field( 

83 None, 

84 description=( 

85 "A callable that produces default values for the discriminator field" 

86 ), 

87 ) 

88 plugin_entry_point: str | None = pydantic.Field( 

89 None, 

90 description=( 

91 "If given, then plugins packages will be supported through this " 

92 "Python entrypoint. The entrypoint can either be a function, " 

93 "which will be called, or simply a module, which will be " 

94 "imported. In either case, models found along the import path of " 

95 "the entrypoint will be registered. If the entrypoint is a " 

96 "function, additional models may be declared in the function." 

97 ), 

98 ) 

99 models: dict[str, type[pydantic.BaseModel]] = pydantic.Field( 

100 {}, 

101 description="The tracked models", 

102 ) 

103 

104 _generation: int = pydantic.PrivateAttr(default=0) 

105 _adapter: pydantic.TypeAdapter | None = pydantic.PrivateAttr(default=None) 

106 _adapter_generation: int = pydantic.PrivateAttr(default=-1) 

107 

108 @pydantic.model_validator(mode="after") 

109 def _ensure_union_mode(self) -> "TrackingGroup": 

110 """There must be a union_mode 

111 

112 This validator works as a guard on _coerce_union_mode to make 

113 """ 

114 if self.union_mode is None: 

115 msg = ( 

116 "union_mode is required. This normally indicates that you " 

117 "subclasses TrackingGroup and wrote an invalid validator, but " 

118 "could also be a bug with dynapydantic, so please file a bug " 

119 "report with a reproducer on how you got here if you suspect " 

120 "a bug." 

121 ) 

122 raise ValueError(msg) 

123 

124 # Ensure the top-level fields are in-sync 

125 if isinstance(self.union_mode, DiscriminatedConfig): 

126 self.discriminator_field = self.union_mode.discriminator_field 

127 self.discriminator_value_generator = ( 

128 self.union_mode.discriminator_value_generator 

129 ) 

130 else: 

131 self.discriminator_field = None 

132 self.discriminator_value_generator = None 

133 

134 return self 

135 

136 @pydantic.model_validator(mode="before") 

137 @classmethod 

138 def _coerce_union_mode(cls, data: ty.Any) -> ty.Any: # noqa: ANN401 

139 """Coerce flat discriminator kwargs into a DiscriminatedConfig. 

140 

141 Allows callers to pass ``discriminator_field`` and 

142 ``discriminator_value_generator`` at the top level and transparently 

143 assembles a ``DiscriminatedConfig`` from them. This avoids an extra 

144 import/nesting layer for the user. 

145 """ 

146 if not isinstance(data, dict): 

147 return data 

148 

149 disc_field = data.get("discriminator_field", None) 

150 has_disc_field = disc_field is not None 

151 union_mode = data.get("union_mode", None) 

152 has_union_mode = union_mode is not None 

153 

154 # If the user passed us both a discriminator field and a union_mode, 

155 # things must be perfectly consistent 

156 if has_disc_field and has_union_mode: 

157 consistent = ( 

158 isinstance(union_mode, DiscriminatedConfig) 

159 and disc_field == union_mode.discriminator_field 

160 and data.get("discriminator_value_generator") 

161 is union_mode.discriminator_value_generator 

162 ) or ( 

163 isinstance(union_mode, dict) 

164 and disc_field == union_mode.get("discriminator_field") 

165 and data.get("discriminator_value_generator") 

166 is union_mode.get("discriminator_value_generator") 

167 ) 

168 if not consistent: 

169 msg = ( 

170 "Received both union_mode and discriminator_field; pass one " 

171 "or the other." 

172 ) 

173 raise ValueError(msg) 

174 

175 if has_disc_field and not has_union_mode: 

176 # Forward arguments to DiscriminatedConfig 

177 data["union_mode"] = { 

178 "discriminator_field": disc_field, 

179 "discriminator_value_generator": data.get( 

180 "discriminator_value_generator", 

181 ), 

182 } 

183 elif not has_disc_field and not has_union_mode: 

184 msg = "Either union_mode or discriminator_field must be given" 

185 raise ValueError(msg) 

186 

187 return data 

188 

189 @property 

190 def _discriminated(self) -> DiscriminatedConfig | None: 

191 """Return the DiscriminatedMode config, or None if not discriminated.""" 

192 return ( 

193 self.union_mode 

194 if isinstance(self.union_mode, DiscriminatedConfig) 

195 else None 

196 ) 

197 

198 def load_plugins(self) -> None: 

199 """Load plugins to discover/register additional models""" 

200 if self.plugin_entry_point is None: 

201 return 

202 

203 from importlib.metadata import entry_points # noqa: PLC0415 

204 

205 for ep in entry_points().select(group=self.plugin_entry_point): 

206 plugin = ep.load() 

207 if callable(plugin): 

208 plugin() 

209 

210 @ty.overload 

211 def register(self, value: str | None = None) -> ty.Callable[[type], type]: ... 

212 

213 @ty.overload 

214 def register(self, value: type[pydantic.BaseModel]) -> type[pydantic.BaseModel]: ... 

215 

216 def register( 

217 self, 

218 value: str | type[pydantic.BaseModel] | None = None, 

219 ) -> ty.Callable[[type], type] | type[pydantic.BaseModel]: 

220 """Register a model into this group (decorator) 

221 

222 Parameters 

223 ---------- 

224 value 

225 Value for the discriminator field. If not given, then 

226 discriminator_value_generator must be non-None or the 

227 discriminator field must be declared by hand. Can also be the type 

228 itself to register (if the ()'s are omitted from the decorator). 

229 """ 

230 if isinstance(value, type): 

231 self.register_model(value) 

232 return value 

233 

234 def _wrapper(cls: type[pydantic.BaseModel]) -> type[pydantic.BaseModel]: 

235 self.register_model(cls, value) 

236 return cls 

237 

238 return _wrapper 

239 

240 def register_model( 

241 self, 

242 cls: type[pydantic.BaseModel], 

243 discriminator_value: str | None = None, 

244 ) -> None: 

245 """Register the given model into this group 

246 

247 Parameters 

248 ---------- 

249 cls 

250 The model to register 

251 discriminator_value 

252 Value for the discriminator field. If not given, then 

253 discriminator_value_generator must be non-None or the 

254 discriminator field must be declared by hand. 

255 """ 

256 if discriminator_value is not None and not isinstance(discriminator_value, str): 

257 msg = ( 

258 "discriminator_value must be a str if given, was " 

259 f"{type(discriminator_value).__name__}" 

260 ) 

261 raise RegistrationError(msg) 

262 

263 if not isinstance(cls, type) or not issubclass(cls, pydantic.BaseModel): 

264 msg = ( 

265 "only pydantic BaseModel subclasses can be registered in a " 

266 f"TrackingGroup. Got {cls}, which was not." 

267 ) 

268 raise RegistrationError(msg) 

269 

270 if (dm := self._discriminated) is not None: 

271 disc = dm.discriminator_field 

272 field = cls.model_fields.get(disc) 

273 

274 if field is None: 

275 if discriminator_value is not None: 

276 _inject_discriminator_field(cls, disc, discriminator_value) 

277 elif dm.discriminator_value_generator is not None: 

278 _inject_discriminator_field( 

279 cls, 

280 disc, 

281 dm.discriminator_value_generator(cls), 

282 ) 

283 else: 

284 msg = ( 

285 f"unable to determine a discriminator value for " 

286 f'{cls.__name__} in tracking group "{self.name}". ' 

287 "No value was passed, discriminator_value_generator " 

288 f'was None and the "{disc}" field was not defined.' 

289 ) 

290 raise RegistrationError(msg) 

291 elif ty.get_origin(field.annotation) is not ty.Literal: 

292 msg = ( 

293 f'the discriminator field "{disc}" already existed in ' 

294 f"{cls.__name__}, but its type annotation was " 

295 f"{field.annotation}, not Literal." 

296 ) 

297 raise RegistrationError(msg) 

298 elif ( 

299 discriminator_value is not None and field.default != discriminator_value 

300 ): 

301 msg = ( 

302 f"the discriminator value for {cls.__name__} was " 

303 f'ambiguous, the passed value was "{discriminator_value}" ' 

304 f' and "{field.default}" via the discriminator ' 

305 f"field ({disc})." 

306 ) 

307 raise AmbiguousDiscriminatorValueError(msg) 

308 

309 self._register_with_discriminator_field(cls) 

310 else: 

311 if discriminator_value is not None: 

312 warnings.warn( 

313 f'A discriminator_value of "{discriminator_value}" was ' 

314 f"explicitly passed for {cls.__name__}, but " 

315 f'union_mode="{self.union_mode}" does not use a ' 

316 "discriminator. The value will be ignored.", 

317 stacklevel=2, 

318 ) 

319 self._register_plain(cls) 

320 

321 def union( 

322 self, 

323 *, 

324 plain: bool | None = None, 

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

326 """Return the union of all registered models 

327 

328 Parameters 

329 ---------- 

330 plain 

331 If set to `True`, a plain union of all members will be returned. 

332 Otherwise, the returned union will be annotated in accordance with 

333 the union mode. 

334 

335 Returns 

336 ------- 

337 Any 

338 If there is 1 registered type, the type itself. If there is > 1, a 

339 union of all registered types. This union may be annotated if 

340 `plain` is not `True`. 

341 

342 Raises 

343 ------ 

344 NoRegisteredTypesError 

345 If no types have been registered yet. 

346 """ 

347 n = len(self.models) 

348 if n == 0: 

349 msg = ( 

350 "Unable to produce a union from the tracking group " 

351 f'"{self.name}", as no types have been registered yet.' 

352 ) 

353 raise NoRegisteredTypesError(msg) 

354 if n == 1: 

355 return next(iter(self.models.values())) 

356 

357 union_mode = "smart" if plain else self.union_mode 

358 

359 if isinstance(union_mode, DiscriminatedConfig): 

360 return ty.Annotated[ 

361 # This is significantly faster than |'ing into a UnionType 

362 ty.Union[ # noqa: UP007 

363 tuple( # type: ignore[not-a-type] 

364 ty.Annotated[x, pydantic.Tag(v)] for v, x in self.models.items() 

365 ) 

366 ], 

367 pydantic.Field(discriminator=union_mode.discriminator_field), 

368 ] 

369 

370 plain_union = ty.Union[ # noqa: UP007 

371 tuple(self.models.values()) # type: ignore[not-a-type] 

372 ] 

373 if union_mode == "left_to_right": 

374 return ty.Annotated[plain_union, pydantic.Field(union_mode="left_to_right")] 

375 

376 # "smart" mode is pydantic's default behavior on a plain union 

377 return plain_union 

378 

379 @property 

380 def generation(self) -> int: 

381 """The generation of the tracking group. 

382 

383 This is a counter that increments every time a new registration occurs 

384 """ 

385 return self._generation 

386 

387 @property 

388 def type_adapter(self) -> pydantic.TypeAdapter: 

389 """Get the pydantic TypeAdapter for the union of all group members""" 

390 if self.generation != self._adapter_generation: 

391 self._adapter = pydantic.TypeAdapter(self.union()) 

392 self._adapter_generation = self.generation 

393 

394 # casting because the if statement ensures it is non-None (because 

395 # _adapter_generation starts at -1 and generation increments from 0. 

396 return ty.cast("pydantic.TypeAdapter", self._adapter) 

397 

398 def _register_with_discriminator_field(self, cls: type[pydantic.BaseModel]) -> None: 

399 """Register the model with the default of the discriminator field 

400 

401 Parameters 

402 ---------- 

403 cls 

404 The class to register, must have the disciminator field set with a 

405 unique default value in the group. 

406 """ 

407 disc = ty.cast("DiscriminatedConfig", self.union_mode).discriminator_field 

408 value = cls.model_fields[disc].default 

409 if value == pydantic_core.PydanticUndefined: 

410 msg = ( 

411 f"{cls.__name__}.{disc} had no default value, it must " 

412 "have one which is unique among all tracked models." 

413 ) 

414 raise RegistrationError(msg) 

415 if not isinstance(value, str): 

416 msg = ( 

417 f"{cls.__name__}.{disc} had a default value of {value}, which " 

418 f"was of type {type(value).__name__}, not str." 

419 ) 

420 raise RegistrationError(msg) 

421 

422 self._do_register(value, cls) 

423 

424 def _register_plain(self, cls: type[pydantic.BaseModel]) -> None: 

425 """Register the model keyed by its class name. 

426 

427 Used for smart / left_to_right modes where no discriminator field 

428 is involved. 

429 

430 Parameters 

431 ---------- 

432 cls 

433 The model to register. 

434 """ 

435 self._do_register(str(id(cls)), cls) 

436 

437 def _do_register(self, key: str, cls: type[pydantic.BaseModel]) -> None: 

438 """Register the given model under the given key 

439 

440 Parameters 

441 ---------- 

442 key 

443 The key under which to register the model 

444 cls 

445 The model to register. 

446 """ 

447 if (other := self.models.get(key)) is not None: 

448 if other is not cls: 

449 msg = ( 

450 f'Cannot register {cls.__name__} under the "{key}" ' 

451 f"identifier, which is already in use by {other.__name__}." 

452 ) 

453 raise RegistrationError(msg) 

454 else: 

455 self._generation += 1 

456 self.models[key] = cls