figrecipe 0.5.0__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.
@@ -0,0 +1,450 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Style loader for figrecipe.
4
+
5
+ Loads style configuration from YAML file and provides centralized access
6
+ to all style parameters.
7
+
8
+ Usage:
9
+ from figrecipe.styles import load_style, get_style, STYLE
10
+
11
+ # Load default style
12
+ style = load_style()
13
+ fig, ax = ps.subplots(**style.to_subplots_kwargs())
14
+
15
+ # Access individual style parameters
16
+ line_width = STYLE.lines.trace_mm
17
+ """
18
+
19
+ __all__ = [
20
+ "load_style",
21
+ "unload_style",
22
+ "get_style",
23
+ "reload_style",
24
+ "list_presets",
25
+ "STYLE",
26
+ "to_subplots_kwargs",
27
+ ]
28
+
29
+ from pathlib import Path
30
+ from typing import Any, Dict, List, Optional, Union
31
+
32
+ from ruamel.yaml import YAML
33
+
34
+
35
+ # Path to presets directory
36
+ _PRESETS_DIR = Path(__file__).parent / "presets"
37
+
38
+ # Path to default style file (for backwards compatibility)
39
+ _DEFAULT_STYLE_PATH = Path(__file__).parent / "FIGRECIPE_STYLE.yaml"
40
+
41
+ # Preset aliases (for branding - FigRecipe is part of SciTeX ecosystem)
42
+ _PRESET_ALIASES = {
43
+ "FIGRECIPE": "SCITEX",
44
+ }
45
+
46
+ # Global style cache
47
+ _STYLE_CACHE: Optional["DotDict"] = None
48
+ _CURRENT_STYLE_NAME: Optional[str] = None
49
+
50
+
51
+ def list_presets() -> List[str]:
52
+ """List available style presets.
53
+
54
+ Returns
55
+ -------
56
+ list of str
57
+ Names of available presets.
58
+ Use `dark=True` parameter for dark variants.
59
+
60
+ Examples
61
+ --------
62
+ >>> fr.list_presets()
63
+ ['MATPLOTLIB', 'SCITEX']
64
+
65
+ >>> fr.load_style("SCITEX") # Scientific publication style
66
+ >>> fr.load_style("SCITEX", dark=True) # Dark variant
67
+ >>> fr.load_style("MATPLOTLIB") # Vanilla matplotlib
68
+ """
69
+ # Show only user-facing presets (not internal file names)
70
+ return ["MATPLOTLIB", "SCITEX"]
71
+
72
+
73
+ class DotDict(dict):
74
+ """Dictionary with dot-notation access to nested keys.
75
+
76
+ Examples
77
+ --------
78
+ >>> d = DotDict({"axes": {"width_mm": 40}})
79
+ >>> d.axes.width_mm
80
+ 40
81
+ """
82
+
83
+ def __getattr__(self, key: str) -> Any:
84
+ # Handle special methods first
85
+ if key == 'to_subplots_kwargs':
86
+ return lambda: to_subplots_kwargs(self)
87
+ try:
88
+ value = self[key]
89
+ if isinstance(value, dict) and not isinstance(value, DotDict):
90
+ value = DotDict(value)
91
+ self[key] = value
92
+ return value
93
+ except KeyError:
94
+ raise AttributeError(f"'{type(self).__name__}' has no attribute '{key}'")
95
+
96
+ def __setattr__(self, key: str, value: Any) -> None:
97
+ self[key] = value
98
+
99
+ def __delattr__(self, key: str) -> None:
100
+ try:
101
+ del self[key]
102
+ except KeyError:
103
+ raise AttributeError(f"'{type(self).__name__}' has no attribute '{key}'")
104
+
105
+ def get(self, key: str, default: Any = None) -> Any:
106
+ """Get value with default, supporting nested keys with dots."""
107
+ if "." in key:
108
+ parts = key.split(".")
109
+ value = self
110
+ for part in parts:
111
+ if isinstance(value, dict) and part in value:
112
+ value = value[part]
113
+ else:
114
+ return default
115
+ return value
116
+ return super().get(key, default)
117
+
118
+
119
+ def _deep_merge(base: Dict, override: Dict) -> Dict:
120
+ """Deep merge two dictionaries, with override taking precedence."""
121
+ result = base.copy()
122
+ for key, value in override.items():
123
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
124
+ result[key] = _deep_merge(result[key], value)
125
+ else:
126
+ result[key] = value
127
+ return result
128
+
129
+
130
+ def _load_yaml(path: Union[str, Path]) -> Dict:
131
+ """Load YAML file and return as dictionary."""
132
+ yaml = YAML()
133
+ yaml.preserve_quotes = True
134
+ with open(path, "r") as f:
135
+ return dict(yaml.load(f))
136
+
137
+
138
+ def reload_style(style: Optional[Union[str, Path]] = None) -> DotDict:
139
+ """Reload style from YAML file (clears cache).
140
+
141
+ Parameters
142
+ ----------
143
+ style : str or Path, optional
144
+ Style preset name (e.g., "SCIENTIFIC") or path to YAML file.
145
+ If None, uses default SCIENTIFIC preset.
146
+
147
+ Returns
148
+ -------
149
+ DotDict
150
+ Style configuration as DotDict for dot-access
151
+ """
152
+ global _STYLE_CACHE, _CURRENT_STYLE_NAME
153
+ _STYLE_CACHE = None
154
+ _CURRENT_STYLE_NAME = None
155
+ return load_style(style)
156
+
157
+
158
+ def _apply_dark_theme(style_dict: Dict) -> Dict:
159
+ """Apply dark theme transformation to a style dictionary.
160
+
161
+ Changes only UI elements (background, text, spines, ticks) -
162
+ NOT the data/figure colors for scientific integrity.
163
+
164
+ Parameters
165
+ ----------
166
+ style_dict : dict
167
+ Original style dictionary
168
+
169
+ Returns
170
+ -------
171
+ dict
172
+ Style dictionary with dark theme applied
173
+ """
174
+ import copy
175
+ result = copy.deepcopy(style_dict)
176
+
177
+ # Monaco/VS Code dark theme colors (from scitex-cloud UIUX.md)
178
+ dark_colors = {
179
+ "figure_bg": "#1e1e1e", # VS Code main background
180
+ "axes_bg": "#1e1e1e", # Same as figure background
181
+ "legend_bg": "#1e1e1e", # Same as figure background
182
+ "text": "#d4d4d4", # VS Code default text
183
+ "spine": "#3c3c3c", # Subtle border color
184
+ "tick": "#d4d4d4", # Match text
185
+ "grid": "#3a3a3a", # Subtle grid
186
+ }
187
+
188
+ # Update theme section
189
+ if "theme" not in result:
190
+ result["theme"] = {}
191
+ result["theme"]["mode"] = "dark"
192
+ result["theme"]["dark"] = dark_colors
193
+
194
+ # Update output to not be transparent (dark bg needs to show)
195
+ if "output" in result:
196
+ result["output"]["transparent"] = False
197
+
198
+ return result
199
+
200
+
201
+ def unload_style() -> None:
202
+ """Unload the current style and reset to matplotlib defaults.
203
+
204
+ After calling this, subsequent `subplots()` calls will use vanilla
205
+ matplotlib behavior without FigRecipe styling.
206
+
207
+ Examples
208
+ --------
209
+ >>> import figrecipe as fr
210
+ >>> fr.load_style("SCITEX") # Apply scientific style
211
+ >>> fig, ax = fr.subplots() # Styled
212
+ >>> fr.unload_style() # Reset to matplotlib defaults
213
+ >>> fig, ax = fr.subplots() # Vanilla matplotlib
214
+ """
215
+ global _STYLE_CACHE, _CURRENT_STYLE_NAME
216
+ _STYLE_CACHE = None
217
+ _CURRENT_STYLE_NAME = None
218
+
219
+ # Reset matplotlib rcParams to defaults
220
+ import matplotlib as mpl
221
+ mpl.rcParams.update(mpl.rcParamsDefault)
222
+
223
+
224
+ def load_style(style: Optional[Union[str, Path, bool]] = "SCITEX", dark: bool = False) -> Optional[DotDict]:
225
+ """Load style configuration from preset or YAML file.
226
+
227
+ Parameters
228
+ ----------
229
+ style : str, Path, bool, or None
230
+ One of:
231
+ - "SCITEX" / "FIGRECIPE": Scientific publication style (default)
232
+ - "MATPLOTLIB": Vanilla matplotlib defaults
233
+ - Path to custom YAML file: "/path/to/my_style.yaml"
234
+ - None or False: Unload style (reset to matplotlib defaults)
235
+ dark : bool, optional
236
+ If True, apply dark theme transformation (default: False)
237
+
238
+ Returns
239
+ -------
240
+ DotDict or None
241
+ Style configuration as DotDict for dot-access.
242
+ Returns None if style is unloaded.
243
+
244
+ Examples
245
+ --------
246
+ >>> import figrecipe as fr
247
+
248
+ >>> # Load scientific style (default)
249
+ >>> style = fr.load_style()
250
+ >>> style = fr.load_style("SCITEX") # explicit
251
+
252
+ >>> # Load dark variant
253
+ >>> fr.load_style("SCITEX_DARK")
254
+ >>> fr.load_style("SCITEX", dark=True) # equivalent
255
+
256
+ >>> # Reset to vanilla matplotlib
257
+ >>> fr.load_style(None) # unload
258
+ >>> fr.load_style(False) # unload
259
+ >>> fr.load_style("MATPLOTLIB") # explicit vanilla
260
+
261
+ >>> # Load custom YAML file
262
+ >>> fr.load_style("/path/to/my_style.yaml")
263
+
264
+ >>> # Access style values
265
+ >>> style = fr.load_style("SCITEX")
266
+ >>> style.fonts.axis_label_pt
267
+ 7
268
+ """
269
+ global _STYLE_CACHE, _CURRENT_STYLE_NAME
270
+
271
+ # Handle None or False as unload
272
+ if style is None or style is False:
273
+ unload_style()
274
+ return None
275
+
276
+ # Handle _DARK suffix in style name
277
+ apply_dark = dark
278
+ base_style = style
279
+ if isinstance(style, str) and style.upper().endswith("_DARK"):
280
+ apply_dark = True
281
+ base_style = style[:-5] # Remove "_DARK" suffix
282
+
283
+ # Build cache key
284
+ cache_key = f"{base_style}{'_DARK' if apply_dark else ''}"
285
+
286
+ # Use cache if available and same style requested
287
+ if _STYLE_CACHE is not None and cache_key == _CURRENT_STYLE_NAME:
288
+ return _STYLE_CACHE
289
+
290
+ # Resolve aliases (e.g., SCITEX -> FIGRECIPE)
291
+ if isinstance(base_style, str):
292
+ resolved_style = _PRESET_ALIASES.get(base_style.upper(), base_style)
293
+ else:
294
+ resolved_style = base_style
295
+
296
+ # Determine the style path
297
+ if isinstance(resolved_style, Path) or (isinstance(resolved_style, str) and ("/" in resolved_style or "\\" in resolved_style or resolved_style.endswith(".yaml"))):
298
+ # Explicit file path
299
+ style_path = Path(resolved_style)
300
+ style_name = str(resolved_style)
301
+ else:
302
+ # Preset name (e.g., "FIGRECIPE", "MATPLOTLIB")
303
+ style_path = _PRESETS_DIR / f"{resolved_style.upper()}.yaml"
304
+ style_name = resolved_style.upper()
305
+
306
+ # Check if file exists
307
+ if not style_path.exists():
308
+ available = list_presets()
309
+ raise FileNotFoundError(
310
+ f"Style not found: {style}\n"
311
+ f"Available presets: {available}\n"
312
+ f"Or provide a path to a custom YAML file."
313
+ )
314
+
315
+ style_dict = _load_yaml(style_path)
316
+
317
+ # Apply dark theme if requested
318
+ if apply_dark:
319
+ style_dict = _apply_dark_theme(style_dict)
320
+ style_name = f"{style_name}_DARK"
321
+
322
+ # Convert to DotDict for convenient access
323
+ result = DotDict(style_dict)
324
+
325
+ # Cache the style
326
+ _STYLE_CACHE = result
327
+ _CURRENT_STYLE_NAME = style_name
328
+
329
+ return result
330
+
331
+
332
+ def get_style() -> DotDict:
333
+ """Get the current loaded style (loads default if not yet loaded).
334
+
335
+ Returns
336
+ -------
337
+ DotDict
338
+ Current style configuration
339
+ """
340
+ global _STYLE_CACHE
341
+ if _STYLE_CACHE is None:
342
+ return load_style()
343
+ return _STYLE_CACHE
344
+
345
+
346
+ def to_subplots_kwargs(style: Optional[DotDict] = None) -> Dict[str, Any]:
347
+ """Convert style DotDict to kwargs for ps.subplots().
348
+
349
+ Parameters
350
+ ----------
351
+ style : DotDict, optional
352
+ Style configuration. If None, uses current loaded style.
353
+
354
+ Returns
355
+ -------
356
+ dict
357
+ Keyword arguments for ps.subplots()
358
+
359
+ Examples
360
+ --------
361
+ >>> style = load_style()
362
+ >>> kwargs = to_subplots_kwargs(style)
363
+ >>> fig, ax = ps.subplots(**kwargs)
364
+ """
365
+ if style is None:
366
+ style = get_style()
367
+
368
+ result = {
369
+ # Axes dimensions
370
+ "axes_width_mm": style.axes.width_mm,
371
+ "axes_height_mm": style.axes.height_mm,
372
+ "axes_thickness_mm": style.axes.thickness_mm,
373
+ # Margins
374
+ "margin_left_mm": style.margins.left_mm,
375
+ "margin_right_mm": style.margins.right_mm,
376
+ "margin_bottom_mm": style.margins.bottom_mm,
377
+ "margin_top_mm": style.margins.top_mm,
378
+ # Spacing
379
+ "space_w_mm": style.spacing.horizontal_mm,
380
+ "space_h_mm": style.spacing.vertical_mm,
381
+ # Ticks
382
+ "tick_length_mm": style.ticks.length_mm,
383
+ "tick_thickness_mm": style.ticks.thickness_mm,
384
+ "n_ticks": style.ticks.n_ticks,
385
+ # Lines
386
+ "trace_thickness_mm": style.lines.trace_mm,
387
+ # Markers
388
+ "marker_size_mm": style.markers.size_mm,
389
+ # Fonts
390
+ "font_family": style.fonts.family,
391
+ "axis_font_size_pt": style.fonts.axis_label_pt,
392
+ "tick_font_size_pt": style.fonts.tick_label_pt,
393
+ "title_font_size_pt": style.fonts.title_pt,
394
+ "suptitle_font_size_pt": style.fonts.suptitle_pt,
395
+ "legend_font_size_pt": style.fonts.legend_pt,
396
+ # Padding
397
+ "label_pad_pt": style.padding.label_pt,
398
+ "tick_pad_pt": style.padding.tick_pt,
399
+ "title_pad_pt": style.padding.title_pt,
400
+ # Output
401
+ "dpi": style.output.dpi,
402
+ # Theme
403
+ "theme": style.theme.mode,
404
+ }
405
+
406
+ # Add theme colors from preset if available
407
+ theme_mode = style.theme.mode
408
+ if "theme" in style and theme_mode in style.theme:
409
+ result["theme_colors"] = dict(style.theme[theme_mode])
410
+
411
+ # Add color palette if available
412
+ if "colors" in style and "palette" in style.colors:
413
+ result["color_palette"] = list(style.colors.palette)
414
+
415
+ return result
416
+
417
+
418
+ # Lazy-loaded global STYLE object
419
+ class _StyleProxy:
420
+ """Proxy object that loads style on first access."""
421
+
422
+ def __getattr__(self, name: str) -> Any:
423
+ return getattr(get_style(), name)
424
+
425
+ def __repr__(self) -> str:
426
+ return repr(get_style())
427
+
428
+ def to_subplots_kwargs(self) -> Dict[str, Any]:
429
+ """Convert style to subplots kwargs."""
430
+ return to_subplots_kwargs()
431
+
432
+
433
+ STYLE = _StyleProxy()
434
+
435
+
436
+ if __name__ == "__main__":
437
+ # Test loading
438
+ print("Loading default style...")
439
+ style = load_style()
440
+ print(f" axes.width_mm: {style.axes.width_mm}")
441
+ print(f" fonts.axis_label_pt: {style.fonts.axis_label_pt}")
442
+ print(f" lines.trace_mm: {style.lines.trace_mm}")
443
+
444
+ print("\nConverting to subplots kwargs...")
445
+ kwargs = to_subplots_kwargs()
446
+ for k, v in list(kwargs.items())[:5]:
447
+ print(f" {k}: {v}")
448
+
449
+ print("\nUsing STYLE proxy...")
450
+ print(f" STYLE.fonts.family: {STYLE.fonts.family}")