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

181 statements  

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

1""" 

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

3""" 

4 

5import inspect 

6import json 

7import os 

8import pathlib 

9import shutil 

10import sys 

11import time 

12import urllib.request 

13from collections.abc import Callable 

14from typing import Any, Optional 

15 

16import packaging.version 

17import typer 

18import watchdog.events 

19import watchdog.observers 

20 

21from .. import data, renderer 

22from . import printer 

23 

24 

25def set_or_update_a_value( 

26 dictionary: dict, 

27 key: str, 

28 value: str, 

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

30) -> dict: # type: ignore 

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

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

33 

34 Args: 

35 dictionary: The dictionary to set or update the value. 

36 key: The key to set or update the value. 

37 value: The value to set or update. 

38 sub_dictionary: The sub dictionary to set or update the value. This is used for 

39 recursive calls. Defaults to None. 

40 """ 

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

42 

43 keys = key.split(".") 

44 

45 updated_dict = sub_dictionary if sub_dictionary is not None else dictionary 

46 

47 if len(keys) == 1: 

48 # Set the value: 

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

50 # Allow users to assign dictionaries: 

51 value = eval(value) 

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

53 # Allow users to assign lists: 

54 value = eval(value) 

55 

56 if isinstance(updated_dict, list): 

57 key = int(key) # type: ignore 

58 

59 updated_dict[key] = value # type: ignore 

60 

61 else: 

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

63 first_key = keys[0] 

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

65 

66 if isinstance(updated_dict, list): 

67 first_key = int(first_key) 

68 

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

70 # Key exists, get the sub dictionary: 

71 sub_dictionary = updated_dict[first_key] # type: ignore 

72 else: 

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

74 sub_dictionary = {} 

75 

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

77 updated_dict[first_key] = updated_sub_dict # type: ignore 

78 

79 return updated_dict # type: ignore 

80 

81 

82def set_or_update_values( 

83 dictionary: dict, 

84 key_and_values: dict[str, str], 

85) -> dict: 

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

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

88 

89 Args: 

90 dictionary: The dictionary to set or update the values. 

91 key_and_values: The key and value pairs to set or update. 

92 """ 

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

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

95 

96 return dictionary 

97 

98 

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

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

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

102 

103 Args: 

104 paths: The paths of the files to be copied. 

105 new_path: The path to copy the files to. 

106 """ 

107 if isinstance(paths, pathlib.Path): 

108 paths = [paths] 

109 

110 if len(paths) == 1: 

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

112 else: 

113 for i, file_path in enumerate(paths): 

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

115 number = i + 1 

116 png_path_with_page_number = ( 

117 pathlib.Path(new_path).parent 

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

119 ) 

120 shutil.copy2(file_path, png_path_with_page_number) 

121 

122 

123def get_latest_version_number_from_pypi() -> Optional[packaging.version.Version]: 

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

125 

126 Example: 

127 ```python 

128 get_latest_version_number_from_pypi() 

129 ``` 

130 returns 

131 `"1.1"` 

132 

133 Returns: 

134 The latest version number of RenderCV from PyPI. Returns None if the version 

135 number cannot be fetched. 

136 """ 

137 version = None 

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

139 try: 

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

141 data = response.read() 

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

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

144 version_string = json_data["info"]["version"] 

145 version = packaging.version.Version(version_string) 

146 except Exception: 

147 pass 

148 

149 return version 

150 

151 

152def copy_templates( 

153 folder_name: str, 

154 copy_to: pathlib.Path, 

155 new_folder_name: Optional[str] = None, 

156) -> Optional[pathlib.Path]: 

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

158 

159 Args: 

160 folder_name: The name of the folder to be copied. 

161 copy_to: The path to copy the folder to. 

162 

163 Returns: 

164 The path to the copied folder. 

165 """ 

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

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

168 if new_folder_name: 

169 destination = copy_to / new_folder_name 

170 else: 

171 destination = copy_to / folder_name 

172 

173 if destination.exists(): 

174 return None 

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

176 shutil.copytree( 

177 template_directory, 

178 destination, 

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

180 ) 

181 

182 return destination 

183 

184 

185def parse_render_command_override_arguments( 

186 extra_arguments: typer.Context, 

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

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

189 pairs and return them as a dictionary. 

190 

191 Args: 

192 extra_arguments: The extra arguments context. 

193 

194 Returns: 

195 The key and value pairs. 

196 """ 

197 key_and_values: dict[str, str] = {} 

198 

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

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

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

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

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

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

205 # below parses `ctx.args` accordingly. 

206 

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

208 message = ( 

209 "There is a problem with the extra arguments" 

210 f" ({','.join(extra_arguments.args)})! Each key should have a corresponding" 

211 " value." 

212 ) 

213 raise ValueError(message) 

214 

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

216 key = extra_arguments.args[i] 

217 value = extra_arguments.args[i + 1] 

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

219 message = f"The key ({key}) should start with double dashes!" 

220 raise ValueError(message) 

221 

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

223 

224 key_and_values[key] = value 

225 

226 return key_and_values 

227 

228 

229def get_default_render_command_cli_arguments() -> dict: 

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

231 

232 Returns: 

233 The default values of the `render` command's CLI arguments. 

234 """ 

235 from .commands import cli_command_render 

236 

237 sig = inspect.signature(cli_command_render) 

238 return { 

239 k: v.default 

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

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

242 } 

243 

244 

245def update_render_command_settings_of_the_input_file( 

246 input_file_as_a_dict: dict, 

247 render_command_cli_arguments: dict, 

248) -> dict: 

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

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

251 

252 Args: 

253 input_file_as_a_dict: The input file as a dictionary. 

254 render_command_cli_arguments: The command line arguments of the `render` 

255 command. 

256 

257 Returns: 

258 The updated input file as a dictionary. 

259 """ 

260 default_render_command_cli_arguments = get_default_render_command_cli_arguments() 

261 

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

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

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

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

266 if "rendercv_settings" not in input_file_as_a_dict: 

267 input_file_as_a_dict["rendercv_settings"] = {} 

268 

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

270 input_file_as_a_dict["rendercv_settings"]["render_command"] = {} 

271 

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

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

274 if ( 

275 value != default_render_command_cli_arguments[key] 

276 or key not in render_command_field 

277 ): 

278 render_command_field[key] = value 

279 

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

281 

282 return input_file_as_a_dict 

283 

284 

285def run_rendercv_with_printer( 

286 input_file_as_a_dict: dict, 

287 working_directory: pathlib.Path, 

288 input_file_path: pathlib.Path, 

289): 

290 """Run RenderCV with a live progress reporter. Working dictionary is where the 

291 output files will be saved. Input file path is required for accessing the template 

292 overrides. 

293 

294 Args: 

295 input_file_as_a_dict: The input file as a dictionary. 

296 working_directory: The working directory where the output files will be saved. 

297 input_file_path: The path of the input file. 

298 """ 

299 render_command_settings_dict = input_file_as_a_dict["rendercv_settings"][ 

300 "render_command" 

301 ] 

302 

303 # Compute the number of steps 

304 # 1. Validate the input file. 

305 # 2. Create the Typst file. 

306 # 3. Render PDF from Typst. 

307 # 4. Render PNGs from PDF. 

308 # 5. Create the Markdown file. 

309 # 6. Render HTML from Markdown. 

310 number_of_steps = 6 

311 if render_command_settings_dict["dont_generate_png"]: 

312 number_of_steps -= 1 

313 

314 if render_command_settings_dict["dont_generate_markdown"]: 

315 number_of_steps -= 2 

316 else: 

317 if render_command_settings_dict["dont_generate_html"]: 

318 number_of_steps -= 1 

319 

320 with printer.LiveProgressReporter(number_of_steps=number_of_steps) as progress: 

321 progress.start_a_step("Validating the input file") 

322 

323 data_model = data.validate_input_dictionary_and_return_the_data_model( 

324 input_file_as_a_dict, 

325 context={"input_file_directory": input_file_path.parent}, 

326 ) 

327 

328 # Change the current working directory to the input file's directory (because 

329 # the template overrides are looked up in the current working directory). The 

330 # output files will be in the original working directory. It should be done 

331 # after the input file is validated (because of the rendercv_settings). 

332 os.chdir(input_file_path.parent) 

333 

334 render_command_settings: data.models.RenderCommandSettings = ( 

335 data_model.rendercv_settings.render_command # type: ignore 

336 ) 

337 output_directory = ( 

338 working_directory / render_command_settings.output_folder_name 

339 ) 

340 

341 progress.finish_the_current_step() 

342 

343 progress.start_a_step("Generating the Typst file") 

344 

345 typst_file_path_in_output_folder = ( 

346 renderer.create_a_typst_file_and_copy_theme_files( 

347 data_model, output_directory 

348 ) 

349 ) 

350 if render_command_settings.typst_path: 

351 copy_files( 

352 typst_file_path_in_output_folder, 

353 render_command_settings.typst_path, 

354 ) 

355 

356 progress.finish_the_current_step() 

357 

358 progress.start_a_step("Rendering the Typst file to a PDF") 

359 

360 pdf_file_path_in_output_folder = renderer.render_a_pdf_from_typst( 

361 typst_file_path_in_output_folder, 

362 ) 

363 if render_command_settings.pdf_path: 

364 copy_files( 

365 pdf_file_path_in_output_folder, 

366 render_command_settings.pdf_path, 

367 ) 

368 

369 progress.finish_the_current_step() 

370 

371 if not render_command_settings.dont_generate_png: 

372 progress.start_a_step("Rendering PNG files from the PDF") 

373 

374 png_file_paths_in_output_folder = renderer.render_pngs_from_typst( 

375 typst_file_path_in_output_folder 

376 ) 

377 if render_command_settings.png_path: 

378 copy_files( 

379 png_file_paths_in_output_folder, 

380 render_command_settings.png_path, 

381 ) 

382 

383 progress.finish_the_current_step() 

384 

385 if not render_command_settings.dont_generate_markdown: 

386 progress.start_a_step("Generating the Markdown file") 

387 

388 markdown_file_path_in_output_folder = renderer.create_a_markdown_file( 

389 data_model, output_directory 

390 ) 

391 if render_command_settings.markdown_path: 

392 copy_files( 

393 markdown_file_path_in_output_folder, 

394 render_command_settings.markdown_path, 

395 ) 

396 

397 progress.finish_the_current_step() 

398 

399 if not render_command_settings.dont_generate_html: 

400 progress.start_a_step( 

401 "Rendering the Markdown file to a HTML (for Grammarly)" 

402 ) 

403 

404 html_file_path_in_output_folder = renderer.render_an_html_from_markdown( 

405 markdown_file_path_in_output_folder 

406 ) 

407 if render_command_settings.html_path: 

408 copy_files( 

409 html_file_path_in_output_folder, 

410 render_command_settings.html_path, 

411 ) 

412 

413 progress.finish_the_current_step() 

414 

415 

416def run_a_function_if_a_file_changes(file_path: pathlib.Path, function: Callable): 

417 """Watch the file located at `file_path` and call the `function` when the file is 

418 modified. The function should not take any arguments. 

419 

420 Args: 

421 file_path (pathlib.Path): The path of the file to watch for. 

422 function (Callable): The function to be called on file modification. 

423 """ 

424 # Run the function immediately for the first time 

425 function() 

426 

427 path_to_watch = str(file_path.absolute()) 

428 if sys.platform == "win32": 

429 # Windows does not support single file watching, so we watch the directory 

430 path_to_watch = str(file_path.parent.absolute()) 

431 

432 class EventHandler(watchdog.events.FileSystemEventHandler): 

433 def __init__(self, function: Callable): 

434 super().__init__() 

435 self.function_to_call = function 

436 

437 def on_modified(self, event: watchdog.events.FileModifiedEvent) -> None: 

438 if event.src_path != str(file_path.absolute()): 

439 return 

440 

441 printer.information( 

442 "\n\nThe input file has been updated. Re-running RenderCV..." 

443 ) 

444 self.function_to_call() 

445 

446 event_handler = EventHandler(function) 

447 

448 observer = watchdog.observers.Observer() 

449 observer.schedule(event_handler, path_to_watch, recursive=True) 

450 observer.start() 

451 try: 

452 while True: 

453 time.sleep(1) 

454 except KeyboardInterrupt: 

455 observer.stop() 

456 observer.join() 

457 

458 

459def read_and_construct_the_input( 

460 input_file_path: pathlib.Path, 

461 cli_render_arguments: dict[str, Any], 

462 extra_data_model_override_arguments: Optional[typer.Context] = None, 

463) -> dict: 

464 """Read RenderCV YAML files and CLI to construct the user's input as a dictionary. 

465 Input file is read, CLI arguments override the input file, and individual design, 

466 locale catalog, etc. files are read if they are provided. 

467 

468 Args: 

469 input_file_path: The path of the input file. 

470 cli_render_arguments: The command line arguments of the `render` command. 

471 extra_data_model_override_arguments: The extra arguments context. Defaults to 

472 None. 

473 

474 Returns: 

475 The input of the user as a dictionary. 

476 """ 

477 input_file_as_a_dict = data.read_a_yaml_file(input_file_path) 

478 

479 # Read individual `design`, `locale`, etc. files if they are provided in the 

480 # input file: 

481 for field in data.rendercv_data_model_fields: 

482 if field in cli_render_arguments and cli_render_arguments[field] is not None: 

483 yaml_path = pathlib.Path(cli_render_arguments[field]).absolute() 

484 yaml_file_as_a_dict = data.read_a_yaml_file(yaml_path) 

485 input_file_as_a_dict[field] = yaml_file_as_a_dict[field] 

486 

487 # Update the input file if there are extra override arguments (for example, 

488 # --cv.phone "123-456-7890"): 

489 if extra_data_model_override_arguments: 

490 key_and_values = parse_render_command_override_arguments( 

491 extra_data_model_override_arguments 

492 ) 

493 input_file_as_a_dict = set_or_update_values( 

494 input_file_as_a_dict, key_and_values 

495 ) 

496 

497 # If non-default CLI arguments are provided, override the 

498 # `rendercv_settings.render_command`: 

499 return update_render_command_settings_of_the_input_file( 

500 input_file_as_a_dict, cli_render_arguments 

501 )