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,609 @@
|
|
|
1
|
+
"""Provide core resume data transformations as pure functions without side effects.
|
|
2
|
+
|
|
3
|
+
All functions here are pure data transformations that take inputs and return outputs
|
|
4
|
+
without external dependencies or side effects.
|
|
5
|
+
|
|
6
|
+
The core Resume class is a pure data container with:
|
|
7
|
+
- Data access and transformation methods
|
|
8
|
+
- Validation (pure)
|
|
9
|
+
- Method chaining for configuration
|
|
10
|
+
- Render plan preparation (pure data transformation)
|
|
11
|
+
|
|
12
|
+
I/O operations (PDF generation, HTML generation, file opening) are handled by the
|
|
13
|
+
shell layer through functions like `to_pdf()` and `to_html()` in the shell module.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import copy
|
|
19
|
+
from functools import lru_cache
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from simple_resume.core.config import normalize_config
|
|
24
|
+
from simple_resume.core.exceptions import (
|
|
25
|
+
ConfigurationError,
|
|
26
|
+
FileSystemError,
|
|
27
|
+
ValidationError,
|
|
28
|
+
)
|
|
29
|
+
from simple_resume.core.models import RenderPlan, ValidationResult
|
|
30
|
+
from simple_resume.core.paths import Paths
|
|
31
|
+
from simple_resume.core.protocols import (
|
|
32
|
+
ContentLoader,
|
|
33
|
+
PaletteLoader,
|
|
34
|
+
PathResolver,
|
|
35
|
+
)
|
|
36
|
+
from simple_resume.core.render.plan import (
|
|
37
|
+
prepare_render_data,
|
|
38
|
+
validate_resume_config,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Module-level dependency injection container
|
|
43
|
+
class _ResumeDependencyContainer:
|
|
44
|
+
"""Container for Resume module dependencies (avoids global statement)."""
|
|
45
|
+
|
|
46
|
+
content_loader: ContentLoader | None = None
|
|
47
|
+
palette_loader: PaletteLoader | None = None
|
|
48
|
+
palette_registry_provider: Any | None = None
|
|
49
|
+
path_resolver: PathResolver | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@lru_cache(maxsize=1)
|
|
53
|
+
def _get_dependency_container() -> _ResumeDependencyContainer:
|
|
54
|
+
"""Return the lazily created dependency container singleton."""
|
|
55
|
+
return _ResumeDependencyContainer()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def set_default_loaders(
|
|
59
|
+
content_loader: ContentLoader | None = None,
|
|
60
|
+
palette_loader: PaletteLoader | None = None,
|
|
61
|
+
path_resolver: PathResolver | None = None,
|
|
62
|
+
palette_registry_provider: Any | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Set default loaders for Resume operations.
|
|
65
|
+
|
|
66
|
+
This function is called by the shell layer during initialization
|
|
67
|
+
to inject the default implementations. Core code should not call this.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
content_loader: Default content loader implementation.
|
|
71
|
+
palette_loader: Default palette loader implementation.
|
|
72
|
+
path_resolver: Default path resolver implementation.
|
|
73
|
+
palette_registry_provider: Callable returning the palette registry.
|
|
74
|
+
|
|
75
|
+
"""
|
|
76
|
+
deps = _get_dependency_container()
|
|
77
|
+
|
|
78
|
+
if content_loader is not None:
|
|
79
|
+
deps.content_loader = content_loader
|
|
80
|
+
if palette_loader is not None:
|
|
81
|
+
deps.palette_loader = palette_loader
|
|
82
|
+
if path_resolver is not None:
|
|
83
|
+
deps.path_resolver = path_resolver
|
|
84
|
+
if palette_registry_provider is not None:
|
|
85
|
+
deps.palette_registry_provider = palette_registry_provider
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _get_content_loader(injected: ContentLoader | None) -> ContentLoader:
|
|
89
|
+
"""Get content loader, preferring injected over default."""
|
|
90
|
+
deps = _get_dependency_container()
|
|
91
|
+
|
|
92
|
+
if injected is not None:
|
|
93
|
+
return injected
|
|
94
|
+
if deps.content_loader is not None:
|
|
95
|
+
return deps.content_loader
|
|
96
|
+
raise ConfigurationError(
|
|
97
|
+
"No content loader available. "
|
|
98
|
+
"Either inject one or ensure shell layer is initialized."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _get_path_resolver(injected: PathResolver | None) -> PathResolver:
|
|
103
|
+
"""Get path resolver, preferring injected over default."""
|
|
104
|
+
deps = _get_dependency_container()
|
|
105
|
+
|
|
106
|
+
if injected is not None:
|
|
107
|
+
return injected
|
|
108
|
+
if deps.path_resolver is not None:
|
|
109
|
+
return deps.path_resolver
|
|
110
|
+
raise ConfigurationError(
|
|
111
|
+
"No path resolver available. "
|
|
112
|
+
"Either inject one or ensure shell layer is initialized."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _load_palette_from_file(path: str | Path) -> dict[str, Any]:
|
|
117
|
+
"""Load palette from file using the default palette loader."""
|
|
118
|
+
deps = _get_dependency_container()
|
|
119
|
+
|
|
120
|
+
if deps.palette_loader is None:
|
|
121
|
+
raise ConfigurationError(
|
|
122
|
+
"No palette loader available. Ensure shell layer is initialized."
|
|
123
|
+
)
|
|
124
|
+
return deps.palette_loader.load_palette_from_file(path)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _get_palette_registry() -> Any:
|
|
128
|
+
"""Resolve palette registry via injected provider to avoid shell import."""
|
|
129
|
+
deps = _get_dependency_container()
|
|
130
|
+
|
|
131
|
+
if deps.palette_registry_provider is None:
|
|
132
|
+
raise ConfigurationError(
|
|
133
|
+
"No palette registry provider available. Ensure shell layer is initialized."
|
|
134
|
+
)
|
|
135
|
+
return deps.palette_registry_provider()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class Resume:
|
|
139
|
+
"""Core resume data container with pure transformation methods.
|
|
140
|
+
|
|
141
|
+
This class provides a pure functional API for resume data:
|
|
142
|
+
- Factory methods for loading (`read_yaml`, `from_data`)
|
|
143
|
+
- Method chaining for configuration (`with_template`, `with_palette`, `with_config`)
|
|
144
|
+
- Validation methods (`validate`, `validate_or_raise`)
|
|
145
|
+
- Render plan preparation (`prepare_render_plan`)
|
|
146
|
+
|
|
147
|
+
I/O operations (PDF/HTML generation) are handled by the shell layer.
|
|
148
|
+
Use `simple_resume.to_pdf()` and `simple_resume.to_html()` for generation.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(
|
|
152
|
+
self,
|
|
153
|
+
processed_resume_data: dict[str, Any],
|
|
154
|
+
*,
|
|
155
|
+
name: str | None = None,
|
|
156
|
+
paths: Paths | None = None,
|
|
157
|
+
filename: str | None = None,
|
|
158
|
+
source_yaml_data: dict[str, Any] | None = None,
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Initialize a `Resume` instance.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
processed_resume_data: Transformed resume data (markdown rendered,
|
|
164
|
+
normalized).
|
|
165
|
+
name: Optional name identifier.
|
|
166
|
+
paths: Optional resolved paths object.
|
|
167
|
+
filename: Optional source filename for error reporting.
|
|
168
|
+
source_yaml_data: Optional untransformed YAML data before processing.
|
|
169
|
+
|
|
170
|
+
"""
|
|
171
|
+
self._data = copy.deepcopy(processed_resume_data)
|
|
172
|
+
self._raw_data = (
|
|
173
|
+
copy.deepcopy(source_yaml_data)
|
|
174
|
+
if source_yaml_data is not None
|
|
175
|
+
else copy.deepcopy(processed_resume_data)
|
|
176
|
+
)
|
|
177
|
+
self._name = name or processed_resume_data.get("full_name", "resume")
|
|
178
|
+
self._paths = paths
|
|
179
|
+
self._filename = filename
|
|
180
|
+
self._validation_result: ValidationResult | None = None
|
|
181
|
+
self._render_plan: RenderPlan | None = None
|
|
182
|
+
self._is_preview = False
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def name(self) -> str:
|
|
186
|
+
"""Get the resume name."""
|
|
187
|
+
return self._name
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def data(self) -> dict[str, Any]:
|
|
191
|
+
"""Get the processed resume data (read-only copy)."""
|
|
192
|
+
return copy.deepcopy(self._data)
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def raw_data(self) -> dict[str, Any]:
|
|
196
|
+
"""Get the raw resume data before processing (read-only copy)."""
|
|
197
|
+
return copy.deepcopy(self._raw_data)
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def paths(self) -> Paths | None:
|
|
201
|
+
"""Get the resolved paths for this resume."""
|
|
202
|
+
return self._paths
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def filename(self) -> str | None:
|
|
206
|
+
"""Get the source filename for error reporting."""
|
|
207
|
+
return self._filename
|
|
208
|
+
|
|
209
|
+
# Class methods for symmetric I/O patterns (pandas-style).
|
|
210
|
+
|
|
211
|
+
@classmethod
|
|
212
|
+
def read_yaml(
|
|
213
|
+
cls,
|
|
214
|
+
name: str = "",
|
|
215
|
+
*,
|
|
216
|
+
paths: Paths | None = None,
|
|
217
|
+
transform_markdown: bool = True,
|
|
218
|
+
content_loader: ContentLoader | None = None,
|
|
219
|
+
path_resolver: PathResolver | None = None,
|
|
220
|
+
**path_overrides: str | Path,
|
|
221
|
+
) -> Resume:
|
|
222
|
+
"""Load a resume from a YAML file.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
name: Resume identifier without extension.
|
|
226
|
+
paths: Optional pre-resolved paths.
|
|
227
|
+
transform_markdown: Whether to transform markdown to HTML.
|
|
228
|
+
content_loader: Optional custom content loader (for dependency injection).
|
|
229
|
+
path_resolver: Optional custom path resolver (for dependency injection).
|
|
230
|
+
**path_overrides: Path configuration overrides.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
`Resume` instance loaded from YAML file.
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
`FileSystemError`: If file cannot be read.
|
|
237
|
+
`ValidationError`: If resume data is invalid.
|
|
238
|
+
|
|
239
|
+
"""
|
|
240
|
+
try:
|
|
241
|
+
if path_overrides and paths is not None:
|
|
242
|
+
raise ConfigurationError(
|
|
243
|
+
"Provide either paths or path_overrides, not both", filename=name
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Use injected dependencies or get defaults
|
|
247
|
+
loader = _get_content_loader(content_loader)
|
|
248
|
+
resolver = _get_path_resolver(path_resolver)
|
|
249
|
+
|
|
250
|
+
# Resolve paths for determining filename
|
|
251
|
+
overrides = dict(path_overrides)
|
|
252
|
+
candidate_path = resolver.candidate_yaml_path(name)
|
|
253
|
+
resolved_paths = resolver.resolve_paths_for_read(
|
|
254
|
+
paths, overrides, candidate_path
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Load content
|
|
258
|
+
data, raw_data = loader.load(name, resolved_paths, transform_markdown)
|
|
259
|
+
|
|
260
|
+
resume_identifier = (
|
|
261
|
+
candidate_path.stem if candidate_path is not None else str(name)
|
|
262
|
+
)
|
|
263
|
+
filename_label = (
|
|
264
|
+
str(candidate_path) if candidate_path is not None else str(name)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return cls(
|
|
268
|
+
processed_resume_data=data,
|
|
269
|
+
name=resume_identifier,
|
|
270
|
+
paths=resolved_paths,
|
|
271
|
+
filename=filename_label,
|
|
272
|
+
source_yaml_data=raw_data,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
except Exception as exc:
|
|
276
|
+
if isinstance(exc, (ValidationError, ConfigurationError)):
|
|
277
|
+
raise
|
|
278
|
+
raise FileSystemError(
|
|
279
|
+
f"Failed to read resume YAML '{name}': {exc}",
|
|
280
|
+
path=name,
|
|
281
|
+
operation="read",
|
|
282
|
+
) from exc
|
|
283
|
+
|
|
284
|
+
@classmethod
|
|
285
|
+
def from_data(
|
|
286
|
+
cls,
|
|
287
|
+
data: dict[str, Any],
|
|
288
|
+
*,
|
|
289
|
+
name: str | None = None,
|
|
290
|
+
paths: Paths | None = None,
|
|
291
|
+
raw_data: dict[str, Any] | None = None,
|
|
292
|
+
) -> Resume:
|
|
293
|
+
"""Create a `Resume` from dictionary data.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
data: Resume data dictionary.
|
|
297
|
+
name: Optional name identifier.
|
|
298
|
+
paths: Optional resolved paths object.
|
|
299
|
+
raw_data: Optional untransformed resume data.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
`Resume` instance created from data.
|
|
303
|
+
|
|
304
|
+
"""
|
|
305
|
+
return cls(
|
|
306
|
+
processed_resume_data=data,
|
|
307
|
+
name=name,
|
|
308
|
+
paths=paths,
|
|
309
|
+
source_yaml_data=raw_data,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# Method chaining support (fluent interface)
|
|
313
|
+
|
|
314
|
+
def with_template(self, template_name: str) -> Resume:
|
|
315
|
+
"""Return a new `Resume` with a different template.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
template_name: Name of template to use.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
New `Resume` instance with updated template.
|
|
322
|
+
|
|
323
|
+
"""
|
|
324
|
+
new_data = copy.deepcopy(self._data)
|
|
325
|
+
new_raw = (
|
|
326
|
+
copy.deepcopy(self._raw_data)
|
|
327
|
+
if getattr(self, "_raw_data", None) is not None
|
|
328
|
+
else copy.deepcopy(self._data)
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
# Template is stored at root level, not in config (see line 908)
|
|
332
|
+
new_data["template"] = template_name # pytype: disable=container-type-mismatch
|
|
333
|
+
new_raw["template"] = template_name # pytype: disable=container-type-mismatch
|
|
334
|
+
|
|
335
|
+
return Resume(
|
|
336
|
+
processed_resume_data=new_data,
|
|
337
|
+
name=self._name,
|
|
338
|
+
paths=self._paths,
|
|
339
|
+
filename=self._filename,
|
|
340
|
+
source_yaml_data=new_raw,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
def with_theme(self, theme_name: str) -> Resume:
|
|
344
|
+
"""Return a new `Resume` with a theme applied.
|
|
345
|
+
|
|
346
|
+
Themes provide preset configurations (colors, layout, spacing).
|
|
347
|
+
User configuration overrides theme defaults.
|
|
348
|
+
|
|
349
|
+
Available themes: modern, classic, bold, minimal, executive
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
theme_name: Name of the theme to apply.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
New `Resume` instance with theme configuration applied.
|
|
356
|
+
|
|
357
|
+
Example:
|
|
358
|
+
>>> resume = Resume.read_yaml("my_resume").with_theme("modern")
|
|
359
|
+
|
|
360
|
+
"""
|
|
361
|
+
new_data = copy.deepcopy(self._data)
|
|
362
|
+
new_raw = (
|
|
363
|
+
copy.deepcopy(self._raw_data)
|
|
364
|
+
if getattr(self, "_raw_data", None) is not None
|
|
365
|
+
else copy.deepcopy(self._data)
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# Add theme key - will be resolved by shell layer during processing
|
|
369
|
+
new_raw["theme"] = theme_name
|
|
370
|
+
|
|
371
|
+
return Resume(
|
|
372
|
+
processed_resume_data=new_data,
|
|
373
|
+
name=self._name,
|
|
374
|
+
paths=self._paths,
|
|
375
|
+
filename=self._filename,
|
|
376
|
+
source_yaml_data=new_raw,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
def with_palette(self, palette: str | dict[str, Any]) -> Resume:
|
|
380
|
+
"""Return a new `Resume` with a different color palette.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
palette: Either palette name (`str`) or palette configuration `dict`.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
New `Resume` instance with updated palette.
|
|
387
|
+
|
|
388
|
+
"""
|
|
389
|
+
new_data = copy.deepcopy(self._data)
|
|
390
|
+
new_raw = (
|
|
391
|
+
copy.deepcopy(self._raw_data)
|
|
392
|
+
if getattr(self, "_raw_data", None) is not None
|
|
393
|
+
else copy.deepcopy(self._data)
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if isinstance(palette, str):
|
|
397
|
+
# Apply palette by name
|
|
398
|
+
if "config" not in new_data:
|
|
399
|
+
new_data["config"] = {}
|
|
400
|
+
if "config" not in new_raw:
|
|
401
|
+
new_raw["config"] = {}
|
|
402
|
+
new_data["config"]["color_scheme"] = palette
|
|
403
|
+
new_raw["config"]["color_scheme"] = palette
|
|
404
|
+
else:
|
|
405
|
+
# Apply palette configuration
|
|
406
|
+
if "config" not in new_data:
|
|
407
|
+
new_data["config"] = {}
|
|
408
|
+
if "config" not in new_raw:
|
|
409
|
+
new_raw["config"] = {}
|
|
410
|
+
new_data["config"]["palette"] = palette
|
|
411
|
+
new_raw["config"]["palette"] = palette
|
|
412
|
+
|
|
413
|
+
return Resume(
|
|
414
|
+
processed_resume_data=new_data,
|
|
415
|
+
name=self._name,
|
|
416
|
+
paths=self._paths,
|
|
417
|
+
filename=self._filename,
|
|
418
|
+
source_yaml_data=new_raw,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
def with_config(self, **config_overrides: Any) -> Resume:
|
|
422
|
+
"""Return a new `Resume` with configuration changes.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
**config_overrides: Configuration key-value pairs to override.
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
New `Resume` instance with updated configuration.
|
|
429
|
+
|
|
430
|
+
"""
|
|
431
|
+
new_data = copy.deepcopy(self._data)
|
|
432
|
+
new_raw = (
|
|
433
|
+
copy.deepcopy(self._raw_data)
|
|
434
|
+
if getattr(self, "_raw_data", None) is not None
|
|
435
|
+
else copy.deepcopy(self._data)
|
|
436
|
+
)
|
|
437
|
+
if "config" not in new_data:
|
|
438
|
+
new_data["config"] = {}
|
|
439
|
+
if "config" not in new_raw:
|
|
440
|
+
new_raw["config"] = {}
|
|
441
|
+
|
|
442
|
+
overrides = dict(config_overrides)
|
|
443
|
+
palette_file = overrides.pop("palette_file", None)
|
|
444
|
+
|
|
445
|
+
if palette_file is not None:
|
|
446
|
+
try:
|
|
447
|
+
palette_payload = _load_palette_from_file(palette_file)
|
|
448
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
449
|
+
raise ConfigurationError(
|
|
450
|
+
f"Failed to load palette file: {palette_file}",
|
|
451
|
+
filename=self._filename,
|
|
452
|
+
) from exc
|
|
453
|
+
|
|
454
|
+
palette_data = copy.deepcopy(palette_payload["palette"])
|
|
455
|
+
new_data["config"]["palette"] = copy.deepcopy(palette_data)
|
|
456
|
+
new_raw["config"]["palette"] = copy.deepcopy(palette_data)
|
|
457
|
+
|
|
458
|
+
# Apply the palette block to individual color fields
|
|
459
|
+
# Normalize both data structures to apply palette colors
|
|
460
|
+
registry = _get_palette_registry()
|
|
461
|
+
new_data["config"], _ = normalize_config(
|
|
462
|
+
new_data["config"], filename=self._filename or "", registry=registry
|
|
463
|
+
)
|
|
464
|
+
new_raw["config"], _ = normalize_config(
|
|
465
|
+
new_raw["config"], filename=self._filename or "", registry=registry
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
palette_override = overrides.get("palette")
|
|
469
|
+
if isinstance(palette_override, dict):
|
|
470
|
+
overrides["palette"] = copy.deepcopy(palette_override)
|
|
471
|
+
|
|
472
|
+
new_data["config"].update(overrides)
|
|
473
|
+
new_raw["config"].update(overrides)
|
|
474
|
+
|
|
475
|
+
return Resume(
|
|
476
|
+
processed_resume_data=new_data,
|
|
477
|
+
name=self._name,
|
|
478
|
+
paths=self._paths,
|
|
479
|
+
filename=self._filename,
|
|
480
|
+
source_yaml_data=new_raw,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
def preview(self) -> Resume:
|
|
484
|
+
"""Return `Resume` in preview mode.
|
|
485
|
+
|
|
486
|
+
Returns:
|
|
487
|
+
New `Resume` instance configured for preview rendering.
|
|
488
|
+
|
|
489
|
+
"""
|
|
490
|
+
new_resume = Resume(
|
|
491
|
+
processed_resume_data=self._data,
|
|
492
|
+
name=self._name,
|
|
493
|
+
paths=self._paths,
|
|
494
|
+
filename=self._filename,
|
|
495
|
+
source_yaml_data=self._raw_data,
|
|
496
|
+
)
|
|
497
|
+
new_resume._is_preview = True
|
|
498
|
+
return new_resume
|
|
499
|
+
|
|
500
|
+
# Instance methods for validation and rendering
|
|
501
|
+
|
|
502
|
+
def validate(self) -> ValidationResult:
|
|
503
|
+
"""Validate this resume's data (inspection tier - never raises).
|
|
504
|
+
|
|
505
|
+
Return a `ValidationResult` object containing validation status,
|
|
506
|
+
errors, and warnings. Never raise exceptions, allowing inspection
|
|
507
|
+
of validation issues without interrupting execution.
|
|
508
|
+
|
|
509
|
+
Use this to:
|
|
510
|
+
- Check validation status without stopping execution.
|
|
511
|
+
- Log warnings or collect error information.
|
|
512
|
+
- Build custom error handling logic.
|
|
513
|
+
|
|
514
|
+
For fail-fast validation, use `validate_or_raise()` instead.
|
|
515
|
+
|
|
516
|
+
Returns:
|
|
517
|
+
`ValidationResult` with validation status and any errors/warnings.
|
|
518
|
+
|
|
519
|
+
Example:
|
|
520
|
+
>>> result = resume.validate()
|
|
521
|
+
>>> if not result.is_valid:
|
|
522
|
+
>>> print(f"Errors: {result.errors}")
|
|
523
|
+
>>> if result.warnings:
|
|
524
|
+
>>> log.warning(f"Warnings: {result.warnings}")
|
|
525
|
+
|
|
526
|
+
"""
|
|
527
|
+
if self._validation_result is None:
|
|
528
|
+
raw_config = self._data.get("config", {})
|
|
529
|
+
filename = self._filename or ""
|
|
530
|
+
registry = _get_palette_registry()
|
|
531
|
+
self._validation_result = validate_resume_config(
|
|
532
|
+
raw_config, filename, registry=registry
|
|
533
|
+
)
|
|
534
|
+
return self._validation_result
|
|
535
|
+
|
|
536
|
+
def validate_or_raise(self) -> ValidationResult:
|
|
537
|
+
"""Validate resume data and raise `ValidationError` on failure.
|
|
538
|
+
|
|
539
|
+
Validate the resume and raise a `ValidationError` if validation
|
|
540
|
+
fails. Use before operations requiring valid data.
|
|
541
|
+
|
|
542
|
+
Use this for:
|
|
543
|
+
- Fail-fast behavior (stop execution on invalid data).
|
|
544
|
+
- Automatic exception propagation.
|
|
545
|
+
- Validation before generation operations.
|
|
546
|
+
|
|
547
|
+
For inspection without raising, use `validate()` instead.
|
|
548
|
+
|
|
549
|
+
Returns:
|
|
550
|
+
`ValidationResult`: The validation result (only if validation succeeds).
|
|
551
|
+
|
|
552
|
+
Raises:
|
|
553
|
+
`ValidationError`: If validation fails with detailed error information.
|
|
554
|
+
|
|
555
|
+
Example:
|
|
556
|
+
>>> result = resume.validate_or_raise() # Raises if invalid
|
|
557
|
+
>>> to_pdf(resume, "output.pdf") # Only runs if validation passed
|
|
558
|
+
|
|
559
|
+
"""
|
|
560
|
+
validation_result = self.validate()
|
|
561
|
+
if not validation_result.is_valid:
|
|
562
|
+
raise ValidationError(
|
|
563
|
+
f"Resume validation failed: {validation_result.errors}",
|
|
564
|
+
errors=validation_result.errors,
|
|
565
|
+
warnings=validation_result.warnings,
|
|
566
|
+
filename=self._filename,
|
|
567
|
+
)
|
|
568
|
+
return validation_result
|
|
569
|
+
|
|
570
|
+
def prepare_render_plan(self, preview: bool | None = None) -> RenderPlan:
|
|
571
|
+
"""Prepare a render plan for this resume.
|
|
572
|
+
|
|
573
|
+
This method prepares the data needed for rendering the resume in
|
|
574
|
+
various formats. It's called by shell layer generation functions.
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
preview: Whether to prepare for preview rendering (defaults to setting).
|
|
578
|
+
|
|
579
|
+
Returns:
|
|
580
|
+
`RenderPlan` with all necessary rendering information.
|
|
581
|
+
|
|
582
|
+
"""
|
|
583
|
+
needs_refresh = self._render_plan is None or (
|
|
584
|
+
preview is not None and preview != self._is_preview
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
if needs_refresh:
|
|
588
|
+
actual_preview = self._is_preview if preview is None else preview
|
|
589
|
+
base_path: Path | str = self._paths.content if self._paths else Path()
|
|
590
|
+
source_data = (
|
|
591
|
+
self._raw_data
|
|
592
|
+
if hasattr(self, "_raw_data") and self._raw_data is not None
|
|
593
|
+
else self._data
|
|
594
|
+
)
|
|
595
|
+
registry = _get_palette_registry()
|
|
596
|
+
self._render_plan = prepare_render_data(
|
|
597
|
+
source_data,
|
|
598
|
+
preview=actual_preview,
|
|
599
|
+
base_path=base_path,
|
|
600
|
+
registry=registry,
|
|
601
|
+
)
|
|
602
|
+
self._is_preview = actual_preview
|
|
603
|
+
|
|
604
|
+
if self._render_plan is None: # pragma: no cover - defensive
|
|
605
|
+
raise RuntimeError("Render plan was not prepared")
|
|
606
|
+
return self._render_plan
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
__all__ = ["Resume", "set_default_loaders"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Provide utilities for skill data processing and formatting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _coerce_items(raw_input: Any) -> list[str]:
|
|
9
|
+
"""Return a list of trimmed string items from arbitrary input."""
|
|
10
|
+
if raw_input is None:
|
|
11
|
+
return []
|
|
12
|
+
if isinstance(raw_input, (list, tuple, set)):
|
|
13
|
+
return [str(element).strip() for element in raw_input if str(element).strip()]
|
|
14
|
+
return [str(raw_input).strip()]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def format_skill_groups(
|
|
18
|
+
skill_data: Any,
|
|
19
|
+
) -> list[dict[str, list[str] | str | None]]:
|
|
20
|
+
"""Normalize skill data into titled groups with string entries."""
|
|
21
|
+
groups: list[dict[str, list[str] | str | None]] = []
|
|
22
|
+
|
|
23
|
+
if skill_data is None:
|
|
24
|
+
return groups
|
|
25
|
+
|
|
26
|
+
def add_group(title: str | None, items: Any) -> None:
|
|
27
|
+
normalized = [entry for entry in _coerce_items(items) if entry]
|
|
28
|
+
if not normalized:
|
|
29
|
+
return
|
|
30
|
+
groups.append(
|
|
31
|
+
{
|
|
32
|
+
"title": str(title).strip() if title else None,
|
|
33
|
+
"items": normalized,
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if isinstance(skill_data, dict):
|
|
38
|
+
for category_name, items in skill_data.items():
|
|
39
|
+
add_group(str(category_name), items)
|
|
40
|
+
return groups
|
|
41
|
+
|
|
42
|
+
if isinstance(skill_data, (list, tuple, set)):
|
|
43
|
+
# Check if all entries are simple strings (not dicts)
|
|
44
|
+
all_simple = all(not isinstance(entry, dict) for entry in skill_data)
|
|
45
|
+
|
|
46
|
+
if all_simple:
|
|
47
|
+
# Create a single group with all items
|
|
48
|
+
add_group(None, list(skill_data))
|
|
49
|
+
else:
|
|
50
|
+
# Mixed content: process each entry separately
|
|
51
|
+
for entry in skill_data:
|
|
52
|
+
if isinstance(entry, dict):
|
|
53
|
+
for category_name, items in entry.items():
|
|
54
|
+
add_group(str(category_name), items)
|
|
55
|
+
else:
|
|
56
|
+
add_group(None, entry)
|
|
57
|
+
return groups
|
|
58
|
+
|
|
59
|
+
add_group(None, skill_data)
|
|
60
|
+
return groups
|