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