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

58 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-12-25 23:06 +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 EngineeringresumesThemeOptions, 

17 ModerncvThemeOptions, 

18 Sb2novThemeOptions, 

19) 

20from . import entry_types 

21from .base import RenderCVBaseModelWithoutExtraKeys 

22 

23# ====================================================================================== 

24# Create validator functions: ========================================================== 

25# ====================================================================================== 

26 

27 

28def validate_design_options( 

29 design: Any, 

30 available_theme_options: dict[str, type], 

31 available_entry_type_names: list[str], 

32) -> Any: 

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

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

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

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

37 

38 Args: 

39 design: The design options to validate. 

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

41 names and the values are the corresponding data models. 

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

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

44 

45 Returns: 

46 The validated design as a Pydantic data model. 

47 """ 

48 from .rendercv_data_model import INPUT_FILE_DIRECTORY 

49 

50 original_working_directory = pathlib.Path.cwd() 

51 

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

53 

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

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

56 return design 

57 if design["theme"] in available_theme_options: 

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

59 # return it: 

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

61 return ThemeDataModel(**design) 

62 # It is a custom theme. Validate it: 

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

64 

65 # Custom theme should only contain letters and digits: 

66 if not theme_name.isalnum(): 

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

68 raise ValueError( 

69 message, 

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

71 theme_name, # this is value of the error 

72 ) 

73 

74 if INPUT_FILE_DIRECTORY is None: 

75 theme_parent_folder = pathlib.Path.cwd() 

76 else: 

77 theme_parent_folder = INPUT_FILE_DIRECTORY 

78 

79 custom_theme_folder = theme_parent_folder / theme_name 

80 

81 # Check if the custom theme folder exists: 

82 if not custom_theme_folder.exists(): 

83 message = ( 

84 ( 

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

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

87 ), 

88 ) 

89 raise ValueError( 

90 message, 

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

92 theme_name, # this is value of the error 

93 ) 

94 

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

96 required_entry_files = [ 

97 entry_type_name + ".j2.tex" for entry_type_name in available_entry_type_names 

98 ] 

99 required_files = [ 

100 "SectionBeginning.j2.tex", # section beginning template 

101 "SectionEnding.j2.tex", # section ending template 

102 "Preamble.j2.tex", # preamble template 

103 "Header.j2.tex", # header template 

104 *required_entry_files, 

105 ] 

106 

107 for file in required_files: 

108 file_path = custom_theme_folder / file 

109 if not file_path.exists(): 

110 message = ( 

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

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

113 ) 

114 raise ValueError( 

115 message, 

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

117 theme_name, # This is value of the error 

118 ) 

119 

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

121 path_to_init_file = custom_theme_folder / "__init__.py" 

122 

123 if path_to_init_file.exists(): 

124 spec = importlib.util.spec_from_file_location( 

125 "theme", 

126 path_to_init_file, 

127 ) 

128 

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

130 try: 

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

132 except SyntaxError as e: 

133 message = ( 

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

135 " error. Please fix it." 

136 ) 

137 raise ValueError(message) from e 

138 except ImportError as e: 

139 message = ( 

140 ( 

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

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

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

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

145 ), 

146 ) 

147 

148 raise ValueError(message) from e 

149 

150 ThemeDataModel = getattr( 

151 theme_module, 

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

153 ) 

154 

155 # Initialize and validate the custom theme data model: 

156 theme_data_model = ThemeDataModel(**design) 

157 else: 

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

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

160 class ThemeOptionsAreNotProvided(RenderCVBaseModelWithoutExtraKeys): 

161 theme: str = theme_name 

162 

163 theme_data_model = ThemeOptionsAreNotProvided(theme=theme_name) 

164 

165 os.chdir(original_working_directory) 

166 

167 return theme_data_model 

168 

169 

170# ====================================================================================== 

171# Create custom types: ================================================================= 

172# ====================================================================================== 

173 

174# Create a custom type named RenderCVBuiltinDesign: 

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

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

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

178RenderCVBuiltinDesign = Annotated[ 

179 ClassicThemeOptions 

180 | ModerncvThemeOptions 

181 | Sb2novThemeOptions 

182 | EngineeringresumesThemeOptions, 

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

184] 

185 

186# Create a custom type named RenderCVDesign: 

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

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

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

190RenderCVDesign = Annotated[ 

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

192 pydantic.BeforeValidator( 

193 lambda design: validate_design_options( 

194 design, 

195 available_theme_options=available_theme_options, 

196 available_entry_type_names=entry_types.available_entry_type_names, # type: ignore 

197 ) 

198 ), 

199] 

200 

201 

202available_theme_options = { 

203 "classic": ClassicThemeOptions, 

204 "moderncv": ModerncvThemeOptions, 

205 "sb2nov": Sb2novThemeOptions, 

206 "engineeringresumes": EngineeringresumesThemeOptions, 

207} 

208 

209available_themes = list(available_theme_options.keys())