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