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

206 statements  

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

10import shutil 

11import sys 

12import time 

13import urllib.request 

14from collections.abc import Callable 

15from typing import Any, Optional 

16 

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[str]: 

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 = json_data["info"]["version"] 

145 except Exception: 

146 pass 

147 

148 return version 

149 

150 

151def get_error_message_and_location_and_value_from_a_custom_error( 

152 error_string: str, 

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

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

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

156 message, location, and the input value. 

157 

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

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

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

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

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

163 return the three values. 

164 

165 Args: 

166 error_string: The error message. 

167 

168 Returns: 

169 The custom message, location, and the input value. 

170 """ 

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

172 match = re.search(pattern, error_string) 

173 if match: 

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

175 return None, None, None 

176 

177 

178def copy_templates( 

179 folder_name: str, 

180 copy_to: pathlib.Path, 

181 new_folder_name: Optional[str] = None, 

182) -> Optional[pathlib.Path]: 

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

184 

185 Args: 

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

187 copy_to: The path to copy the folder to. 

188 

189 Returns: 

190 The path to the copied folder. 

191 """ 

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

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

194 if new_folder_name: 

195 destination = copy_to / new_folder_name 

196 else: 

197 destination = copy_to / folder_name 

198 

199 if destination.exists(): 

200 return None 

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

202 shutil.copytree( 

203 template_directory, 

204 destination, 

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

206 ) 

207 

208 return destination 

209 

210 

211def parse_render_command_override_arguments( 

212 extra_arguments: typer.Context, 

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

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

215 pairs and return them as a dictionary. 

216 

217 Args: 

218 extra_arguments: The extra arguments context. 

219 

220 Returns: 

221 The key and value pairs. 

222 """ 

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

224 

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

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

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

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

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

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

231 # below parses `ctx.args` accordingly. 

232 

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

234 message = ( 

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

236 " corresponding value." 

237 ) 

238 raise ValueError(message) 

239 

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

241 key = extra_arguments.args[i] 

242 value = extra_arguments.args[i + 1] 

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

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

245 raise ValueError(message) 

246 

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

248 

249 key_and_values[key] = value 

250 

251 return key_and_values 

252 

253 

254def get_default_render_command_cli_arguments() -> dict: 

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

256 

257 Returns: 

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

259 """ 

260 from .commands import cli_command_render 

261 

262 sig = inspect.signature(cli_command_render) 

263 return { 

264 k: v.default 

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

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

267 } 

268 

269 

270def update_render_command_settings_of_the_input_file( 

271 input_file_as_a_dict: dict, 

272 render_command_cli_arguments: dict, 

273) -> dict: 

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

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

276 

277 Args: 

278 input_file_as_a_dict: The input file as a dictionary. 

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

280 command. 

281 

282 Returns: 

283 The updated input file as a dictionary. 

284 """ 

285 default_render_command_cli_arguments = get_default_render_command_cli_arguments() 

286 

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

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

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

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

291 if "rendercv_settings" not in input_file_as_a_dict: 

292 input_file_as_a_dict["rendercv_settings"] = {} 

293 

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

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

296 

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

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

299 if ( 

300 value != default_render_command_cli_arguments[key] 

301 or key not in render_command_field 

302 ): 

303 render_command_field[key] = value 

304 

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

306 

307 return input_file_as_a_dict 

308 

309 

310def make_given_keywords_bold_in_a_dictionary( 

311 dictionary: dict, keywords: list[str] 

312) -> dict: 

313 """Iterate over the dictionary recursively and make the given keywords bold. 

314 

315 Args: 

316 dictionary: The dictionary to make the keywords bold. 

317 keywords: The keywords to make bold. 

318 

319 Returns: 

320 The dictionary with the given keywords bold. 

321 """ 

322 new_dictionary = dictionary.copy() 

323 for keyword in keywords: 

324 for key, value in dictionary.items(): 

325 if isinstance(value, str): 

326 new_dictionary[key] = value.replace(keyword, f"**{keyword}**") 

327 elif isinstance(value, dict): 

328 new_dictionary[key] = make_given_keywords_bold_in_a_dictionary( 

329 value, keywords 

330 ) 

331 elif isinstance(value, list): 

332 for i, item in enumerate(value): 

333 if isinstance(item, str): 

334 new_dictionary[key][i] = item.replace(keyword, f"**{keyword}**") 

335 elif isinstance(item, dict): 

336 new_dictionary[key][i] = ( 

337 make_given_keywords_bold_in_a_dictionary(item, keywords) 

338 ) 

339 return new_dictionary 

340 

341 

342def run_rendercv_with_printer( 

343 input_file_as_a_dict: dict, 

344 working_directory: pathlib.Path, 

345 input_file_path: pathlib.Path, 

346): 

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

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

349 overrides. 

350 

351 Args: 

352 input_file_as_a_dict: The input file as a dictionary. 

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

354 input_file_path: The path of the input file. 

355 """ 

356 render_command_settings_dict = input_file_as_a_dict["rendercv_settings"][ 

357 "render_command" 

358 ] 

359 

360 # Compute the number of steps 

361 # 1. Validate the input file. 

362 # 2. Create the LaTeX file. 

363 # 3. Render PDF from LaTeX. 

364 # 4. Render PNGs from PDF. 

365 # 5. Create the Markdown file. 

366 # 6. Render HTML from Markdown. 

367 number_of_steps = 6 

368 if render_command_settings_dict["dont_generate_png"]: 

369 number_of_steps -= 1 

370 

371 if render_command_settings_dict["dont_generate_markdown"]: 

372 number_of_steps -= 2 

373 else: 

374 if render_command_settings_dict["dont_generate_html"]: 

375 number_of_steps -= 1 

376 

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

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

379 

380 data_model = data.validate_input_dictionary_and_return_the_data_model( 

381 input_file_as_a_dict, 

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

383 ) 

384 

385 # If the `bold_keywords` field is provided in the `rendercv_settings`, make the 

386 # given keywords bold in the `cv.sections` field: 

387 if data_model.rendercv_settings and data_model.rendercv_settings.bold_keywords: 

388 cv_field_as_dictionary = data_model.cv.model_dump(by_alias=True) 

389 new_sections_field = make_given_keywords_bold_in_a_dictionary( 

390 cv_field_as_dictionary["sections"], 

391 data_model.rendercv_settings.bold_keywords, 

392 ) 

393 cv_field_as_dictionary["sections"] = new_sections_field 

394 data_model.cv = data.models.CurriculumVitae(**cv_field_as_dictionary) 

395 

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

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

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

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

400 os.chdir(input_file_path.parent) 

401 

402 render_command_settings: data.models.RenderCommandSettings = ( 

403 data_model.rendercv_settings.render_command # type: ignore 

404 ) # type: ignore 

405 output_directory = ( 

406 working_directory / render_command_settings.output_folder_name # type: ignore 

407 ) 

408 

409 progress.finish_the_current_step() 

410 

411 progress.start_a_step("Generating the LaTeX file") 

412 

413 latex_file_path_in_output_folder = ( 

414 renderer.create_a_latex_file_and_copy_theme_files( 

415 data_model, output_directory 

416 ) 

417 ) 

418 if render_command_settings.latex_path: 

419 copy_files( 

420 latex_file_path_in_output_folder, 

421 render_command_settings.latex_path, 

422 ) 

423 

424 progress.finish_the_current_step() 

425 

426 progress.start_a_step("Rendering the LaTeX file to a PDF") 

427 

428 pdf_file_path_in_output_folder = renderer.render_a_pdf_from_latex( 

429 latex_file_path_in_output_folder, 

430 render_command_settings.use_local_latex_command, 

431 ) 

432 if render_command_settings.pdf_path: 

433 copy_files( 

434 pdf_file_path_in_output_folder, 

435 render_command_settings.pdf_path, 

436 ) 

437 

438 progress.finish_the_current_step() 

439 

440 if not render_command_settings.dont_generate_png: 

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

442 

443 png_file_paths_in_output_folder = renderer.render_pngs_from_pdf( 

444 pdf_file_path_in_output_folder 

445 ) 

446 if render_command_settings.png_path: 

447 copy_files( 

448 png_file_paths_in_output_folder, 

449 render_command_settings.png_path, 

450 ) 

451 

452 progress.finish_the_current_step() 

453 

454 if not render_command_settings.dont_generate_markdown: 

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

456 

457 markdown_file_path_in_output_folder = renderer.create_a_markdown_file( 

458 data_model, output_directory 

459 ) 

460 if render_command_settings.markdown_path: 

461 copy_files( 

462 markdown_file_path_in_output_folder, 

463 render_command_settings.markdown_path, 

464 ) 

465 

466 progress.finish_the_current_step() 

467 

468 if not render_command_settings.dont_generate_html: 

469 progress.start_a_step( 

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

471 ) 

472 

473 html_file_path_in_output_folder = renderer.render_an_html_from_markdown( 

474 markdown_file_path_in_output_folder 

475 ) 

476 if render_command_settings.html_path: 

477 copy_files( 

478 html_file_path_in_output_folder, 

479 render_command_settings.html_path, 

480 ) 

481 

482 progress.finish_the_current_step() 

483 

484 

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

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

487 modified. The function should not take any arguments. 

488 

489 Args: 

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

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

492 """ 

493 # Run the function immediately for the first time 

494 function() 

495 

496 path_to_watch = str(file_path.absolute()) 

497 if sys.platform == "win32": 

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

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

500 

501 class EventHandler(watchdog.events.FileSystemEventHandler): 

502 def __init__(self, function: Callable): 

503 super().__init__() 

504 self.function_to_call = function 

505 

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

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

508 return 

509 

510 printer.information( 

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

512 ) 

513 self.function_to_call() 

514 

515 event_handler = EventHandler(function) 

516 

517 observer = watchdog.observers.Observer() 

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

519 observer.start() 

520 try: 

521 while True: 

522 time.sleep(1) 

523 except KeyboardInterrupt: 

524 observer.stop() 

525 observer.join() 

526 

527 

528def read_and_construct_the_input( 

529 input_file_path: pathlib.Path, 

530 cli_render_arguments: dict[str, Any], 

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

532) -> dict: 

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

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

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

536 

537 Args: 

538 input_file_path: The path of the input file. 

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

540 extra_data_model_override_arguments: The extra arguments context. Defaults to 

541 None. 

542 

543 Returns: 

544 The input of the user as a dictionary. 

545 """ 

546 input_file_as_a_dict = data.read_a_yaml_file(input_file_path) 

547 

548 # Read individual `design`, `locale_catalog`, etc. files if they are provided in the 

549 # input file: 

550 for field in data.rendercv_data_model_fields: 

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

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

553 yaml_file_as_a_dict = data.read_a_yaml_file(yaml_path) 

554 input_file_as_a_dict[field] = yaml_file_as_a_dict[field] 

555 

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

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

558 if extra_data_model_override_arguments: 

559 key_and_values = parse_render_command_override_arguments( 

560 extra_data_model_override_arguments 

561 ) 

562 input_file_as_a_dict = set_or_update_values( 

563 input_file_as_a_dict, key_and_values 

564 ) 

565 

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

567 # `rendercv_settings.render_command`: 

568 return update_render_command_settings_of_the_input_file( 

569 input_file_as_a_dict, cli_render_arguments 

570 )