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,284 @@
1
+ """Lazy-loaded generation functions for optimal import performance.
2
+
3
+ This module provides thin wrappers around the core generation functions
4
+ with lazy loading to reduce startup memory footprint.
5
+
6
+ .. versionadded:: 0.1.0
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib
12
+ from functools import lru_cache
13
+ from pathlib import Path
14
+ from types import ModuleType
15
+ from typing import TYPE_CHECKING, Any, cast
16
+
17
+ from simple_resume.core.models import GenerationConfig
18
+
19
+ if TYPE_CHECKING:
20
+ from simple_resume.core.result import BatchGenerationResult, GenerationResult
21
+ from simple_resume.shell.generate.core import GenerateOptions
22
+
23
+
24
+ class _LazyCoreLoader:
25
+ """Lazy loader for core generation functions."""
26
+
27
+ def __init__(self) -> None:
28
+ self._core: ModuleType | None = None
29
+ self._loaded = False
30
+
31
+ def _load_core(self) -> ModuleType:
32
+ """Load core module if not already loaded."""
33
+ if not self._loaded:
34
+ self._core = importlib.import_module(".core", package=__package__)
35
+ self._loaded = True
36
+ if self._core is None: # pragma: no cover
37
+ raise RuntimeError("Failed to load core module")
38
+ return self._core
39
+
40
+ @property
41
+ def generate_pdf(self) -> Any:
42
+ """Get generate_pdf function from core module."""
43
+ return self._load_core().generate_pdf
44
+
45
+ @property
46
+ def generate_html(self) -> Any:
47
+ """Get generate_html function from core module."""
48
+ return self._load_core().generate_html
49
+
50
+ @property
51
+ def generate_all(self) -> Any:
52
+ """Get generate_all function from core module."""
53
+ return self._load_core().generate_all
54
+
55
+ @property
56
+ def generate_resume(self) -> Any:
57
+ """Get generate_resume function from core module."""
58
+ return self._load_core().generate_resume
59
+
60
+ @property
61
+ def generate(self) -> Any:
62
+ """Get generate function from core module."""
63
+ return self._load_core().generate
64
+
65
+ @property
66
+ def preview(self) -> Any:
67
+ """Get preview function from core module."""
68
+ return self._load_core().preview
69
+
70
+
71
+ @lru_cache(maxsize=1)
72
+ def _get_lazy_core_loader() -> _LazyCoreLoader:
73
+ """Provide a lazily created singleton loader without module globals."""
74
+ return _LazyCoreLoader()
75
+
76
+
77
+ def generate_pdf(
78
+ config: GenerationConfig,
79
+ **config_overrides: Any,
80
+ ) -> BatchGenerationResult:
81
+ """Generate PDF resumes using a configuration object.
82
+
83
+ Args:
84
+ config: Generation configuration specifying sources, formats, and options.
85
+ **config_overrides: Additional configuration overrides.
86
+
87
+ Returns:
88
+ BatchGenerationResult containing generated PDF files and metadata.
89
+
90
+ Example:
91
+ >>> from simple_resume import generate_pdf
92
+ >>> from simple_resume.core.models import GenerationConfig
93
+ >>> config = GenerationConfig(sources=["resume.yaml"], formats=["pdf"])
94
+ >>> result = generate_pdf(config)
95
+ >>> print(result.successful)
96
+
97
+ .. versionadded:: 0.1.0
98
+
99
+ """
100
+ lazy_core = _get_lazy_core_loader()
101
+ result: BatchGenerationResult = cast(
102
+ "BatchGenerationResult", lazy_core.generate_pdf(config, **config_overrides)
103
+ )
104
+ return result
105
+
106
+
107
+ def generate_html(
108
+ config: GenerationConfig,
109
+ **config_overrides: Any,
110
+ ) -> BatchGenerationResult:
111
+ """Generate HTML resumes using a configuration object.
112
+
113
+ Args:
114
+ config: Generation configuration specifying sources, formats, and options.
115
+ **config_overrides: Additional configuration overrides.
116
+
117
+ Returns:
118
+ BatchGenerationResult containing generated HTML files and metadata.
119
+
120
+ Example:
121
+ >>> from simple_resume import generate_html
122
+ >>> from simple_resume.core.models import GenerationConfig
123
+ >>> config = GenerationConfig(sources=["resume.yaml"], formats=["html"])
124
+ >>> result = generate_html(config)
125
+
126
+ .. versionadded:: 0.1.0
127
+
128
+ """
129
+ lazy_core = _get_lazy_core_loader()
130
+ result: BatchGenerationResult = cast(
131
+ "BatchGenerationResult", lazy_core.generate_html(config, **config_overrides)
132
+ )
133
+ return result
134
+
135
+
136
+ def generate_all(
137
+ config: GenerationConfig,
138
+ **config_overrides: Any,
139
+ ) -> BatchGenerationResult:
140
+ """Generate resumes in all specified formats.
141
+
142
+ Args:
143
+ config: Generation configuration specifying sources, formats, and options.
144
+ **config_overrides: Additional configuration overrides.
145
+
146
+ Returns:
147
+ BatchGenerationResult containing all generated files and metadata.
148
+
149
+ Example:
150
+ >>> from simple_resume import generate_all
151
+ >>> from simple_resume.core.models import GenerationConfig
152
+ >>> config = GenerationConfig(sources=["resume.yaml"], formats=["pdf", "html"])
153
+ >>> result = generate_all(config)
154
+
155
+ .. versionadded:: 0.1.0
156
+
157
+ """
158
+ lazy_core = _get_lazy_core_loader()
159
+ result: BatchGenerationResult = cast(
160
+ "BatchGenerationResult", lazy_core.generate_all(config, **config_overrides)
161
+ )
162
+ return result
163
+
164
+
165
+ def generate_resume(
166
+ config: GenerationConfig,
167
+ **config_overrides: Any,
168
+ ) -> GenerationResult:
169
+ """Generate a single resume in a specific format.
170
+
171
+ Args:
172
+ config: Generation configuration specifying source and format.
173
+ **config_overrides: Additional configuration overrides.
174
+
175
+ Returns:
176
+ GenerationResult for the generated resume.
177
+
178
+ Example:
179
+ >>> from simple_resume import generate_resume
180
+ >>> from simple_resume.core.models import GenerationConfig
181
+ >>> config = GenerationConfig(sources=["resume.yaml"], formats=["pdf"])
182
+ >>> result = generate_resume(config)
183
+
184
+ .. versionadded:: 0.1.0
185
+
186
+ """
187
+ lazy_core = _get_lazy_core_loader()
188
+ result: GenerationResult = cast(
189
+ "GenerationResult", lazy_core.generate_resume(config, **config_overrides)
190
+ )
191
+ return result
192
+
193
+
194
+ def generate(
195
+ source: str | Path,
196
+ options: GenerateOptions | None = None,
197
+ **overrides: Any,
198
+ ) -> dict[str, GenerationResult | BatchGenerationResult]:
199
+ """Render one or more formats for the same source.
200
+
201
+ This is the primary entry point for generating resumes. It accepts a
202
+ source file path and optional configuration.
203
+
204
+ Args:
205
+ source: Path to the resume YAML file.
206
+ options: Optional GenerateOptions with formats and settings.
207
+ **overrides: Additional configuration overrides.
208
+
209
+ Returns:
210
+ Dictionary mapping format names to GenerationResult or BatchGenerationResult.
211
+
212
+ Example:
213
+ >>> from simple_resume import generate
214
+ >>> result = generate("resume.yaml")
215
+ >>> for fmt, r in result.items():
216
+ ... print(f"Generated {fmt}: {r}")
217
+
218
+ .. versionadded:: 0.1.0
219
+
220
+ """
221
+ lazy_core = _get_lazy_core_loader()
222
+ result: dict[str, GenerationResult | BatchGenerationResult] = cast(
223
+ "dict[str, GenerationResult | BatchGenerationResult]",
224
+ lazy_core.generate(source, options, **overrides),
225
+ )
226
+ return result
227
+
228
+
229
+ def preview(
230
+ source: str | Path,
231
+ *,
232
+ data_dir: str | Path | None = None,
233
+ template: str | None = None,
234
+ browser: str | None = None,
235
+ open_after: bool = True,
236
+ **overrides: Any,
237
+ ) -> GenerationResult | BatchGenerationResult:
238
+ """Render a single resume to HTML and open in browser.
239
+
240
+ This convenience function generates an HTML preview and optionally
241
+ opens it in the default web browser.
242
+
243
+ Args:
244
+ source: Path to the resume YAML file.
245
+ data_dir: Optional data directory override.
246
+ template: Optional template name override.
247
+ browser: Optional browser command (e.g., "firefox", "chrome").
248
+ open_after: Whether to open the file after generation (default: True).
249
+ **overrides: Additional configuration overrides.
250
+
251
+ Returns:
252
+ GenerationResult for the generated HTML preview.
253
+
254
+ Example:
255
+ >>> from simple_resume import preview
256
+ >>> result = preview("resume.yaml") # Opens in browser
257
+ >>> result = preview("resume.yaml", open_after=False) # Just generate
258
+
259
+ .. versionadded:: 0.1.0
260
+
261
+ """
262
+ lazy_core = _get_lazy_core_loader()
263
+ result: GenerationResult | BatchGenerationResult = cast(
264
+ "GenerationResult | BatchGenerationResult",
265
+ lazy_core.preview(
266
+ source,
267
+ data_dir=data_dir,
268
+ template=template,
269
+ browser=browser,
270
+ open_after=open_after,
271
+ **overrides,
272
+ ),
273
+ )
274
+ return result
275
+
276
+
277
+ __all__ = [
278
+ "generate_pdf",
279
+ "generate_html",
280
+ "generate_all",
281
+ "generate_resume",
282
+ "generate",
283
+ "preview",
284
+ ]
@@ -0,0 +1,199 @@
1
+ """Consolidated path and file handling utilities.
2
+
3
+ This module provides centralized path handling following the Path-first principle:
4
+ - Accept str | Path at API boundaries for flexibility
5
+ - Normalize to Path immediately after receiving input
6
+ - Use Path objects internally throughout the codebase
7
+ - Convert to str only when required by external APIs
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from oyaml import safe_load
17
+
18
+ from simple_resume.core.paths import Paths
19
+ from simple_resume.shell.config import FILE_DEFAULT, resolve_paths
20
+
21
+
22
+ def candidate_yaml_path(name: str | os.PathLike[str]) -> Path | None:
23
+ """Return a Path if ``name`` resembles a supported resume file, else ``None``.
24
+
25
+ Notes:
26
+ We treat ``.json`` specially: a bare "resume.json" is often meant as a resume
27
+ *name* (looked up under the data-dir input folder). To avoid surprising
28
+ "file not found" errors, we only treat ``.json`` as a direct path when it
29
+ looks like a real path (has directory components or is absolute).
30
+
31
+ """
32
+ if isinstance(name, (str, os.PathLike)):
33
+ maybe_path = Path(name)
34
+ suffix = maybe_path.suffix.lower()
35
+ if suffix in {".yaml", ".yml"}:
36
+ return maybe_path
37
+ if suffix == ".json":
38
+ if maybe_path.is_absolute() or len(maybe_path.parts) > 1:
39
+ return maybe_path
40
+ return None
41
+
42
+
43
+ def resolve_paths_for_read(
44
+ supplied_paths: Paths | None,
45
+ overrides: dict[str, Any],
46
+ candidate: Path | None,
47
+ ) -> Paths:
48
+ """Resolve path configuration for read operations."""
49
+ if supplied_paths is not None:
50
+ return supplied_paths
51
+
52
+ if overrides:
53
+ return resolve_paths(**overrides)
54
+
55
+ if candidate is not None:
56
+ if candidate.parent.name == "input":
57
+ base_dir = candidate.parent.parent
58
+ else:
59
+ base_dir = candidate.parent
60
+
61
+ base_paths = resolve_paths(data_dir=base_dir)
62
+ return Paths(
63
+ data=base_paths.data,
64
+ input=candidate.parent,
65
+ output=base_paths.output,
66
+ content=base_paths.content,
67
+ templates=base_paths.templates,
68
+ static=base_paths.static,
69
+ )
70
+
71
+ return resolve_paths(**overrides)
72
+
73
+
74
+ def normalize_resume_name(name: str | os.PathLike[str]) -> str:
75
+ """Normalize resume identifiers by stripping extensions and defaults.
76
+
77
+ Args:
78
+ name: Resume identifier (filename, stem, or path)
79
+
80
+ Returns:
81
+ Normalized resume name without extension
82
+
83
+ """
84
+ if not name:
85
+ return FILE_DEFAULT
86
+ if isinstance(name, (str, os.PathLike)):
87
+ candidate = Path(name)
88
+ suffix = candidate.suffix.lower()
89
+ if suffix in {".yaml", ".yml", ".json"}:
90
+ return candidate.stem
91
+ return candidate.name or str(name)
92
+ return str(name)
93
+
94
+
95
+ def find_resume_file(
96
+ resume_name: str, input_path: Path, *, include_uppercase: bool = True
97
+ ) -> Path:
98
+ """Find a resume file in the input directory by name.
99
+
100
+ Args:
101
+ resume_name: Name of the resume (without extension)
102
+ input_path: Directory to search for resume files
103
+ include_uppercase: Whether to search for uppercase extensions
104
+
105
+ Returns:
106
+ Path to the found resume file
107
+
108
+ """
109
+ candidates: list[Path] = []
110
+ extensions = ["yaml", "yml", "json"]
111
+
112
+ # Search for files with both lowercase and uppercase extensions
113
+ for ext in extensions:
114
+ candidates.extend(input_path.glob(f"{resume_name}.{ext}"))
115
+ if include_uppercase:
116
+ candidates.extend(input_path.glob(f"{resume_name}.{ext.upper()}"))
117
+
118
+ if candidates:
119
+ return candidates[0]
120
+
121
+ # Fallback to default yaml file
122
+ return input_path / f"{resume_name}.yaml"
123
+
124
+
125
+ def read_yaml_file(path: str | Path) -> dict[str, Any]:
126
+ """Read and parse a YAML file.
127
+
128
+ Args:
129
+ path: Path to the YAML file
130
+
131
+ Returns:
132
+ Parsed YAML content as dictionary
133
+
134
+ Raises:
135
+ ValueError: If YAML content is not a dictionary
136
+ FileNotFoundError: If file doesn't exist
137
+
138
+ """
139
+ path_obj = Path(path)
140
+ if not path_obj.exists():
141
+ raise FileNotFoundError(f"YAML file not found: {path}")
142
+
143
+ with open(path_obj, encoding="utf-8") as file:
144
+ content = safe_load(file)
145
+
146
+ if content is None:
147
+ return {}
148
+
149
+ if not isinstance(content, dict):
150
+ raise ValueError(
151
+ f"YAML file must contain a dictionary at the root level, "
152
+ f"but found {type(content).__name__}: {path_obj}"
153
+ )
154
+
155
+ return content
156
+
157
+
158
+ def ensure_directory_exists(path: Path) -> Path:
159
+ """Ensure a directory exists, creating it if necessary.
160
+
161
+ Args:
162
+ path: Directory path to ensure exists
163
+
164
+ Returns:
165
+ The same path for convenience
166
+
167
+ """
168
+ path.mkdir(parents=True, exist_ok=True)
169
+ return path
170
+
171
+
172
+ def resolve_output_path(base_dir: Path, filename: str, extension: str = ".pdf") -> Path:
173
+ """Resolve an output file path with proper extension.
174
+
175
+ Args:
176
+ base_dir: Base output directory
177
+ filename: Base filename (without extension)
178
+ extension: Desired file extension
179
+
180
+ Returns:
181
+ Resolved output file path
182
+
183
+ """
184
+ if not filename.endswith(extension):
185
+ filename = f"{filename}{extension}"
186
+
187
+ ensure_directory_exists(base_dir)
188
+ return base_dir / filename
189
+
190
+
191
+ __all__ = [
192
+ "candidate_yaml_path",
193
+ "resolve_paths_for_read",
194
+ "normalize_resume_name",
195
+ "find_resume_file",
196
+ "read_yaml_file",
197
+ "ensure_directory_exists",
198
+ "resolve_output_path",
199
+ ]
@@ -0,0 +1 @@
1
+ """Palette-related functionality for the shell layer."""
@@ -0,0 +1,63 @@
1
+ """Shell layer palette fetching with network I/O.
2
+
3
+ This module executes the network operations described by PaletteFetchRequest
4
+ objects created by the pure core logic. This isolates all network I/O
5
+ to the shell layer.
6
+ """
7
+
8
+ from typing import Any
9
+
10
+ from simple_resume.core.palettes import PaletteFetchRequest
11
+ from simple_resume.core.palettes.exceptions import PaletteLookupError
12
+ from simple_resume.shell.palettes.remote import ColourLoversClient
13
+
14
+
15
+ def execute_palette_fetch(
16
+ request: PaletteFetchRequest,
17
+ ) -> tuple[list[str], dict[str, Any]]:
18
+ """Shell operation - performs network I/O to fetch palette.
19
+
20
+ This function executes the network operation described by the request
21
+ and returns the actual color data.
22
+
23
+ Args:
24
+ request: Palette fetch request describing the network operation
25
+
26
+ Returns:
27
+ Tuple of (colors, metadata) from the remote source
28
+
29
+ Raises:
30
+ PaletteLookupError: If palette fetch fails or returns no results
31
+
32
+ """
33
+ if request.source not in {"colourlovers", "remote"}:
34
+ raise PaletteLookupError(f"Unsupported remote source: {request.source}")
35
+
36
+ client = ColourLoversClient()
37
+ # Convert list of keywords to comma-separated string for the API
38
+ keywords_str = ",".join(request.keywords) if request.keywords else None
39
+ palettes = client.fetch(
40
+ keywords=keywords_str,
41
+ num_results=request.num_results,
42
+ order_by=request.order_by,
43
+ )
44
+
45
+ if not palettes:
46
+ raise PaletteLookupError(f"No palettes found for keywords: {request.keywords}")
47
+
48
+ palette = palettes[0]
49
+ colors = list(palette.swatches)
50
+
51
+ metadata = {
52
+ "source": request.source,
53
+ "name": palette.name,
54
+ "attribution": palette.metadata,
55
+ "size": len(colors),
56
+ }
57
+
58
+ return colors, metadata
59
+
60
+
61
+ __all__ = [
62
+ "execute_palette_fetch",
63
+ ]