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
« 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
4import pydantic
5from pydantic.fields import FieldInfo
7from rendercv.exception import RenderCVInternalError
9type FieldSpec = tuple[type[Any], FieldInfo]
12def sanitize_defaults(value: Any) -> Any:
13 """Recursively convert CommentedMap/CommentedSeq to dict/list.
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.
19 Args:
20 value: The value to sanitize (can be nested dict/list structure).
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
32def resolve_nested_model(field_info: FieldInfo) -> pydantic.BaseModel | None:
33 """Resolve a FieldInfo's default to a Pydantic model instance.
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.
41 Args:
42 field_info: Pydantic field info to inspect.
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
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.
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.
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 ```
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).
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 )
104 # Sanitize defaults to remove ruamel.yaml metadata
105 defaults = sanitize_defaults(defaults)
107 field_specs: dict[str, Any] = {}
108 base_fields = base_class.model_fields
110 for field_name, default_value in defaults.items():
111 base_field_info = base_fields[field_name]
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 )
126 class_name = generate_model_name(variant_name, class_name_suffix)
128 return pydantic.create_model(
129 class_name,
130 __base__=base_class,
131 __module__=module_name,
132 **field_specs,
133 )
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.
145 Why:
146 Typos in theme definitions cause silent failures. Early validation
147 prevents variants with undefined fields from being created.
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
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)
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)
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
182 base_field_info = base_fields[field_name]
183 nested_obj = resolve_nested_model(base_field_info)
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)
197def generate_model_name(variant_name: str, class_name_suffix: str) -> str:
198 """Convert snake_case variant name to PascalCase class name with suffix.
200 Args:
201 variant_name: Snake_case name.
202 class_name_suffix: Suffix to append.
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}"
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.
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.
225 Args:
226 original_description: Original field description.
227 old_default: Old default value.
228 new_default: New default value to replace with.
230 Returns:
231 Updated description or None if no description exists.
232 """
233 if original_description is None:
234 return None
236 # Simple string replacement of old default with new default
237 old_default_str = str(old_default)
238 new_default_str = str(new_default)
240 return original_description.replace(f"`{old_default_str}`", f"`{new_default_str}`")
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.
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.
254 Args:
255 discriminator_value: Value for the discriminator.
256 base_field_info: Base model's field info.
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]
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 )
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)
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.
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.
292 Args:
293 base_nested_obj: Base model instance to merge into.
294 updates: Dictionary updates with arbitrary nesting depth.
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] = {}
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)
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
318 return base_nested_obj.model_copy(update=merged_updates)
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.
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.
332 Args:
333 base_model_class: Base nested model class.
334 updates: Field updates with potential nested dicts.
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
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
347 base_field_info = base_fields[field_name]
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)
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 )
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 )
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.
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.
389 Args:
390 default_value: Dictionary updates to apply to nested model.
391 base_field_info: Base model's field info.
393 Returns:
394 Tuple of variant class annotation and Field with default_factory.
395 """
396 base_nested_obj = resolve_nested_model(base_field_info)
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 )
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 )
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 )
426 return (
427 cast(type[Any], base_field_info.annotation),
428 new_field,
429 )
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.
438 Args:
439 default_value: New default value for field.
440 base_field_info: Base model's field info.
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 )
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)