Coverage for rendercv/cli/printer.py: 95%
175 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.printer` module contains all the functions and classes that are used
3to print nice-looking messages to the terminal.
4"""
6import functools
7from collections.abc import Callable
8from typing import Optional
10import jinja2
11import pydantic
12import rich
13import rich.live
14import rich.panel
15import rich.progress
16import rich.table
17import rich.text
18import ruamel.yaml
19import ruamel.yaml.parser
20import typer
21from rich import print
23from .. import __version__
24from . import utilities
27class LiveProgressReporter(rich.live.Live):
28 """This class is a wrapper around `rich.live.Live` that provides the live progress
29 reporting functionality.
31 Args:
32 number_of_steps: The number of steps to be finished.
33 end_message: The message to be printed when the progress is finished. Defaults
34 to "Your CV is rendered!".
35 """
37 def __init__(self, number_of_steps: int, end_message: str = "Your CV is rendered!"):
38 class TimeElapsedColumn(rich.progress.ProgressColumn):
39 def render(self, task: "rich.progress.Task") -> rich.text.Text:
40 elapsed = task.finished_time if task.finished else task.elapsed
41 delta = f"{elapsed:.1f} s"
42 return rich.text.Text(str(delta), style="progress.elapsed")
44 self.step_progress = rich.progress.Progress(
45 TimeElapsedColumn(), rich.progress.TextColumn("{task.description}")
46 )
48 self.overall_progress = rich.progress.Progress(
49 TimeElapsedColumn(),
50 rich.progress.BarColumn(),
51 rich.progress.TextColumn("{task.description}"),
52 )
54 self.group = rich.console.Group(
55 rich.panel.Panel(rich.console.Group(self.step_progress)),
56 self.overall_progress,
57 )
59 self.overall_task_id = self.overall_progress.add_task("", total=number_of_steps)
60 self.number_of_steps = number_of_steps
61 self.end_message = end_message
62 self.current_step = 0
63 self.overall_progress.update(
64 self.overall_task_id,
65 description=(
66 f"[bold #AAAAAA]({self.current_step} out of"
67 f" {self.number_of_steps} steps finished)"
68 ),
69 )
70 super().__init__(self.group)
72 def __enter__(self) -> "LiveProgressReporter":
73 """Overwrite the `__enter__` method for the correct return type."""
74 self.start(refresh=self._renderable is not None)
75 return self
77 def start_a_step(self, step_name: str):
78 """Start a step and update the progress bars.
80 Args:
81 step_name: The name of the step.
82 """
83 self.current_step_name = step_name
84 self.current_step_id = self.step_progress.add_task(
85 f"{self.current_step_name} has started."
86 )
88 def finish_the_current_step(self):
89 """Finish the current step and update the progress bars."""
90 self.step_progress.stop_task(self.current_step_id)
91 self.step_progress.update(
92 self.current_step_id, description=f"{self.current_step_name} has finished."
93 )
94 self.current_step += 1
95 self.overall_progress.update(
96 self.overall_task_id,
97 description=(
98 f"[bold #AAAAAA]({self.current_step} out of"
99 f" {self.number_of_steps} steps finished)"
100 ),
101 advance=1,
102 )
103 if self.current_step == self.number_of_steps:
104 self.end()
106 def end(self):
107 """End the live progress reporting."""
108 self.overall_progress.update(
109 self.overall_task_id,
110 description=f"[yellow]{self.end_message}",
111 )
114def warn_if_new_version_is_available() -> bool:
115 """Check if a new version of RenderCV is available and print a warning message if
116 there is a new version. Also, return True if there is a new version, and False
117 otherwise.
119 Returns:
120 True if there is a new version, and False otherwise.
121 """
122 latest_version = utilities.get_latest_version_number_from_pypi()
123 if latest_version is not None and __version__ != latest_version:
124 warning(
125 f"A new version of RenderCV is available! You are using v{__version__},"
126 f" and the latest version is v{latest_version}."
127 )
128 return True
129 return False
132def welcome():
133 """Print a welcome message to the terminal."""
134 warn_if_new_version_is_available()
136 table = rich.table.Table(
137 title=(
138 "\nWelcome to [bold]Render[dodger_blue3]CV[/dodger_blue3][/bold]! Some"
139 " useful links:"
140 ),
141 title_justify="left",
142 )
144 table.add_column("Title", style="magenta", justify="left")
145 table.add_column("Link", style="cyan", justify="right", no_wrap=True)
147 table.add_row("[bold]RenderCV App", "https://rendercv.com")
148 table.add_row("Documentation", "https://docs.rendercv.com")
149 table.add_row("Source code", "https://github.com/rendercv/rendercv/")
150 table.add_row("Bug reports", "https://github.com/rendercv/rendercv/issues/")
151 table.add_row("Feature requests", "https://github.com/rendercv/rendercv/issues/")
152 table.add_row("Discussions", "https://github.com/rendercv/rendercv/discussions/")
153 table.add_row("RenderCV Pipeline", "https://github.com/rendercv/rendercv-pipeline/")
155 print(table)
158def warning(text: str):
159 """Print a warning message to the terminal.
161 Args:
162 text: The text of the warning message.
163 """
164 print(f"[bold yellow]{text}")
167def error(text: Optional[str] = None, exception: Optional[Exception] = None):
168 """Print an error message to the terminal and exit the program. If an exception is
169 given, then print the exception's message as well. If neither text nor exception is
170 given, then print an empty line and exit the program.
172 Args:
173 text: The text of the error message.
174 exception: An exception object. Defaults to None.
175 """
176 if exception is not None:
177 exception_messages = [str(arg) for arg in exception.args]
178 exception_message = "\n\n".join(exception_messages)
179 if text is None:
180 text = "An error occurred:"
182 print(
183 f"\n[bold red]{text}[/bold red]\n\n[orange4]{exception_message}[/orange4]\n"
184 )
185 elif text is not None:
186 print(f"\n[bold red]{text}\n")
187 else:
188 print()
191def information(text: str):
192 """Print an information message to the terminal.
194 Args:
195 text: The text of the information message.
196 """
197 print(f"[green]{text}")
200def print_validation_errors(exception: pydantic.ValidationError):
201 """Take a Pydantic validation error and print the error messages in a nice table.
203 Pydantic's `ValidationError` object is a complex object that contains a lot of
204 information about the error. This function takes a `ValidationError` object and
205 extracts the error messages, locations, and the input values. Then, it prints them
206 in a nice table with [Rich](https://rich.readthedocs.io/en/latest/).
208 Args:
209 exception: The Pydantic validation error object.
210 """
211 # This dictionary is used to convert the error messages that Pydantic returns to
212 # more user-friendly messages.
213 error_dictionary: dict[str, str] = {
214 "Input should be 'present'": (
215 "This is not a valid date! Please use either YYYY-MM-DD, YYYY-MM, or YYYY"
216 ' format or "present"!'
217 ),
218 "Input should be a valid integer, unable to parse string as an integer": (
219 "This is not a valid date! Please use either YYYY-MM-DD, YYYY-MM, or YYYY"
220 " format!"
221 ),
222 "String should match pattern '\\d{4}-\\d{2}(-\\d{2})?'": (
223 "This is not a valid date! Please use either YYYY-MM-DD, YYYY-MM, or YYYY"
224 " format!"
225 ),
226 "String should match pattern '\\b10\\..*'": (
227 'A DOI prefix should always start with "10.". For example,'
228 ' "10.1109/TASC.2023.3340648".'
229 ),
230 "URL scheme should be 'http' or 'https'": "This is not a valid URL!",
231 "Field required": "This field is required!",
232 "value is not a valid phone number": "This is not a valid phone number!",
233 "month must be in 1..12": "The month must be between 1 and 12!",
234 "day is out of range for month": "The day is out of range for the month!",
235 "Extra inputs are not permitted": (
236 "This field is unknown for this object! Please remove it."
237 ),
238 "Input should be a valid string": "This field should be a string!",
239 "Input should be a valid list": (
240 "This field should contain a list of items but it doesn't!"
241 ),
242 }
244 unwanted_texts = ["value is not a valid email address: ", "Value error, "]
246 # Check if this is a section error. If it is, we need to handle it differently.
247 # This is needed because how dm.validate_section_input function raises an exception.
248 # This is done to tell the user which which EntryType RenderCV excepts to see.
249 errors = exception.errors()
250 for error_object in errors.copy():
251 if (
252 "There are problems with the entries." in error_object["msg"]
253 and "ctx" in error_object
254 ):
255 location = error_object["loc"]
256 ctx_object = error_object["ctx"]
257 if "error" in ctx_object:
258 inner_error_object = ctx_object["error"]
259 if hasattr(inner_error_object, "__cause__"):
260 cause_object = inner_error_object.__cause__
261 cause_object_errors = cause_object.errors()
262 for cause_error_object in cause_object_errors:
263 # we use [1:] to avoid `entries` location. It is a location for
264 # RenderCV's own data model, not the user's data model.
265 cause_error_object["loc"] = tuple(
266 list(location) + list(cause_error_object["loc"][1:])
267 )
268 errors.extend(cause_object_errors)
270 # some locations are not really the locations in the input file, but some
271 # information about the model coming from Pydantic. We need to remove them.
272 # (e.g. avoid stuff like .end_date.literal['present'])
273 unwanted_locations = ["tagged-union", "list", "literal", "int", "constrained-str"]
274 for error_object in errors:
275 location = [str(location_element) for location_element in error_object["loc"]]
276 new_location = [str(location_element) for location_element in location]
277 for location_element in location:
278 for unwanted_location in unwanted_locations:
279 if unwanted_location in location_element:
280 new_location.remove(location_element)
281 error_object["loc"] = new_location # type: ignore
283 # Parse all the errors and create a new list of errors.
284 new_errors: list[dict[str, str]] = []
285 for error_object in errors:
286 message = error_object["msg"]
287 location = ".".join(error_object["loc"]) # type: ignore
288 input = error_object["input"]
290 # Check if this is a custom error message:
291 custom_message, custom_location, custom_input_value = (
292 utilities.get_error_message_and_location_and_value_from_a_custom_error(
293 message
294 )
295 )
296 if custom_message is not None:
297 message = custom_message
298 if custom_location:
299 # If the custom location is not empty, then add it to the location.
300 location = f"{location}.{custom_location}"
301 input = custom_input_value
303 # Don't show unwanted texts in the error message:
304 for unwanted_text in unwanted_texts:
305 message = message.replace(unwanted_text, "")
307 # Convert the error message to a more user-friendly message if it's in the
308 # error_dictionary:
309 if message in error_dictionary:
310 message = error_dictionary[message]
312 # Special case for end_date because Pydantic returns multiple end_date errors
313 # since it has multiple valid formats:
314 if "end_date" in location:
315 message = (
316 "This is not a valid end date! Please use either YYYY-MM-DD, YYYY-MM,"
317 ' or YYYY format or "present"!'
318 )
320 # If the input is a dictionary or a list (the model itself fails to validate),
321 # then don't show the input. It looks confusing and it is not helpful.
322 if isinstance(input, dict | list):
323 input = ""
325 new_error = {
326 "loc": str(location),
327 "msg": message,
328 "input": str(input),
329 }
331 # if new_error is not in new_errors, then add it to new_errors
332 if new_error not in new_errors:
333 new_errors.append(new_error)
335 # Print the errors in a nice table:
336 table = rich.table.Table(
337 title="[bold red]\nThere are some errors in the data model!\n",
338 title_justify="left",
339 show_lines=True,
340 )
341 table.add_column("Location", style="cyan", no_wrap=True)
342 table.add_column("Input Value", style="magenta")
343 table.add_column("Error Message", style="orange4")
345 for error_object in new_errors:
346 table.add_row(
347 error_object["loc"],
348 error_object["input"],
349 error_object["msg"],
350 )
352 print(table)
355def handle_and_print_raised_exceptions_without_exit(function: Callable) -> Callable:
356 """Return a wrapper function that handles exceptions. It does not exit the program
357 after an exception is raised. It just prints the error message and continues the
358 execution.
360 A decorator in Python is a syntactic convenience that allows a Python to interpret
361 the code below:
363 ```python
364 @handle_exceptions
365 def my_function():
366 pass
367 ```
369 as
371 ```python
372 my_function = handle_exceptions(my_function)
373 ```
375 which means that the function `my_function` is modified by the `handle_exceptions`.
377 Args:
378 function: The function to be wrapped.
380 Returns:
381 The wrapped function.
382 """
384 @functools.wraps(function)
385 def wrapper(*args, **kwargs):
386 code = 4
387 try:
388 function(*args, **kwargs)
389 except pydantic.ValidationError as e:
390 print_validation_errors(e)
391 except ruamel.yaml.YAMLError as e:
392 error(
393 "There is a YAML error in the input file!\n\nTry to use quotation marks"
394 " to make sure the YAML parser understands the field is a string.",
395 e,
396 )
397 except FileNotFoundError as e:
398 error(exception=e)
399 except UnicodeDecodeError as e:
400 # find the problematic character that cannot be decoded with utf-8
401 bad_character = str(e.object[e.start : e.end])
402 try:
403 bad_character_context = str(e.object[e.start - 16 : e.end + 16])
404 except IndexError:
405 bad_character_context = ""
407 error(
408 "The input file contains a character that cannot be decoded with"
409 f" UTF-8 ({bad_character}):\n {bad_character_context}",
410 )
411 except ValueError as e:
412 error(exception=e)
413 except typer.Exit:
414 pass
415 except jinja2.exceptions.TemplateSyntaxError as e:
416 error(
417 f"There is a problem with the template ({e.filename}) at line"
418 f" {e.lineno}!",
419 e,
420 )
421 except RuntimeError as e:
422 error(exception=e)
423 except Exception as e:
424 raise e
425 else:
426 code = 0
428 return code
430 return wrapper
433def handle_and_print_raised_exceptions(function: Callable) -> Callable:
434 """Return a wrapper function that handles exceptions. It exits the program after an
435 exception is raised.
437 A decorator in Python is a syntactic convenience that allows a Python to interpret
438 the code below:
440 ```python
441 @handle_exceptions
442 def my_function():
443 pass
444 ```
446 as
448 ```python
449 my_function = handle_exceptions(my_function)
450 ```
452 which means that the function `my_function` is modified by the `handle_exceptions`.
454 Args:
455 function: The function to be wrapped.
457 Returns:
458 The wrapped function.
459 """
461 @functools.wraps(function)
462 def wrapper(*args, **kwargs):
463 without_exit_wrapper = handle_and_print_raised_exceptions_without_exit(function)
465 code = without_exit_wrapper(*args, **kwargs)
467 if code != 0:
468 raise typer.Exit(code)
470 return wrapper