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.
- simple_resume/__init__.py +132 -0
- simple_resume/core/__init__.py +47 -0
- simple_resume/core/colors.py +215 -0
- simple_resume/core/config.py +672 -0
- simple_resume/core/constants/__init__.py +207 -0
- simple_resume/core/constants/colors.py +98 -0
- simple_resume/core/constants/files.py +28 -0
- simple_resume/core/constants/layout.py +58 -0
- simple_resume/core/dependencies.py +258 -0
- simple_resume/core/effects.py +154 -0
- simple_resume/core/exceptions.py +261 -0
- simple_resume/core/file_operations.py +68 -0
- simple_resume/core/generate/__init__.py +21 -0
- simple_resume/core/generate/exceptions.py +69 -0
- simple_resume/core/generate/html.py +233 -0
- simple_resume/core/generate/pdf.py +659 -0
- simple_resume/core/generate/plan.py +131 -0
- simple_resume/core/hydration.py +55 -0
- simple_resume/core/importers/__init__.py +3 -0
- simple_resume/core/importers/json_resume.py +284 -0
- simple_resume/core/latex/__init__.py +60 -0
- simple_resume/core/latex/context.py +56 -0
- simple_resume/core/latex/conversion.py +227 -0
- simple_resume/core/latex/escaping.py +68 -0
- simple_resume/core/latex/fonts.py +93 -0
- simple_resume/core/latex/formatting.py +81 -0
- simple_resume/core/latex/sections.py +218 -0
- simple_resume/core/latex/types.py +84 -0
- simple_resume/core/markdown.py +127 -0
- simple_resume/core/models.py +102 -0
- simple_resume/core/palettes/__init__.py +38 -0
- simple_resume/core/palettes/common.py +73 -0
- simple_resume/core/palettes/data/default_palettes.json +58 -0
- simple_resume/core/palettes/exceptions.py +33 -0
- simple_resume/core/palettes/fetch_types.py +52 -0
- simple_resume/core/palettes/generators.py +137 -0
- simple_resume/core/palettes/registry.py +76 -0
- simple_resume/core/palettes/resolution.py +123 -0
- simple_resume/core/palettes/sources.py +162 -0
- simple_resume/core/paths.py +21 -0
- simple_resume/core/protocols.py +134 -0
- simple_resume/core/py.typed +0 -0
- simple_resume/core/render/__init__.py +37 -0
- simple_resume/core/render/manage.py +199 -0
- simple_resume/core/render/plan.py +405 -0
- simple_resume/core/result.py +226 -0
- simple_resume/core/resume.py +609 -0
- simple_resume/core/skills.py +60 -0
- simple_resume/core/validation.py +321 -0
- simple_resume/py.typed +0 -0
- simple_resume/shell/__init__.py +3 -0
- simple_resume/shell/assets/static/css/README.md +213 -0
- simple_resume/shell/assets/static/css/common.css +641 -0
- simple_resume/shell/assets/static/css/fonts.css +42 -0
- simple_resume/shell/assets/static/css/preview.css +82 -0
- simple_resume/shell/assets/static/css/print.css +99 -0
- simple_resume/shell/assets/static/fonts/AvenirLTStd-Book.otf +0 -0
- simple_resume/shell/assets/static/fonts/AvenirLTStd-Light.otf +0 -0
- simple_resume/shell/assets/static/fonts/AvenirLTStd-Medium.otf +0 -0
- simple_resume/shell/assets/static/fonts/AvenirLTStd-Oblique.otf +0 -0
- simple_resume/shell/assets/static/fonts/AvenirLTStd-Roman.otf +0 -0
- simple_resume/shell/assets/static/fonts/fontawesome/Font Awesome 6 Brands-Regular-400.otf +0 -0
- simple_resume/shell/assets/static/fonts/fontawesome/Font Awesome 6 Free-Solid-900.otf +0 -0
- simple_resume/shell/assets/static/images/default_profile_1.jpg +0 -0
- simple_resume/shell/assets/static/images/default_profile_2.png +0 -0
- simple_resume/shell/assets/static/schema.json +236 -0
- simple_resume/shell/assets/static/themes/README.md +208 -0
- simple_resume/shell/assets/static/themes/bold.yaml +64 -0
- simple_resume/shell/assets/static/themes/classic.yaml +64 -0
- simple_resume/shell/assets/static/themes/executive.yaml +64 -0
- simple_resume/shell/assets/static/themes/minimal.yaml +64 -0
- simple_resume/shell/assets/static/themes/modern.yaml +64 -0
- simple_resume/shell/assets/templates/html/cover.html +129 -0
- simple_resume/shell/assets/templates/html/demo.html +13 -0
- simple_resume/shell/assets/templates/html/resume_base.html +453 -0
- simple_resume/shell/assets/templates/html/resume_no_bars.html +316 -0
- simple_resume/shell/assets/templates/html/resume_with_bars.html +362 -0
- simple_resume/shell/cli/__init__.py +35 -0
- simple_resume/shell/cli/main.py +975 -0
- simple_resume/shell/cli/palette.py +75 -0
- simple_resume/shell/cli/random_palette_demo.py +407 -0
- simple_resume/shell/config.py +96 -0
- simple_resume/shell/effect_executor.py +211 -0
- simple_resume/shell/file_opener.py +308 -0
- simple_resume/shell/generate/__init__.py +37 -0
- simple_resume/shell/generate/core.py +650 -0
- simple_resume/shell/generate/lazy.py +284 -0
- simple_resume/shell/io_utils.py +199 -0
- simple_resume/shell/palettes/__init__.py +1 -0
- simple_resume/shell/palettes/fetch.py +63 -0
- simple_resume/shell/palettes/loader.py +321 -0
- simple_resume/shell/palettes/remote.py +179 -0
- simple_resume/shell/pdf_executor.py +52 -0
- simple_resume/shell/py.typed +0 -0
- simple_resume/shell/render/__init__.py +1 -0
- simple_resume/shell/render/latex.py +308 -0
- simple_resume/shell/render/operations.py +240 -0
- simple_resume/shell/resume_extensions.py +737 -0
- simple_resume/shell/runtime/__init__.py +7 -0
- simple_resume/shell/runtime/content.py +190 -0
- simple_resume/shell/runtime/generate.py +497 -0
- simple_resume/shell/runtime/lazy.py +138 -0
- simple_resume/shell/runtime/lazy_import.py +173 -0
- simple_resume/shell/service_locator.py +80 -0
- simple_resume/shell/services.py +256 -0
- simple_resume/shell/session/__init__.py +6 -0
- simple_resume/shell/session/config.py +35 -0
- simple_resume/shell/session/manage.py +386 -0
- simple_resume/shell/strategies.py +181 -0
- simple_resume/shell/themes/__init__.py +35 -0
- simple_resume/shell/themes/loader.py +230 -0
- simple_resume-0.1.9.dist-info/METADATA +201 -0
- simple_resume-0.1.9.dist-info/RECORD +116 -0
- simple_resume-0.1.9.dist-info/WHEEL +4 -0
- simple_resume-0.1.9.dist-info/entry_points.txt +5 -0
- 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())
|