Coverage for src / rendercv / schema / models / design / design.py: 100%
64 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
1import importlib
2import importlib.util
3import pathlib
4import re
5from typing import Annotated, Any
7import pydantic
8import pydantic_core
10from rendercv.exception import RenderCVInternalError
12from ...pydantic_error_handling import CustomPydanticErrorTypes
13from ..validation_context import get_input_file_path
14from .built_in_design import BuiltInDesign, built_in_design_adapter
15from .classic_theme import ClassicTheme
17custom_theme_name_pattern = re.compile(r"^[a-z0-9]+$")
20def validate_design(design: Any, info: pydantic.ValidationInfo) -> Any:
21 """Validate design options for built-in or custom themes with dynamic loading.
23 Why:
24 Users can use built-in themes or create custom themes in local folders.
25 Validation attempts built-in first, then falls back to dynamic import
26 of custom theme classes from theme folder's __init__.py.
28 Args:
29 design: Design dictionary to validate.
30 info: Validation context containing input file path.
32 Returns:
33 Validated design model (built-in or custom theme class).
34 """
35 try:
36 return built_in_design_adapter.validate_python(design)
37 except pydantic.ValidationError as e:
38 errors = e.errors()
39 # Detect if validation failed because the theme name doesn't match any
40 # built-in theme. Pydantic's discriminator errors include the discriminator
41 # field name in ctx. This format is tied to Pydantic's error structure:
42 custom_theme = False
43 for error in errors:
44 if (
45 "ctx" in error
46 and "discriminator" in error["ctx"]
47 and error["ctx"]["discriminator"] == "'theme'"
48 ):
49 custom_theme = True
50 break
52 if custom_theme:
53 pass
54 else:
55 raise e
57 # Then it's a custom theme:
58 input_file_path = get_input_file_path(info)
59 relative_to = input_file_path.parent if input_file_path else pathlib.Path.cwd()
60 theme_name = str(design["theme"])
62 # Custom theme should only contain letters and digits:
63 if not custom_theme_name_pattern.match(theme_name):
64 raise pydantic_core.PydanticCustomError(
65 CustomPydanticErrorTypes.other.value,
66 "The custom theme name should only contain lowercase letters and digits."
67 " The provided value is `{theme_name}`.",
68 {
69 "theme_name": theme_name,
70 "loc": ("design", "theme"),
71 "input": theme_name,
72 },
73 )
75 custom_theme_folder = relative_to / theme_name
76 # Check if the custom theme folder exists:
77 if not custom_theme_folder.exists():
78 raise pydantic_core.PydanticCustomError(
79 CustomPydanticErrorTypes.other.value,
80 "The custom theme folder `{custom_theme_folder}` does not exist. It should"
81 " be in the same directory as the input file.",
82 {"custom_theme_folder": custom_theme_folder.absolute()},
83 )
84 # Check if at least there is one *.j2.typ file in the custom theme folder:
85 if not any(custom_theme_folder.rglob("*.j2.typ")):
86 raise pydantic_core.PydanticCustomError(
87 CustomPydanticErrorTypes.other.value,
88 "The custom theme folder `{custom_theme_folder}` does not contain any"
89 " *.j2.typ files. It should contain at least one *.j2.typ file.",
90 {"custom_theme_folder": custom_theme_folder.absolute()},
91 )
93 # Import __init__.py file from the custom theme folder if it exists:
94 path_to_init_file = custom_theme_folder / "__init__.py"
95 if path_to_init_file.exists():
96 spec = importlib.util.spec_from_file_location(
97 "theme",
98 path_to_init_file,
99 )
100 if spec is None:
101 msg = f"Failed to load spec from {path_to_init_file}"
102 raise RenderCVInternalError(msg)
104 theme_module = importlib.util.module_from_spec(spec)
105 try:
106 if spec.loader is None:
107 msg = f"spec.loader is None for {path_to_init_file}"
108 raise RenderCVInternalError(msg)
109 spec.loader.exec_module(theme_module)
110 except SyntaxError as e:
111 raise pydantic_core.PydanticCustomError(
112 CustomPydanticErrorTypes.other.value,
113 "The custom theme {theme_name}'s __init__.py file has a syntax"
114 " error. Please fix it.",
115 {"theme_name": theme_name},
116 ) from e
117 except ImportError as e:
118 raise pydantic_core.PydanticCustomError(
119 CustomPydanticErrorTypes.other.value,
120 "The custom theme {theme_name}'s __init__.py file has an import error!"
121 " Check the import statements.",
122 {"theme_name": theme_name},
123 ) from e
125 model_name = f"{theme_name.capitalize()}Theme"
126 try:
127 theme_data_model_class = getattr(
128 theme_module,
129 model_name,
130 )
131 except AttributeError as e:
132 message = (
133 f"The custom theme {theme_name} does not have a {model_name} class."
134 )
135 raise ValueError(message) from e
137 # Initialize and validate the custom theme data model:
138 theme_data_model = theme_data_model_class(**design)
139 else:
140 # Then it means there is no __init__.py file in the custom theme folder.
141 # Create a dummy data model and use that instead.
142 class ThemeOptionsAreNotProvided(ClassicTheme):
143 theme: str = theme_name
145 theme_data_model = ThemeOptionsAreNotProvided(theme=theme_name)
147 return theme_data_model
150# RenderCV supports custom themes as well. For JSON schema, expose only BuiltInDesign.
151# The validator runs after the built-in validation to support custom themes.
152Design = Annotated[
153 BuiltInDesign,
154 pydantic.WrapValidator(lambda v, _, info: validate_design(v, info)),
155]