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
« 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"""
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
16import packaging.version
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[packaging.version.Version]:
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_string = json_data["info"]["version"]
145 version = packaging.version.Version(version_string)
146 except Exception:
147 pass
149 return version
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`.
159 Args:
160 folder_name: The name of the folder to be copied.
161 copy_to: The path to copy the folder to.
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
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 )
182 return destination
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.
191 Args:
192 extra_arguments: The extra arguments context.
194 Returns:
195 The key and value pairs.
196 """
197 key_and_values: dict[str, str] = {}
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.
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)
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)
222 key = key.replace("--", "")
224 key_and_values[key] = value
226 return key_and_values
229def get_default_render_command_cli_arguments() -> dict:
230 """Get the default values of the `render` command's CLI arguments.
232 Returns:
233 The default values of the `render` command's CLI arguments.
234 """
235 from .commands import cli_command_render
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 }
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.
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.
257 Returns:
258 The updated input file as a dictionary.
259 """
260 default_render_command_cli_arguments = get_default_render_command_cli_arguments()
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"] = {}
269 if "render_command" not in input_file_as_a_dict["rendercv_settings"]:
270 input_file_as_a_dict["rendercv_settings"]["render_command"] = {}
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
280 input_file_as_a_dict["rendercv_settings"]["render_command"] = render_command_field
282 return input_file_as_a_dict
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.
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 ]
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
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
320 with printer.LiveProgressReporter(number_of_steps=number_of_steps) as progress:
321 progress.start_a_step("Validating the input file")
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 )
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)
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 )
341 progress.finish_the_current_step()
343 progress.start_a_step("Generating the Typst file")
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 )
356 progress.finish_the_current_step()
358 progress.start_a_step("Rendering the Typst file to a PDF")
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 )
369 progress.finish_the_current_step()
371 if not render_command_settings.dont_generate_png:
372 progress.start_a_step("Rendering PNG files from the PDF")
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 )
383 progress.finish_the_current_step()
385 if not render_command_settings.dont_generate_markdown:
386 progress.start_a_step("Generating the Markdown file")
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 )
397 progress.finish_the_current_step()
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 )
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 )
413 progress.finish_the_current_step()
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.
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()
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())
432 class EventHandler(watchdog.events.FileSystemEventHandler):
433 def __init__(self, function: Callable):
434 super().__init__()
435 self.function_to_call = function
437 def on_modified(self, event: watchdog.events.FileModifiedEvent) -> None:
438 if event.src_path != str(file_path.absolute()):
439 return
441 printer.information(
442 "\n\nThe input file has been updated. Re-running RenderCV..."
443 )
444 self.function_to_call()
446 event_handler = EventHandler(function)
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()
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.
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.
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)
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]
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 )
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 )