Coverage for rendercv/cli/utilities.py: 98%

102 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-10-07 17:51 +0000

1""" 

2The `rendercv.cli.utilities` module contains utility functions that are required by CLI. 

3""" 

4 

5import inspect 

6import json 

7import pathlib 

8import re 

9import shutil 

10import urllib.request 

11from typing import Optional 

12 

13import typer 

14 

15 

16def set_or_update_a_value( 

17 dictionary: dict, 

18 key: str, 

19 value: str, 

20 sub_dictionary: Optional[dict | list] = None, 

21) -> dict: # type: ignore 

22 """Set or update a value in a dictionary for the given key. For example, a key can 

23 be `cv.sections.education.3.institution` and the value can be "Bogazici University". 

24 

25 Args: 

26 dictionary (dict): The dictionary to set or update the value. 

27 key (str): The key to set or update the value. 

28 value (Any): The value to set or update. 

29 sub_dictionary (pydantic.BaseModel | dict | list, optional): The sub dictionary 

30 to set or update the value. This is used for recursive calls. Defaults to 

31 None. 

32 """ 

33 # Recursively call this function until the last key is reached: 

34 

35 keys = key.split(".") 

36 

37 if sub_dictionary is not None: 

38 updated_dict = sub_dictionary 

39 else: 

40 updated_dict = dictionary 

41 

42 if len(keys) == 1: 

43 # Set the value: 

44 if value.startswith("{") and value.endswith("}"): 

45 # Allow users to assign dictionaries: 

46 value = eval(value) 

47 elif value.startswith("[") and value.endswith("]"): 

48 # Allow users to assign lists: 

49 value = eval(value) 

50 

51 if isinstance(updated_dict, list): 

52 key = int(key) # type: ignore 

53 

54 updated_dict[key] = value # type: ignore 

55 

56 else: 

57 # get the first key and call the function with remaining keys: 

58 first_key = keys[0] 

59 key = ".".join(keys[1:]) 

60 

61 if isinstance(updated_dict, list): 

62 first_key = int(first_key) 

63 

64 if isinstance(first_key, int) or first_key in updated_dict: 

65 # Key exists, get the sub dictionary: 

66 sub_dictionary = updated_dict[first_key] # type: ignore 

67 else: 

68 # Key does not exist, create a new sub dictionary: 

69 sub_dictionary = dict() 

70 

71 updated_sub_dict = set_or_update_a_value(dictionary, key, value, sub_dictionary) 

72 updated_dict[first_key] = updated_sub_dict # type: ignore 

73 

74 return updated_dict # type: ignore 

75 

76 

77def set_or_update_values( 

78 dictionary: dict, 

79 key_and_values: dict[str, str], 

80) -> dict: 

81 """Set or update values in a dictionary for the given keys. It uses the 

82 `set_or_update_a_value` function to set or update the values. 

83 

84 Args: 

85 dictionary (dict): The dictionary to set or update the values. 

86 key_and_values (dict[str, str]): The key and value pairs to set or update. 

87 """ 

88 for key, value in key_and_values.items(): 

89 dictionary = set_or_update_a_value(dictionary, key, value) # type: ignore 

90 

91 return dictionary 

92 

93 

94def copy_files(paths: list[pathlib.Path] | pathlib.Path, new_path: pathlib.Path): 

95 """Copy files to the given path. If there are multiple files, then rename the new 

96 path by adding a number to the end of the path. 

97 

98 Args: 

99 paths (list[pathlib.Path]): The paths of the files to be copied. 

100 new_path (pathlib.Path): The path to copy the files to. 

101 """ 

102 if isinstance(paths, pathlib.Path): 

103 paths = [paths] 

104 

105 if len(paths) == 1: 

106 shutil.copy2(paths[0], new_path) 

107 else: 

108 for i, file_path in enumerate(paths): 

109 # append a number to the end of the path: 

110 number = i + 1 

111 png_path_with_page_number = ( 

112 pathlib.Path(new_path).parent 

113 / f"{pathlib.Path(new_path).stem}_{number}.png" 

114 ) 

115 shutil.copy2(file_path, png_path_with_page_number) 

116 

117 

118def get_latest_version_number_from_pypi() -> Optional[str]: 

119 """Get the latest version number of RenderCV from PyPI. 

120 

121 Example: 

122 ```python 

123 get_latest_version_number_from_pypi() 

124 ``` 

125 returns 

126 `#!python "1.1"` 

127 

128 Returns: 

129 Optional[str]: The latest version number of RenderCV from PyPI. Returns None if 

130 the version number cannot be fetched. 

131 """ 

132 version = None 

133 url = "https://pypi.org/pypi/rendercv/json" 

134 try: 

135 with urllib.request.urlopen(url) as response: 

136 data = response.read() 

137 encoding = response.info().get_content_charset("utf-8") 

138 json_data = json.loads(data.decode(encoding)) 

139 version = json_data["info"]["version"] 

140 except Exception: 

141 pass 

142 

143 return version 

144 

145 

146def get_error_message_and_location_and_value_from_a_custom_error( 

147 error_string: str, 

148) -> tuple[Optional[str], Optional[str], Optional[str]]: 

149 """Look at a string and figure out if it's a custom error message that has been 

150 sent from `rendercv.data.reader.read_input_file`. If it is, then return the custom 

151 message, location, and the input value. 

152 

153 This is done because sometimes we raise an error about a specific field in the model 

154 validation level, but Pydantic doesn't give us the exact location of the error 

155 because it's a model-level error. So, we raise a custom error with three string 

156 arguments: message, location, and input value. Those arguments then combined into a 

157 string by Python. This function is used to parse that custom error message and 

158 return the three values. 

159 

160 Args: 

161 error_string (str): The error message. 

162 

163 Returns: 

164 tuple[Optional[str], Optional[str], Optional[str]]: The custom message, 

165 location, and the input value. 

166 """ 

167 pattern = r"""\(['"](.*)['"], '(.*)', '(.*)'\)""" 

168 match = re.search(pattern, error_string) 

169 if match: 

170 return match.group(1), match.group(2), match.group(3) 

171 else: 

172 return None, None, None 

173 

174 

175def copy_templates( 

176 folder_name: str, 

177 copy_to: pathlib.Path, 

178 new_folder_name: Optional[str] = None, 

179) -> Optional[pathlib.Path]: 

180 """Copy one of the folders found in `rendercv.templates` to `copy_to`. 

181 

182 Args: 

183 folder_name (str): The name of the folder to be copied. 

184 copy_to (pathlib.Path): The path to copy the folder to. 

185 

186 Returns: 

187 Optional[pathlib.Path]: The path to the copied folder. 

188 """ 

189 # copy the package's theme files to the current directory 

190 template_directory = pathlib.Path(__file__).parent.parent / "themes" / folder_name 

191 if new_folder_name: 

192 destination = copy_to / new_folder_name 

193 else: 

194 destination = copy_to / folder_name 

195 

196 if destination.exists(): 

197 return None 

198 else: 

199 # copy the folder but don't include __init__.py: 

200 shutil.copytree( 

201 template_directory, 

202 destination, 

203 ignore=shutil.ignore_patterns("__init__.py", "__pycache__"), 

204 ) 

205 

206 return destination 

207 

208 

209def parse_render_command_override_arguments( 

210 extra_arguments: typer.Context, 

211) -> dict["str", "str"]: 

212 """Parse extra arguments given to the `render` command as data model key and value 

213 pairs and return them as a dictionary. 

214 

215 Args: 

216 extra_arguments (typer.Context): The extra arguments context. 

217 

218 Returns: 

219 dict["str", "str"]: The key and value pairs. 

220 """ 

221 key_and_values: dict["str", "str"] = dict() 

222 

223 # `extra_arguments.args` is a list of arbitrary arguments that haven't been 

224 # specified in `cli_render_command` function's definition. They are used to allow 

225 # users to edit their data model in CLI. The elements with even indexes in this list 

226 # are keys that start with double dashed, such as 

227 # `--cv.sections.education.0.institution`. The following elements are the 

228 # corresponding values of the key, such as `"Bogazici University"`. The for loop 

229 # below parses `ctx.args` accordingly. 

230 

231 if len(extra_arguments.args) % 2 != 0: 

232 raise ValueError( 

233 "There is a problem with the extra arguments! Each key should have" 

234 " a corresponding value." 

235 ) 

236 

237 for i in range(0, len(extra_arguments.args), 2): 

238 key = extra_arguments.args[i] 

239 value = extra_arguments.args[i + 1] 

240 if not key.startswith("--"): 

241 raise ValueError(f"The key ({key}) should start with double dashes!") 

242 

243 key = key.replace("--", "") 

244 

245 key_and_values[key] = value 

246 

247 return key_and_values 

248 

249 

250def get_default_render_command_cli_arguments() -> dict: 

251 """Get the default values of the `render` command's CLI arguments. 

252 

253 Returns: 

254 dict: The default values of the `render` command's CLI arguments. 

255 """ 

256 from .commands import cli_command_render 

257 

258 sig = inspect.signature(cli_command_render) 

259 default_render_command_cli_arguments = { 

260 k: v.default 

261 for k, v in sig.parameters.items() 

262 if v.default is not inspect.Parameter.empty 

263 } 

264 

265 return default_render_command_cli_arguments 

266 

267 

268def update_render_command_settings_of_the_input_file( 

269 input_file_as_a_dict: dict, 

270 render_command_cli_arguments: dict, 

271) -> dict: 

272 """Update the input file's `rendercv_settings.render_command` field with the given 

273 (non-default) values of the `render` command's CLI arguments. 

274 

275 Args: 

276 input_file_as_a_dict (dict): The input file as a dictionary. 

277 render_command_cli_arguments (dict): The command line arguments of the `render` 

278 command. 

279 

280 Returns: 

281 dict: The updated input file as a dictionary. 

282 """ 

283 default_render_command_cli_arguments = get_default_render_command_cli_arguments() 

284 

285 # Loop through `render_command_cli_arguments` and if the value is not the default 

286 # value, overwrite the value in the input file's `rendercv_settings.render_command` 

287 # field. If the field is the default value, check if it exists in the input file. 

288 # If it doesn't exist, add it to the input file. If it exists, don't do anything. 

289 if "rendercv_settings" not in input_file_as_a_dict: 

290 input_file_as_a_dict["rendercv_settings"] = dict() 

291 

292 if "render_command" not in input_file_as_a_dict["rendercv_settings"]: 

293 input_file_as_a_dict["rendercv_settings"]["render_command"] = dict() 

294 

295 render_command_field = input_file_as_a_dict["rendercv_settings"]["render_command"] 

296 for key, value in render_command_cli_arguments.items(): 

297 if value != default_render_command_cli_arguments[key]: 

298 render_command_field[key] = value 

299 elif key not in render_command_field: 

300 render_command_field[key] = value 

301 

302 input_file_as_a_dict["rendercv_settings"]["render_command"] = render_command_field 

303 

304 return input_file_as_a_dict