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,672 @@
1
+ """Provide pure configuration normalization helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import copy
7
+ from contextlib import ExitStack
8
+ from importlib import resources
9
+ from itertools import cycle
10
+ from typing import Any, Callable
11
+
12
+ from simple_resume.core.colors import (
13
+ ColorCalculationService,
14
+ darken_color,
15
+ get_contrasting_text_color,
16
+ is_valid_color,
17
+ )
18
+ from simple_resume.core.constants.colors import (
19
+ BOLD_DARKEN_FACTOR,
20
+ CONFIG_COLOR_FIELDS,
21
+ CONFIG_DIRECT_COLOR_KEYS,
22
+ DEFAULT_BOLD_COLOR,
23
+ DEFAULT_COLOR_SCHEME,
24
+ )
25
+ from simple_resume.core.palettes import resolve_palette_config
26
+ from simple_resume.core.palettes.exceptions import (
27
+ PaletteError,
28
+ PaletteLookupError,
29
+ )
30
+ from simple_resume.core.palettes.fetch_types import PaletteFetchRequest
31
+ from simple_resume.core.palettes.registry import PaletteRegistry
32
+
33
+ # Keep an open handle to package resources so they're available even when the
34
+ # distribution is zipped (e.g., installed from a wheel).
35
+ _asset_stack = ExitStack()
36
+ PACKAGE_ROOT = _asset_stack.enter_context(
37
+ resources.as_file(resources.files("simple_resume"))
38
+ )
39
+ atexit.register(_asset_stack.close)
40
+
41
+ PATH_CONTENT = PACKAGE_ROOT
42
+ TEMPLATE_LOC = PACKAGE_ROOT / "templates"
43
+ STATIC_LOC = PACKAGE_ROOT / "static"
44
+
45
+
46
+ def _coerce_number(value: Any, *, field: str, prefix: str) -> float | int | None:
47
+ """Coerce a value to a number (float or int) or None.
48
+
49
+ Args:
50
+ value: The value to coerce.
51
+ field: The name of the field being coerced, for error messages.
52
+ prefix: A prefix for error messages.
53
+
54
+ Returns:
55
+ The coerced numeric value, or None if the input is None.
56
+
57
+ Raises:
58
+ ValueError: If the value cannot be coerced to a number.
59
+
60
+ """
61
+ if value is None:
62
+ return None
63
+ if isinstance(value, bool):
64
+ raise ValueError(f"{prefix}{field} must be numeric. Got bool value {value!r}")
65
+ if isinstance(value, (int, float)):
66
+ return value
67
+ if isinstance(value, str):
68
+ stripped = value.strip()
69
+ if not stripped:
70
+ raise ValueError(f"{prefix}{field} must be numeric. Got empty string.")
71
+ try:
72
+ number = float(stripped)
73
+ return int(number) if number.is_integer() else number
74
+ except ValueError as exc:
75
+ raise ValueError(f"{prefix}{field} must be numeric. Got {value!r}") from exc
76
+ raise ValueError(f"{prefix}{field} must be numeric. Got {type(value).__name__}")
77
+
78
+
79
+ def apply_config_defaults(config: dict[str, Any]) -> None:
80
+ """Apply default layout, padding, and font weight settings to the configuration.
81
+
82
+ This function centralizes ALL default values to prevent silent failures.
83
+ Templates should only use .get() as a safety net with matching defaults.
84
+
85
+ Args:
86
+ config: The configuration dictionary to modify in-place.
87
+
88
+ """
89
+ # ==========================================================================
90
+ # Page Dimensions (A4 default)
91
+ # ==========================================================================
92
+ config.setdefault("page_width", 210)
93
+ config.setdefault("page_height", 297)
94
+ config.setdefault("sidebar_width", 65)
95
+
96
+ # ==========================================================================
97
+ # Base Padding
98
+ # ==========================================================================
99
+ config.setdefault("padding", 12)
100
+ base_padding = config["padding"]
101
+
102
+ # ==========================================================================
103
+ # Sidebar Padding (derived from base padding)
104
+ # ==========================================================================
105
+ config.setdefault("sidebar_padding_left", base_padding - 2)
106
+ config.setdefault("sidebar_padding_right", base_padding - 2)
107
+ config.setdefault("sidebar_padding_top", 0)
108
+ config.setdefault("sidebar_padding_bottom", base_padding)
109
+
110
+ # ==========================================================================
111
+ # Pitch Section Padding
112
+ # ==========================================================================
113
+ config.setdefault("pitch_padding_top", 10)
114
+ config.setdefault("pitch_padding_bottom", 8)
115
+ config.setdefault("pitch_padding_left", 6)
116
+
117
+ # ==========================================================================
118
+ # Heading Padding
119
+ # ==========================================================================
120
+ config.setdefault("h2_padding_left", 6)
121
+ config.setdefault("h2_padding_top", 8)
122
+ config.setdefault("h3_padding_top", 7)
123
+
124
+ # ==========================================================================
125
+ # Section Layout
126
+ # ==========================================================================
127
+ config.setdefault("section_heading_margin_top", 7)
128
+ config.setdefault("section_heading_margin_bottom", 1)
129
+ config.setdefault("section_heading_text_margin", -6)
130
+ config.setdefault("section_icon_design_scale", 1)
131
+ config.setdefault("entry_margin_bottom", 4)
132
+ config.setdefault("tech_stack_margin_bottom", 2)
133
+
134
+ # ==========================================================================
135
+ # Section Icon Settings
136
+ # ==========================================================================
137
+ config.setdefault("section_icon_circle_size", 7.8)
138
+ config.setdefault("section_icon_circle_x_offset", 0)
139
+ config.setdefault("section_icon_design_size", 3.5)
140
+ config.setdefault("section_icon_design_x_offset", 0)
141
+ config.setdefault("section_icon_design_y_offset", 0)
142
+
143
+ # ==========================================================================
144
+ # Container Widths & Padding
145
+ # ==========================================================================
146
+ config.setdefault("date_container_width", 15)
147
+ config.setdefault("description_container_padding_left", 4)
148
+ config.setdefault("skill_container_padding_top", 3)
149
+ config.setdefault("skill_spacer_padding_top", 3)
150
+ config.setdefault("profile_image_padding_bottom", 8)
151
+
152
+ # ==========================================================================
153
+ # Frame (Preview Mode)
154
+ # ==========================================================================
155
+ config.setdefault("frame_padding", 15)
156
+
157
+ # ==========================================================================
158
+ # Cover Page Padding
159
+ # ==========================================================================
160
+ config.setdefault("cover_padding_top", 15)
161
+ config.setdefault("cover_padding_bottom", 15)
162
+ config.setdefault("cover_padding_h", 20)
163
+
164
+ # ==========================================================================
165
+ # Contact Section
166
+ # ==========================================================================
167
+ config.setdefault("contact_icon_size", 5)
168
+ config.setdefault("contact_icon_margin_top", 0.5)
169
+ config.setdefault("contact_text_padding_left", 2)
170
+
171
+ # ==========================================================================
172
+ # Font Settings
173
+ # ==========================================================================
174
+ config.setdefault("bold_font_weight", 600)
175
+ config.setdefault("description_font_size", 8.5)
176
+ config.setdefault("date_font_size", 9)
177
+ config.setdefault("sidebar_font_size", 8.5)
178
+
179
+
180
+ def validate_dimensions(config: dict[str, Any], filename_prefix: str) -> None:
181
+ """Validate page and sidebar dimensions in the configuration.
182
+
183
+ Ensures that width and height are positive, and sidebar width is less
184
+ than page width.
185
+
186
+ Args:
187
+ config: The configuration dictionary containing dimension settings.
188
+ filename_prefix: A prefix for error messages, typically the source filename.
189
+
190
+ Raises:
191
+ ValueError: If any dimension is invalid.
192
+
193
+ """
194
+ page_width = _coerce_number(
195
+ config.get("page_width"), field="page_width", prefix=filename_prefix
196
+ )
197
+ page_height = _coerce_number(
198
+ config.get("page_height"), field="page_height", prefix=filename_prefix
199
+ )
200
+ sidebar_width = _coerce_number(
201
+ config.get("sidebar_width"), field="sidebar_width", prefix=filename_prefix
202
+ )
203
+
204
+ if page_width is not None and page_width <= 0:
205
+ raise ValueError(
206
+ f"{filename_prefix}Invalid resume config: page_width must be positive. "
207
+ f"Got page_width={config.get('page_width')}"
208
+ )
209
+
210
+ if page_height is not None and page_height <= 0:
211
+ raise ValueError(
212
+ f"{filename_prefix}Invalid resume config: page_height must be positive. "
213
+ f"Got page_height={config.get('page_height')}"
214
+ )
215
+
216
+ if page_width is not None:
217
+ config["page_width"] = page_width
218
+ if page_height is not None:
219
+ config["page_height"] = page_height
220
+ if sidebar_width is not None:
221
+ config["sidebar_width"] = sidebar_width
222
+
223
+ if sidebar_width is not None:
224
+ if sidebar_width <= 0:
225
+ raise ValueError(
226
+ f"{filename_prefix}Sidebar width must be positive. "
227
+ f"Got {config.get('sidebar_width')}"
228
+ )
229
+ if page_width is not None and sidebar_width >= page_width:
230
+ raise ValueError(
231
+ f"{filename_prefix}Sidebar width ({config.get('sidebar_width')}mm) "
232
+ f"must be less than page width ({config.get('page_width')}mm)"
233
+ )
234
+
235
+
236
+ def _normalize_color_scheme(config: dict[str, Any]) -> None:
237
+ """Normalize the 'color_scheme' field in the configuration.
238
+
239
+ Ensures 'color_scheme' is a string and defaults to "default" if empty or
240
+ not a string.
241
+
242
+ Args:
243
+ config: The configuration dictionary to modify in-place.
244
+
245
+ """
246
+ raw_scheme = config.get("color_scheme", "")
247
+ if isinstance(raw_scheme, str):
248
+ config["color_scheme"] = raw_scheme.strip() or "default"
249
+ else:
250
+ config["color_scheme"] = "default"
251
+
252
+
253
+ def _validate_color_fields(config: dict[str, Any], filename_prefix: str) -> None:
254
+ """Validate and set default values for color-related fields in the configuration.
255
+
256
+ Args:
257
+ config: The configuration dictionary to modify in-place.
258
+ filename_prefix: A prefix for error messages, typically the source filename.
259
+
260
+ Raises:
261
+ ValueError: If a color value is invalid or not a hex string.
262
+
263
+ """
264
+ for field in CONFIG_COLOR_FIELDS:
265
+ value = config.get(field)
266
+ if not value:
267
+ default_value = DEFAULT_COLOR_SCHEME.get(field)
268
+ if default_value:
269
+ config[field] = default_value
270
+ value = config.get(field)
271
+ if value is None:
272
+ continue
273
+ if not isinstance(value, str):
274
+ raise ValueError(
275
+ f"{filename_prefix}Invalid color format for '{field}': {value}. "
276
+ "Expected hex color string."
277
+ )
278
+ if not is_valid_color(value):
279
+ raise ValueError(
280
+ f"{filename_prefix}Invalid color format for '{field}': {value}. "
281
+ "Expected hex color like '#0395DE' or '#FFF'"
282
+ )
283
+
284
+
285
+ def _auto_calculate_sidebar_text_color(config: dict[str, Any]) -> None:
286
+ """Automatically calculate and set the sidebar text color for contrast.
287
+
288
+ Args:
289
+ config: The configuration dictionary to modify in-place.
290
+
291
+ """
292
+ config["sidebar_text_color"] = ColorCalculationService.calculate_sidebar_text_color(
293
+ config
294
+ )
295
+
296
+
297
+ def _handle_sidebar_bold_color(config: dict[str, Any], filename_prefix: str) -> None:
298
+ """Handle explicit or automatically calculated sidebar bold color.
299
+
300
+ If 'sidebar_bold_color' is explicitly set, validates it.
301
+ Otherwise, calculates it using ColorCalculationService.
302
+
303
+ Args:
304
+ config: The configuration dictionary to modify in-place.
305
+ filename_prefix: A prefix for error messages.
306
+
307
+ Raises:
308
+ ValueError: If an explicitly set color is invalid.
309
+
310
+ """
311
+ explicit_color = config.get("sidebar_bold_color")
312
+ if explicit_color:
313
+ if not isinstance(explicit_color, str):
314
+ raise ValueError(
315
+ f"{filename_prefix}Invalid color format for 'sidebar_bold_color': "
316
+ f"{explicit_color}. Expected hex color string."
317
+ )
318
+ if not is_valid_color(explicit_color):
319
+ raise ValueError(
320
+ f"{filename_prefix}Invalid color format for 'sidebar_bold_color': "
321
+ f"{explicit_color}. Expected hex color like '#0395DE' or '#FFF'"
322
+ )
323
+ return
324
+
325
+ config["sidebar_bold_color"] = ColorCalculationService.calculate_sidebar_bold_color(
326
+ config
327
+ )
328
+
329
+
330
+ def _handle_icon_color(config: dict[str, Any], filename_prefix: str) -> None:
331
+ """Handle explicit or automatically calculated icon colors.
332
+
333
+ If 'heading_icon_color' is explicitly set, validates it.
334
+ Otherwise, calculates heading and sidebar icon colors using ColorCalculationService.
335
+
336
+ Args:
337
+ config: The configuration dictionary to modify in-place.
338
+ filename_prefix: A prefix for error messages.
339
+
340
+ Raises:
341
+ ValueError: If an explicitly set heading icon color is invalid.
342
+
343
+ """
344
+ heading_icon_color = config.get("heading_icon_color")
345
+ if heading_icon_color:
346
+ if not isinstance(heading_icon_color, str):
347
+ raise ValueError(
348
+ f"{filename_prefix}Invalid color format for 'heading_icon_color': "
349
+ f"{heading_icon_color}. Expected hex color string."
350
+ )
351
+ if not is_valid_color(heading_icon_color):
352
+ raise ValueError(
353
+ f"{filename_prefix}Invalid color format for 'heading_icon_color': "
354
+ f"{heading_icon_color}. Expected hex color like '#0395DE' or '#FFF'"
355
+ )
356
+
357
+ config["heading_icon_color"] = ColorCalculationService.calculate_heading_icon_color(
358
+ config
359
+ )
360
+ config["sidebar_icon_color"] = ColorCalculationService.calculate_sidebar_icon_color(
361
+ config
362
+ )
363
+
364
+
365
+ def _handle_bold_color(config: dict[str, Any], filename_prefix: str) -> None:
366
+ """Handle explicit or automatically calculated bold text color.
367
+
368
+ If 'bold_color' is explicitly set, validates it.
369
+ Otherwise, derives it from 'frame_color' or uses a default.
370
+
371
+ Args:
372
+ config: The configuration dictionary to modify in-place.
373
+ filename_prefix: A prefix for error messages.
374
+
375
+ Raises:
376
+ ValueError: If an explicitly set bold color is invalid.
377
+
378
+ """
379
+ bold_color = config.get("bold_color")
380
+ if bold_color:
381
+ if not isinstance(bold_color, str):
382
+ raise ValueError(
383
+ f"{filename_prefix}Invalid color format for 'bold_color': "
384
+ f"{bold_color}. Expected hex color string."
385
+ )
386
+ if not is_valid_color(bold_color):
387
+ raise ValueError(
388
+ f"{filename_prefix}Invalid color format for 'bold_color': "
389
+ f"{bold_color}. Expected hex color like '#0395DE' or '#FFF'"
390
+ )
391
+ return
392
+
393
+ frame_color = config.get("frame_color")
394
+ if isinstance(frame_color, str) and is_valid_color(frame_color):
395
+ config["bold_color"] = darken_color(frame_color, BOLD_DARKEN_FACTOR)
396
+ return
397
+ config["bold_color"] = DEFAULT_COLOR_SCHEME.get("bold_color", DEFAULT_BOLD_COLOR)
398
+
399
+
400
+ def prepare_config(config: dict[str, Any], *, filename: str = "") -> bool:
401
+ """Apply defaults and validate dimensions prior to palette resolution."""
402
+ filename_prefix = f"{filename}: " if filename else ""
403
+ apply_config_defaults(config)
404
+ validate_dimensions(config, filename_prefix)
405
+ return bool(config.get("sidebar_text_color"))
406
+
407
+
408
+ def finalize_config(
409
+ config: dict[str, Any],
410
+ *,
411
+ filename: str = "",
412
+ sidebar_text_locked: bool = False,
413
+ ) -> None:
414
+ """Finalize color fields after palette resolution."""
415
+ filename_prefix = f"{filename}: " if filename else ""
416
+ _normalize_color_scheme(config)
417
+ _validate_color_fields(config, filename_prefix)
418
+ if not sidebar_text_locked:
419
+ _auto_calculate_sidebar_text_color(config)
420
+ _handle_icon_color(config, filename_prefix)
421
+ _handle_bold_color(config, filename_prefix)
422
+ _handle_sidebar_bold_color(config, filename_prefix)
423
+
424
+
425
+ # ============================================================================
426
+ # Configuration Processing (Consolidated from configuration_processor.py)
427
+ # ============================================================================
428
+
429
+
430
+ def _is_direct_color_block(block: dict[str, Any]) -> bool:
431
+ """Check if block contains direct color definitions (not palette config)."""
432
+ if not isinstance(block, dict):
433
+ return False
434
+
435
+ has_direct_colors = any(field in block for field in CONFIG_DIRECT_COLOR_KEYS)
436
+ palette_block_keys = {
437
+ "source",
438
+ "name",
439
+ "colors",
440
+ "size",
441
+ "seed",
442
+ "hue_range",
443
+ "luminance_range",
444
+ "chroma",
445
+ "keywords",
446
+ "num_results",
447
+ "order_by",
448
+ }
449
+ has_palette_config = any(key in block for key in palette_block_keys)
450
+ return has_direct_colors and not has_palette_config
451
+
452
+
453
+ def _process_direct_colors(
454
+ config: dict[str, Any],
455
+ block: dict[str, Any],
456
+ ) -> dict[str, Any] | None:
457
+ """Process direct color definitions by merging into config."""
458
+ # Direct color definitions: merge into config directly
459
+ for field in CONFIG_DIRECT_COLOR_KEYS:
460
+ if field in block:
461
+ config[field] = block[field]
462
+
463
+ # Automatically calculate sidebar text color based on sidebar background
464
+ if config.get("sidebar_color"):
465
+ config["sidebar_text_color"] = get_contrasting_text_color(
466
+ config["sidebar_color"]
467
+ )
468
+
469
+ # Return metadata indicating a direct color definition
470
+ return {
471
+ "source": "direct",
472
+ "fields": [f for f in CONFIG_DIRECT_COLOR_KEYS if f in block],
473
+ }
474
+
475
+
476
+ def _resolve_palette_block(
477
+ block: dict[str, Any],
478
+ *,
479
+ registry: PaletteRegistry,
480
+ palette_fetcher: Callable[[PaletteFetchRequest], tuple[list[str], dict[str, Any]]]
481
+ | None = None,
482
+ ) -> tuple[list[str] | None, dict[str, Any]]:
483
+ """Resolve palette block to color swatches and metadata.
484
+
485
+ This function uses dependency injection for both registry lookup and
486
+ network operations. The registry is required for local palette lookups.
487
+ If a palette_fetcher is provided, it will be used for remote palettes.
488
+
489
+ Args:
490
+ block: Palette configuration block.
491
+ registry: Palette registry for looking up named palettes (required).
492
+ palette_fetcher: Optional callable that executes PaletteFetchRequest.
493
+
494
+ Returns:
495
+ Tuple of (colors, metadata).
496
+
497
+ Raises:
498
+ PaletteLookupError: If palette cannot be resolved.
499
+ PaletteError: If palette configuration is invalid.
500
+
501
+ """
502
+ # Use the new pure resolution system with injected registry
503
+ resolution = resolve_palette_config(block, registry=registry)
504
+
505
+ if resolution.has_colors:
506
+ # Local sources (registry, generator) - already have colors
507
+ return resolution.colors, resolution.metadata or {}
508
+
509
+ elif resolution.needs_fetch:
510
+ # Remote source - need to execute fetch request via injected dependency
511
+ if palette_fetcher is None:
512
+ raise PaletteLookupError(
513
+ "Remote palette fetching requires palette_fetcher parameter. "
514
+ "Provide a fetch function from the shell layer."
515
+ )
516
+
517
+ # Execute the network operation using the injected fetcher
518
+ if resolution.fetch_request is None:
519
+ raise PaletteLookupError(
520
+ "Internal error: fetch_request is None despite needs_fetch being True"
521
+ )
522
+ return palette_fetcher(resolution.fetch_request)
523
+
524
+ else:
525
+ raise PaletteLookupError(f"Unable to resolve palette: {block}")
526
+
527
+
528
+ def _process_palette_colors(
529
+ config: dict[str, Any],
530
+ block: dict[str, Any],
531
+ *,
532
+ registry: PaletteRegistry,
533
+ palette_fetcher: Callable[[PaletteFetchRequest], tuple[list[str], dict[str, Any]]]
534
+ | None = None,
535
+ ) -> dict[str, Any] | None:
536
+ """Process palette block by resolving colors and applying to config."""
537
+ try:
538
+ swatches, palette_meta = _resolve_palette_block(
539
+ block, registry=registry, palette_fetcher=palette_fetcher
540
+ )
541
+ except PaletteError:
542
+ raise
543
+ except (TypeError, ValueError, KeyError, AttributeError) as exc:
544
+ # Common errors when palette configuration is malformed
545
+ raise PaletteError(f"Invalid palette block: {exc}") from exc
546
+
547
+ if not swatches:
548
+ return None
549
+
550
+ # Cycle through swatches to cover all required fields
551
+ iterator = cycle(swatches)
552
+ for field in CONFIG_COLOR_FIELDS:
553
+ if field not in config or not config[field]:
554
+ config[field] = next(iterator)
555
+
556
+ # Automatically calculate sidebar text color based on sidebar background
557
+ if config.get("sidebar_color"):
558
+ config["sidebar_text_color"] = get_contrasting_text_color(
559
+ config["sidebar_color"]
560
+ )
561
+
562
+ # Set color scheme name if provided
563
+ if "color_scheme" not in config and "name" in block:
564
+ config["color_scheme"] = str(block["name"])
565
+
566
+ return palette_meta
567
+
568
+
569
+ def apply_palette_block(
570
+ config: dict[str, Any],
571
+ *,
572
+ registry: PaletteRegistry,
573
+ palette_fetcher: Callable[[PaletteFetchRequest], tuple[list[str], dict[str, Any]]]
574
+ | None = None,
575
+ ) -> dict[str, Any] | None:
576
+ """Apply a palette block to the configuration using simplified logic.
577
+
578
+ Args:
579
+ config: Configuration dictionary to modify.
580
+ registry: Palette registry for looking up named palettes (required).
581
+ palette_fetcher: Optional callable to handle remote palette fetching.
582
+
583
+ """
584
+ block = config.get("palette")
585
+ if not isinstance(block, dict):
586
+ return None
587
+
588
+ # Simple conditional logic instead of complex Strategy pattern
589
+ if _is_direct_color_block(block):
590
+ return _process_direct_colors(config, block)
591
+ else:
592
+ return _process_palette_colors(
593
+ config, block, registry=registry, palette_fetcher=palette_fetcher
594
+ )
595
+
596
+
597
+ def normalize_config(
598
+ raw_config: dict[str, Any],
599
+ filename: str = "",
600
+ *,
601
+ registry: PaletteRegistry,
602
+ palette_fetcher: Callable[[PaletteFetchRequest], tuple[list[str], dict[str, Any]]]
603
+ | None = None,
604
+ ) -> tuple[dict[str, Any], dict[str, Any] | None]:
605
+ """Return a normalized copy of the configuration.
606
+
607
+ Args:
608
+ raw_config: Raw configuration dictionary.
609
+ filename: Source filename for error messages.
610
+ registry: Palette registry for looking up named palettes (required).
611
+ palette_fetcher: Optional callable to handle remote palette fetching.
612
+
613
+ Returns:
614
+ Tuple of (normalized_config, palette_metadata).
615
+
616
+ """
617
+ working = copy.deepcopy(raw_config)
618
+ sidebar_locked = prepare_config(working, filename=filename)
619
+ palette_meta = apply_palette_block(
620
+ working,
621
+ registry=registry,
622
+ palette_fetcher=palette_fetcher,
623
+ )
624
+ finalize_config(
625
+ working,
626
+ filename=filename,
627
+ sidebar_text_locked=sidebar_locked,
628
+ )
629
+ return working, palette_meta
630
+
631
+
632
+ def validate_config(
633
+ config: dict[str, Any],
634
+ filename: str = "",
635
+ *,
636
+ registry: PaletteRegistry,
637
+ palette_fetcher: Callable[[PaletteFetchRequest], tuple[list[str], dict[str, Any]]]
638
+ | None = None,
639
+ ) -> None:
640
+ """Normalize the configuration in-place if present (pure operation).
641
+
642
+ Args:
643
+ config: Configuration dictionary to normalize in-place.
644
+ filename: Source filename for error messages.
645
+ registry: Palette registry for looking up named palettes (required).
646
+ palette_fetcher: Optional callable to handle remote palette fetching.
647
+
648
+ """
649
+ if not config:
650
+ return
651
+ normalized, _ = normalize_config(
652
+ config,
653
+ filename=filename,
654
+ registry=registry,
655
+ palette_fetcher=palette_fetcher,
656
+ )
657
+ config.clear()
658
+ config.update(normalized)
659
+
660
+
661
+ __all__ = [
662
+ "CONFIG_COLOR_FIELDS",
663
+ "DEFAULT_BOLD_COLOR",
664
+ "DEFAULT_COLOR_SCHEME",
665
+ "CONFIG_DIRECT_COLOR_KEYS",
666
+ "apply_palette_block",
667
+ "finalize_config",
668
+ "prepare_config",
669
+ "_resolve_palette_block",
670
+ "normalize_config",
671
+ "validate_config",
672
+ ]