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,7 @@
1
+ """Runtime package namespace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from simple_resume.shell.runtime import lazy
6
+
7
+ __all__ = ["lazy"]
@@ -0,0 +1,190 @@
1
+ """Imperative helpers for loading and hydrating resume content."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from simple_resume.core.config import normalize_config
11
+ from simple_resume.core.exceptions import FileSystemError
12
+ from simple_resume.core.hydration import hydrate_resume_structure
13
+ from simple_resume.core.importers.json_resume import (
14
+ json_resume_to_simple_resume,
15
+ looks_like_json_resume,
16
+ )
17
+ from simple_resume.core.markdown import render_markdown_content
18
+ from simple_resume.core.paths import Paths
19
+ from simple_resume.shell.io_utils import (
20
+ candidate_yaml_path,
21
+ find_resume_file,
22
+ normalize_resume_name,
23
+ read_yaml_file,
24
+ resolve_paths_for_read,
25
+ )
26
+ from simple_resume.shell.palettes.fetch import execute_palette_fetch
27
+ from simple_resume.shell.palettes.loader import get_palette_registry
28
+ from simple_resume.shell.themes import resolve_theme_in_data
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ def _normalize_with_palette(
34
+ config: dict[str, Any],
35
+ filename: str = "",
36
+ ) -> tuple[dict[str, Any], dict[str, Any] | None]:
37
+ """Normalize configuration with palette fetching enabled (shell I/O)."""
38
+ # Get registry from shell layer (singleton with I/O)
39
+ registry = get_palette_registry()
40
+
41
+ return normalize_config(
42
+ config,
43
+ filename=filename,
44
+ registry=registry,
45
+ palette_fetcher=execute_palette_fetch,
46
+ )
47
+
48
+
49
+ def load_resume_yaml(
50
+ name: str | Path = "",
51
+ *,
52
+ paths: Paths | None = None,
53
+ ) -> tuple[dict[str, Any], str, Paths]:
54
+ """Read resume YAML content and return payload, filename, and paths."""
55
+ candidate_path: Path | None = None
56
+ if isinstance(name, (str, Path)):
57
+ candidate_path = candidate_yaml_path(name)
58
+
59
+ overrides: dict[str, Any] = {}
60
+ if candidate_path is not None:
61
+ if not candidate_path.exists():
62
+ raise FileSystemError(
63
+ f"Resume file not found: {candidate_path}",
64
+ path=str(candidate_path),
65
+ operation="read",
66
+ )
67
+ resolved_paths = resolve_paths_for_read(paths, overrides, candidate_path)
68
+ yaml_content = read_yaml_file(candidate_path)
69
+ return yaml_content, candidate_path.name, resolved_paths
70
+
71
+ resume_name = normalize_resume_name(name)
72
+ resolved_paths = resolve_paths_for_read(paths, overrides, None)
73
+ input_path = resolved_paths.input
74
+
75
+ source_path = find_resume_file(resume_name, input_path)
76
+ yaml_content = read_yaml_file(source_path)
77
+ return yaml_content, source_path.name, resolved_paths
78
+
79
+
80
+ def hydrate_resume_data(
81
+ source_yaml: dict[str, Any],
82
+ *,
83
+ filename: str = "",
84
+ transform_markdown: bool = True,
85
+ ) -> dict[str, Any]:
86
+ """Return normalized resume data using pure core helpers.
87
+
88
+ If the source YAML contains a 'theme' key, the theme is loaded
89
+ and merged with the config before processing.
90
+ """
91
+ # Resolve theme references before hydration
92
+ resolved_data = resolve_theme_in_data(source_yaml)
93
+
94
+ if looks_like_json_resume(resolved_data):
95
+ resolved_data = json_resume_to_simple_resume(resolved_data)
96
+
97
+ return hydrate_resume_structure(
98
+ resolved_data,
99
+ filename=filename,
100
+ transform_markdown=transform_markdown,
101
+ normalize_config_fn=_normalize_with_palette,
102
+ render_markdown_fn=render_markdown_content,
103
+ )
104
+
105
+
106
+ def get_content(
107
+ name: str = "",
108
+ *,
109
+ paths: Paths | None = None,
110
+ transform_markdown: bool = True,
111
+ ) -> dict[str, Any]:
112
+ """Load, hydrate, and optionally transform a resume payload.
113
+
114
+ Theme references are automatically resolved before processing.
115
+ """
116
+ raw_data, filename, _ = load_resume_yaml(name, paths=paths)
117
+
118
+ # Resolve theme references before hydration
119
+ resolved_data = resolve_theme_in_data(raw_data)
120
+
121
+ if looks_like_json_resume(resolved_data):
122
+ resolved_data = json_resume_to_simple_resume(resolved_data)
123
+
124
+ return hydrate_resume_structure(
125
+ resolved_data,
126
+ filename=filename,
127
+ transform_markdown=transform_markdown,
128
+ normalize_config_fn=_normalize_with_palette,
129
+ render_markdown_fn=render_markdown_content,
130
+ )
131
+
132
+
133
+ def load_palette_from_file(palette_file: str | Path) -> dict[str, Any]:
134
+ """Load palette configuration from a YAML file."""
135
+ path = Path(palette_file)
136
+ if not path.exists():
137
+ raise FileNotFoundError(f"Palette file not found: {path}")
138
+ if path.suffix.lower() not in {".yaml", ".yml"}:
139
+ raise ValueError("Palette file must be a YAML file")
140
+
141
+ # Check for missing trailing newline (common YAML parsing issue)
142
+ with path.open("rb") as f:
143
+ f.seek(0, 2) # Seek to end
144
+ if f.tell() > 0: # File is not empty
145
+ f.seek(-1, 2) # Seek to last byte
146
+ last_byte = f.read(1)
147
+ if last_byte != b"\n":
148
+ logger.warning(
149
+ "Palette file '%s' is missing a trailing newline. "
150
+ "This may cause YAML parsing issues. "
151
+ "Consider adding a newline at the end of the file.",
152
+ path.name,
153
+ )
154
+
155
+ content = read_yaml_file(path)
156
+ palette_data: Any = content.get("palette", content)
157
+
158
+ if isinstance(palette_data, dict) and "config" in palette_data:
159
+ config_block = palette_data["config"]
160
+ if isinstance(config_block, dict):
161
+ nested_palette = config_block.get("palette")
162
+ if isinstance(nested_palette, dict):
163
+ palette_data = nested_palette
164
+ else:
165
+ palette_data = config_block
166
+
167
+ if not isinstance(palette_data, dict):
168
+ raise ValueError("Palette configuration must be a dictionary")
169
+
170
+ return {"palette": copy.deepcopy(palette_data)}
171
+
172
+
173
+ def apply_external_palette(
174
+ config: dict[str, Any],
175
+ palette_file: str | Path,
176
+ ) -> dict[str, Any]:
177
+ """Return a new configuration with palette data applied."""
178
+ palette_payload = load_palette_from_file(palette_file)
179
+ updated = copy.deepcopy(config)
180
+ updated["palette"] = palette_payload["palette"]
181
+ return updated
182
+
183
+
184
+ __all__ = [
185
+ "apply_external_palette",
186
+ "get_content",
187
+ "hydrate_resume_data",
188
+ "load_palette_from_file",
189
+ "load_resume_yaml",
190
+ ]
@@ -0,0 +1,497 @@
1
+ """Unified generation helpers that orchestrate shell operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass, field, replace
7
+ from pathlib import Path
8
+ from typing import Any, Callable, TypeVar, cast
9
+
10
+ import simple_resume.shell.session as session_mod
11
+ from simple_resume.core.constants import OutputFormat
12
+ from simple_resume.core.exceptions import (
13
+ ConfigurationError,
14
+ FileSystemError,
15
+ ValidationError,
16
+ )
17
+ from simple_resume.core.generate.exceptions import (
18
+ GenerationError,
19
+ )
20
+ from simple_resume.core.generate.plan import (
21
+ CommandType,
22
+ GeneratePlanOptions,
23
+ GenerationCommand,
24
+ build_generation_plan,
25
+ )
26
+ from simple_resume.core.models import GenerationConfig
27
+ from simple_resume.core.paths import Paths
28
+ from simple_resume.core.result import (
29
+ BatchGenerationResult,
30
+ GenerationResult,
31
+ )
32
+ from simple_resume.core.validation import validate_directory_path
33
+ from simple_resume.shell.generate import core as generate_core
34
+
35
+ T = TypeVar("T")
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class GenerateOptions:
40
+ """Configuration for convenience helpers like `generate` and `preview`."""
41
+
42
+ formats: tuple[OutputFormat | str, ...] = (OutputFormat.PDF,)
43
+ preview: bool = False
44
+ template: str | None = None
45
+ browser: str | None = None
46
+ open_after: bool = False
47
+ overrides: dict[str, Any] = field(default_factory=dict)
48
+
49
+
50
+ def generate_pdf(
51
+ config: GenerationConfig,
52
+ **overrides: Any,
53
+ ) -> GenerationResult | BatchGenerationResult:
54
+ """Generate PDF output for one or more resumes."""
55
+
56
+ def _runner(
57
+ session: session_mod.ResumeSession,
58
+ ) -> GenerationResult | BatchGenerationResult:
59
+ if config.name:
60
+ resume = session.resume(config.name)
61
+ if overrides:
62
+ resume = resume.with_config(**overrides)
63
+ output_path = config.output_path if config.output_path is not None else None
64
+ return generate_core.to_pdf(
65
+ resume, output_path=output_path, open_after=config.open_after
66
+ )
67
+ return session.generate_all(
68
+ format=OutputFormat.PDF,
69
+ pattern=config.pattern,
70
+ open_after=config.open_after,
71
+ **overrides,
72
+ )
73
+
74
+ return _run_with_session(
75
+ config,
76
+ overrides=overrides,
77
+ default_format=OutputFormat.PDF,
78
+ runner=_runner,
79
+ )
80
+
81
+
82
+ def generate_html(
83
+ config: GenerationConfig,
84
+ **overrides: Any,
85
+ ) -> GenerationResult | BatchGenerationResult:
86
+ """Generate HTML output for one or more resumes."""
87
+
88
+ def _runner(
89
+ session: session_mod.ResumeSession,
90
+ ) -> GenerationResult | BatchGenerationResult:
91
+ if config.name:
92
+ resume = session.resume(config.name)
93
+ if overrides:
94
+ resume = resume.with_config(**overrides)
95
+ output_path = config.output_path if config.output_path is not None else None
96
+ return generate_core.to_html(
97
+ resume,
98
+ output_path=output_path,
99
+ open_after=config.open_after,
100
+ browser=config.browser,
101
+ )
102
+ return session.generate_all(
103
+ format=OutputFormat.HTML,
104
+ pattern=config.pattern,
105
+ open_after=config.open_after,
106
+ browser=config.browser,
107
+ **overrides,
108
+ )
109
+
110
+ return _run_with_session(
111
+ config,
112
+ overrides=overrides,
113
+ default_format=OutputFormat.HTML,
114
+ runner=_runner,
115
+ )
116
+
117
+
118
+ def generate_markdown(
119
+ config: GenerationConfig,
120
+ **overrides: Any,
121
+ ) -> GenerationResult | BatchGenerationResult:
122
+ """Generate intermediate markdown output for one or more resumes."""
123
+
124
+ def _runner(
125
+ session: session_mod.ResumeSession,
126
+ ) -> GenerationResult | BatchGenerationResult:
127
+ if config.name:
128
+ resume = session.resume(config.name)
129
+ if overrides:
130
+ resume = resume.with_config(**overrides)
131
+ output_path = config.output_path if config.output_path is not None else None
132
+ return generate_core.to_markdown(
133
+ resume,
134
+ output_path=output_path,
135
+ )
136
+ return session.generate_all(
137
+ format=OutputFormat.MARKDOWN,
138
+ pattern=config.pattern,
139
+ open_after=False, # Intermediate formats don't auto-open
140
+ **overrides,
141
+ )
142
+
143
+ return _run_with_session(
144
+ config,
145
+ overrides=overrides,
146
+ default_format=OutputFormat.MARKDOWN,
147
+ runner=_runner,
148
+ )
149
+
150
+
151
+ def generate_tex(
152
+ config: GenerationConfig,
153
+ **overrides: Any,
154
+ ) -> GenerationResult | BatchGenerationResult:
155
+ """Generate intermediate LaTeX (.tex) output for one or more resumes."""
156
+
157
+ def _runner(
158
+ session: session_mod.ResumeSession,
159
+ ) -> GenerationResult | BatchGenerationResult:
160
+ if config.name:
161
+ resume = session.resume(config.name)
162
+ if overrides:
163
+ resume = resume.with_config(**overrides)
164
+ output_path = config.output_path if config.output_path is not None else None
165
+ return generate_core.to_tex(
166
+ resume,
167
+ output_path=output_path,
168
+ )
169
+ return session.generate_all(
170
+ format=OutputFormat.TEX,
171
+ pattern=config.pattern,
172
+ open_after=False, # Intermediate formats don't auto-open
173
+ **overrides,
174
+ )
175
+
176
+ return _run_with_session(
177
+ config,
178
+ overrides=overrides,
179
+ default_format=OutputFormat.TEX,
180
+ runner=_runner,
181
+ )
182
+
183
+
184
+ def generate_all(
185
+ config: GenerationConfig,
186
+ **overrides: Any,
187
+ ) -> dict[str, BatchGenerationResult | GenerationResult]:
188
+ """Generate multiple formats, returning a mapping of format -> result."""
189
+ requested_formats = config.formats or (OutputFormat.PDF,)
190
+ normalized_formats = _normalize_formats(requested_formats)
191
+ if not normalized_formats:
192
+ raise ValueError("Unsupported format configuration - no formats provided")
193
+
194
+ def _runner(
195
+ session: session_mod.ResumeSession,
196
+ ) -> dict[str, BatchGenerationResult | GenerationResult]:
197
+ results: dict[str, BatchGenerationResult | GenerationResult] = {}
198
+
199
+ if config.name:
200
+ resume = session.resume(config.name)
201
+ if overrides:
202
+ resume = resume.with_config(**overrides)
203
+ for fmt in normalized_formats:
204
+ if fmt is OutputFormat.PDF:
205
+ results[fmt.value] = generate_core.to_pdf(
206
+ resume,
207
+ output_path=config.output_path,
208
+ open_after=config.open_after,
209
+ )
210
+ elif fmt is OutputFormat.HTML:
211
+ results[fmt.value] = generate_core.to_html(
212
+ resume,
213
+ output_path=config.output_path,
214
+ open_after=config.open_after,
215
+ browser=config.browser,
216
+ )
217
+ elif fmt is OutputFormat.MARKDOWN:
218
+ results[fmt.value] = generate_core.to_markdown(
219
+ resume,
220
+ output_path=config.output_path,
221
+ )
222
+ elif fmt is OutputFormat.TEX:
223
+ results[fmt.value] = generate_core.to_tex(
224
+ resume,
225
+ output_path=config.output_path,
226
+ )
227
+ else:
228
+ raise ValueError(f"Unsupported format: {fmt}")
229
+ return results
230
+
231
+ for fmt in normalized_formats:
232
+ results[fmt.value] = session.generate_all(
233
+ format=fmt,
234
+ pattern=config.pattern,
235
+ open_after=config.open_after,
236
+ browser=config.browser if fmt is OutputFormat.HTML else None,
237
+ **overrides,
238
+ )
239
+ return results
240
+
241
+ return _run_with_session(
242
+ config,
243
+ overrides=overrides,
244
+ default_format=normalized_formats[0],
245
+ runner=_runner,
246
+ )
247
+
248
+
249
+ def generate_resume(
250
+ config: GenerationConfig,
251
+ **overrides: Any,
252
+ ) -> GenerationResult | BatchGenerationResult | dict[str, Any]:
253
+ """Generate resumes according to the normalized plan output."""
254
+ format_value = config.format or OutputFormat.PDF
255
+ normalized_format = _normalize_format(format_value)
256
+ output_path = Path(config.output_path) if config.output_path is not None else None
257
+
258
+ plan_options = GeneratePlanOptions(
259
+ name=config.name,
260
+ data_dir=Path(config.data_dir)
261
+ if isinstance(config.data_dir, str)
262
+ else config.data_dir,
263
+ template=config.template,
264
+ output_path=output_path,
265
+ output_dir=Path(config.output_dir)
266
+ if isinstance(config.output_dir, str)
267
+ else config.output_dir,
268
+ preview=config.preview,
269
+ open_after=config.open_after,
270
+ browser=config.browser,
271
+ formats=[normalized_format],
272
+ overrides=overrides,
273
+ paths=config.paths,
274
+ pattern=config.pattern,
275
+ )
276
+ commands = build_generation_plan(plan_options)
277
+ executions = execute_generation_commands(commands)
278
+ return (
279
+ cast(
280
+ GenerationResult | BatchGenerationResult | dict[str, Any], executions[0][1]
281
+ )
282
+ if executions
283
+ else {}
284
+ )
285
+
286
+
287
+ def generate(
288
+ source: str | Path,
289
+ options: GenerateOptions | None = None,
290
+ ) -> dict[str, GenerationResult | BatchGenerationResult]:
291
+ """High-level convenience wrapper similar to requests-style helpers."""
292
+ options = options or GenerateOptions()
293
+ source_path = Path(source)
294
+ formats = _normalize_formats(options.formats)
295
+
296
+ if not formats:
297
+ raise ValueError("GenerateOptions.formats must include at least one format")
298
+
299
+ overrides = dict(options.overrides)
300
+
301
+ if source_path.is_file():
302
+ config = GenerationConfig(
303
+ name=source_path.stem,
304
+ data_dir=source_path.parent,
305
+ template=options.template,
306
+ preview=options.preview,
307
+ open_after=options.open_after,
308
+ browser=options.browser,
309
+ )
310
+ else:
311
+ config = GenerationConfig(
312
+ data_dir=source_path,
313
+ template=options.template,
314
+ preview=options.preview,
315
+ open_after=options.open_after,
316
+ browser=options.browser,
317
+ )
318
+
319
+ if len(formats) == 1:
320
+ fmt = formats[0]
321
+ if fmt is OutputFormat.PDF:
322
+ return {"pdf": generate_pdf(config, **overrides)}
323
+ if fmt is OutputFormat.HTML:
324
+ return {"html": generate_html(config, **overrides)}
325
+ if fmt is OutputFormat.MARKDOWN:
326
+ return {"markdown": generate_markdown(config, **overrides)}
327
+ if fmt is OutputFormat.TEX:
328
+ return {"tex": generate_tex(config, **overrides)}
329
+ raise ValueError(f"Unsupported format: {fmt}")
330
+
331
+ # Create new config with formats
332
+ # (GenerationConfig is frozen, so create new instance)
333
+ updated_config = replace(config, formats=list(formats))
334
+ return generate_all(updated_config, **overrides)
335
+
336
+
337
+ def preview(
338
+ source: str | Path, **overrides: Any
339
+ ) -> GenerationResult | BatchGenerationResult:
340
+ """Render a single resume to HTML in preview mode."""
341
+ source_path = Path(source)
342
+ if not source_path.is_file():
343
+ raise ValueError("preview requires a specific resume file path")
344
+
345
+ config = GenerationConfig(
346
+ name=source_path.stem,
347
+ data_dir=source_path.parent,
348
+ preview=True,
349
+ browser=overrides.pop("browser", None),
350
+ )
351
+ return generate_html(config, **overrides)
352
+
353
+
354
+ def execute_generation_commands(
355
+ commands: Sequence[GenerationCommand],
356
+ ) -> list[tuple[GenerationCommand, object]]:
357
+ """Execute normalized generation commands."""
358
+ results: list[tuple[GenerationCommand, object]] = []
359
+ for command in commands:
360
+ overrides = command.overrides or {}
361
+ if command.kind in (CommandType.SINGLE, CommandType.BATCH_SINGLE):
362
+ format_type = command.format
363
+ if format_type is None:
364
+ raise ValueError("Missing format for generation command")
365
+ executor = _FORMAT_EXECUTORS.get(format_type)
366
+ if executor is None:
367
+ raise ValueError(f"Unsupported format: {format_type}")
368
+ result = executor(command.config, **overrides)
369
+ results.append((command, result))
370
+ continue
371
+
372
+ if command.kind is CommandType.BATCH_ALL:
373
+ result = generate_all(command.config, **overrides)
374
+ results.append((command, result))
375
+ continue
376
+
377
+ raise ValueError(f"Unsupported command type: {command.kind}")
378
+ return results
379
+
380
+
381
+ def _run_with_session(
382
+ config: GenerationConfig,
383
+ *,
384
+ overrides: dict[str, Any],
385
+ default_format: OutputFormat,
386
+ runner: Callable[[session_mod.ResumeSession], T],
387
+ ) -> T:
388
+ """Execute a shell operation inside a managed ResumeSession."""
389
+ session_config = _build_session_config(config, overrides, default_format)
390
+ data_dir = _resolve_data_dir(config)
391
+
392
+ try:
393
+ with session_mod.ResumeSession(
394
+ data_dir=data_dir,
395
+ paths=config.paths,
396
+ config=session_config,
397
+ ) as session:
398
+ return runner(session)
399
+ except (ValidationError, ConfigurationError, FileSystemError, GenerationError):
400
+ raise
401
+ except Exception as exc: # pragma: no cover - defensive
402
+ label = default_format.value.upper()
403
+ raise GenerationError(
404
+ f"Failed to generate {label}s: {exc}",
405
+ format_type=default_format.value,
406
+ ) from exc
407
+
408
+
409
+ def _build_session_config(
410
+ config: GenerationConfig,
411
+ overrides: dict[str, Any],
412
+ default_format: OutputFormat,
413
+ ) -> session_mod.SessionConfig:
414
+ """Return a SessionConfig derived from the generation config."""
415
+ session_metadata = dict(overrides)
416
+ return session_mod.SessionConfig(
417
+ paths=config.paths if isinstance(config.paths, Paths) else None,
418
+ default_template=config.template,
419
+ default_format=default_format,
420
+ auto_open=config.open_after,
421
+ preview_mode=config.preview,
422
+ output_dir=Path(config.output_dir) if config.output_dir else None,
423
+ session_metadata=session_metadata,
424
+ )
425
+
426
+
427
+ def _resolve_data_dir(config: GenerationConfig) -> Path | None:
428
+ """Return a validated data directory if provided."""
429
+ if config.paths is not None:
430
+ return None
431
+ if not config.data_dir:
432
+ return None
433
+ return validate_directory_path(
434
+ config.data_dir, must_exist=False, create_if_missing=False
435
+ )
436
+
437
+
438
+ def _normalize_formats(
439
+ formats: Sequence[OutputFormat | str] | None,
440
+ ) -> list[OutputFormat]:
441
+ """Normalize a sequence of format strings or enums to `OutputFormat` enum values.
442
+
443
+ Args:
444
+ formats: A sequence of format strings (e.g., "pdf", "html") or
445
+ `OutputFormat` enums.
446
+
447
+ Returns:
448
+ A list of normalized `OutputFormat` enum values.
449
+
450
+ """
451
+ if not formats:
452
+ return []
453
+ return [_normalize_format(value) for value in formats]
454
+
455
+
456
+ def _normalize_format(value: OutputFormat | str) -> OutputFormat:
457
+ """Normalize a single format string or enum to an `OutputFormat` enum value.
458
+
459
+ Args:
460
+ value: The format string (e.g., "pdf", "html") or `OutputFormat` enum.
461
+
462
+ Returns:
463
+ A normalized `OutputFormat` enum value.
464
+
465
+ Raises:
466
+ ValueError: If the format is unsupported.
467
+
468
+ """
469
+ if isinstance(value, OutputFormat):
470
+ return value
471
+ normalized = value.strip().lower()
472
+ try:
473
+ return OutputFormat(normalized)
474
+ except ValueError as exc: # pragma: no cover - defensive
475
+ raise ValueError(f"Unsupported format: {value}") from exc
476
+
477
+
478
+ _FORMAT_EXECUTORS: dict[OutputFormat, Callable[..., object]] = {
479
+ OutputFormat.PDF: generate_pdf,
480
+ OutputFormat.HTML: generate_html,
481
+ OutputFormat.MARKDOWN: generate_markdown,
482
+ OutputFormat.TEX: generate_tex,
483
+ }
484
+
485
+
486
+ __all__ = [
487
+ "GenerateOptions",
488
+ "execute_generation_commands",
489
+ "generate",
490
+ "generate_all",
491
+ "generate_html",
492
+ "generate_markdown",
493
+ "generate_pdf",
494
+ "generate_resume",
495
+ "generate_tex",
496
+ "preview",
497
+ ]