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