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

1import importlib 

2import importlib.util 

3import pathlib 

4import re 

5from typing import Annotated, Any 

6 

7import pydantic 

8import pydantic_core 

9 

10from rendercv.exception import RenderCVInternalError 

11 

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 

16 

17custom_theme_name_pattern = re.compile(r"^[a-z0-9]+$") 

18 

19 

20def validate_design(design: Any, info: pydantic.ValidationInfo) -> Any: 

21 """Validate design options for built-in or custom themes with dynamic loading. 

22 

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. 

27 

28 Args: 

29 design: Design dictionary to validate. 

30 info: Validation context containing input file path. 

31 

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 

51 

52 if custom_theme: 

53 pass 

54 else: 

55 raise e 

56 

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"]) 

61 

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 ) 

74 

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 ) 

92 

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) 

103 

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 

124 

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 

136 

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 

144 

145 theme_data_model = ThemeOptionsAreNotProvided(theme=theme_name) 

146 

147 return theme_data_model 

148 

149 

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]