Coverage for rendercv/cli/printer.py: 95%

133 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-26 00:25 +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 collections.abc import Callable 

8from typing import Optional 

9 

10import jinja2 

11import packaging.version 

12import pydantic 

13import rich 

14import rich.live 

15import rich.panel 

16import rich.progress 

17import rich.table 

18import rich.text 

19import ruamel.yaml 

20import typer 

21from rich import print 

22 

23from .. import __version__, data 

24from . import utilities 

25 

26 

27class LiveProgressReporter(rich.live.Live): 

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

29 reporting functionality. 

30 

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

36 

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 assert elapsed is not None 

42 elapsed = elapsed * 1000 # Convert to milliseconds 

43 delta = f"{elapsed:.0f} ms" 

44 return rich.text.Text(str(delta), style="progress.elapsed") 

45 

46 self.step_progress = rich.progress.Progress( 

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

48 ) 

49 

50 self.overall_progress = rich.progress.Progress( 

51 TimeElapsedColumn(), 

52 rich.progress.BarColumn(), 

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

54 ) 

55 

56 self.group = rich.console.Group( 

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

58 self.overall_progress, 

59 ) 

60 

61 self.overall_task_id = self.overall_progress.add_task("", total=number_of_steps) 

62 self.number_of_steps = number_of_steps 

63 self.end_message = end_message 

64 self.current_step = 0 

65 self.overall_progress.update( 

66 self.overall_task_id, 

67 description=( 

68 f"[bold #AAAAAA]({self.current_step} out of" 

69 f" {self.number_of_steps} steps finished)" 

70 ), 

71 ) 

72 super().__init__(self.group) 

73 

74 def __enter__(self) -> "LiveProgressReporter": 

75 """Overwrite the `__enter__` method for the correct return type.""" 

76 self.start(refresh=self._renderable is not None) 

77 return self 

78 

79 def start_a_step(self, step_name: str): 

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

81 

82 Args: 

83 step_name: The name of the step. 

84 """ 

85 self.current_step_name = step_name 

86 self.current_step_id = self.step_progress.add_task( 

87 f"{self.current_step_name} has started." 

88 ) 

89 

90 def finish_the_current_step(self): 

91 """Finish the current step and update the progress bars.""" 

92 self.step_progress.stop_task(self.current_step_id) 

93 self.step_progress.update( 

94 self.current_step_id, description=f"{self.current_step_name} has finished." 

95 ) 

96 self.current_step += 1 

97 self.overall_progress.update( 

98 self.overall_task_id, 

99 description=( 

100 f"[bold #AAAAAA]({self.current_step} out of" 

101 f" {self.number_of_steps} steps finished)" 

102 ), 

103 advance=1, 

104 ) 

105 if self.current_step == self.number_of_steps: 

106 self.end() 

107 

108 def end(self): 

109 """End the live progress reporting.""" 

110 self.overall_progress.update( 

111 self.overall_task_id, 

112 description=f"[yellow]{self.end_message}", 

113 ) 

114 

115 

116def warn_if_new_version_is_available() -> bool: 

117 """Check if a new version of RenderCV is available and print a warning message if 

118 there is a new version. Also, return True if there is a new version, and False 

119 otherwise. 

120 

121 Returns: 

122 True if there is a new version, and False otherwise. 

123 """ 

124 latest_version = utilities.get_latest_version_number_from_pypi() 

125 version = packaging.version.Version(__version__) 

126 if latest_version is not None and version < latest_version: 

127 warning( 

128 f"A new version of RenderCV is available! You are using v{__version__}," 

129 f" and the latest version is v{latest_version}." 

130 ) 

131 return True 

132 return False 

133 

134 

135def welcome(): 

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

137 warn_if_new_version_is_available() 

138 

139 table = rich.table.Table( 

140 title=( 

141 "\nWelcome to [bold]Render[dodger_blue3]CV[/dodger_blue3][/bold]! Some" 

142 " useful links:" 

143 ), 

144 title_justify="left", 

145 ) 

146 

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

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

149 

150 table.add_row("[bold]RenderCV App", "https://rendercv.com") 

151 table.add_row("Documentation", "https://docs.rendercv.com") 

152 table.add_row("Source code", "https://github.com/rendercv/rendercv/") 

153 table.add_row("Bug reports", "https://github.com/rendercv/rendercv/issues/") 

154 table.add_row("Feature requests", "https://github.com/rendercv/rendercv/issues/") 

155 table.add_row("Discussions", "https://github.com/rendercv/rendercv/discussions/") 

156 table.add_row("RenderCV Pipeline", "https://github.com/rendercv/rendercv-pipeline/") 

157 

158 print(table) 

159 

160 

161def warning(text: str): 

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

163 

164 Args: 

165 text: The text of the warning message. 

166 """ 

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

168 

169 

170def error(text: Optional[str] = None, exception: Optional[Exception] = None): 

171 """Print an error message to the terminal and exit the program. If an exception is 

172 given, then print the exception's message as well. If neither text nor exception is 

173 given, then print an empty line and exit the program. 

174 

175 Args: 

176 text: The text of the error message. 

177 exception: An exception object. Defaults to None. 

178 """ 

179 if exception is not None: 

180 exception_messages = [str(arg) for arg in exception.args] 

181 exception_message = "\n\n".join(exception_messages) 

182 if text is None: 

183 text = "An error occurred:" 

184 

185 print( 

186 f"\n[bold red]{text}[/bold red]\n\n[orange4]{exception_message}[/orange4]\n" 

187 ) 

188 elif text is not None: 

189 print(f"\n[bold red]{text}\n") 

190 else: 

191 print() 

192 

193 

194def information(text: str): 

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

196 

197 Args: 

198 text: The text of the information message. 

199 """ 

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

201 

202 

203def print_validation_errors(exception: pydantic.ValidationError): 

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

205 

206 Pydantic's `ValidationError` object is a complex object that contains a lot of 

207 information about the error. This function takes a `ValidationError` object and 

208 extracts the error messages, locations, and the input values. Then, it prints them 

209 in a nice table with [Rich](https://rich.readthedocs.io/en/latest/). 

210 

211 Args: 

212 exception: The Pydantic validation error object. 

213 """ 

214 errors = data.parse_validation_errors(exception) 

215 

216 # Print the errors in a nice table: 

217 table = rich.table.Table( 

218 title="[bold red]\nThere are some errors in the data model!\n", 

219 title_justify="left", 

220 show_lines=True, 

221 ) 

222 table.add_column("Location", style="cyan", no_wrap=True) 

223 table.add_column("Input Value", style="magenta") 

224 table.add_column("Error Message", style="orange4") 

225 

226 for error_object in errors: 

227 table.add_row( 

228 ".".join(error_object["loc"]), 

229 error_object["input"], 

230 error_object["msg"], 

231 ) 

232 

233 print(table) 

234 

235 

236def handle_and_print_raised_exceptions_without_exit(function: Callable) -> Callable: 

237 """Return a wrapper function that handles exceptions. It does not exit the program 

238 after an exception is raised. It just prints the error message and continues the 

239 execution. 

240 

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

242 the code below: 

243 

244 ```python 

245 @handle_exceptions 

246 def my_function(): 

247 pass 

248 ``` 

249 

250 as 

251 

252 ```python 

253 my_function = handle_exceptions(my_function) 

254 ``` 

255 

256 which means that the function `my_function` is modified by the `handle_exceptions`. 

257 

258 Args: 

259 function: The function to be wrapped. 

260 

261 Returns: 

262 The wrapped function. 

263 """ 

264 

265 @functools.wraps(function) 

266 def wrapper(*args, **kwargs): 

267 code = 4 

268 try: 

269 function(*args, **kwargs) 

270 except pydantic.ValidationError as e: 

271 print_validation_errors(e) 

272 except ruamel.yaml.YAMLError as e: 

273 error( 

274 "There is a YAML error in the input file!\n\nTry to use quotation marks" 

275 " to make sure the YAML parser understands the field is a string.", 

276 e, 

277 ) 

278 except FileNotFoundError as e: 

279 error(exception=e) 

280 except UnicodeDecodeError as e: 

281 # find the problematic character that cannot be decoded with utf-8 

282 bad_character = str(e.object[e.start : e.end]) 

283 try: 

284 bad_character_context = str(e.object[e.start - 16 : e.end + 16]) 

285 except IndexError: 

286 bad_character_context = "" 

287 

288 error( 

289 "The input file contains a character that cannot be decoded with" 

290 f" UTF-8 ({bad_character}):\n {bad_character_context}", 

291 ) 

292 except ValueError as e: 

293 error(exception=e) 

294 except typer.Exit: 

295 pass 

296 except jinja2.exceptions.TemplateSyntaxError as e: 

297 error( 

298 f"There is a problem with the template ({e.filename}) at line" 

299 f" {e.lineno}!", 

300 e, 

301 ) 

302 except RuntimeError as e: 

303 error(exception=e) 

304 except Exception as e: 

305 raise e 

306 else: 

307 code = 0 

308 

309 return code 

310 

311 return wrapper 

312 

313 

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

315 """Return a wrapper function that handles exceptions. It exits the program after an 

316 exception is raised. 

317 

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

319 the code below: 

320 

321 ```python 

322 @handle_exceptions 

323 def my_function(): 

324 pass 

325 ``` 

326 

327 as 

328 

329 ```python 

330 my_function = handle_exceptions(my_function) 

331 ``` 

332 

333 which means that the function `my_function` is modified by the `handle_exceptions`. 

334 

335 Args: 

336 function: The function to be wrapped. 

337 

338 Returns: 

339 The wrapped function. 

340 """ 

341 

342 @functools.wraps(function) 

343 def wrapper(*args, **kwargs): 

344 without_exit_wrapper = handle_and_print_raised_exceptions_without_exit(function) 

345 

346 code = without_exit_wrapper(*args, **kwargs) 

347 

348 if code != 0: 

349 raise typer.Exit(code) 

350 

351 return wrapper