Coverage for src / rendercv / schema / variant_pydantic_model_generator.py: 100%

108 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-25 13:49 +0000

1from collections.abc import Callable 

2from typing import Any, Literal, cast 

3 

4import pydantic 

5from pydantic.fields import FieldInfo 

6 

7from rendercv.exception import RenderCVInternalError 

8 

9type FieldSpec = tuple[type[Any], FieldInfo] 

10 

11 

12def sanitize_defaults(value: Any) -> Any: 

13 """Recursively convert CommentedMap/CommentedSeq to dict/list. 

14 

15 Why: 

16 ruamel.yaml returns custom types that behave like dict/list but confuse Pydantic 

17 and JSON schema generation. Stripping metadata ensures clean defaults. 

18 

19 Args: 

20 value: The value to sanitize (can be nested dict/list structure). 

21 

22 Returns: 

23 The sanitized value with standard Python types. 

24 """ 

25 if isinstance(value, list): 

26 return [sanitize_defaults(v) for v in value] 

27 if isinstance(value, dict): 

28 return {k: sanitize_defaults(v) for k, v in value.items()} 

29 return value 

30 

31 

32def resolve_nested_model(field_info: FieldInfo) -> pydantic.BaseModel | None: 

33 """Resolve a FieldInfo's default to a Pydantic model instance. 

34 

35 Why: 

36 Multiple functions need to inspect whether a field's default is a nested 

37 Pydantic model. The default can come from either a direct value or a 

38 factory callable. This helper encapsulates the resolution and isinstance 

39 check, eliminating repeated cast() calls across the module. 

40 

41 Args: 

42 field_info: Pydantic field info to inspect. 

43 

44 Returns: 

45 Model instance if the default is a BaseModel, otherwise None. 

46 """ 

47 if field_info.default_factory is not None: 

48 factory = cast(Callable[[], Any], field_info.default_factory) 

49 obj = factory() 

50 if isinstance(obj, pydantic.BaseModel): 

51 return obj 

52 elif isinstance(field_info.default, pydantic.BaseModel): 

53 return field_info.default 

54 return None 

55 

56 

57def create_variant_pydantic_model[T: pydantic.BaseModel]( 

58 *, 

59 variant_name: str, 

60 defaults: dict[str, Any], 

61 base_class: type[T], 

62 discriminator_field: str, 

63 class_name_suffix: str, 

64 module_name: str, 

65 require_all_fields: bool = False, 

66) -> type[T]: 

67 """Create Pydantic model variant with customized defaults. 

68 

69 Why: 

70 Themes share common structure but differ in default colors, fonts, 

71 and spacing. Variant generation creates theme-specific classes that 

72 inherit validation logic while exposing different defaults in JSON schema. 

73 

74 Example: 

75 ```py 

76 ModernTheme = create_variant_pydantic_model( 

77 variant_name="modern", 

78 defaults={"theme": "modern", "colors": {"primary": "#000"}}, 

79 base_class=BaseTheme, 

80 discriminator_field="theme", 

81 class_name_suffix="Theme", 

82 module_name="rendercv.themes", 

83 ) 

84 # Creates class "ModernTheme" with theme="modern" as Literal type 

85 ``` 

86 

87 Args: 

88 variant_name: Snake_case name for PascalCase conversion. 

89 defaults: Field overrides with nested dict support. 

90 base_class: Base model to inherit from. 

91 discriminator_field: Field to constrain as Literal for tagged unions. 

92 class_name_suffix: Appended to generated class name. 

93 module_name: Module path for the generated class. 

94 require_all_fields: If True, every field in the base model must be 

95 present in defaults (checked recursively for nested models). 

96 

97 Returns: 

98 New model class with overrides applied. 

99 """ 

100 validate_defaults_against_base( 

101 defaults, base_class, variant_name, require_all_fields=require_all_fields 

102 ) 

103 

104 # Sanitize defaults to remove ruamel.yaml metadata 

105 defaults = sanitize_defaults(defaults) 

106 

107 field_specs: dict[str, Any] = {} 

108 base_fields = base_class.model_fields 

109 

110 for field_name, default_value in defaults.items(): 

111 base_field_info = base_fields[field_name] 

112 

113 if field_name == discriminator_field: 

114 field_specs[field_name] = create_discriminator_field_spec( 

115 default_value, base_field_info 

116 ) 

117 elif isinstance(default_value, dict): 

118 field_specs[field_name] = create_nested_field_spec( 

119 default_value, base_field_info 

120 ) 

121 else: 

122 field_specs[field_name] = create_simple_field_spec( 

123 default_value, base_field_info 

124 ) 

125 

126 class_name = generate_model_name(variant_name, class_name_suffix) 

127 

128 return pydantic.create_model( 

129 class_name, 

130 __base__=base_class, 

131 __module__=module_name, 

132 **field_specs, 

133 ) 

134 

135 

136def validate_defaults_against_base( 

137 defaults: dict[str, Any], 

138 base_class: type[pydantic.BaseModel], 

139 variant_name: str, 

140 *, 

141 require_all_fields: bool = False, 

142) -> None: 

143 """Validate that all fields in defaults exist in the base model. 

144 

145 Why: 

146 Typos in theme definitions cause silent failures. Early validation 

147 prevents variants with undefined fields from being created. 

148 

149 Args: 

150 defaults: Field overrides to validate. 

151 base_class: Base model defining valid fields. 

152 variant_name: Variant identifier for error messages. 

153 require_all_fields: If True, every field in the base model must be 

154 present in defaults. Nested Pydantic model fields are checked 

155 recursively. Useful for locales where falling back to English 

156 defaults is incorrect. 

157 """ 

158 base_fields = base_class.model_fields 

159 

160 for field_name in defaults: 

161 if field_name not in base_fields: 

162 message = ( 

163 f"Field '{field_name}' in defaults for '{variant_name}' " 

164 f"is not defined in {base_class.__name__}" 

165 ) 

166 raise RenderCVInternalError(message) 

167 

168 if require_all_fields: 

169 missing_fields = set(base_fields.keys()) - set(defaults.keys()) 

170 if missing_fields: 

171 message = ( 

172 f"Missing fields {sorted(missing_fields)} in defaults for" 

173 f" '{variant_name}' (base class: {base_class.__name__})" 

174 ) 

175 raise RenderCVInternalError(message) 

176 

177 # Recursively check nested Pydantic model fields 

178 for field_name, default_value in defaults.items(): 

179 if not isinstance(default_value, dict): 

180 continue 

181 

182 base_field_info = base_fields[field_name] 

183 nested_obj = resolve_nested_model(base_field_info) 

184 

185 if nested_obj is not None: 

186 nested_fields = type(nested_obj).model_fields 

187 missing_nested = set(nested_fields.keys()) - set(default_value.keys()) 

188 if missing_nested: 

189 message = ( 

190 f"Missing nested fields {sorted(missing_nested)} in" 

191 f" '{field_name}' for '{variant_name}'" 

192 f" (nested class: {type(nested_obj).__name__})" 

193 ) 

194 raise RenderCVInternalError(message) 

195 

196 

197def generate_model_name(variant_name: str, class_name_suffix: str) -> str: 

198 """Convert snake_case variant name to PascalCase class name with suffix. 

199 

200 Args: 

201 variant_name: Snake_case name. 

202 class_name_suffix: Suffix to append. 

203 

204 Returns: 

205 PascalCase class name with suffix. 

206 """ 

207 # Convert snake_case to PascalCase: my_variant_name -> MyVariantName 

208 # Instead of title(), just capitalize first letter of each word 

209 pascal_case = "".join(word.capitalize() for word in variant_name.split("_")) 

210 return f"{pascal_case}{class_name_suffix}" 

211 

212 

213def update_description_with_new_default( 

214 original_description: str | None, 

215 old_default: object, 

216 new_default: object, 

217) -> str | None: 

218 """Update field description to reflect new default value. 

219 

220 Why: 

221 JSON schema descriptions must show current defaults. When variants 

222 override defaults, descriptions need updating so IDE tooltips display 

223 accurate information. 

224 

225 Args: 

226 original_description: Original field description. 

227 old_default: Old default value. 

228 new_default: New default value to replace with. 

229 

230 Returns: 

231 Updated description or None if no description exists. 

232 """ 

233 if original_description is None: 

234 return None 

235 

236 # Simple string replacement of old default with new default 

237 old_default_str = str(old_default) 

238 new_default_str = str(new_default) 

239 

240 return original_description.replace(f"`{old_default_str}`", f"`{new_default_str}`") 

241 

242 

243def create_discriminator_field_spec( 

244 discriminator_value: Any, 

245 base_field_info: FieldInfo, 

246) -> FieldSpec: 

247 """Create field spec for discriminator field with Literal type constraint. 

248 

249 Why: 

250 Pydantic discriminated unions require Literal types for routing. 

251 Converting theme="classic" to Literal["classic"] enables automatic 

252 theme class selection during validation. 

253 

254 Args: 

255 discriminator_value: Value for the discriminator. 

256 base_field_info: Base model's field info. 

257 

258 Returns: 

259 Tuple of Literal type annotation and Field with default value. 

260 """ 

261 field_annotation = Literal[discriminator_value] # ty: ignore[invalid-type-form] 

262 

263 # Update description with new default value 

264 updated_description = update_description_with_new_default( 

265 base_field_info.description, 

266 base_field_info.default, 

267 discriminator_value, 

268 ) 

269 

270 new_field = cast( 

271 FieldInfo, 

272 pydantic.Field( 

273 default=discriminator_value, 

274 description=updated_description, 

275 title=base_field_info.title, 

276 ), 

277 ) 

278 return (cast(type[Any], field_annotation), new_field) 

279 

280 

281def deep_merge_nested_object[T: pydantic.BaseModel]( 

282 base_nested_obj: T, 

283 updates: dict[str, Any], 

284) -> T: 

285 """Recursively merge nested dictionary updates into Pydantic model instance. 

286 

287 Why: 

288 Theme variants often override only specific nested fields like 

289 `colors.primary` while keeping other color defaults. Deep merge 

290 enables partial updates without requiring full object replacement. 

291 

292 Args: 

293 base_nested_obj: Base model instance to merge into. 

294 updates: Dictionary updates with arbitrary nesting depth. 

295 

296 Returns: 

297 New model instance with updates applied. 

298 """ 

299 # Build the final update dict by recursively merging nested objects 

300 merged_updates: dict[str, Any] = {} 

301 

302 for key, value in updates.items(): 

303 # Check if this is a nested dict that should be recursively merged 

304 if isinstance(value, dict): 

305 # Get the current value of this field from base_nested_obj 

306 current_value = getattr(base_nested_obj, key, None) 

307 

308 # If the current value is a Pydantic model, recursively merge 

309 if isinstance(current_value, pydantic.BaseModel): 

310 merged_updates[key] = deep_merge_nested_object(current_value, value) 

311 else: 

312 # Not a Pydantic model, just use the dict as-is 

313 merged_updates[key] = value 

314 else: 

315 # Simple value, use directly 

316 merged_updates[key] = value 

317 

318 return base_nested_obj.model_copy(update=merged_updates) 

319 

320 

321def create_nested_model_variant_model( 

322 base_model_class: type[pydantic.BaseModel], 

323 updates: dict[str, Any], 

324) -> type[pydantic.BaseModel]: 

325 """Create variant class for nested model with updated field descriptions. 

326 

327 Why: 

328 Nested field defaults must reflect in JSON schema for accurate IDE 

329 tooltips. Creating variant classes ensures descriptions update at all 

330 nesting levels, not just the top level. 

331 

332 Args: 

333 base_model_class: Base nested model class. 

334 updates: Field updates with potential nested dicts. 

335 

336 Returns: 

337 New model class with updated descriptions and defaults. 

338 """ 

339 field_specs: dict[str, Any] = {} 

340 base_fields = base_model_class.model_fields 

341 

342 for field_name, new_value in updates.items(): 

343 if field_name not in base_fields: 

344 # Skip fields that don't exist in the base model 

345 continue 

346 

347 base_field_info = base_fields[field_name] 

348 

349 if isinstance(new_value, dict): 

350 # Check if this field is a nested Pydantic model 

351 nested_obj = resolve_nested_model(base_field_info) 

352 

353 if nested_obj is not None: 

354 # Recursively create nested field spec 

355 field_specs[field_name] = create_nested_field_spec( 

356 new_value, base_field_info 

357 ) 

358 else: 

359 # Not a nested model, just a dict field - treat as simple value 

360 field_specs[field_name] = create_simple_field_spec( 

361 new_value, base_field_info 

362 ) 

363 else: 

364 # Simple value - update description 

365 field_specs[field_name] = create_simple_field_spec( 

366 new_value, base_field_info 

367 ) 

368 

369 # Create variant class inheriting from base 

370 return pydantic.create_model( 

371 base_model_class.__name__, 

372 __base__=base_model_class, 

373 __module__=base_model_class.__module__, 

374 **field_specs, 

375 ) 

376 

377 

378def create_nested_field_spec( 

379 default_value: dict[str, Any], 

380 base_field_info: FieldInfo, 

381) -> FieldSpec: 

382 """Create field spec for nested Pydantic model with partial overrides. 

383 

384 Why: 

385 Nested model fields require variant classes to preserve accurate JSON 

386 schema metadata. This ensures nested defaults appear correctly in IDE 

387 autocompletion and documentation. 

388 

389 Args: 

390 default_value: Dictionary updates to apply to nested model. 

391 base_field_info: Base model's field info. 

392 

393 Returns: 

394 Tuple of variant class annotation and Field with default_factory. 

395 """ 

396 base_nested_obj = resolve_nested_model(base_field_info) 

397 

398 if base_nested_obj is not None: 

399 # Create a variant class with updated field specs and descriptions 

400 base_model_class = type(base_nested_obj) 

401 variant_class = create_nested_model_variant_model( 

402 base_model_class, default_value 

403 ) 

404 

405 new_field = cast( 

406 FieldInfo, 

407 pydantic.Field( 

408 default_factory=variant_class, 

409 description=base_field_info.description, 

410 title=base_field_info.title, 

411 ), 

412 ) 

413 

414 return (variant_class, new_field) 

415 # No Pydantic model found, just use the dict directly 

416 # (This should be rare - it means the field type is just dict) 

417 new_field = cast( 

418 FieldInfo, 

419 pydantic.Field( 

420 default=default_value, 

421 description=base_field_info.description, 

422 title=base_field_info.title, 

423 ), 

424 ) 

425 

426 return ( 

427 cast(type[Any], base_field_info.annotation), 

428 new_field, 

429 ) 

430 

431 

432def create_simple_field_spec( 

433 default_value: Any, 

434 base_field_info: FieldInfo, 

435) -> FieldSpec: 

436 """Create field spec for simple field with updated default. 

437 

438 Args: 

439 default_value: New default value for field. 

440 base_field_info: Base model's field info. 

441 

442 Returns: 

443 Tuple of field annotation and Field with default value. 

444 """ 

445 # Update description with new default value 

446 updated_description = update_description_with_new_default( 

447 base_field_info.description, 

448 base_field_info.default, 

449 default_value, 

450 ) 

451 

452 new_field = cast( 

453 FieldInfo, 

454 pydantic.Field( 

455 default=default_value, 

456 description=updated_description, 

457 title=base_field_info.title, 

458 ), 

459 ) 

460 return (cast(type[Any], base_field_info.annotation), new_field)