Coverage for rendercv/cli/commands.py: 100%

110 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-10-07 17:51 +0000

1""" 

2The `rendercv.cli.commands` module contains all the command-line interface (CLI) 

3commands of RenderCV. 

4""" 

5 

6import os 

7import pathlib 

8from typing import Annotated, Optional 

9 

10import typer 

11from rich import print 

12 

13from .. import __version__, data, renderer 

14from . import printer, utilities 

15 

16app = typer.Typer( 

17 rich_markup_mode="rich", 

18 add_completion=False, 

19 # to make `rendercv --version` work: 

20 invoke_without_command=True, 

21 no_args_is_help=True, 

22 context_settings={"help_option_names": ["-h", "--help"]}, 

23 # don't show local variables in unhandled exceptions: 

24 pretty_exceptions_show_locals=False, 

25) 

26 

27 

28@app.command( 

29 name="render", 

30 help=( 

31 "Render a YAML input file. Example: [yellow]rendercv render" 

32 " John_Doe_CV.yaml[/yellow]. Details: [cyan]rendercv render --help[/cyan]" 

33 ), 

34 # allow extra arguments for updating the data model (for overriding the values of 

35 # the input file): 

36 context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, 

37) 

38@printer.handle_and_print_raised_exceptions 

39def cli_command_render( 

40 input_file_name: Annotated[ 

41 str, typer.Argument(help="Name of the YAML input file.") 

42 ], 

43 use_local_latex_command: Annotated[ 

44 Optional[str], 

45 typer.Option( 

46 "--use-local-latex-command", 

47 "-use", 

48 help=( 

49 "Use the local LaTeX installation with the given command instead of the" 

50 " RenderCV's TinyTeX." 

51 ), 

52 ), 

53 ] = None, 

54 output_folder_name: Annotated[ 

55 str, 

56 typer.Option( 

57 "--output-folder-name", 

58 "-o", 

59 help="Name of the output folder.", 

60 ), 

61 ] = "rendercv_output", 

62 latex_path: Annotated[ 

63 Optional[str], 

64 typer.Option( 

65 "--latex-path", 

66 "-latex", 

67 help="Copy the LaTeX file to the given path.", 

68 ), 

69 ] = None, 

70 pdf_path: Annotated[ 

71 Optional[str], 

72 typer.Option( 

73 "--pdf-path", 

74 "-pdf", 

75 help="Copy the PDF file to the given path.", 

76 ), 

77 ] = None, 

78 markdown_path: Annotated[ 

79 Optional[str], 

80 typer.Option( 

81 "--markdown-path", 

82 "-md", 

83 help="Copy the Markdown file to the given path.", 

84 ), 

85 ] = None, 

86 html_path: Annotated[ 

87 Optional[str], 

88 typer.Option( 

89 "--html-path", 

90 "-html", 

91 help="Copy the HTML file to the given path.", 

92 ), 

93 ] = None, 

94 png_path: Annotated[ 

95 Optional[str], 

96 typer.Option( 

97 "--png-path", 

98 "-png", 

99 help="Copy the PNG file to the given path.", 

100 ), 

101 ] = None, 

102 dont_generate_markdown: Annotated[ 

103 bool, 

104 typer.Option( 

105 "--dont-generate-markdown", 

106 "-nomd", 

107 help="Don't generate the Markdown and HTML file.", 

108 ), 

109 ] = False, 

110 dont_generate_html: Annotated[ 

111 bool, 

112 typer.Option( 

113 "--dont-generate-html", 

114 "-nohtml", 

115 help="Don't generate the HTML file.", 

116 ), 

117 ] = False, 

118 dont_generate_png: Annotated[ 

119 bool, 

120 typer.Option( 

121 "--dont-generate-png", 

122 "-nopng", 

123 help="Don't generate the PNG file.", 

124 ), 

125 ] = False, 

126 # This is a dummy argument for the help message for 

127 # extra_data_model_override_argumets: 

128 _: Annotated[ 

129 Optional[str], 

130 typer.Option( 

131 "--YAMLLOCATION", 

132 help="Overrides the value of YAMLLOCATION. For example," 

133 ' [cyan bold]--cv.phone "123-456-7890"[/cyan bold].', 

134 ), 

135 ] = None, 

136 extra_data_model_override_argumets: typer.Context = None, # type: ignore 

137): 

138 """Render a CV from a YAML input file.""" 

139 printer.welcome() 

140 

141 input_file_path: pathlib.Path = pathlib.Path(input_file_name).absolute() 

142 original_working_directory = pathlib.Path.cwd() 

143 

144 input_file_as_a_dict = data.read_a_yaml_file(input_file_path) 

145 

146 # Update the input file if there are extra override arguments (for example, 

147 # --cv.phone "123-456-7890"): 

148 if extra_data_model_override_argumets: 

149 key_and_values = utilities.parse_render_command_override_arguments( 

150 extra_data_model_override_argumets 

151 ) 

152 input_file_as_a_dict = utilities.set_or_update_values( 

153 input_file_as_a_dict, key_and_values 

154 ) 

155 

156 # If non-default CLI arguments are provided, override the `rendercv_settings.render_command`: 

157 cli_render_arguments = { 

158 "use_local_latex_command": use_local_latex_command, 

159 "output_folder_name": output_folder_name, 

160 "latex_path": latex_path, 

161 "pdf_path": pdf_path, 

162 "markdown_path": markdown_path, 

163 "html_path": html_path, 

164 "png_path": png_path, 

165 "dont_generate_png": dont_generate_png, 

166 "dont_generate_markdown": dont_generate_markdown, 

167 "dont_generate_html": dont_generate_html, 

168 } 

169 input_file_as_a_dict = utilities.update_render_command_settings_of_the_input_file( 

170 input_file_as_a_dict, cli_render_arguments 

171 ) 

172 render_command_settings_dict = input_file_as_a_dict["rendercv_settings"][ 

173 "render_command" 

174 ] 

175 

176 # Compute the number of steps 

177 # 1. Validate the input file. 

178 # 2. Create the LaTeX file. 

179 # 3. Render PDF from LaTeX. 

180 # 4. Render PNGs from PDF. 

181 # 5. Create the Markdown file. 

182 # 6. Render HTML from Markdown. 

183 number_of_steps = 6 

184 if render_command_settings_dict["dont_generate_png"]: 

185 number_of_steps -= 1 

186 

187 if render_command_settings_dict["dont_generate_markdown"]: 

188 number_of_steps -= 2 

189 else: 

190 if render_command_settings_dict["dont_generate_html"]: 

191 number_of_steps -= 1 

192 

193 with printer.LiveProgressReporter(number_of_steps=number_of_steps) as progress: 

194 progress.start_a_step("Validating the input file") 

195 

196 data_model = data.validate_input_dictionary_and_return_the_data_model( 

197 input_file_as_a_dict 

198 ) 

199 

200 render_command_settings: data.models.RenderCommandSettings = ( 

201 data_model.rendercv_settings.render_command # type: ignore 

202 ) # type: ignore 

203 output_directory = ( 

204 original_working_directory / render_command_settings.output_folder_name # type: ignore 

205 ) 

206 

207 progress.finish_the_current_step() 

208 

209 # Change the current working directory to the input file's directory (because 

210 # the template overrides are looked up in the current working directory). The 

211 # output files will be in the original working directory. 

212 os.chdir(input_file_path.parent) 

213 

214 progress.start_a_step("Generating the LaTeX file") 

215 

216 latex_file_path_in_output_folder = ( 

217 renderer.create_a_latex_file_and_copy_theme_files( 

218 data_model, output_directory 

219 ) 

220 ) 

221 if render_command_settings.latex_path: 

222 utilities.copy_files( 

223 latex_file_path_in_output_folder, 

224 render_command_settings.latex_path, 

225 ) 

226 

227 progress.finish_the_current_step() 

228 

229 progress.start_a_step("Rendering the LaTeX file to a PDF") 

230 

231 pdf_file_path_in_output_folder = renderer.render_a_pdf_from_latex( 

232 latex_file_path_in_output_folder, 

233 render_command_settings.use_local_latex_command, 

234 ) 

235 if render_command_settings.pdf_path: 

236 utilities.copy_files( 

237 pdf_file_path_in_output_folder, 

238 render_command_settings.pdf_path, 

239 ) 

240 

241 progress.finish_the_current_step() 

242 

243 if not render_command_settings.dont_generate_png: 

244 progress.start_a_step("Rendering PNG files from the PDF") 

245 

246 png_file_paths_in_output_folder = renderer.render_pngs_from_pdf( 

247 pdf_file_path_in_output_folder 

248 ) 

249 if render_command_settings.png_path: 

250 utilities.copy_files( 

251 png_file_paths_in_output_folder, 

252 render_command_settings.png_path, 

253 ) 

254 

255 progress.finish_the_current_step() 

256 

257 if not render_command_settings.dont_generate_markdown: 

258 progress.start_a_step("Generating the Markdown file") 

259 

260 markdown_file_path_in_output_folder = renderer.create_a_markdown_file( 

261 data_model, output_directory 

262 ) 

263 if render_command_settings.markdown_path: 

264 utilities.copy_files( 

265 markdown_file_path_in_output_folder, 

266 render_command_settings.markdown_path, 

267 ) 

268 

269 progress.finish_the_current_step() 

270 

271 if not render_command_settings.dont_generate_html: 

272 progress.start_a_step( 

273 "Rendering the Markdown file to a HTML (for Grammarly)" 

274 ) 

275 

276 html_file_path_in_output_folder = renderer.render_an_html_from_markdown( 

277 markdown_file_path_in_output_folder 

278 ) 

279 if render_command_settings.html_path: 

280 utilities.copy_files( 

281 html_file_path_in_output_folder, 

282 render_command_settings.html_path, 

283 ) 

284 

285 progress.finish_the_current_step() 

286 

287 

288@app.command( 

289 name="new", 

290 help=( 

291 "Generate a YAML input file to get started. Example: [yellow]rendercv new" 

292 ' "John Doe"[/yellow]. Details: [cyan]rendercv new --help[/cyan]' 

293 ), 

294) 

295def cli_command_new( 

296 full_name: Annotated[str, typer.Argument(help="Your full name.")], 

297 theme: Annotated[ 

298 str, 

299 typer.Option( 

300 help=( 

301 "The name of the theme. Available themes are:" 

302 f" {', '.join(data.available_themes)}." 

303 ) 

304 ), 

305 ] = "classic", 

306 dont_create_theme_source_files: Annotated[ 

307 bool, 

308 typer.Option( 

309 "--dont-create-theme-source-files", 

310 "-nolatex", 

311 help="Don't create theme source files.", 

312 ), 

313 ] = False, 

314 dont_create_markdown_source_files: Annotated[ 

315 bool, 

316 typer.Option( 

317 "--dont-create-markdown-source-files", 

318 "-nomd", 

319 help="Don't create the Markdown source files.", 

320 ), 

321 ] = False, 

322): 

323 """Generate a YAML input file and the LaTeX and Markdown source files.""" 

324 created_files_and_folders = [] 

325 

326 input_file_name = f"{full_name.replace(' ', '_')}_CV.yaml" 

327 input_file_path = pathlib.Path(input_file_name) 

328 

329 if input_file_path.exists(): 

330 printer.warning( 

331 f'The input file "{input_file_name}" already exists! A new input file is' 

332 " not created." 

333 ) 

334 else: 

335 try: 

336 data.create_a_sample_yaml_input_file( 

337 input_file_path, name=full_name, theme=theme 

338 ) 

339 created_files_and_folders.append(input_file_path.name) 

340 except ValueError as e: 

341 # if the theme is not in the available themes, then raise an error 

342 printer.error(exception=e) 

343 

344 if not dont_create_theme_source_files: 

345 # copy the package's theme files to the current directory 

346 theme_folder = utilities.copy_templates(theme, pathlib.Path.cwd()) 

347 if theme_folder is not None: 

348 created_files_and_folders.append(theme_folder.name) 

349 else: 

350 printer.warning( 

351 f'The theme folder "{theme}" already exists! The theme files are not' 

352 " created." 

353 ) 

354 

355 if not dont_create_markdown_source_files: 

356 # copy the package's markdown files to the current directory 

357 markdown_folder = utilities.copy_templates("markdown", pathlib.Path.cwd()) 

358 if markdown_folder is not None: 

359 created_files_and_folders.append(markdown_folder.name) 

360 else: 

361 printer.warning( 

362 'The "markdown" folder already exists! The Markdown files are not' 

363 " created." 

364 ) 

365 

366 if len(created_files_and_folders) > 0: 

367 created_files_and_folders_string = ",\n".join(created_files_and_folders) 

368 printer.information( 

369 "The following RenderCV input file and folders have been" 

370 f" created:\n{created_files_and_folders_string}" 

371 ) 

372 

373 

374@app.command( 

375 name="create-theme", 

376 help=( 

377 "Create a custom theme folder based on an existing theme. Example:" 

378 " [yellow]rendercv create-theme customtheme[/yellow]. Details: [cyan]rendercv" 

379 " create-theme --help[/cyan]" 

380 ), 

381) 

382def cli_command_create_theme( 

383 theme_name: Annotated[ 

384 str, 

385 typer.Argument(help="The name of the new theme."), 

386 ], 

387 based_on: Annotated[ 

388 str, 

389 typer.Option( 

390 help=( 

391 "The name of the existing theme to base the new theme on. Available" 

392 f" themes are: {', '.join(data.available_themes)}." 

393 ) 

394 ), 

395 ] = "classic", 

396): 

397 """Create a custom theme based on an existing theme.""" 

398 if based_on not in data.available_themes: 

399 printer.error( 

400 f'The theme "{based_on}" is not in the list of available themes:' 

401 f' {", ".join(data.available_themes)}' 

402 ) 

403 

404 theme_folder = utilities.copy_templates( 

405 based_on, pathlib.Path.cwd(), new_folder_name=theme_name 

406 ) 

407 

408 if theme_folder is None: 

409 printer.warning( 

410 f'The theme folder "{theme_name}" already exists! The theme files are not' 

411 " created." 

412 ) 

413 return 

414 

415 based_on_theme_directory = ( 

416 pathlib.Path(__file__).parent.parent / "themes" / based_on 

417 ) 

418 based_on_theme_init_file = based_on_theme_directory / "__init__.py" 

419 based_on_theme_init_file_contents = based_on_theme_init_file.read_text() 

420 

421 # generate the new init file: 

422 class_name = f"{theme_name.capitalize()}ThemeOptions" 

423 literal_name = f'Literal["{theme_name}"]' 

424 new_init_file_contents = based_on_theme_init_file_contents.replace( 

425 f'Literal["{based_on}"]', literal_name 

426 ).replace(f"{based_on.capitalize()}ThemeOptions", class_name) 

427 

428 # create the new __init__.py file: 

429 (theme_folder / "__init__.py").write_text(new_init_file_contents) 

430 

431 printer.information(f'The theme folder "{theme_folder.name}" has been created.') 

432 

433 

434@app.callback() 

435def cli_command_no_args( 

436 version_requested: Annotated[ 

437 Optional[bool], typer.Option("--version", "-v", help="Show the version.") 

438 ] = None, 

439): 

440 if version_requested: 

441 there_is_a_new_version = printer.warn_if_new_version_is_available() 

442 if not there_is_a_new_version: 

443 print(f"RenderCV v{__version__}")