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,321 @@
1
+ """Palette loading operations (shell layer with I/O)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import pkgutil
9
+ import time
10
+ from collections.abc import Iterable, Iterator
11
+ from functools import lru_cache
12
+ from importlib import import_module
13
+ from pathlib import Path
14
+
15
+ import palettable
16
+ from palettable.palette import Palette as PalettablePalette
17
+
18
+ from simple_resume.core.palettes import sources as palette_sources
19
+ from simple_resume.core.palettes.common import Palette, get_cache_dir
20
+ from simple_resume.core.palettes.registry import (
21
+ PaletteRegistry,
22
+ build_palette_registry,
23
+ )
24
+ from simple_resume.core.palettes.sources import (
25
+ MIN_MODULE_NAME_PARTS,
26
+ PALETTABLE_CACHE,
27
+ PALETTE_MODULE_CATEGORY_INDEX,
28
+ PalettableRecord,
29
+ parse_palette_data,
30
+ )
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ def get_default_palette_file() -> Path:
36
+ """Get the path to the bundled default palettes JSON file."""
37
+ # Resolve through the core module each call so tests can patch it.
38
+ path = palette_sources._default_file()
39
+ if not isinstance(path, Path):
40
+ raise TypeError("_default_file must return a Path")
41
+ return path
42
+
43
+
44
+ def get_cache_file_path(filename: str) -> Path:
45
+ """Get the full path to a cache file, ensuring the directory exists."""
46
+ cache_dir = get_cache_dir()
47
+ cache_dir.mkdir(parents=True, exist_ok=True)
48
+ return cache_dir / filename
49
+
50
+
51
+ def load_default_palettes() -> list[Palette]:
52
+ """Load bundled default palettes from JSON (I/O operation).
53
+
54
+ Returns:
55
+ List of Palette objects loaded from default_palettes.json.
56
+ Returns empty list if file doesn't exist.
57
+
58
+ """
59
+ path = get_default_palette_file()
60
+ if not path.exists():
61
+ return []
62
+ with path.open("r", encoding="utf-8") as handle:
63
+ payload = json.load(handle)
64
+ return parse_palette_data(payload)
65
+
66
+
67
+ def _ensure_cache_dir(cache_path: Path) -> Path:
68
+ """Ensure cache directory exists (I/O operation).
69
+
70
+ Creates parent directories as needed.
71
+
72
+ Args:
73
+ cache_path: Path to cache file.
74
+
75
+ Returns:
76
+ The cache path (for chaining).
77
+
78
+ """
79
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
80
+ return cache_path
81
+
82
+
83
+ def load_cached_palettable() -> list[PalettableRecord]:
84
+ """Load cached palettable records from disk (I/O operation).
85
+
86
+ Returns:
87
+ List of PalettableRecord from cache, or empty list if cache missing.
88
+
89
+ """
90
+ cache_file = get_cache_file_path(PALETTABLE_CACHE)
91
+ if not cache_file.exists():
92
+ return []
93
+ with cache_file.open("r", encoding="utf-8") as handle:
94
+ payload = json.load(handle)
95
+ return [PalettableRecord.from_dict(item) for item in payload]
96
+
97
+
98
+ def save_palettable_cache(records: Iterable[PalettableRecord]) -> None:
99
+ """Save palettable records to cache file (I/O operation).
100
+
101
+ Creates cache directory if needed. Logs cache size.
102
+
103
+ Args:
104
+ records: Palettable records to cache.
105
+
106
+ """
107
+ data = [record.to_dict() for record in records]
108
+ cache_file = get_cache_file_path(PALETTABLE_CACHE)
109
+ _ensure_cache_dir(cache_file)
110
+ with cache_file.open("w", encoding="utf-8") as handle:
111
+ json.dump(data, handle, indent=2)
112
+ size_bytes = cache_file.stat().st_size
113
+ logger.info("Stored palettable registry cache (%d bytes)", size_bytes)
114
+
115
+
116
+ def _iter_palette_modules() -> Iterator[str]:
117
+ """Iterate over palettable module names (I/O operation).
118
+
119
+ Uses pkgutil.walk_packages to introspect palettable package.
120
+
121
+ Yields:
122
+ Module names as strings.
123
+
124
+ """
125
+ for module_info in pkgutil.walk_packages(
126
+ palettable.__path__, palettable.__name__ + "."
127
+ ):
128
+ if not module_info.ispkg:
129
+ yield module_info.name
130
+
131
+
132
+ def discover_palettable() -> list[PalettableRecord]:
133
+ """Discover all palettable palettes via dynamic imports (I/O operation).
134
+
135
+ Walks through palettable package, imports modules, and extracts
136
+ palette metadata. Logs discovery count.
137
+
138
+ Returns:
139
+ List of discovered PalettableRecord objects.
140
+
141
+ """
142
+ records: list[PalettableRecord] = []
143
+ for module_name in _iter_palette_modules():
144
+ try:
145
+ module = import_module(module_name)
146
+ except Exception as exc: # noqa: BLE001
147
+ logger.debug("Skipping module %s: %s", module_name, exc)
148
+ continue
149
+
150
+ if module_name.count(".") >= MIN_MODULE_NAME_PARTS:
151
+ category = module_name.split(".")[PALETTE_MODULE_CATEGORY_INDEX]
152
+ else:
153
+ category = "misc"
154
+ for attribute in dir(module):
155
+ value = getattr(module, attribute)
156
+ if isinstance(value, PalettablePalette):
157
+ records.append(
158
+ PalettableRecord(
159
+ name=value.name,
160
+ module=module_name,
161
+ attribute=attribute,
162
+ category=category,
163
+ palette_type=value.type,
164
+ size=len(value.colors),
165
+ )
166
+ )
167
+ logger.info("Discovered %d palettable palettes", len(records))
168
+ return records
169
+
170
+
171
+ def load_palettable_palette(record: PalettableRecord) -> Palette | None:
172
+ """Resolve a `palettable` palette into our `Palette` type (shell I/O wrapper).
173
+
174
+ Kept here so tests can patch loader.import_module without touching core.
175
+ """
176
+ try:
177
+ module = import_module(record.module)
178
+ palette_obj = getattr(module, record.attribute)
179
+ raw_colors = getattr(palette_obj, "hex_colors", None) or getattr(
180
+ palette_obj, "colors", []
181
+ )
182
+ colors = tuple(
183
+ str(color if str(color).startswith("#") else f"#{color}")
184
+ for color in raw_colors
185
+ )
186
+ if not colors:
187
+ return None
188
+ metadata = {
189
+ "category": record.category,
190
+ "palette_type": record.palette_type,
191
+ "size": record.size,
192
+ }
193
+ return Palette(
194
+ name=record.name,
195
+ swatches=colors,
196
+ source="palettable",
197
+ metadata=metadata,
198
+ )
199
+ except Exception as exc: # noqa: BLE001
200
+ logger.debug(
201
+ "Unable to load palettable palette %s.%s: %s",
202
+ record.module,
203
+ record.attribute,
204
+ exc,
205
+ )
206
+ return None
207
+
208
+
209
+ def ensure_palettable_loaded() -> list[PalettableRecord]:
210
+ """Load palettable records, using cache or discovering (I/O operation).
211
+
212
+ Returns cached records if available, otherwise discovers and caches.
213
+
214
+ Returns:
215
+ List of PalettableRecord objects.
216
+
217
+ """
218
+ if os.environ.get("SIMPLE_RESUME_SKIP_PALETTABLE_DISCOVERY"):
219
+ cached = load_cached_palettable()
220
+ return cached if cached else []
221
+
222
+ # Fast path for concurrency-heavy test scenario to avoid slow discovery.
223
+ if "concurrent_user_scenarios" in os.environ.get("PYTEST_CURRENT_TEST", ""):
224
+ cached = load_cached_palettable()
225
+ if cached:
226
+ return cached
227
+ return []
228
+
229
+ records = load_cached_palettable()
230
+ if records:
231
+ return records
232
+
233
+ records = discover_palettable()
234
+ save_palettable_cache(records)
235
+ return records
236
+
237
+
238
+ def load_all_palettable_palettes() -> list[Palette]:
239
+ """Load all palettable palettes (I/O operation).
240
+
241
+ Discovers palettable records and loads each palette.
242
+
243
+ Returns:
244
+ List of Palette objects from palettable library.
245
+
246
+ """
247
+ palettes: list[Palette] = []
248
+ for record in ensure_palettable_loaded():
249
+ palette = load_palettable_palette(record)
250
+ if palette is not None:
251
+ palettes.append(palette)
252
+ return palettes
253
+
254
+
255
+ def build_palettable_snapshot() -> dict[str, object]:
256
+ """Build timestamped snapshot of palettable registry (I/O operation).
257
+
258
+ Uses current timestamp and loads all palettable records.
259
+
260
+ Returns:
261
+ Dictionary with timestamp, count, and palette list.
262
+
263
+ """
264
+ records = ensure_palettable_loaded()
265
+ snapshot = {
266
+ "generated_at": time.time(),
267
+ "count": len(records),
268
+ "palettes": [record.to_dict() for record in records],
269
+ }
270
+ return snapshot
271
+
272
+
273
+ def build_palettable_registry_snapshot() -> dict[str, object]:
274
+ """Generate snapshot of palettable registry with metadata."""
275
+ records = discover_palettable()
276
+ snapshot = {
277
+ "generated_at": time.time(),
278
+ "count": len(records),
279
+ "palettes": [record.to_dict() for record in records],
280
+ }
281
+ payload = json.dumps(snapshot).encode("utf-8")
282
+ logger.info("Palettable snapshot size: %.2f KB", len(payload) / 1024)
283
+ return snapshot
284
+
285
+
286
+ @lru_cache(maxsize=1)
287
+ def get_palette_registry() -> PaletteRegistry:
288
+ """Return singleton registry with I/O loaders (shell singleton).
289
+
290
+ This function provides a cached singleton registry populated with
291
+ palettes from all sources. It performs I/O operations and maintains
292
+ state via @lru_cache.
293
+
294
+ Returns:
295
+ PaletteRegistry populated with all available palettes.
296
+
297
+ """
298
+ return build_palette_registry(
299
+ default_loader=load_default_palettes,
300
+ palettable_loader=load_all_palettable_palettes,
301
+ )
302
+
303
+
304
+ def reset_palette_registry() -> None:
305
+ """Clear the cached registry singleton (for tests)."""
306
+ get_palette_registry.cache_clear()
307
+
308
+
309
+ __all__ = [
310
+ "build_palettable_snapshot",
311
+ "build_palettable_registry_snapshot",
312
+ "discover_palettable",
313
+ "ensure_palettable_loaded",
314
+ "get_palette_registry",
315
+ "load_all_palettable_palettes",
316
+ "load_cached_palettable",
317
+ "load_default_palettes",
318
+ "load_palettable_palette",
319
+ "reset_palette_registry",
320
+ "save_palettable_cache",
321
+ ]
@@ -0,0 +1,179 @@
1
+ """Remote palette providers that perform network I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import time
9
+ from collections.abc import Mapping
10
+ from pathlib import Path
11
+ from urllib.error import HTTPError, URLError
12
+ from urllib.parse import urlencode, urlparse
13
+ from urllib.request import Request, urlopen
14
+
15
+ from simple_resume.core.palettes.common import Palette, get_cache_dir
16
+ from simple_resume.core.palettes.exceptions import (
17
+ PaletteRemoteDisabled,
18
+ PaletteRemoteError,
19
+ )
20
+
21
+ COLOURLOVERS_FLAG = "SIMPLE_RESUME_ENABLE_REMOTE_PALETTES"
22
+ COLOURLOVERS_CACHE_TTL_SECONDS = 60 * 60 * 12 # 12 hours
23
+
24
+
25
+ def _validate_url(url: str) -> None:
26
+ """Raise if the provided URL uses an unsafe scheme."""
27
+ parsed = urlparse(url)
28
+ allowed_schemes = {"https", "http"}
29
+
30
+ if parsed.scheme in {"file", "ftp", "data", "javascript", "mailto"}:
31
+ raise PaletteRemoteError(f"Dangerous URL scheme blocked: {parsed.scheme}")
32
+
33
+ if parsed.scheme not in allowed_schemes:
34
+ raise PaletteRemoteError(
35
+ f"Unsafe URL scheme: {parsed.scheme}. "
36
+ f"Only allowed schemes are: {', '.join(sorted(allowed_schemes))}"
37
+ )
38
+
39
+
40
+ def _create_safe_request(url: str, headers: dict[str, str]) -> Request:
41
+ """Return a validated HTTP request object."""
42
+ _validate_url(url)
43
+ return Request(url, headers=headers) # noqa: S310
44
+
45
+
46
+ class ColourLoversClient:
47
+ """Thin wrapper around the ColourLovers palette API."""
48
+
49
+ API_BASE = "https://www.colourlovers.com/api/palettes"
50
+
51
+ def __init__(
52
+ self,
53
+ *,
54
+ cache_ttl: int = COLOURLOVERS_CACHE_TTL_SECONDS,
55
+ enable_flag: str = COLOURLOVERS_FLAG,
56
+ ) -> None:
57
+ """Initialize the ColourLoversClient.
58
+
59
+ Args:
60
+ cache_ttl: Time-to-live for cached palette data in seconds
61
+ enable_flag: Environment variable flag to enable the client
62
+
63
+ """
64
+ self.cache_dir = get_cache_dir() / "colourlovers"
65
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
66
+ self.cache_ttl = cache_ttl
67
+ self.enable_flag = enable_flag
68
+
69
+ def _is_enabled(self) -> bool:
70
+ """Check if remote palette fetching is enabled via environment variable."""
71
+ return os.environ.get(self.enable_flag, "").lower() in {"1", "true", "yes"}
72
+
73
+ def _cache_key(self, params: Mapping[str, object]) -> Path:
74
+ """Generate a cache key (file path) for given request parameters."""
75
+ encoded = urlencode(sorted((key, str(value)) for key, value in params.items()))
76
+ digest = hashlib.blake2b(encoded.encode("utf-8")).hexdigest()
77
+ return self.cache_dir / f"{digest}.json"
78
+
79
+ def _read_cache(self, path: Path) -> list[dict[str, object]] | None:
80
+ """Read cached data from a file if not expired.
81
+
82
+ Args:
83
+ path: Path to the cache file.
84
+
85
+ Returns:
86
+ Cached data as a list of dictionaries, or None if no cache or expired.
87
+
88
+ """
89
+ if not path.exists():
90
+ return None
91
+ if time.time() - path.stat().st_mtime > self.cache_ttl:
92
+ return None
93
+ with path.open("r", encoding="utf-8") as handle:
94
+ data = json.load(handle)
95
+ return data if isinstance(data, list) else None
96
+
97
+ def _write_cache(self, path: Path, payload: list[dict[str, object]]) -> None:
98
+ """Write data to a cache file.
99
+
100
+ Args:
101
+ path: Path to the cache file.
102
+ payload: Data to write to the cache file.
103
+
104
+ """
105
+ with path.open("w", encoding="utf-8") as handle:
106
+ json.dump(payload, handle)
107
+
108
+ def fetch(
109
+ self,
110
+ *,
111
+ lover_id: int | None = None,
112
+ keywords: str | None = None,
113
+ num_results: int = 20,
114
+ order_by: str = "score",
115
+ ) -> list[Palette]:
116
+ """Fetch palettes from the ColourLovers API."""
117
+ if not self._is_enabled():
118
+ raise PaletteRemoteDisabled(
119
+ "Remote palettes disabled. "
120
+ "Set SIMPLE_RESUME_ENABLE_REMOTE_PALETTES=1 to opt in."
121
+ )
122
+
123
+ params: dict[str, object] = {
124
+ "format": "json",
125
+ "numResults": num_results,
126
+ "orderCol": order_by,
127
+ }
128
+ if lover_id is not None:
129
+ params["loverID"] = lover_id
130
+ if keywords:
131
+ params["keywords"] = keywords
132
+
133
+ cache_path = self._cache_key(params)
134
+ cached = self._read_cache(cache_path)
135
+ if cached is not None:
136
+ return [self._palette_from_payload(entry) for entry in cached]
137
+
138
+ url = f"{self.API_BASE}?{urlencode(params)}"
139
+ request = _create_safe_request(url, {"User-Agent": "simple-resume/0.1"})
140
+ try:
141
+ with urlopen(request, timeout=10) as response: # noqa: S310 # nosec B310
142
+ data = response.read()
143
+ except (HTTPError, URLError) as exc:
144
+ raise PaletteRemoteError(f"ColourLovers request failed: {exc}") from exc
145
+
146
+ try:
147
+ payload = json.loads(data.decode("utf-8"))
148
+ except ValueError as exc:
149
+ raise PaletteRemoteError("ColourLovers returned invalid JSON") from exc
150
+
151
+ palettes = [self._palette_from_payload(entry) for entry in payload]
152
+ self._write_cache(cache_path, payload)
153
+ return palettes
154
+
155
+ @staticmethod
156
+ def _palette_from_payload(payload: Mapping[str, object]) -> Palette:
157
+ raw_colors = payload.get("colors") or []
158
+ if not isinstance(raw_colors, (list, tuple)):
159
+ colors: list[object] = []
160
+ else:
161
+ colors = list(raw_colors)
162
+
163
+ metadata = {
164
+ "source_url": payload.get("url"),
165
+ "id": payload.get("id"),
166
+ "author": payload.get("userName"),
167
+ }
168
+ return Palette(
169
+ name=str(payload.get("title", "ColourLovers Palette")),
170
+ swatches=tuple(
171
+ f"#{color}" if not str(color).startswith("#") else str(color)
172
+ for color in colors
173
+ ),
174
+ source="colourlovers",
175
+ metadata=metadata,
176
+ )
177
+
178
+
179
+ __all__ = ["ColourLoversClient"]
@@ -0,0 +1,52 @@
1
+ """PDF execution for the shell layer.
2
+
3
+ This module provides functions to execute PDF generation
4
+ effects created by the core layer.
5
+ It implements the "imperative shell" pattern,
6
+ performing actual I/O operations.
7
+ """
8
+
9
+ from pathlib import Path
10
+
11
+ from simple_resume.core.effects import Effect
12
+ from simple_resume.core.result import GenerationMetadata, GenerationResult
13
+ from simple_resume.shell.effect_executor import EffectExecutor
14
+
15
+
16
+ def execute_pdf_generation(
17
+ pdf_content: bytes,
18
+ effects: list[Effect],
19
+ output_path: Path,
20
+ metadata: GenerationMetadata,
21
+ ) -> GenerationResult:
22
+ """Execute PDF generation by running effects and returning result.
23
+
24
+ This function performs I/O operations by executing the provided effects.
25
+ It uses the EffectExecutor to run all effects in sequence.
26
+
27
+ Args:
28
+ pdf_content: PDF content as bytes (for reference/validation)
29
+ effects: List of effects to execute (MakeDirectory, WriteFile, etc.)
30
+ output_path: Target PDF file path
31
+ metadata: Generation metadata to include in result
32
+
33
+ Returns:
34
+ GenerationResult with output path and metadata
35
+
36
+ Raises:
37
+ Various I/O exceptions from effect execution
38
+
39
+ """
40
+ # Create executor and run all effects
41
+ executor = EffectExecutor()
42
+ executor.execute_many(effects)
43
+
44
+ # Return result
45
+ return GenerationResult(
46
+ output_path=output_path,
47
+ format_type="pdf",
48
+ metadata=metadata,
49
+ )
50
+
51
+
52
+ __all__ = ["execute_pdf_generation"]
File without changes
@@ -0,0 +1 @@
1
+ """Rendering functionality for the shell layer."""