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
« 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"""
5import inspect
6import json
7import pathlib
8import re
9import shutil
10import urllib.request
11from typing import Optional
13import typer
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".
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:
35 keys = key.split(".")
37 if sub_dictionary is not None:
38 updated_dict = sub_dictionary
39 else:
40 updated_dict = dictionary
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)
51 if isinstance(updated_dict, list):
52 key = int(key) # type: ignore
54 updated_dict[key] = value # type: ignore
56 else:
57 # get the first key and call the function with remaining keys:
58 first_key = keys[0]
59 key = ".".join(keys[1:])
61 if isinstance(updated_dict, list):
62 first_key = int(first_key)
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()
71 updated_sub_dict = set_or_update_a_value(dictionary, key, value, sub_dictionary)
72 updated_dict[first_key] = updated_sub_dict # type: ignore
74 return updated_dict # type: ignore
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.
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
91 return dictionary
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.
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]
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)
118def get_latest_version_number_from_pypi() -> Optional[str]:
119 """Get the latest version number of RenderCV from PyPI.
121 Example:
122 ```python
123 get_latest_version_number_from_pypi()
124 ```
125 returns
126 `#!python "1.1"`
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
143 return version
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.
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.
160 Args:
161 error_string (str): The error message.
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
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`.
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.
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
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 )
206 return destination
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.
215 Args:
216 extra_arguments (typer.Context): The extra arguments context.
218 Returns:
219 dict["str", "str"]: The key and value pairs.
220 """
221 key_and_values: dict["str", "str"] = dict()
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.
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 )
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!")
243 key = key.replace("--", "")
245 key_and_values[key] = value
247 return key_and_values
250def get_default_render_command_cli_arguments() -> dict:
251 """Get the default values of the `render` command's CLI arguments.
253 Returns:
254 dict: The default values of the `render` command's CLI arguments.
255 """
256 from .commands import cli_command_render
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 }
265 return default_render_command_cli_arguments
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.
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.
280 Returns:
281 dict: The updated input file as a dictionary.
282 """
283 default_render_command_cli_arguments = get_default_render_command_cli_arguments()
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()
292 if "render_command" not in input_file_as_a_dict["rendercv_settings"]:
293 input_file_as_a_dict["rendercv_settings"]["render_command"] = dict()
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
302 input_file_as_a_dict["rendercv_settings"]["render_command"] = render_command_field
304 return input_file_as_a_dict