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
« 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"""
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
17import typer
18import watchdog.events
19import watchdog.observers
21from .. import data, renderer
22from . import printer
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".
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:
43 keys = key.split(".")
45 updated_dict = sub_dictionary if sub_dictionary is not None else dictionary
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)
56 if isinstance(updated_dict, list):
57 key = int(key) # type: ignore
59 updated_dict[key] = value # type: ignore
61 else:
62 # get the first key and call the function with remaining keys:
63 first_key = keys[0]
64 key = ".".join(keys[1:])
66 if isinstance(updated_dict, list):
67 first_key = int(first_key)
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 = {}
76 updated_sub_dict = set_or_update_a_value(dictionary, key, value, sub_dictionary)
77 updated_dict[first_key] = updated_sub_dict # type: ignore
79 return updated_dict # type: ignore
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.
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
96 return dictionary
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.
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]
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)
123def get_latest_version_number_from_pypi() -> Optional[str]:
124 """Get the latest version number of RenderCV from PyPI.
126 Example:
127 ```python
128 get_latest_version_number_from_pypi()
129 ```
130 returns
131 `"1.1"`
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
148 return version
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.
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.
165 Args:
166 error_string: The error message.
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
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`.
185 Args:
186 folder_name: The name of the folder to be copied.
187 copy_to: The path to copy the folder to.
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
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 )
208 return destination
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.
217 Args:
218 extra_arguments: The extra arguments context.
220 Returns:
221 The key and value pairs.
222 """
223 key_and_values: dict[str, str] = {}
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.
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)
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)
247 key = key.replace("--", "")
249 key_and_values[key] = value
251 return key_and_values
254def get_default_render_command_cli_arguments() -> dict:
255 """Get the default values of the `render` command's CLI arguments.
257 Returns:
258 The default values of the `render` command's CLI arguments.
259 """
260 from .commands import cli_command_render
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 }
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.
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.
282 Returns:
283 The updated input file as a dictionary.
284 """
285 default_render_command_cli_arguments = get_default_render_command_cli_arguments()
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"] = {}
294 if "render_command" not in input_file_as_a_dict["rendercv_settings"]:
295 input_file_as_a_dict["rendercv_settings"]["render_command"] = {}
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
305 input_file_as_a_dict["rendercv_settings"]["render_command"] = render_command_field
307 return input_file_as_a_dict
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.
315 Args:
316 dictionary: The dictionary to make the keywords bold.
317 keywords: The keywords to make bold.
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
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.
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 ]
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
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
377 with printer.LiveProgressReporter(number_of_steps=number_of_steps) as progress:
378 progress.start_a_step("Validating the input file")
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 )
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)
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)
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 )
409 progress.finish_the_current_step()
411 progress.start_a_step("Generating the LaTeX file")
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 )
424 progress.finish_the_current_step()
426 progress.start_a_step("Rendering the LaTeX file to a PDF")
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 )
438 progress.finish_the_current_step()
440 if not render_command_settings.dont_generate_png:
441 progress.start_a_step("Rendering PNG files from the PDF")
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 )
452 progress.finish_the_current_step()
454 if not render_command_settings.dont_generate_markdown:
455 progress.start_a_step("Generating the Markdown file")
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 )
466 progress.finish_the_current_step()
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 )
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 )
482 progress.finish_the_current_step()
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.
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()
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())
501 class EventHandler(watchdog.events.FileSystemEventHandler):
502 def __init__(self, function: Callable):
503 super().__init__()
504 self.function_to_call = function
506 def on_modified(self, event: watchdog.events.FileModifiedEvent) -> None:
507 if event.src_path != str(file_path.absolute()):
508 return
510 printer.information(
511 "\n\nThe input file has been updated. Re-running RenderCV..."
512 )
513 self.function_to_call()
515 event_handler = EventHandler(function)
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()
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.
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.
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)
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]
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 )
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 )