simple-resume 0.1.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. simple_resume/__init__.py +132 -0
  2. simple_resume/core/__init__.py +47 -0
  3. simple_resume/core/colors.py +215 -0
  4. simple_resume/core/config.py +672 -0
  5. simple_resume/core/constants/__init__.py +207 -0
  6. simple_resume/core/constants/colors.py +98 -0
  7. simple_resume/core/constants/files.py +28 -0
  8. simple_resume/core/constants/layout.py +58 -0
  9. simple_resume/core/dependencies.py +258 -0
  10. simple_resume/core/effects.py +154 -0
  11. simple_resume/core/exceptions.py +261 -0
  12. simple_resume/core/file_operations.py +68 -0
  13. simple_resume/core/generate/__init__.py +21 -0
  14. simple_resume/core/generate/exceptions.py +69 -0
  15. simple_resume/core/generate/html.py +233 -0
  16. simple_resume/core/generate/pdf.py +659 -0
  17. simple_resume/core/generate/plan.py +131 -0
  18. simple_resume/core/hydration.py +55 -0
  19. simple_resume/core/importers/__init__.py +3 -0
  20. simple_resume/core/importers/json_resume.py +284 -0
  21. simple_resume/core/latex/__init__.py +60 -0
  22. simple_resume/core/latex/context.py +56 -0
  23. simple_resume/core/latex/conversion.py +227 -0
  24. simple_resume/core/latex/escaping.py +68 -0
  25. simple_resume/core/latex/fonts.py +93 -0
  26. simple_resume/core/latex/formatting.py +81 -0
  27. simple_resume/core/latex/sections.py +218 -0
  28. simple_resume/core/latex/types.py +84 -0
  29. simple_resume/core/markdown.py +127 -0
  30. simple_resume/core/models.py +102 -0
  31. simple_resume/core/palettes/__init__.py +38 -0
  32. simple_resume/core/palettes/common.py +73 -0
  33. simple_resume/core/palettes/data/default_palettes.json +58 -0
  34. simple_resume/core/palettes/exceptions.py +33 -0
  35. simple_resume/core/palettes/fetch_types.py +52 -0
  36. simple_resume/core/palettes/generators.py +137 -0
  37. simple_resume/core/palettes/registry.py +76 -0
  38. simple_resume/core/palettes/resolution.py +123 -0
  39. simple_resume/core/palettes/sources.py +162 -0
  40. simple_resume/core/paths.py +21 -0
  41. simple_resume/core/protocols.py +134 -0
  42. simple_resume/core/py.typed +0 -0
  43. simple_resume/core/render/__init__.py +37 -0
  44. simple_resume/core/render/manage.py +199 -0
  45. simple_resume/core/render/plan.py +405 -0
  46. simple_resume/core/result.py +226 -0
  47. simple_resume/core/resume.py +609 -0
  48. simple_resume/core/skills.py +60 -0
  49. simple_resume/core/validation.py +321 -0
  50. simple_resume/py.typed +0 -0
  51. simple_resume/shell/__init__.py +3 -0
  52. simple_resume/shell/assets/static/css/README.md +213 -0
  53. simple_resume/shell/assets/static/css/common.css +641 -0
  54. simple_resume/shell/assets/static/css/fonts.css +42 -0
  55. simple_resume/shell/assets/static/css/preview.css +82 -0
  56. simple_resume/shell/assets/static/css/print.css +99 -0
  57. simple_resume/shell/assets/static/fonts/AvenirLTStd-Book.otf +0 -0
  58. simple_resume/shell/assets/static/fonts/AvenirLTStd-Light.otf +0 -0
  59. simple_resume/shell/assets/static/fonts/AvenirLTStd-Medium.otf +0 -0
  60. simple_resume/shell/assets/static/fonts/AvenirLTStd-Oblique.otf +0 -0
  61. simple_resume/shell/assets/static/fonts/AvenirLTStd-Roman.otf +0 -0
  62. simple_resume/shell/assets/static/fonts/fontawesome/Font Awesome 6 Brands-Regular-400.otf +0 -0
  63. simple_resume/shell/assets/static/fonts/fontawesome/Font Awesome 6 Free-Solid-900.otf +0 -0
  64. simple_resume/shell/assets/static/images/default_profile_1.jpg +0 -0
  65. simple_resume/shell/assets/static/images/default_profile_2.png +0 -0
  66. simple_resume/shell/assets/static/schema.json +236 -0
  67. simple_resume/shell/assets/static/themes/README.md +208 -0
  68. simple_resume/shell/assets/static/themes/bold.yaml +64 -0
  69. simple_resume/shell/assets/static/themes/classic.yaml +64 -0
  70. simple_resume/shell/assets/static/themes/executive.yaml +64 -0
  71. simple_resume/shell/assets/static/themes/minimal.yaml +64 -0
  72. simple_resume/shell/assets/static/themes/modern.yaml +64 -0
  73. simple_resume/shell/assets/templates/html/cover.html +129 -0
  74. simple_resume/shell/assets/templates/html/demo.html +13 -0
  75. simple_resume/shell/assets/templates/html/resume_base.html +453 -0
  76. simple_resume/shell/assets/templates/html/resume_no_bars.html +316 -0
  77. simple_resume/shell/assets/templates/html/resume_with_bars.html +362 -0
  78. simple_resume/shell/cli/__init__.py +35 -0
  79. simple_resume/shell/cli/main.py +975 -0
  80. simple_resume/shell/cli/palette.py +75 -0
  81. simple_resume/shell/cli/random_palette_demo.py +407 -0
  82. simple_resume/shell/config.py +96 -0
  83. simple_resume/shell/effect_executor.py +211 -0
  84. simple_resume/shell/file_opener.py +308 -0
  85. simple_resume/shell/generate/__init__.py +37 -0
  86. simple_resume/shell/generate/core.py +650 -0
  87. simple_resume/shell/generate/lazy.py +284 -0
  88. simple_resume/shell/io_utils.py +199 -0
  89. simple_resume/shell/palettes/__init__.py +1 -0
  90. simple_resume/shell/palettes/fetch.py +63 -0
  91. simple_resume/shell/palettes/loader.py +321 -0
  92. simple_resume/shell/palettes/remote.py +179 -0
  93. simple_resume/shell/pdf_executor.py +52 -0
  94. simple_resume/shell/py.typed +0 -0
  95. simple_resume/shell/render/__init__.py +1 -0
  96. simple_resume/shell/render/latex.py +308 -0
  97. simple_resume/shell/render/operations.py +240 -0
  98. simple_resume/shell/resume_extensions.py +737 -0
  99. simple_resume/shell/runtime/__init__.py +7 -0
  100. simple_resume/shell/runtime/content.py +190 -0
  101. simple_resume/shell/runtime/generate.py +497 -0
  102. simple_resume/shell/runtime/lazy.py +138 -0
  103. simple_resume/shell/runtime/lazy_import.py +173 -0
  104. simple_resume/shell/service_locator.py +80 -0
  105. simple_resume/shell/services.py +256 -0
  106. simple_resume/shell/session/__init__.py +6 -0
  107. simple_resume/shell/session/config.py +35 -0
  108. simple_resume/shell/session/manage.py +386 -0
  109. simple_resume/shell/strategies.py +181 -0
  110. simple_resume/shell/themes/__init__.py +35 -0
  111. simple_resume/shell/themes/loader.py +230 -0
  112. simple_resume-0.1.9.dist-info/METADATA +201 -0
  113. simple_resume-0.1.9.dist-info/RECORD +116 -0
  114. simple_resume-0.1.9.dist-info/WHEEL +4 -0
  115. simple_resume-0.1.9.dist-info/entry_points.txt +5 -0
  116. simple_resume-0.1.9.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,975 @@
1
+ """Provide a command-line interface for simple-resume, backed by the generation API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import sys
8
+ from collections.abc import Callable, Iterable
9
+ from os import PathLike
10
+ from pathlib import Path
11
+ from typing import Any, Protocol, cast
12
+
13
+ from simple_resume import __version__
14
+ from simple_resume.core.constants import OutputFormat
15
+ from simple_resume.core.exceptions import SimpleResumeError, ValidationError
16
+ from simple_resume.core.generate.exceptions import GenerationError
17
+ from simple_resume.core.generate.plan import (
18
+ CommandType,
19
+ GeneratePlanOptions,
20
+ GenerationCommand,
21
+ build_generation_plan,
22
+ )
23
+ from simple_resume.core.result import BatchGenerationResult, GenerationResult
24
+ from simple_resume.core.resume import Resume
25
+ from simple_resume.shell.config import resolve_paths
26
+ from simple_resume.shell.resume_extensions import (
27
+ render_markdown_file,
28
+ render_tex_file,
29
+ to_html,
30
+ to_markdown,
31
+ to_pdf,
32
+ to_tex,
33
+ )
34
+ from simple_resume.shell.runtime.generate import execute_generation_commands
35
+ from simple_resume.shell.services import register_default_services
36
+ from simple_resume.shell.session import ResumeSession, SessionConfig
37
+
38
+
39
+ class GenerationResultProtocol(Protocol):
40
+ """A protocol for objects representing generation results."""
41
+
42
+ @property
43
+ def exists(self) -> bool:
44
+ """Check if the generated output exists and is valid."""
45
+ ...
46
+
47
+
48
+ def _handle_unexpected_error(exc: Exception, context: str) -> int:
49
+ """Handle unexpected exceptions with proper logging and classification.
50
+
51
+ Args:
52
+ exc: The unexpected exception.
53
+ context: Context where the error occurred (e.g., "generation", "validation").
54
+
55
+ Returns:
56
+ Appropriate exit code.
57
+
58
+ """
59
+ logger = logging.getLogger(__name__)
60
+
61
+ # Classify the error type for better user experience.
62
+ if isinstance(exc, (PermissionError, OSError)):
63
+ error_type = "File System Error"
64
+ exit_code = 2
65
+ suggestion = "Check file permissions and disk space"
66
+ elif isinstance(exc, (KeyError, AttributeError, TypeError)):
67
+ error_type = "Internal Error"
68
+ exit_code = 3
69
+ suggestion = "This may be a bug - please report it"
70
+ elif isinstance(exc, MemoryError):
71
+ error_type = "Resource Error"
72
+ exit_code = 4
73
+ suggestion = "System ran out of memory"
74
+ elif isinstance(exc, (ValueError, IndexError)):
75
+ error_type = "Input Error"
76
+ exit_code = 5
77
+ suggestion = "Check your input files and parameters"
78
+ else:
79
+ error_type = "Unexpected Error"
80
+ exit_code = 1
81
+ suggestion = "Check logs for details"
82
+
83
+ # Log the full error for debugging.
84
+ logger.error(
85
+ f"{error_type} in {context}: {exc}",
86
+ exc_info=True,
87
+ extra={
88
+ "error_type": error_type,
89
+ "context": context,
90
+ "exception_type": type(exc).__name__,
91
+ },
92
+ )
93
+
94
+ # Show user-friendly message.
95
+ print(f"{error_type}: {exc}")
96
+ if suggestion:
97
+ print(f"Suggestion: {suggestion}")
98
+
99
+ return exit_code
100
+
101
+
102
+ def main() -> int:
103
+ """Run the CLI entry point."""
104
+ # Register default services for CLI operations
105
+ register_default_services()
106
+
107
+ parser = create_parser()
108
+ try:
109
+ args = parser.parse_args()
110
+ except KeyboardInterrupt:
111
+ print("\nOperation cancelled by user.")
112
+ return 130
113
+
114
+ handlers = {
115
+ "generate": handle_generate_command,
116
+ "session": handle_session_command,
117
+ "validate": handle_validate_command,
118
+ }
119
+
120
+ try:
121
+ command = getattr(args, "command", "")
122
+ handler = handlers.get(command)
123
+ if handler is None:
124
+ print(f"Error: Unknown command {command}")
125
+ parser.print_help()
126
+ return 1
127
+ return handler(args)
128
+ except KeyboardInterrupt:
129
+ print("\nOperation cancelled by user.")
130
+ return 130
131
+ except Exception as exc: # pragma: no cover - safety net
132
+ return _handle_unexpected_error(exc, "main command execution")
133
+
134
+
135
+ MIN_GENERATE_ARGS = 2
136
+
137
+
138
+ def create_parser() -> argparse.ArgumentParser:
139
+ """Create and return the CLI argument parser."""
140
+ parser = argparse.ArgumentParser(
141
+ prog="simple-resume",
142
+ description="Generate professional resumes from YAML data",
143
+ )
144
+ parser.add_argument(
145
+ "--version",
146
+ action="version",
147
+ version=f"simple-resume {__version__}",
148
+ )
149
+
150
+ subparsers = parser.add_subparsers(dest="command", required=True)
151
+
152
+ # generate subcommand
153
+ generate_parser = subparsers.add_parser(
154
+ "generate",
155
+ help="Generate resume(s) in the chosen format(s)",
156
+ )
157
+ generate_parser.add_argument(
158
+ "name",
159
+ nargs="?",
160
+ help="Resume name when generating a specific file",
161
+ )
162
+ generate_parser.add_argument(
163
+ "--format",
164
+ "-f",
165
+ choices=["pdf", "html", "markdown", "tex"],
166
+ default="markdown",
167
+ help="Output format (default: markdown). Use markdown/tex for intermediate "
168
+ "files that can be edited before final render.",
169
+ )
170
+ generate_parser.add_argument(
171
+ "--formats",
172
+ nargs="+",
173
+ choices=["pdf", "html", "markdown", "tex"],
174
+ help="Generate in multiple formats (only valid when name is supplied)",
175
+ )
176
+ generate_parser.add_argument(
177
+ "--output-mode",
178
+ "-m",
179
+ choices=["markdown", "tex"],
180
+ help="Intermediate format: markdown (for HTML) or tex (for PDF). "
181
+ "Overrides the output_mode in config file.",
182
+ )
183
+ generate_parser.add_argument(
184
+ "--no-render",
185
+ action="store_true",
186
+ help="Only generate intermediate files (markdown/tex) without "
187
+ "rendering to final PDF/HTML output.",
188
+ )
189
+ generate_parser.add_argument(
190
+ "--render-file",
191
+ type=Path,
192
+ metavar="FILE",
193
+ help="Render an existing .md or .tex file to PDF/HTML instead of "
194
+ "processing YAML input.",
195
+ )
196
+ generate_parser.add_argument(
197
+ "--template",
198
+ "-t",
199
+ help="Template name to apply",
200
+ )
201
+ generate_parser.add_argument(
202
+ "--output",
203
+ "-o",
204
+ type=Path,
205
+ help="Destination file or directory",
206
+ )
207
+ generate_parser.add_argument(
208
+ "--data-dir",
209
+ "-d",
210
+ type=Path,
211
+ help="Directory containing resume input files",
212
+ )
213
+ generate_parser.add_argument(
214
+ "--open",
215
+ action="store_true",
216
+ help="Open generated files after completion",
217
+ )
218
+ generate_parser.add_argument(
219
+ "--preview",
220
+ action="store_true",
221
+ help="Enable preview mode",
222
+ )
223
+ generate_parser.add_argument(
224
+ "--browser",
225
+ help="Browser command for opening HTML output",
226
+ )
227
+ generate_parser.add_argument("--theme-color", help="Override theme color (hex)")
228
+ generate_parser.add_argument("--palette", help="Palette name or YAML file path")
229
+ generate_parser.add_argument(
230
+ "--page-width",
231
+ type=int,
232
+ help="Page width in millimetres",
233
+ )
234
+ generate_parser.add_argument(
235
+ "--page-height",
236
+ type=int,
237
+ help="Page height in millimetres",
238
+ )
239
+
240
+ # session subcommand
241
+ session_parser = subparsers.add_parser(
242
+ "session",
243
+ help="Interactive session for batch operations",
244
+ )
245
+ session_parser.add_argument(
246
+ "--data-dir",
247
+ "-d",
248
+ type=Path,
249
+ help="Directory containing resume input files",
250
+ )
251
+ session_parser.add_argument(
252
+ "--template",
253
+ "-t",
254
+ help="Default template applied during the session",
255
+ )
256
+ session_parser.add_argument(
257
+ "--preview",
258
+ action="store_true",
259
+ help="Toggle preview mode for the session",
260
+ )
261
+
262
+ # validate subcommand
263
+ validate_parser = subparsers.add_parser(
264
+ "validate",
265
+ help="Validate resume data without generating output",
266
+ )
267
+ validate_parser.add_argument(
268
+ "name",
269
+ nargs="?",
270
+ help="Optional resume name (omit to validate all files)",
271
+ )
272
+ validate_parser.add_argument(
273
+ "--data-dir",
274
+ "-d",
275
+ type=Path,
276
+ help="Directory containing resume input files",
277
+ )
278
+
279
+ return parser
280
+
281
+
282
+ def handle_generate_command(args: argparse.Namespace) -> int:
283
+ """Handle the generate subcommand using generation helpers."""
284
+ # Handle --render-file separately (renders existing .md/.tex file)
285
+ render_file = getattr(args, "render_file", None)
286
+ if render_file is not None:
287
+ return _handle_render_file(args, render_file)
288
+
289
+ overrides = _build_config_overrides(args)
290
+ try:
291
+ formats = _resolve_cli_formats(args)
292
+ plan_options = _build_plan_options(args, overrides, formats)
293
+ commands = build_generation_plan(plan_options)
294
+ return _execute_generation_plan(commands)
295
+ except KeyboardInterrupt:
296
+ print("\nOperation cancelled by user.")
297
+ return 130
298
+ except SimpleResumeError as exc:
299
+ print(f"Error: {exc}")
300
+ return 1
301
+ except Exception as exc: # pragma: no cover - safety net
302
+ return _handle_unexpected_error(exc, "resume generation")
303
+
304
+
305
+ def _handle_render_file(args: argparse.Namespace, render_file: Path) -> int: # noqa: PLR0911
306
+ """Render an existing .md or .tex file to PDF/HTML.
307
+
308
+ Args:
309
+ args: The parsed command-line arguments.
310
+ render_file: Path to the .md or .tex file to render.
311
+
312
+ Returns:
313
+ Exit code (0 for success, non-zero for failure).
314
+
315
+ """
316
+ if not render_file.exists():
317
+ print(f"Error: File not found: {render_file}")
318
+ return 1
319
+
320
+ suffix = render_file.suffix.lower()
321
+ output_value = _to_path_or_none(getattr(args, "output", None))
322
+ open_after = _bool_flag(getattr(args, "open", False))
323
+
324
+ try:
325
+ if suffix == ".md":
326
+ # Render markdown to HTML
327
+ output_path = output_value or render_file.with_suffix(".html")
328
+ result = render_markdown_file(
329
+ render_file,
330
+ output_path=output_path,
331
+ open_after=open_after,
332
+ )
333
+ if result.exists:
334
+ print(f"HTML generated: {result.output_path}")
335
+ return 0
336
+ print("Failed to generate HTML")
337
+ return 1
338
+
339
+ if suffix == ".tex":
340
+ # Render LaTeX to PDF
341
+ output_path = output_value or render_file.with_suffix(".pdf")
342
+ result = render_tex_file(
343
+ render_file,
344
+ output_path=output_path,
345
+ open_after=open_after,
346
+ )
347
+ if result.exists:
348
+ print(f"PDF generated: {result.output_path}")
349
+ return 0
350
+ print("Failed to generate PDF")
351
+ return 1
352
+
353
+ print(f"Error: Unsupported file type: {suffix}")
354
+ print("Use .md for markdown or .tex for LaTeX files")
355
+ return 1
356
+
357
+ except SimpleResumeError as exc:
358
+ print(f"Error: {exc}")
359
+ return 1
360
+ except Exception as exc: # pragma: no cover - safety net
361
+ return _handle_unexpected_error(exc, "file rendering")
362
+
363
+
364
+ def handle_session_command(args: argparse.Namespace) -> int:
365
+ """Handle the session subcommand using the session API."""
366
+ session_config = SessionConfig(
367
+ default_template=getattr(args, "template", None),
368
+ preview_mode=getattr(args, "preview", False),
369
+ )
370
+ data_dir = _to_path_or_none(getattr(args, "data_dir", None))
371
+
372
+ try:
373
+ with ResumeSession(data_dir=data_dir, config=session_config) as session:
374
+ print("Starting Simple-Resume Session")
375
+ print("=" * 40)
376
+ print(f"Data directory : {session.paths.input}")
377
+ print(f"Output directory: {session.paths.output}")
378
+ print()
379
+
380
+ while True:
381
+ try:
382
+ command = input("simple-resume> ").strip()
383
+ except EOFError:
384
+ print()
385
+ break
386
+
387
+ if not command:
388
+ continue
389
+
390
+ lower = command.lower()
391
+ if lower in {"exit", "quit"}:
392
+ break
393
+ if lower in {"help", "?"}:
394
+ _print_session_help()
395
+ continue
396
+ if lower == "list":
397
+ _session_list_resumes(session)
398
+ continue
399
+ if command.startswith("generate"):
400
+ parts = command.split()
401
+ if len(parts) >= MIN_GENERATE_ARGS:
402
+ resume_name = parts[1]
403
+ _session_generate_resume(
404
+ session,
405
+ resume_name,
406
+ session_config.default_template,
407
+ )
408
+ else:
409
+ print("Usage: generate <resume_name>")
410
+ continue
411
+
412
+ print(f"Unknown command: {command}")
413
+ print("Session ended.")
414
+ return 0
415
+ except KeyboardInterrupt:
416
+ print("\nSession cancelled by user.")
417
+ return 130
418
+ except SimpleResumeError as exc:
419
+ print(f"Session error: {exc}")
420
+ return 1
421
+ except Exception as exc: # pragma: no cover - safety net
422
+ return _handle_unexpected_error(exc, "session management")
423
+
424
+
425
+ def handle_validate_command(args: argparse.Namespace) -> int:
426
+ """Validate one or more resumes without generating output."""
427
+ data_dir = _to_path_or_none(getattr(args, "data_dir", None))
428
+
429
+ try:
430
+ if args.name:
431
+ return _validate_single_resume_cli(args.name, data_dir)
432
+ return _validate_all_resumes_cli(data_dir)
433
+ except SimpleResumeError as exc:
434
+ print(f"Validation error: {exc}")
435
+ return 1
436
+ except Exception as exc: # pragma: no cover - safety net
437
+ return _handle_unexpected_error(exc, "resume validation")
438
+
439
+
440
+ def _resolve_cli_formats(args: argparse.Namespace) -> list[OutputFormat]:
441
+ """Normalize format arguments to `OutputFormat` values with safe defaults.
442
+
443
+ By default, intermediate formats (markdown/tex) are upgraded to their
444
+ final counterparts (html/pdf). When --no-render flag is set, intermediate
445
+ formats are preserved as-is.
446
+ """
447
+ raw_formats = getattr(args, "formats", None)
448
+ no_render_flag = getattr(args, "no_render", False)
449
+ candidates: Iterable[OutputFormat | str | None]
450
+
451
+ if raw_formats:
452
+ candidates = raw_formats
453
+ else:
454
+ candidates = [getattr(args, "format", OutputFormat.MARKDOWN.value)]
455
+
456
+ resolved: list[OutputFormat] = []
457
+ for value in candidates:
458
+ fmt = _coerce_output_format(value)
459
+ # By default, upgrade intermediate formats to final formats
460
+ # Unless --no-render is set, which preserves intermediate formats
461
+ if not no_render_flag:
462
+ if fmt is OutputFormat.MARKDOWN:
463
+ fmt = OutputFormat.HTML
464
+ elif fmt in (OutputFormat.TEX, OutputFormat.LATEX):
465
+ fmt = OutputFormat.PDF
466
+ resolved.append(fmt)
467
+ return resolved
468
+
469
+
470
+ def _coerce_output_format(value: OutputFormat | str | None) -> OutputFormat:
471
+ """Convert CLI-provided format values to `OutputFormat` with helpful errors."""
472
+ if isinstance(value, OutputFormat):
473
+ return value
474
+ if isinstance(value, str):
475
+ try:
476
+ return OutputFormat(value)
477
+ except ValueError as exc:
478
+ raise ValidationError(
479
+ f"{value!r} is not a supported output format",
480
+ context={"format": value},
481
+ ) from exc
482
+ # Argparse guarantees a string, but unit tests often rely on bare mocks.
483
+ # Default to PDF format so patches still exercise the code path.
484
+ return OutputFormat.PDF
485
+
486
+
487
+ def _summarize_batch_result(
488
+ result: GenerationResult | BatchGenerationResult,
489
+ format_type: OutputFormat | str,
490
+ ) -> int:
491
+ """Summarize batch generation results for CLI output.
492
+
493
+ Args:
494
+ result: The batch generation result object.
495
+ format_type: The format type (e.g., PDF, HTML).
496
+
497
+ Returns:
498
+ An exit code (0 for success, 1 for partial failure).
499
+
500
+ """
501
+ label = format_type.value if isinstance(format_type, OutputFormat) else format_type
502
+ if isinstance(result, BatchGenerationResult):
503
+ latex_skips: list[str] = []
504
+ other_failures: list[tuple[str, Exception]] = []
505
+
506
+ for name, error in (result.errors or {}).items():
507
+ if isinstance(error, GenerationError) and "LaTeX" in str(error):
508
+ latex_skips.append(name)
509
+ else:
510
+ other_failures.append((name, error))
511
+
512
+ print(f"{label.upper()} generation summary")
513
+ print(f"Successful: {result.successful}")
514
+ print(f"Failed: {len(other_failures)}")
515
+ if latex_skips:
516
+ print(f"Skipped (LaTeX): {len(latex_skips)}")
517
+ info_icon = "\N{INFORMATION SOURCE}\N{VARIATION SELECTOR-16}"
518
+ templates = ", ".join(sorted(latex_skips))
519
+ print(f"{info_icon} Skipped LaTeX template(s): {templates}")
520
+
521
+ for name, error in other_failures:
522
+ print(f"{name}: {error}")
523
+
524
+ return 0 if not other_failures else 1
525
+
526
+ return 0 if _did_generation_succeed(result) else 1
527
+
528
+
529
+ def _did_generation_succeed(result: GenerationResult) -> bool:
530
+ """Check if generation succeeded.
531
+
532
+ Args:
533
+ result: Generation result with `exists` property.
534
+
535
+ Returns:
536
+ `True` if generation succeeded (output file exists), `False` otherwise.
537
+
538
+ """
539
+ return result.exists
540
+
541
+
542
+ # ---------------------------------------------------------------------------
543
+ # Session helpers
544
+ # ---------------------------------------------------------------------------
545
+
546
+
547
+ def _session_generate_resume(
548
+ session: ResumeSession,
549
+ resume_name: str,
550
+ default_template: str | None = None,
551
+ ) -> None:
552
+ """Generate a single resume within an interactive session.
553
+
554
+ Args:
555
+ session: The active `ResumeSession`.
556
+ resume_name: The name of the resume to generate.
557
+ default_template: Default template to apply if not specified in resume.
558
+
559
+ """
560
+ try:
561
+ resume = session.resume(resume_name)
562
+ except (KeyError, FileNotFoundError, ValueError) as exc:
563
+ # Expected errors when resume doesn't exist or has invalid data.
564
+ print(f"Resume not found: {resume_name} ({exc})")
565
+ return
566
+ except Exception as exc: # pragma: no cover - unexpected error
567
+ logger = logging.getLogger(__name__)
568
+ msg = f"Unexpected error loading resume {resume_name}: {exc}"
569
+ logger.warning(msg, exc_info=True)
570
+ print(f"Resume not found: {resume_name} ({exc})")
571
+ return
572
+
573
+ if default_template:
574
+ resume = resume.with_template(default_template)
575
+
576
+ session_format = getattr(session.config, "default_format", OutputFormat.PDF)
577
+ formats = [_coerce_output_format(session_format)]
578
+ overrides = session.config.session_metadata.get("overrides", {})
579
+ overrides_dict = dict(overrides) if isinstance(overrides, dict) else {}
580
+
581
+ plan_options = GeneratePlanOptions(
582
+ name=resume_name,
583
+ data_dir=session.paths.input,
584
+ template=default_template or session.config.default_template,
585
+ output_path=None,
586
+ output_dir=None,
587
+ preview=session.config.preview_mode,
588
+ open_after=session.config.auto_open,
589
+ browser=session.config.session_metadata.get("browser"),
590
+ formats=formats,
591
+ overrides=overrides_dict,
592
+ )
593
+
594
+ commands = build_generation_plan(plan_options)
595
+ _run_session_generation(resume, session, commands)
596
+
597
+
598
+ def _session_list_resumes(session: ResumeSession) -> None:
599
+ files = list(_iter_yaml_files(session))
600
+ if not files:
601
+ print("No resumes found.")
602
+ return
603
+
604
+ print("Available resumes:")
605
+ for file_path in sorted(files):
606
+ print(f" - {Path(file_path).stem}")
607
+
608
+
609
+ def _iter_yaml_files(session: ResumeSession) -> Iterable[Path]:
610
+ finder: Callable[[], Iterable[Path]] | None = getattr(
611
+ session, "_find_yaml_files", None
612
+ )
613
+ if callable(finder):
614
+ for candidate in finder():
615
+ yield Path(candidate)
616
+ return
617
+
618
+ yield from session.paths.input.glob("*.yaml")
619
+ yield from session.paths.input.glob("*.yml")
620
+ yield from session.paths.input.glob("*.json")
621
+
622
+
623
+ def _print_session_help() -> None:
624
+ print("Available commands:")
625
+ print(" generate <name> Generate resume with the provided name")
626
+ print(" list List available resumes")
627
+ print(" help, ? Show this help message")
628
+ print(" exit, quit Exit the session")
629
+
630
+
631
+ def _run_session_generation(
632
+ resume: Resume, session: ResumeSession, commands: list[GenerationCommand]
633
+ ) -> None:
634
+ """Execute planner commands inside an active `ResumeSession`."""
635
+ output_dir = session.paths.output
636
+ resume_label = getattr(resume, "_name", "resume")
637
+
638
+ for command in commands:
639
+ if command.kind is not CommandType.SINGLE:
640
+ print("Session generate only supports single-resume commands today.")
641
+ continue
642
+
643
+ format_type = command.format or OutputFormat.PDF
644
+ output_path = command.config.output_path
645
+ if output_path is None:
646
+ suffix_map = {
647
+ OutputFormat.PDF: ".pdf",
648
+ OutputFormat.HTML: ".html",
649
+ OutputFormat.MARKDOWN: ".md",
650
+ OutputFormat.TEX: ".tex",
651
+ }
652
+ suffix = suffix_map.get(format_type, ".pdf")
653
+ output_path = output_dir / f"{resume_label}{suffix}"
654
+
655
+ try:
656
+ if format_type is OutputFormat.PDF:
657
+ result = to_pdf(
658
+ resume,
659
+ output_path=output_path,
660
+ open_after=command.config.open_after,
661
+ )
662
+ elif format_type is OutputFormat.HTML:
663
+ result = to_html(
664
+ resume,
665
+ output_path=output_path,
666
+ open_after=command.config.open_after,
667
+ browser=command.config.browser,
668
+ )
669
+ elif format_type is OutputFormat.MARKDOWN:
670
+ result = to_markdown(
671
+ resume,
672
+ output_path=output_path,
673
+ )
674
+ elif format_type is OutputFormat.TEX:
675
+ result = to_tex(
676
+ resume,
677
+ output_path=output_path,
678
+ )
679
+ else:
680
+ print(f"Unsupported format: {format_type}")
681
+ continue
682
+ except SimpleResumeError as exc:
683
+ print(f"Generation error for {resume_label}: {exc}")
684
+ continue
685
+
686
+ # Friendly labels for output messages
687
+ label_map = {
688
+ OutputFormat.PDF: "PDF",
689
+ OutputFormat.HTML: "HTML",
690
+ OutputFormat.MARKDOWN: "Markdown",
691
+ OutputFormat.TEX: "LaTeX",
692
+ }
693
+ label = label_map.get(format_type, format_type.value.upper())
694
+ if _did_generation_succeed(result):
695
+ output_label = getattr(result, "output_path", output_path)
696
+ print(f"{label} generated: {output_label}")
697
+ else:
698
+ print(f"Failed to generate {label}")
699
+
700
+
701
+ # ---------------------------------------------------------------------------
702
+ # Validation helpers
703
+ # ---------------------------------------------------------------------------
704
+
705
+
706
+ def _log_validation_result(name: str, validation: Any) -> bool:
707
+ if validation.is_valid:
708
+ warnings = _normalize_warnings(getattr(validation, "warnings", []))
709
+ if warnings:
710
+ for warning in warnings:
711
+ print(f"Warning - {name}: {warning}")
712
+ else:
713
+ print(f"{name} is valid")
714
+ return True
715
+
716
+ print(f"Error - {name}: {'; '.join(validation.errors)}")
717
+ return False
718
+
719
+
720
+ def _normalize_warnings(warnings: Any) -> list[str]:
721
+ if not warnings:
722
+ return []
723
+ if isinstance(warnings, (list, tuple, set)):
724
+ return [str(warning) for warning in warnings if warning]
725
+ return [str(warnings)]
726
+
727
+
728
+ def _normalize_errors(errors: Any, default: list[str]) -> list[str]:
729
+ """Normalize errors to a list of strings, with a default if empty."""
730
+ if isinstance(errors, (list, tuple, set)):
731
+ return [str(error) for error in errors if error]
732
+ if errors:
733
+ return [str(errors)]
734
+ return default
735
+
736
+
737
+ def _validate_single_resume_cli(name: str, data_dir: Path | None) -> int:
738
+ paths = resolve_paths(data_dir=data_dir) if data_dir else None
739
+ resume = Resume.read_yaml(name, paths=paths)
740
+ try:
741
+ validation = resume.validate_or_raise()
742
+ except ValidationError as exc:
743
+ errors = _normalize_errors([], exc.errors)
744
+ print(f"Error - {name}: {'; '.join(errors)}")
745
+ return 1
746
+
747
+ # Use the validation result from validate_or_raise() - no redundant calls
748
+ warnings = _normalize_warnings(validation.warnings)
749
+ if warnings:
750
+ for warning in warnings:
751
+ print(f"Warning - {name}: {warning}")
752
+ else:
753
+ print(f"{name} is valid")
754
+ return 0
755
+
756
+
757
+ def _validate_all_resumes_cli(data_dir: Path | None) -> int:
758
+ session_config = SessionConfig(default_template=None)
759
+ with ResumeSession(data_dir=data_dir, config=session_config) as session:
760
+ yaml_files = list(_iter_yaml_files(session))
761
+ if not yaml_files:
762
+ print("No resumes found to validate.")
763
+ return 0
764
+
765
+ valid = 0
766
+ for file_path in yaml_files:
767
+ resume_name = Path(file_path).stem
768
+ resume = session.resume(resume_name)
769
+ try:
770
+ validation = resume.validate_or_raise()
771
+ except ValidationError as exc:
772
+ errors = _normalize_errors([], exc.errors)
773
+ print(f"Error - {resume_name}: {'; '.join(errors)}")
774
+ continue
775
+
776
+ # Use the validation result from validate_or_raise() - no redundant calls
777
+ warnings = _normalize_warnings(validation.warnings)
778
+ if warnings:
779
+ for warning in warnings:
780
+ print(f"Warning - {resume_name}: {warning}")
781
+ else:
782
+ print(f"{resume_name} is valid")
783
+ valid += 1
784
+
785
+ print(f"\nValidation complete: {valid}/{len(yaml_files)} resumes are valid")
786
+ return 0 if valid == len(yaml_files) else 1
787
+
788
+
789
+ def _to_path_or_none(value: Any) -> Path | None:
790
+ """Convert value to `Path` or `None`."""
791
+ if value in (None, "", False):
792
+ return None
793
+ if isinstance(value, Path):
794
+ return value
795
+ if isinstance(value, str):
796
+ return Path(value)
797
+ fspath = getattr(value, "__fspath__", None)
798
+ if callable(fspath):
799
+ fspath_result = fspath()
800
+ if isinstance(fspath_result, (str, Path)):
801
+ return Path(fspath_result)
802
+ if isinstance(fspath_result, PathLike):
803
+ return Path(fspath_result)
804
+ return None
805
+
806
+
807
+ def _select_output_path(output: Path | None) -> Path | None:
808
+ if isinstance(output, Path):
809
+ return output if output.is_file() or output.suffix else output
810
+ return None
811
+
812
+
813
+ def _select_output_dir(output: Path | None) -> Path | None:
814
+ if isinstance(output, Path):
815
+ return output if output.is_dir() else output.parent
816
+ return None
817
+
818
+
819
+ def _looks_like_palette_file(palette: str | Path) -> bool:
820
+ """Check if palette argument looks like a YAML palette path."""
821
+ path = Path(palette)
822
+ return path.suffix.lower() in {".yaml", ".yml"}
823
+
824
+
825
+ def _build_config_overrides(args: argparse.Namespace) -> dict[str, Any]:
826
+ """Construct a dictionary of configuration overrides from CLI arguments.
827
+
828
+ Args:
829
+ args: The parsed command-line arguments.
830
+
831
+ Returns:
832
+ A dictionary of configuration overrides.
833
+
834
+ """
835
+ overrides: dict[str, Any] = {}
836
+ theme_color = getattr(args, "theme_color", None)
837
+ palette = getattr(args, "palette", None)
838
+ page_width = getattr(args, "page_width", None)
839
+ page_height = getattr(args, "page_height", None)
840
+ output_mode = getattr(args, "output_mode", None)
841
+
842
+ if isinstance(output_mode, str) and output_mode:
843
+ overrides["output_mode"] = output_mode
844
+
845
+ if isinstance(theme_color, str) and theme_color:
846
+ overrides["theme_color"] = theme_color
847
+
848
+ if isinstance(palette, (str, Path)) and palette:
849
+ if _looks_like_palette_file(palette):
850
+ palette_path = Path(palette)
851
+ if palette_path.is_file():
852
+ overrides["palette_file"] = str(palette_path)
853
+ else:
854
+ print(
855
+ f"Palette file '{palette_path}' not found. "
856
+ "Defaulting to resume or preset colors already configured."
857
+ )
858
+ else:
859
+ overrides["color_scheme"] = str(palette)
860
+
861
+ if isinstance(page_width, (int, float)):
862
+ overrides["page_width"] = page_width
863
+ if isinstance(page_height, (int, float)):
864
+ overrides["page_height"] = page_height
865
+
866
+ return overrides
867
+
868
+
869
+ def _build_plan_options(
870
+ args: argparse.Namespace,
871
+ overrides: dict[str, Any],
872
+ formats: list[OutputFormat],
873
+ ) -> GeneratePlanOptions:
874
+ """Build `GeneratePlanOptions` from CLI arguments and overrides."""
875
+ data_dir = _to_path_or_none(getattr(args, "data_dir", None))
876
+ output_value = _to_path_or_none(getattr(args, "output", None))
877
+
878
+ if getattr(args, "name", None):
879
+ output_path = _select_output_path(output_value)
880
+ output_dir = None
881
+ else:
882
+ output_path = None
883
+ output_dir = _select_output_dir(output_value)
884
+
885
+ return GeneratePlanOptions(
886
+ name=getattr(args, "name", None),
887
+ data_dir=data_dir,
888
+ template=getattr(args, "template", None),
889
+ output_path=output_path,
890
+ output_dir=output_dir,
891
+ preview=_bool_flag(getattr(args, "preview", False)),
892
+ open_after=_bool_flag(getattr(args, "open", False)),
893
+ browser=getattr(args, "browser", None),
894
+ formats=formats,
895
+ overrides=overrides,
896
+ )
897
+
898
+
899
+ def _execute_generation_plan(commands: list[GenerationCommand]) -> int:
900
+ """Execute a list of generation commands and summarize their results for CLI output.
901
+
902
+ Args:
903
+ commands: A list of `GenerationCommand` objects to execute.
904
+
905
+ Returns:
906
+ An exit code (0 for full success, non-zero for any failures).
907
+
908
+ """
909
+ exit_code = 0
910
+ executions = execute_generation_commands(commands)
911
+ for command, result in executions:
912
+ if command.kind is CommandType.SINGLE:
913
+ label = command.format.value.upper() if command.format else "OUTPUT"
914
+ single_result = cast(GenerationResult, result)
915
+ if _did_generation_succeed(single_result):
916
+ output = getattr(result, "output_path", "generated")
917
+ print(f"{label} generated: {output}")
918
+ else:
919
+ print(f"Failed to generate {label}")
920
+ exit_code = max(exit_code, 1)
921
+ continue
922
+
923
+ if command.kind is CommandType.BATCH_SINGLE:
924
+ format_type = command.format
925
+ if format_type is None:
926
+ print("Error: Missing format for batch command")
927
+ exit_code = max(exit_code, 1)
928
+ continue
929
+ batch_payload = cast(GenerationResult | BatchGenerationResult, result)
930
+ result_code = _summarize_batch_result(batch_payload, format_type)
931
+ exit_code = max(exit_code, result_code)
932
+ continue
933
+
934
+ if not isinstance(result, dict):
935
+ print("Error: Batch-all command returned unexpected payload")
936
+ exit_code = max(exit_code, 1)
937
+ continue
938
+
939
+ # Cast to proper type since we know it's a
940
+ # dict[str, BatchGenerationResult | GenerationResult]
941
+ result_dict = cast(dict[str, BatchGenerationResult | GenerationResult], result)
942
+
943
+ plan_code = 0
944
+ for result_format, plan_result in result_dict.items():
945
+ if isinstance(plan_result, BatchGenerationResult):
946
+ batch_code = _summarize_batch_result(plan_result, result_format)
947
+ plan_code = max(plan_code, batch_code)
948
+ elif isinstance(plan_result, GenerationResult) and _did_generation_succeed(
949
+ plan_result
950
+ ):
951
+ output = getattr(plan_result, "output_path", "generated")
952
+ print(f"{result_format.upper()} generated: {output}")
953
+ else:
954
+ print(f"Failed to generate {result_format.upper()}")
955
+ plan_code = 1
956
+ exit_code = max(exit_code, plan_code)
957
+
958
+ return exit_code
959
+
960
+
961
+ def _bool_flag(value: Any) -> bool:
962
+ """Coerce a value to a boolean flag.
963
+
964
+ Args:
965
+ value: The value to coerce.
966
+
967
+ Returns:
968
+ True if the value is truthy, False otherwise.
969
+
970
+ """
971
+ return value if isinstance(value, bool) else False
972
+
973
+
974
+ if __name__ == "__main__": # pragma: no cover
975
+ sys.exit(main())