eagleplot 3.0.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.
Files changed (73) hide show
  1. eagleplot/Home.py +362 -0
  2. eagleplot/__init__.py +19 -0
  3. eagleplot/annotations.py +215 -0
  4. eagleplot/assets/logo.ico +0 -0
  5. eagleplot/assets/logo.png +0 -0
  6. eagleplot/assets/nccu-horiz-logo.png +0 -0
  7. eagleplot/assets/nccu-wings.png +0 -0
  8. eagleplot/batch.py +446 -0
  9. eagleplot/cli.py +719 -0
  10. eagleplot/data_tools.py +849 -0
  11. eagleplot/example-data/Artificial/bimodal_distribution.csv +501 -0
  12. eagleplot/example-data/Artificial/correlation_matrix.npy +0 -0
  13. eagleplot/example-data/Artificial/gaussian_surface.npy +0 -0
  14. eagleplot/example-data/Artificial/grouped_categorical.csv +241 -0
  15. eagleplot/example-data/Artificial/ir_spectrum.csv +1802 -0
  16. eagleplot/example-data/Artificial/molecular_properties.csv +121 -0
  17. eagleplot/example-data/Artificial/normal_distribution.csv +401 -0
  18. eagleplot/example-data/Artificial/reaction_kinetics.csv +121 -0
  19. eagleplot/example-data/Artificial/scatter_correlation.csv +201 -0
  20. eagleplot/example-data/Artificial/sine_cosine_waves.csv +301 -0
  21. eagleplot/gallery/box_plot.png +0 -0
  22. eagleplot/gallery/contour_plot.png +0 -0
  23. eagleplot/gallery/distribution_plot.png +0 -0
  24. eagleplot/gallery/heatmap.png +0 -0
  25. eagleplot/gallery/histogram.png +0 -0
  26. eagleplot/gallery/interactive_3d_surface.png +0 -0
  27. eagleplot/gallery/interactive_heatmap.png +0 -0
  28. eagleplot/gallery/interactive_histogram.png +0 -0
  29. eagleplot/gallery/interactive_line.png +0 -0
  30. eagleplot/gallery/interactive_scatter.png +0 -0
  31. eagleplot/gallery/line_plot.png +0 -0
  32. eagleplot/gallery/manifest.json +196 -0
  33. eagleplot/gallery/regression_plot.png +0 -0
  34. eagleplot/gallery/scatter_plot.png +0 -0
  35. eagleplot/io.py +1008 -0
  36. eagleplot/math.py +1210 -0
  37. eagleplot/molecular/__init__.py +21 -0
  38. eagleplot/molecular/atom_data.py +287 -0
  39. eagleplot/molecular/parsers.py +747 -0
  40. eagleplot/nist.py +241 -0
  41. eagleplot/pages/10_Molecular_Viz.py +225 -0
  42. eagleplot/pages/11_Batch_Analysis.py +325 -0
  43. eagleplot/pages/12_Help.py +199 -0
  44. eagleplot/pages/13_Settings.py +388 -0
  45. eagleplot/pages/1_Data_Upload.py +521 -0
  46. eagleplot/pages/2_Quick_Plot.py +1693 -0
  47. eagleplot/pages/3_Analysis_Tools.py +2510 -0
  48. eagleplot/pages/4_Multi_Plot_Dashboard.py +791 -0
  49. eagleplot/pages/5_Advanced_Plotting.py +691 -0
  50. eagleplot/pages/6_Export_Results.py +442 -0
  51. eagleplot/pages/7_Gallery.py +201 -0
  52. eagleplot/pages/8_Data_Tools.py +434 -0
  53. eagleplot/pages/9_Spectroscopy.py +821 -0
  54. eagleplot/pages/__init__.py +8 -0
  55. eagleplot/peaks.py +513 -0
  56. eagleplot/plotting.py +2630 -0
  57. eagleplot/plugin_loader.py +208 -0
  58. eagleplot/report.py +448 -0
  59. eagleplot/signal.py +494 -0
  60. eagleplot/spectroscopy.py +830 -0
  61. eagleplot/utils/__init__.py +76 -0
  62. eagleplot/utils/access.py +77 -0
  63. eagleplot/utils/data_preview.py +352 -0
  64. eagleplot/utils/plot_config.py +2721 -0
  65. eagleplot/utils/session_state.py +535 -0
  66. eagleplot/utils/theming.py +312 -0
  67. eagleplot/utils/user_settings.py +294 -0
  68. eagleplot-3.0.0.dist-info/METADATA +304 -0
  69. eagleplot-3.0.0.dist-info/RECORD +73 -0
  70. eagleplot-3.0.0.dist-info/WHEEL +5 -0
  71. eagleplot-3.0.0.dist-info/entry_points.txt +2 -0
  72. eagleplot-3.0.0.dist-info/licenses/LICENSE +21 -0
  73. eagleplot-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,312 @@
1
+ """Theme reading, writing, and CSS generation for the EaglePlot GUI.
2
+
3
+ Why this module exists
4
+ ----------------------
5
+ Three related defects motivated it (audit A-17, A-18, A-19):
6
+
7
+ - **A-17** — ``Home.py`` injected a stylesheet with the NCCU dark palette baked in
8
+ as literals, while the Settings page let the user change the theme. Nothing
9
+ connected the two, so on a light theme the hero subtitle was white-on-white and
10
+ the scrollbar stayed black. Worse, two of those literals disagreed with each
11
+ other, so one was always low-contrast *whatever* theme was active.
12
+ :func:`app_css` now derives every colour from the live theme.
13
+
14
+ - **A-18** — the Settings page wrote to ``.streamlit/config.toml`` resolved relative
15
+ to the package, returned silently when the file did not exist, and reported
16
+ "Theme saved" regardless. That silent path was the default for every
17
+ pip-installed user, since the wheel does not ship ``.streamlit/``.
18
+ :func:`write_theme` targets the location Streamlit actually reads, creates it
19
+ when missing, and raises on failure so the caller can tell the truth.
20
+
21
+ - **A-19** — the old writer re-serialised the whole file from parsed TOML, which
22
+ destroyed every comment, dropped non-dict top-level values, and emitted Python
23
+ ``repr`` for lists (invalid TOML). :func:`write_theme` rewrites only the
24
+ ``[theme]`` keys and leaves the rest of the file byte-identical, writing
25
+ atomically so an interrupted save cannot truncate the config.
26
+
27
+ Config file location
28
+ --------------------
29
+ Streamlit reads ``$CWD/.streamlit/config.toml`` and ``~/.streamlit/config.toml``.
30
+ It does *not* look inside the installed package, so the project-level path
31
+ relative to the working directory is the only target that works both from a source
32
+ checkout and from a ``pip install``.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import os
38
+ import re
39
+ import tempfile
40
+ from pathlib import Path
41
+ from typing import Dict, List, Optional
42
+
43
+ # tomllib is stdlib from Python 3.11; EaglePlot supports 3.9+ (G-013).
44
+ try:
45
+ import tomllib
46
+ except ModuleNotFoundError: # Python 3.9 / 3.10
47
+ import tomli as tomllib # type: ignore[no-redef]
48
+
49
+ __all__ = [
50
+ "THEME_KEYS",
51
+ "STREAMLIT_DEFAULTS",
52
+ "get_config_path",
53
+ "read_theme",
54
+ "write_theme",
55
+ "active_theme",
56
+ "app_css",
57
+ ]
58
+
59
+ THEME_KEYS: List[str] = [
60
+ "base",
61
+ "primaryColor",
62
+ "backgroundColor",
63
+ "secondaryBackgroundColor",
64
+ "textColor",
65
+ ]
66
+
67
+ #: Streamlit's own light-theme defaults, used when nothing else is available.
68
+ STREAMLIT_DEFAULTS: Dict[str, str] = {
69
+ "base": "light",
70
+ "primaryColor": "#1f77b4",
71
+ "backgroundColor": "#ffffff",
72
+ "secondaryBackgroundColor": "#f0f2f6",
73
+ "textColor": "#262730",
74
+ }
75
+
76
+ _KEY_LINE = re.compile(r"^\s*[A-Za-z_][A-Za-z0-9_]*\s*=")
77
+ _SECTION_LINE = re.compile(r"^\s*\[")
78
+
79
+
80
+ def get_config_path() -> Path:
81
+ """Return the ``config.toml`` path Streamlit will actually read.
82
+
83
+ Returns
84
+ -------
85
+ Path
86
+ ``<cwd>/.streamlit/config.toml``. Resolved against the working directory
87
+ rather than the package, because Streamlit does not read config from
88
+ inside an installed package -- see the module docstring.
89
+ """
90
+ return Path.cwd() / ".streamlit" / "config.toml"
91
+
92
+
93
+ def read_theme() -> Dict[str, str]:
94
+ """Read the ``[theme]`` table, falling back to the live theme then defaults.
95
+
96
+ Returns
97
+ -------
98
+ dict
99
+ All five :data:`THEME_KEYS`, always populated.
100
+ """
101
+ theme = dict(STREAMLIT_DEFAULTS)
102
+ theme.update(active_theme())
103
+
104
+ path = get_config_path()
105
+ if path.is_file():
106
+ try:
107
+ with open(path, "rb") as fh:
108
+ stored = tomllib.load(fh).get("theme", {})
109
+ theme.update({k: str(v) for k, v in stored.items() if k in THEME_KEYS})
110
+ except (OSError, tomllib.TOMLDecodeError):
111
+ pass
112
+ return theme
113
+
114
+
115
+ def active_theme() -> Dict[str, str]:
116
+ """Return the theme Streamlit is currently running with.
117
+
118
+ Reads ``st.get_option("theme.*")``, which reflects the merged config actually
119
+ in effect -- so injected CSS matches what the user sees even when the config
120
+ file is absent, unreadable, or overridden by a CLI flag or environment
121
+ variable.
122
+
123
+ Returns
124
+ -------
125
+ dict
126
+ Subset of :data:`THEME_KEYS` that Streamlit reports a value for. Empty if
127
+ Streamlit is unavailable (so this module stays importable in tests).
128
+ """
129
+ try:
130
+ import streamlit as st
131
+ except ImportError: # pragma: no cover - streamlit is a hard dependency
132
+ return {}
133
+
134
+ resolved: Dict[str, str] = {}
135
+ for key in THEME_KEYS:
136
+ try:
137
+ value = st.get_option(f"theme.{key}")
138
+ except Exception:
139
+ continue
140
+ if value:
141
+ resolved[key] = str(value)
142
+ return resolved
143
+
144
+
145
+ def _render_theme_body(theme: Dict[str, str]) -> List[str]:
146
+ """Render the ``[theme]`` key lines, in :data:`THEME_KEYS` order."""
147
+ return [f'{key} = "{theme[key]}"\n' for key in THEME_KEYS if theme.get(key)]
148
+
149
+
150
+ def write_theme(theme: Dict[str, str]) -> Path:
151
+ """Rewrite only the ``[theme]`` keys in ``config.toml``.
152
+
153
+ Everything outside the ``[theme]`` table -- other sections, and every comment
154
+ including the file header -- is preserved byte-for-byte. Comments *inside* the
155
+ table are kept too; only ``key = value`` lines are replaced. The write is
156
+ atomic, so an interrupted save cannot leave a truncated config.
157
+
158
+ Parameters
159
+ ----------
160
+ theme : dict
161
+ Values for :data:`THEME_KEYS`. Keys outside that set are ignored; keys
162
+ with a falsy value are omitted from the output.
163
+
164
+ Returns
165
+ -------
166
+ Path
167
+ The file that was written, for display to the user.
168
+
169
+ Raises
170
+ ------
171
+ OSError
172
+ If the file or its parent directory cannot be created or written. Callers
173
+ must surface this rather than reporting success -- see A-18.
174
+ """
175
+ path = get_config_path()
176
+ existing: List[str] = []
177
+ if path.is_file():
178
+ existing = path.read_text(encoding="utf-8").splitlines(keepends=True)
179
+
180
+ out: List[str] = []
181
+ index = 0
182
+ replaced = False
183
+
184
+ while index < len(existing):
185
+ line = existing[index]
186
+ if line.strip() != "[theme]":
187
+ out.append(line)
188
+ index += 1
189
+ continue
190
+
191
+ # Found the table: emit the header, then the new keys, then walk the old
192
+ # body dropping only `key = value` lines so comments and blanks survive.
193
+ out.append(line)
194
+ out.extend(_render_theme_body(theme))
195
+ replaced = True
196
+ index += 1
197
+ while index < len(existing) and not _SECTION_LINE.match(existing[index]):
198
+ if not _KEY_LINE.match(existing[index]):
199
+ out.append(existing[index])
200
+ index += 1
201
+
202
+ if not replaced:
203
+ if out and not out[-1].endswith("\n"):
204
+ out.append("\n")
205
+ out.append("\n[theme]\n")
206
+ out.extend(_render_theme_body(theme))
207
+
208
+ path.parent.mkdir(parents=True, exist_ok=True)
209
+
210
+ # Atomic replace: write a sibling temp file, flush, then rename over the target.
211
+ fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=".config-", suffix=".toml")
212
+ try:
213
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
214
+ fh.write("".join(out))
215
+ fh.flush()
216
+ os.fsync(fh.fileno())
217
+ os.replace(tmp_name, path)
218
+ except BaseException:
219
+ Path(tmp_name).unlink(missing_ok=True)
220
+ raise
221
+ return path
222
+
223
+
224
+ def _hex_to_rgba(colour: str, alpha: float) -> str:
225
+ """Convert ``#rrggbb`` (or ``#rgb``) to a CSS ``rgba()`` string.
226
+
227
+ Falls back to a mid grey for anything unparseable, so a malformed colour in
228
+ config.toml degrades to readable rather than breaking the stylesheet.
229
+ """
230
+ text = colour.strip().lstrip("#")
231
+ if len(text) == 3:
232
+ text = "".join(ch * 2 for ch in text)
233
+ try:
234
+ red, green, blue = (int(text[i : i + 2], 16) for i in (0, 2, 4))
235
+ except (ValueError, IndexError):
236
+ red, green, blue = (128, 128, 128)
237
+ return f"rgba({red}, {green}, {blue}, {alpha})"
238
+
239
+
240
+ def app_css(theme: Optional[Dict[str, str]] = None) -> str:
241
+ """Build the app-wide stylesheet from the active theme.
242
+
243
+ Every colour is derived from *theme*, so the injected CSS follows whatever the
244
+ user selected in Settings instead of assuming the NCCU dark palette (A-17).
245
+
246
+ Parameters
247
+ ----------
248
+ theme : dict, optional
249
+ Theme mapping; defaults to :func:`read_theme`.
250
+
251
+ Returns
252
+ -------
253
+ str
254
+ A ``<style>`` block, ready for ``st.markdown(..., unsafe_allow_html=True)``.
255
+
256
+ Notes
257
+ -----
258
+ The font is declared as a stack of locally-available families. An earlier
259
+ version pulled Nunito from ``fonts.googleapis.com``, which fails on an
260
+ air-gapped lab machine and discloses a third-party request on every load
261
+ (A-35); Streamlit also does not reliably preserve ``<link>`` elements passed
262
+ through ``st.markdown``, so it may never have loaded at all.
263
+ """
264
+ resolved = dict(theme or read_theme())
265
+ primary = resolved.get("primaryColor", STREAMLIT_DEFAULTS["primaryColor"])
266
+ text = resolved.get("textColor", STREAMLIT_DEFAULTS["textColor"])
267
+ background = resolved.get("backgroundColor", STREAMLIT_DEFAULTS["backgroundColor"])
268
+ secondary = resolved.get(
269
+ "secondaryBackgroundColor", STREAMLIT_DEFAULTS["secondaryBackgroundColor"]
270
+ )
271
+
272
+ muted = _hex_to_rgba(text, 0.55)
273
+ faint = _hex_to_rgba(text, 0.45)
274
+
275
+ return f"""
276
+ <style>
277
+ html, body, [class*="css"] {{
278
+ font-family: 'Nunito', 'Avenir Next', 'Avenir', 'Segoe UI',
279
+ 'Helvetica Neue', Arial, sans-serif !important;
280
+ }}
281
+ /* Hide Streamlit chrome. The sidebar collapse control is deliberately NOT
282
+ hidden -- doing so locked the sidebar open, which cost plot canvas width on
283
+ small screens and at high browser zoom (A-29). */
284
+ header[data-testid="stHeader"] {{ background: transparent !important; }}
285
+ [data-testid="stDecoration"] {{ display: none !important; }}
286
+ [data-testid="stToolbar"] {{ display: none !important; }}
287
+ #MainMenu {{ display: none !important; }}
288
+ footer {{ display: none !important; }}
289
+ /* Active page indicator */
290
+ [data-testid="stSidebar"] a[aria-current="page"] {{
291
+ border-left: 3px solid {primary} !important;
292
+ padding-left: 0.4rem !important;
293
+ color: {primary} !important;
294
+ font-weight: 600 !important;
295
+ }}
296
+ /* Muted text helpers, derived from the theme's text colour */
297
+ .eagleplot-muted {{ color: {muted}; margin-top: 0; }}
298
+ .eagleplot-section-label {{
299
+ margin: 0.6rem 0 0.15rem 0;
300
+ font-size: 0.68rem;
301
+ color: {faint};
302
+ text-transform: uppercase;
303
+ letter-spacing: 0.07em;
304
+ font-weight: 700;
305
+ }}
306
+ /* Scrollbar, tinted to the active theme rather than a fixed dark palette */
307
+ ::-webkit-scrollbar {{ width: 5px; height: 5px; }}
308
+ ::-webkit-scrollbar-track {{ background: {background}; }}
309
+ ::-webkit-scrollbar-thumb {{ background: {secondary}; border-radius: 3px; }}
310
+ ::-webkit-scrollbar-thumb:hover {{ background: {primary}; }}
311
+ </style>
312
+ """
@@ -0,0 +1,294 @@
1
+ """User settings persistence for the EaglePlot GUI.
2
+
3
+ Saves user defaults and named configuration presets to a local
4
+ ``config.json`` file alongside the app. The file is intentionally
5
+ kept outside version control (add it to .gitignore).
6
+
7
+ Storage layout
8
+ --------------
9
+ config.json ::
10
+
11
+ {
12
+ "version": "1.0",
13
+ "defaults": {
14
+ "grid": true,
15
+ "fontsize": 12,
16
+ ...
17
+ },
18
+ "presets": {
19
+ "My Dark Theme": { "grid": false, ... },
20
+ "Publication": { "fontsize": 14, ... }
21
+ },
22
+ "workflows": {
23
+ "My Pipeline": [
24
+ {"operation": "statistics", "params": {}},
25
+ {"operation": "peak_analysis",
26
+ "params": {"y_col": "intensity"}}
27
+ ]
28
+ }
29
+ }
30
+
31
+ Public API
32
+ ----------
33
+ get_config_path() -> Path
34
+ Return the path to config.json (next to gui.py).
35
+ load_config() -> dict
36
+ Read config.json; returns empty structure if missing.
37
+ save_config(config: dict) -> None
38
+ Write the full config dict to disk.
39
+ get_defaults() -> dict
40
+ Return the "defaults" sub-dict.
41
+ save_defaults(defaults: dict) -> None
42
+ Overwrite the stored defaults.
43
+ list_presets() -> list[str]
44
+ Return sorted preset names.
45
+ save_preset(name: str, settings: dict) -> None
46
+ Save (or overwrite) a named preset.
47
+ load_preset(name: str) -> dict
48
+ Return a preset dict, or {} if not found.
49
+ delete_preset(name: str) -> bool
50
+ Remove a preset; returns True if it existed.
51
+ list_workflows() -> list[str]
52
+ Return sorted workflow names.
53
+ save_workflow(name: str, steps: list[dict]) -> None
54
+ Save (or overwrite) a named workflow.
55
+ load_workflow(name: str) -> list[dict]
56
+ Return a workflow's step list, or [] if not found.
57
+ delete_workflow(name: str) -> bool
58
+ Remove a workflow; returns True if it existed.
59
+ """
60
+
61
+ from __future__ import annotations
62
+
63
+ import json
64
+ from pathlib import Path
65
+ from typing import Dict, List
66
+
67
+ # ── Location ──────────────────────────────────────────────────────────────
68
+ # Stored next to gui.py (two directories up from this file).
69
+ _APP_ROOT = Path(__file__).parent.parent.parent
70
+ _CONFIG_PATH = _APP_ROOT / "config.json"
71
+
72
+ _EMPTY: Dict = {
73
+ "version": "1.0",
74
+ "defaults": {},
75
+ "presets": {},
76
+ "workflows": {},
77
+ }
78
+
79
+ # Keys exposed in the Settings UI (subset of _STYLE_KEYS that make sense as
80
+ # persistent defaults; data-column selectors are omitted).
81
+ SAVEABLE_KEYS: List[str] = [
82
+ "grid",
83
+ "grid_linestyle",
84
+ "grid_which",
85
+ "fontsize",
86
+ "fontfamily",
87
+ "fontcolor",
88
+ "linewidth",
89
+ "legend_frameon",
90
+ "legend_framealpha",
91
+ "legend_position",
92
+ "y_scale",
93
+ "y_notation",
94
+ "y_transform",
95
+ "color_palette",
96
+ ]
97
+
98
+ # ── Read / write ──────────────────────────────────────────────────────────
99
+
100
+
101
+ def get_config_path() -> Path:
102
+ """Return the absolute path to config.json."""
103
+ return _CONFIG_PATH
104
+
105
+
106
+ def load_config() -> Dict:
107
+ """Read config.json from disk.
108
+
109
+ Returns
110
+ -------
111
+ config : dict
112
+ Full config dict, or an empty structure if the file is missing or
113
+ corrupt.
114
+ """
115
+ if not _CONFIG_PATH.exists():
116
+ return {
117
+ "version": "1.0",
118
+ "defaults": {},
119
+ "presets": {},
120
+ "workflows": {},
121
+ }
122
+ try:
123
+ with open(_CONFIG_PATH, "r", encoding="utf-8") as fh:
124
+ data = json.load(fh)
125
+ # Ensure required top-level keys exist
126
+ data.setdefault("version", "1.0")
127
+ data.setdefault("defaults", {})
128
+ data.setdefault("presets", {})
129
+ data.setdefault("workflows", {})
130
+ return data
131
+ except (json.JSONDecodeError, OSError):
132
+ return dict(_EMPTY)
133
+
134
+
135
+ def save_config(config: Dict) -> None:
136
+ """Write the full config dict to disk.
137
+
138
+ Parameters
139
+ ----------
140
+ config : dict
141
+ Full config structure (version, defaults, presets).
142
+ """
143
+ _CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
144
+ with open(_CONFIG_PATH, "w", encoding="utf-8") as fh:
145
+ json.dump(config, fh, indent=2)
146
+
147
+
148
+ # ── Defaults ──────────────────────────────────────────────────────────────
149
+
150
+
151
+ def get_defaults() -> Dict:
152
+ """Return the stored defaults dict (may be empty).
153
+
154
+ Returns
155
+ -------
156
+ defaults : dict
157
+ Persisted default settings, e.g. ``{"grid": True, "fontsize": 12}``.
158
+ """
159
+ return load_config().get("defaults", {})
160
+
161
+
162
+ def save_defaults(defaults: Dict) -> None:
163
+ """Overwrite the stored defaults.
164
+
165
+ Parameters
166
+ ----------
167
+ defaults : dict
168
+ Mapping of setting keys to values.
169
+ """
170
+ config = load_config()
171
+ config["defaults"] = defaults
172
+ save_config(config)
173
+
174
+
175
+ # ── Presets ───────────────────────────────────────────────────────────────
176
+
177
+
178
+ def list_presets() -> List[str]:
179
+ """Return a sorted list of saved preset names.
180
+
181
+ Returns
182
+ -------
183
+ names : list[str]
184
+ """
185
+ return sorted(load_config().get("presets", {}).keys())
186
+
187
+
188
+ def save_preset(name: str, settings: Dict) -> None:
189
+ """Save (or overwrite) a named preset.
190
+
191
+ Parameters
192
+ ----------
193
+ name : str
194
+ Human-readable preset name.
195
+ settings : dict
196
+ Configuration dict to save.
197
+ """
198
+ config = load_config()
199
+ config["presets"][name] = settings
200
+ save_config(config)
201
+
202
+
203
+ def load_preset(name: str) -> Dict:
204
+ """Return a preset dict by name.
205
+
206
+ Parameters
207
+ ----------
208
+ name : str
209
+ Preset name.
210
+
211
+ Returns
212
+ -------
213
+ settings : dict
214
+ Preset dict, or ``{}`` if the preset does not exist.
215
+ """
216
+ return load_config().get("presets", {}).get(name, {})
217
+
218
+
219
+ def delete_preset(name: str) -> bool:
220
+ """Remove a named preset.
221
+
222
+ Parameters
223
+ ----------
224
+ name : str
225
+ Preset name to remove.
226
+
227
+ Returns
228
+ -------
229
+ removed : bool
230
+ True if the preset existed and was removed.
231
+ """
232
+ config = load_config()
233
+ if name in config.get("presets", {}):
234
+ del config["presets"][name]
235
+ save_config(config)
236
+ return True
237
+ return False
238
+
239
+
240
+ # ── Workflows ─────────────────────────────────────────────────────────────
241
+
242
+
243
+ def list_workflows() -> List[str]:
244
+ """Return a sorted list of saved workflow names.
245
+
246
+ Returns
247
+ -------
248
+ names : list[str]
249
+ Alphabetically sorted workflow names stored in config.json.
250
+ """
251
+ return sorted(load_config().get("workflows", {}).keys())
252
+
253
+
254
+ def save_workflow(name: str, steps: List[Dict]) -> None:
255
+ """Save (or overwrite) a named workflow.
256
+
257
+ Args:
258
+ name: Human-readable workflow name.
259
+ steps: List of operation dicts, e.g.
260
+ ``[{"operation": "statistics", "params": {}}]``.
261
+ Stored under ``config["workflows"][name]``.
262
+ """
263
+ config = load_config()
264
+ config["workflows"][name] = steps
265
+ save_config(config)
266
+
267
+
268
+ def load_workflow(name: str) -> List[Dict]:
269
+ """Return a workflow's step list by name.
270
+
271
+ Args:
272
+ name: Workflow name.
273
+
274
+ Returns:
275
+ List of step dicts, or ``[]`` if the workflow does not exist.
276
+ """
277
+ return load_config().get("workflows", {}).get(name, [])
278
+
279
+
280
+ def delete_workflow(name: str) -> bool:
281
+ """Remove a named workflow.
282
+
283
+ Args:
284
+ name: Workflow name to remove.
285
+
286
+ Returns:
287
+ ``True`` if the workflow existed and was removed, ``False`` otherwise.
288
+ """
289
+ config = load_config()
290
+ if name in config.get("workflows", {}):
291
+ del config["workflows"][name]
292
+ save_config(config)
293
+ return True
294
+ return False