Coverage for rendercv/data/models/design.py: 100%

57 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-26 00:25 +0000

1""" 

2The `rendercv.data.models.design` module contains the data model of the `design` field 

3of the input file. 

4""" 

5 

6import importlib 

7import importlib.util 

8import os 

9import pathlib 

10from typing import Annotated, Any 

11 

12import pydantic 

13 

14from ...themes import ( 

15 ClassicThemeOptions, 

16 EngineeringclassicThemeOptions, 

17 EngineeringresumesThemeOptions, 

18 ModerncvThemeOptions, 

19 Sb2novThemeOptions, 

20) 

21from . import entry_types 

22from .base import RenderCVBaseModelWithoutExtraKeys 

23 

24# ====================================================================================== 

25# Create validator functions: ========================================================== 

26# ====================================================================================== 

27 

28 

29def validate_design_options( 

30 design: Any, 

31 available_theme_options: dict[str, type], 

32 available_entry_type_names: list[str], 

33) -> Any: 

34 """Chech if the design options are for a built-in theme or a custom theme. If it is 

35 a built-in theme, validate it with the corresponding data model. If it is a custom 

36 theme, check if the necessary files are provided and validate it with the custom 

37 theme data model, found in the `__init__.py` file of the custom theme folder. 

38 

39 Args: 

40 design: The design options to validate. 

41 available_theme_options: The available theme options. The keys are the theme 

42 names and the values are the corresponding data models. 

43 available_entry_type_names: The available entry type names. These are used to 

44 validate if all the templates are provided in the custom theme folder. 

45 

46 Returns: 

47 The validated design as a Pydantic data model. 

48 """ 

49 from .rendercv_data_model import INPUT_FILE_DIRECTORY 

50 

51 original_working_directory = pathlib.Path.cwd() 

52 

53 # Change the working directory to the input file directory: 

54 

55 if isinstance(design, tuple(available_theme_options.values())): 

56 # Then it means it is an already validated built-in theme. Return it as it is: 

57 return design 

58 if design["theme"] in available_theme_options: 

59 # Then it is a built-in theme, but it is not validated yet. Validate it and 

60 # return it: 

61 ThemeDataModel = available_theme_options[design["theme"]] 

62 return ThemeDataModel(**design) 

63 # It is a custom theme. Validate it: 

64 theme_name: str = str(design["theme"]) 

65 

66 # Custom theme should only contain letters and digits: 

67 if not theme_name.isalnum(): 

68 message = "The custom theme name should only contain letters and digits." 

69 raise ValueError( 

70 message, 

71 "theme", # this is the location of the error 

72 theme_name, # this is value of the error 

73 ) 

74 

75 if INPUT_FILE_DIRECTORY is None: 

76 theme_parent_folder = pathlib.Path.cwd() 

77 else: 

78 theme_parent_folder = INPUT_FILE_DIRECTORY 

79 

80 custom_theme_folder = theme_parent_folder / theme_name 

81 

82 # Check if the custom theme folder exists: 

83 if not custom_theme_folder.exists(): 

84 message = ( 

85 ( 

86 f"The custom theme folder `{custom_theme_folder}` does not exist." 

87 " It should be in the working directory as the input file." 

88 ), 

89 ) 

90 raise ValueError( 

91 message, 

92 "", # this is the location of the error 

93 theme_name, # this is value of the error 

94 ) 

95 

96 # check if all the necessary files are provided in the custom theme folder: 

97 required_entry_files = [ 

98 custom_theme_folder / (entry_type_name + ".j2.typ") 

99 for entry_type_name in available_entry_type_names 

100 ] 

101 required_files = [ 

102 custom_theme_folder / "SectionBeginning.j2.typ", # section beginning template 

103 custom_theme_folder / "SectionEnding.j2.typ", # section ending template 

104 custom_theme_folder / "Preamble.j2.typ", # preamble template 

105 custom_theme_folder / "Header.j2.typ", # header template 

106 *required_entry_files, 

107 ] 

108 

109 for file in required_files: 

110 if not file.exists(): 

111 message = ( 

112 f"You provided a custom theme, but the file `{file}` is not" 

113 f" found in the folder `{custom_theme_folder}`." 

114 ) 

115 raise ValueError( 

116 message, 

117 "", # This is the location of the error 

118 theme_name, # This is value of the error 

119 ) 

120 

121 # Import __init__.py file from the custom theme folder if it exists: 

122 path_to_init_file = custom_theme_folder / "__init__.py" 

123 

124 if path_to_init_file.exists(): 

125 spec = importlib.util.spec_from_file_location( 

126 "theme", 

127 path_to_init_file, 

128 ) 

129 

130 theme_module = importlib.util.module_from_spec(spec) # type: ignore 

131 try: 

132 spec.loader.exec_module(theme_module) # type: ignore 

133 except SyntaxError as e: 

134 message = ( 

135 f"The custom theme {theme_name}'s __init__.py file has a syntax" 

136 " error. Please fix it." 

137 ) 

138 raise ValueError(message) from e 

139 except ImportError as e: 

140 message = ( 

141 ( 

142 f"The custom theme {theme_name}'s __init__.py file has an" 

143 " import error. If you have copy-pasted RenderCV's built-in" 

144 " themes, make sure to update the import statements (e.g.," 

145 ' "from . import" to "from rendercv.themes import").' 

146 ), 

147 ) 

148 

149 raise ValueError(message) from e 

150 

151 ThemeDataModel = getattr( 

152 theme_module, 

153 f"{theme_name.capitalize()}ThemeOptions", # type: ignore 

154 ) 

155 

156 # Initialize and validate the custom theme data model: 

157 theme_data_model = ThemeDataModel(**design) 

158 else: 

159 # Then it means there is no __init__.py file in the custom theme folder. 

160 # Create a dummy data model and use that instead. 

161 class ThemeOptionsAreNotProvided(RenderCVBaseModelWithoutExtraKeys): 

162 theme: str = theme_name 

163 

164 theme_data_model = ThemeOptionsAreNotProvided(theme=theme_name) 

165 

166 os.chdir(original_working_directory) 

167 

168 return theme_data_model 

169 

170 

171# ====================================================================================== 

172# Create custom types: ================================================================= 

173# ====================================================================================== 

174 

175available_theme_options = { 

176 "classic": ClassicThemeOptions, 

177 "sb2nov": Sb2novThemeOptions, 

178 "engineeringresumes": EngineeringresumesThemeOptions, 

179 "engineeringclassic": EngineeringclassicThemeOptions, 

180 "moderncv": ModerncvThemeOptions, 

181} 

182 

183available_themes = list(available_theme_options.keys()) 

184 

185# Create a custom type named RenderCVBuiltinDesign: 

186# It is a union of all the design options and the correct design option is determined by 

187# the theme field, thanks to Pydantic's discriminator feature. 

188# See https://docs.pydantic.dev/2.7/concepts/fields/#discriminator for more information 

189RenderCVBuiltinDesign = Annotated[ 

190 ClassicThemeOptions 

191 | Sb2novThemeOptions 

192 | EngineeringresumesThemeOptions 

193 | EngineeringclassicThemeOptions 

194 | ModerncvThemeOptions, 

195 pydantic.Field(discriminator="theme"), 

196] 

197 

198# Create a custom type named RenderCVDesign: 

199# RenderCV supports custom themes as well. Therefore, `Any` type is used to allow custom 

200# themes. However, the JSON Schema generation is skipped, otherwise, the JSON Schema 

201# would accept any `design` field in the YAML input file. 

202RenderCVDesign = Annotated[ 

203 pydantic.json_schema.SkipJsonSchema[Any] | RenderCVBuiltinDesign, 

204 pydantic.BeforeValidator( 

205 lambda design: validate_design_options( 

206 design, 

207 available_theme_options=available_theme_options, 

208 available_entry_type_names=entry_types.available_entry_type_names, # type: ignore 

209 ) 

210 ), 

211]