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,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
|
+
]
|