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.
- eagleplot/Home.py +362 -0
- eagleplot/__init__.py +19 -0
- eagleplot/annotations.py +215 -0
- eagleplot/assets/logo.ico +0 -0
- eagleplot/assets/logo.png +0 -0
- eagleplot/assets/nccu-horiz-logo.png +0 -0
- eagleplot/assets/nccu-wings.png +0 -0
- eagleplot/batch.py +446 -0
- eagleplot/cli.py +719 -0
- eagleplot/data_tools.py +849 -0
- eagleplot/example-data/Artificial/bimodal_distribution.csv +501 -0
- eagleplot/example-data/Artificial/correlation_matrix.npy +0 -0
- eagleplot/example-data/Artificial/gaussian_surface.npy +0 -0
- eagleplot/example-data/Artificial/grouped_categorical.csv +241 -0
- eagleplot/example-data/Artificial/ir_spectrum.csv +1802 -0
- eagleplot/example-data/Artificial/molecular_properties.csv +121 -0
- eagleplot/example-data/Artificial/normal_distribution.csv +401 -0
- eagleplot/example-data/Artificial/reaction_kinetics.csv +121 -0
- eagleplot/example-data/Artificial/scatter_correlation.csv +201 -0
- eagleplot/example-data/Artificial/sine_cosine_waves.csv +301 -0
- eagleplot/gallery/box_plot.png +0 -0
- eagleplot/gallery/contour_plot.png +0 -0
- eagleplot/gallery/distribution_plot.png +0 -0
- eagleplot/gallery/heatmap.png +0 -0
- eagleplot/gallery/histogram.png +0 -0
- eagleplot/gallery/interactive_3d_surface.png +0 -0
- eagleplot/gallery/interactive_heatmap.png +0 -0
- eagleplot/gallery/interactive_histogram.png +0 -0
- eagleplot/gallery/interactive_line.png +0 -0
- eagleplot/gallery/interactive_scatter.png +0 -0
- eagleplot/gallery/line_plot.png +0 -0
- eagleplot/gallery/manifest.json +196 -0
- eagleplot/gallery/regression_plot.png +0 -0
- eagleplot/gallery/scatter_plot.png +0 -0
- eagleplot/io.py +1008 -0
- eagleplot/math.py +1210 -0
- eagleplot/molecular/__init__.py +21 -0
- eagleplot/molecular/atom_data.py +287 -0
- eagleplot/molecular/parsers.py +747 -0
- eagleplot/nist.py +241 -0
- eagleplot/pages/10_Molecular_Viz.py +225 -0
- eagleplot/pages/11_Batch_Analysis.py +325 -0
- eagleplot/pages/12_Help.py +199 -0
- eagleplot/pages/13_Settings.py +388 -0
- eagleplot/pages/1_Data_Upload.py +521 -0
- eagleplot/pages/2_Quick_Plot.py +1693 -0
- eagleplot/pages/3_Analysis_Tools.py +2510 -0
- eagleplot/pages/4_Multi_Plot_Dashboard.py +791 -0
- eagleplot/pages/5_Advanced_Plotting.py +691 -0
- eagleplot/pages/6_Export_Results.py +442 -0
- eagleplot/pages/7_Gallery.py +201 -0
- eagleplot/pages/8_Data_Tools.py +434 -0
- eagleplot/pages/9_Spectroscopy.py +821 -0
- eagleplot/pages/__init__.py +8 -0
- eagleplot/peaks.py +513 -0
- eagleplot/plotting.py +2630 -0
- eagleplot/plugin_loader.py +208 -0
- eagleplot/report.py +448 -0
- eagleplot/signal.py +494 -0
- eagleplot/spectroscopy.py +830 -0
- eagleplot/utils/__init__.py +76 -0
- eagleplot/utils/access.py +77 -0
- eagleplot/utils/data_preview.py +352 -0
- eagleplot/utils/plot_config.py +2721 -0
- eagleplot/utils/session_state.py +535 -0
- eagleplot/utils/theming.py +312 -0
- eagleplot/utils/user_settings.py +294 -0
- eagleplot-3.0.0.dist-info/METADATA +304 -0
- eagleplot-3.0.0.dist-info/RECORD +73 -0
- eagleplot-3.0.0.dist-info/WHEEL +5 -0
- eagleplot-3.0.0.dist-info/entry_points.txt +2 -0
- eagleplot-3.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|