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

1""" 

2The `rendercv.cli.printer` module contains all the functions and classes that are used 

3to print nice-looking messages to the terminal. 

4""" 

5 

6import functools 

7from typing import Callable, Optional 

8 

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 

21 

22from .. import __version__ 

23from . import utilities 

24 

25 

26class LiveProgressReporter(rich.live.Live): 

27 """This class is a wrapper around `rich.live.Live` that provides the live progress 

28 reporting functionality. 

29 

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 """ 

35 

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") 

42 

43 self.step_progress = rich.progress.Progress( 

44 TimeElapsedColumn(), rich.progress.TextColumn("{task.description}") 

45 ) 

46 

47 self.overall_progress = rich.progress.Progress( 

48 TimeElapsedColumn(), 

49 rich.progress.BarColumn(), 

50 rich.progress.TextColumn("{task.description}"), 

51 ) 

52 

53 self.group = rich.console.Group( 

54 rich.panel.Panel(rich.console.Group(self.step_progress)), 

55 self.overall_progress, 

56 ) 

57 

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) 

70 

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 

75 

76 def start_a_step(self, step_name: str): 

77 """Start a step and update the progress bars. 

78 

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 ) 

86 

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() 

104 

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 ) 

111 

112 

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. 

117 

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 

130 

131 

132def welcome(): 

133 """Print a welcome message to the terminal.""" 

134 warn_if_new_version_is_available() 

135 

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 ) 

143 

144 table.add_column("Title", style="magenta", justify="left") 

145 table.add_column("Link", style="cyan", justify="right", no_wrap=True) 

146 

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 ) 

156 

157 print(table) 

158 

159 

160def warning(text: str): 

161 """Print a warning message to the terminal. 

162 

163 Args: 

164 text (str): The text of the warning message. 

165 """ 

166 print(f"[bold yellow]{text}") 

167 

168 

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. 

173 

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:" 

183 

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() 

191 

192 raise typer.Exit(code=4) 

193 

194 

195def information(text: str): 

196 """Print an information message to the terminal. 

197 

198 Args: 

199 text (str): The text of the information message. 

200 """ 

201 print(f"[green]{text}") 

202 

203 

204def print_validation_errors(exception: pydantic.ValidationError): 

205 """Take a Pydantic validation error and print the error messages in a nice table. 

206 

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/). 

211 

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 } 

247 

248 unwanted_texts = ["value is not a valid email address: ", "Value error, "] 

249 

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) 

273 

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 

287 

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"] 

294 

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 

307 

308 # Don't show unwanted texts in the error message: 

309 for unwanted_text in unwanted_texts: 

310 message = message.replace(unwanted_text, "") 

311 

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] 

316 

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 ) 

324 

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 = "" 

329 

330 new_error = { 

331 "loc": str(location), 

332 "msg": message, 

333 "input": str(input), 

334 } 

335 

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) 

339 

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") 

349 

350 for error_object in new_errors: 

351 table.add_row( 

352 error_object["loc"], 

353 error_object["input"], 

354 error_object["msg"], 

355 ) 

356 

357 print(table) 

358 error() # exit the program 

359 

360 

361def handle_and_print_raised_exceptions(function: Callable) -> Callable: 

362 """Return a wrapper function that handles exceptions. 

363 

364 A decorator in Python is a syntactic convenience that allows a Python to interpret 

365 the code below: 

366 

367 ```python 

368 @handle_exceptions 

369 def my_function(): 

370 pass 

371 ``` 

372 

373 as 

374 

375 ```python 

376 handle_exceptions(my_function)() 

377 ``` 

378 

379 which is step by step equivalent to 

380 

381 1. Execute `#!python handle_exceptions(my_function)` which will return the 

382 function called `wrapper`. 

383 2. Execute `#!python wrapper()`. 

384 

385 Args: 

386 function (Callable): The function to be wrapped. 

387 

388 Returns: 

389 Callable: The wrapped function. 

390 """ 

391 

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 = "" 

413 

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 

432 

433 return wrapper