scistackplotdb 0.1.35__tar.gz → 0.1.37__tar.gz
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.
- {scistackplotdb-0.1.35 → scistackplotdb-0.1.37}/PKG-INFO +5 -5
- {scistackplotdb-0.1.35 → scistackplotdb-0.1.37}/src/scistackplotdb/__init__.py +2 -0
- {scistackplotdb-0.1.35 → scistackplotdb-0.1.37}/src/scistackplotdb/endpoint.py +29 -4
- {scistackplotdb-0.1.35 → scistackplotdb-0.1.37}/src/scistackplotdb/load.py +158 -1
- scistackplotdb-0.1.37/src/scistackplotdb/saved.py +686 -0
- scistackplotdb-0.1.35/src/scistackplotdb/saved.py +0 -350
- {scistackplotdb-0.1.35 → scistackplotdb-0.1.37}/.gitignore +0 -0
- {scistackplotdb-0.1.35 → scistackplotdb-0.1.37}/README.md +0 -0
- {scistackplotdb-0.1.35 → scistackplotdb-0.1.37}/pyproject.toml +0 -0
- {scistackplotdb-0.1.35 → scistackplotdb-0.1.37}/src/scistackplotdb/_versioned.py +0 -0
- {scistackplotdb-0.1.35 → scistackplotdb-0.1.37}/src/scistackplotdb/bundle_section.py +0 -0
- {scistackplotdb-0.1.35 → scistackplotdb-0.1.37}/src/scistackplotdb/hierarchy.py +0 -0
- {scistackplotdb-0.1.35 → scistackplotdb-0.1.37}/src/scistackplotdb/presets.py +0 -0
- {scistackplotdb-0.1.35 → scistackplotdb-0.1.37}/src/scistackplotdb/source.py +0 -0
- {scistackplotdb-0.1.35 → scistackplotdb-0.1.37}/src/scistackplotdb/variants.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: scistackplotdb
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.37
|
|
4
4
|
Summary: scidb-backed plotting: load variables into long tables and plot them with scistackplot
|
|
5
5
|
Author: SciStack Contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -17,10 +17,10 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
17
17
|
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
18
18
|
Classifier: Typing :: Typed
|
|
19
19
|
Requires-Python: >=3.10
|
|
20
|
-
Requires-Dist: sciduckdb==0.1.
|
|
21
|
-
Requires-Dist: scistack-db==0.1.
|
|
22
|
-
Requires-Dist: scistacklog==0.1.
|
|
23
|
-
Requires-Dist: scistackplot==0.1.
|
|
20
|
+
Requires-Dist: sciduckdb==0.1.37
|
|
21
|
+
Requires-Dist: scistack-db==0.1.37
|
|
22
|
+
Requires-Dist: scistacklog==0.1.37
|
|
23
|
+
Requires-Dist: scistackplot==0.1.37
|
|
24
24
|
Provides-Extra: dev
|
|
25
25
|
Requires-Dist: matplotlib>=3.6; extra == 'dev'
|
|
26
26
|
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
@@ -57,6 +57,7 @@ from .saved import (
|
|
|
57
57
|
SavedPlotExists,
|
|
58
58
|
SavedPlotInfo,
|
|
59
59
|
check_name,
|
|
60
|
+
follow_latest_saved_plot,
|
|
60
61
|
hide_saved_plot,
|
|
61
62
|
list_saved_plots,
|
|
62
63
|
load_saved_plot,
|
|
@@ -97,6 +98,7 @@ __all__ = [
|
|
|
97
98
|
"rename_saved_plot",
|
|
98
99
|
"hide_saved_plot",
|
|
99
100
|
"saved_plot_history",
|
|
101
|
+
"follow_latest_saved_plot",
|
|
100
102
|
"check_name",
|
|
101
103
|
"SavedPlot",
|
|
102
104
|
"SavedPlotInfo",
|
|
@@ -81,6 +81,7 @@ def generate_endpoint(
|
|
|
81
81
|
function_source = generate_plot_function(spec, table, function_name=name)
|
|
82
82
|
foreach_source = _foreach_call(
|
|
83
83
|
spec=spec,
|
|
84
|
+
table=table,
|
|
84
85
|
function_name=name,
|
|
85
86
|
input_variable=input_variable,
|
|
86
87
|
x_variable=x_variable,
|
|
@@ -140,6 +141,29 @@ def _pin_literal(value) -> str:
|
|
|
140
141
|
return repr(list(value) if isinstance(value, tuple) else value)
|
|
141
142
|
|
|
142
143
|
|
|
144
|
+
def row_expression(input_variable: str, variant_set, table=None) -> str:
|
|
145
|
+
"""One variant ROW as a ``Variant(...)`` load — through the row's
|
|
146
|
+
EFFECTIVE selection (``scistackplot.variants.export_selection``), so the
|
|
147
|
+
exported code loads what the preview drew: current code for a row that
|
|
148
|
+
names none, the frozen version for a reopened saved plot.
|
|
149
|
+
|
|
150
|
+
Grouping variables keep calling :func:`variant_expression` with their raw
|
|
151
|
+
selection: an empty grouping pin means "nothing filtered", not "current".
|
|
152
|
+
"""
|
|
153
|
+
from types import SimpleNamespace
|
|
154
|
+
|
|
155
|
+
from scistackplot.variants import export_selection
|
|
156
|
+
|
|
157
|
+
# The TABLE's default, exactly what the preview applied: a stacked
|
|
158
|
+
# multi-variable table has none, so its rows export as written.
|
|
159
|
+
selection = export_selection(
|
|
160
|
+
variant_set,
|
|
161
|
+
latest_column=(table.latest_column if table is not None else None) or LATEST_COLUMN,
|
|
162
|
+
default_pin=table.default_pin if table is not None else None,
|
|
163
|
+
)
|
|
164
|
+
return variant_expression(input_variable, SimpleNamespace(selection=selection))
|
|
165
|
+
|
|
166
|
+
|
|
143
167
|
def variant_expression(input_variable: str, variant_set) -> str:
|
|
144
168
|
"""A ``Variant(...)`` call selecting one named variant's records.
|
|
145
169
|
|
|
@@ -191,7 +215,7 @@ def variant_expression(input_variable: str, variant_set) -> str:
|
|
|
191
215
|
return expression
|
|
192
216
|
|
|
193
217
|
|
|
194
|
-
def _single_variant_expression(input_variable: str, spec) -> str:
|
|
218
|
+
def _single_variant_expression(input_variable: str, spec, table=None) -> str:
|
|
195
219
|
"""The lone variant's pin, or the bare variable when nothing is selected.
|
|
196
220
|
|
|
197
221
|
The row may name its own variable, in which case that is what the endpoint
|
|
@@ -200,12 +224,13 @@ def _single_variant_expression(input_variable: str, spec) -> str:
|
|
|
200
224
|
sets = defined_sets(spec.variant_sets)
|
|
201
225
|
if len(sets) != 1:
|
|
202
226
|
return input_variable
|
|
203
|
-
return
|
|
227
|
+
return row_expression(sets[0].variable or input_variable, sets[0], table)
|
|
204
228
|
|
|
205
229
|
|
|
206
230
|
def _foreach_call(
|
|
207
231
|
*,
|
|
208
232
|
spec: PlotSpec,
|
|
233
|
+
table: LongTable | None = None,
|
|
209
234
|
function_name: str,
|
|
210
235
|
input_variable: str,
|
|
211
236
|
x_variable: str | None,
|
|
@@ -223,14 +248,14 @@ def _foreach_call(
|
|
|
223
248
|
# the wrong variant's selection.
|
|
224
249
|
inputs = [
|
|
225
250
|
f' "{generated.param}": '
|
|
226
|
-
f"{
|
|
251
|
+
f"{row_expression(generated.variable, variant, table)},"
|
|
227
252
|
for generated, variant in zip(
|
|
228
253
|
variant_inputs, defined_sets(spec.variant_sets), strict=True
|
|
229
254
|
)
|
|
230
255
|
]
|
|
231
256
|
table_inputs = [generated.param for generated in variant_inputs]
|
|
232
257
|
else:
|
|
233
|
-
pinned = _single_variant_expression(input_variable, spec)
|
|
258
|
+
pinned = _single_variant_expression(input_variable, spec, table)
|
|
234
259
|
inputs = [f' "df": {pinned},']
|
|
235
260
|
table_inputs = ["df"]
|
|
236
261
|
if x_variable:
|
|
@@ -24,6 +24,8 @@ import pandas as pd
|
|
|
24
24
|
from scistacklog import Log
|
|
25
25
|
from scistackplot import CODE_FACTOR_PREFIX, RUN_FACTOR_PREFIX
|
|
26
26
|
from scistackplot.framesize import format_extent, frame_extent
|
|
27
|
+
from scistackplot.shape import Shape, classify_value
|
|
28
|
+
from scistackplot.table import natural_sort_key
|
|
27
29
|
|
|
28
30
|
LAYER = "scistackplotdb"
|
|
29
31
|
|
|
@@ -425,7 +427,8 @@ def load_variable(
|
|
|
425
427
|
) as timer:
|
|
426
428
|
with timer.phase("column_metadata"):
|
|
427
429
|
keys = schema_keys(db)
|
|
428
|
-
|
|
430
|
+
column_types = data_column_types_for(db, variable)
|
|
431
|
+
columns = list(column_types)
|
|
429
432
|
if not columns:
|
|
430
433
|
Log.warn("variable %r has no data columns", variable, layer=LAYER)
|
|
431
434
|
return VariableFrame(name=variable, frame=pd.DataFrame())
|
|
@@ -513,6 +516,9 @@ def load_variable(
|
|
|
513
516
|
variant_columns or "none",
|
|
514
517
|
layer=LAYER,
|
|
515
518
|
)
|
|
519
|
+
if columns and len(frame):
|
|
520
|
+
with timer.phase("field_coverage"):
|
|
521
|
+
_log_field_coverage(variable, frame, column_types, levels)
|
|
516
522
|
if len(frame) > 1:
|
|
517
523
|
with timer.phase("identical_content"):
|
|
518
524
|
_log_identical_content(db, variable, frame, keys)
|
|
@@ -527,6 +533,157 @@ def load_variable(
|
|
|
527
533
|
)
|
|
528
534
|
|
|
529
535
|
|
|
536
|
+
#: A scalar field that holds a number in fewer than this share of its
|
|
537
|
+
#: records is WARNed, with where its numbers are.
|
|
538
|
+
SPARSE_FIELD_SHARE = 0.5
|
|
539
|
+
|
|
540
|
+
#: How many distinct levels of a key, and how many text examples, a coverage
|
|
541
|
+
#: line names before it says "+N more".
|
|
542
|
+
_COVERAGE_LEVELS_SHOWN = 6
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
@dataclass(frozen=True)
|
|
546
|
+
class FieldCoverage:
|
|
547
|
+
"""What one scalar data column holds, record by record: how many cells
|
|
548
|
+
are a number, NULL/NaN, text (with a few examples), or something else."""
|
|
549
|
+
|
|
550
|
+
column: str
|
|
551
|
+
declared_type: str
|
|
552
|
+
records: int
|
|
553
|
+
numeric: int
|
|
554
|
+
null: int
|
|
555
|
+
text: int
|
|
556
|
+
other: int
|
|
557
|
+
text_examples: tuple[str, ...] = ()
|
|
558
|
+
|
|
559
|
+
@property
|
|
560
|
+
def missing(self) -> int:
|
|
561
|
+
return self.records - self.numeric
|
|
562
|
+
|
|
563
|
+
def describe(self) -> str:
|
|
564
|
+
parts = [f"{self.null} NULL"] if self.null else []
|
|
565
|
+
if self.text:
|
|
566
|
+
examples = ", ".join(repr(t) for t in self.text_examples)
|
|
567
|
+
parts.append(f"{self.text} text (e.g. {examples})")
|
|
568
|
+
if self.other:
|
|
569
|
+
parts.append(f"{self.other} other")
|
|
570
|
+
return (
|
|
571
|
+
f"{self.column} ({self.declared_type}) {self.numeric}/{self.records} numeric"
|
|
572
|
+
+ (f" ({', '.join(parts)})" if parts else "")
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def field_coverage(
|
|
577
|
+
frame: pd.DataFrame, column_types: dict[str, str]
|
|
578
|
+
) -> tuple[list[FieldCoverage], pd.DataFrame]:
|
|
579
|
+
"""Per scalar column of ``frame``: what its cells hold. Array (1-D/2-D)
|
|
580
|
+
columns are skipped — a signal is not "missing a number". Also returns a
|
|
581
|
+
boolean frame, one column per covered field, True where the cell is a
|
|
582
|
+
number (for :func:`_log_field_coverage` to say WHERE the numbers are)."""
|
|
583
|
+
|
|
584
|
+
out: list[FieldCoverage] = []
|
|
585
|
+
is_number = pd.DataFrame(index=frame.index)
|
|
586
|
+
for column, declared in column_types.items():
|
|
587
|
+
if column not in frame.columns:
|
|
588
|
+
continue
|
|
589
|
+
values = frame[column]
|
|
590
|
+
if pd.api.types.is_numeric_dtype(values) and not pd.api.types.is_bool_dtype(values):
|
|
591
|
+
mask = values.notna()
|
|
592
|
+
out.append(
|
|
593
|
+
FieldCoverage(column, declared, len(values), int(mask.sum()), int((~mask).sum()), 0, 0)
|
|
594
|
+
)
|
|
595
|
+
is_number[column] = mask.to_numpy()
|
|
596
|
+
continue
|
|
597
|
+
numeric = null = text = other = 0
|
|
598
|
+
arrays = False
|
|
599
|
+
examples: list[str] = []
|
|
600
|
+
mask = []
|
|
601
|
+
for value in values.to_numpy():
|
|
602
|
+
if value is None or (isinstance(value, float) and value != value):
|
|
603
|
+
null += 1
|
|
604
|
+
mask.append(False)
|
|
605
|
+
elif classify_value(value) is Shape.SCALAR:
|
|
606
|
+
numeric += 1
|
|
607
|
+
mask.append(True)
|
|
608
|
+
elif isinstance(value, str):
|
|
609
|
+
text += 1
|
|
610
|
+
mask.append(False)
|
|
611
|
+
if value not in examples and len(examples) < 3:
|
|
612
|
+
examples.append(value)
|
|
613
|
+
elif isinstance(value, (list, tuple, np.ndarray)):
|
|
614
|
+
arrays = True
|
|
615
|
+
break
|
|
616
|
+
else:
|
|
617
|
+
other += 1
|
|
618
|
+
mask.append(False)
|
|
619
|
+
if arrays:
|
|
620
|
+
continue
|
|
621
|
+
out.append(
|
|
622
|
+
FieldCoverage(column, declared, len(values), numeric, null, text, other, tuple(examples))
|
|
623
|
+
)
|
|
624
|
+
is_number[column] = mask
|
|
625
|
+
return out, is_number
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _levels_text(values: pd.Series) -> str:
|
|
629
|
+
levels = sorted({str(v) for v in values if v is not None}, key=natural_sort_key)
|
|
630
|
+
shown = levels[:_COVERAGE_LEVELS_SHOWN]
|
|
631
|
+
more = len(levels) - len(shown)
|
|
632
|
+
return "{" + ", ".join(shown) + (f", +{more} more" if more else "") + "}"
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _log_field_coverage(
|
|
636
|
+
variable: str, frame: pd.DataFrame, column_types: dict[str, str], levels: list[str]
|
|
637
|
+
) -> None:
|
|
638
|
+
"""INFO: every scalar field that is not a number in every record. WARN,
|
|
639
|
+
per field, when fewer than :data:`SPARSE_FIELD_SHARE` of its records hold
|
|
640
|
+
one — naming the levels those records sit at, because a plot of such a
|
|
641
|
+
field shows only them and reads as a broken grouping ("Dist_m showed one
|
|
642
|
+
group/one subject": 6 of 714 records held a number, scidb.log
|
|
643
|
+
2026-10-15). The plot is right; the data is what to look at.
|
|
644
|
+
|
|
645
|
+
Never raises: a diagnostic must not break a load.
|
|
646
|
+
"""
|
|
647
|
+
try:
|
|
648
|
+
coverage, is_number = field_coverage(frame, column_types)
|
|
649
|
+
gaps = [c for c in coverage if c.missing]
|
|
650
|
+
if not gaps:
|
|
651
|
+
return
|
|
652
|
+
Log.info(
|
|
653
|
+
"field coverage of %s: %d of %d scalar field(s) are not a number in every "
|
|
654
|
+
"record — %s",
|
|
655
|
+
variable,
|
|
656
|
+
len(gaps),
|
|
657
|
+
len(coverage),
|
|
658
|
+
"; ".join(c.describe() for c in gaps),
|
|
659
|
+
layer=LAYER,
|
|
660
|
+
)
|
|
661
|
+
for c in gaps:
|
|
662
|
+
if c.numeric >= SPARSE_FIELD_SHARE * c.records:
|
|
663
|
+
continue
|
|
664
|
+
where = frame.loc[is_number[c.column].to_numpy(dtype=bool)]
|
|
665
|
+
located = ", ".join(
|
|
666
|
+
f"{key} ∈ {_levels_text(where[key])}"
|
|
667
|
+
for key in levels
|
|
668
|
+
if key in where.columns
|
|
669
|
+
)
|
|
670
|
+
Log.warn(
|
|
671
|
+
"%s.%s holds a number in only %d of %d record(s) — %s. A plot of it can "
|
|
672
|
+
"show only those records%s. If the source has values for the others, "
|
|
673
|
+
"they were not saved as numbers: check what the loader wrote for "
|
|
674
|
+
"this column.",
|
|
675
|
+
variable,
|
|
676
|
+
c.column,
|
|
677
|
+
c.numeric,
|
|
678
|
+
c.records,
|
|
679
|
+
c.describe(),
|
|
680
|
+
f", at {located}" if c.numeric and located else "",
|
|
681
|
+
layer=LAYER,
|
|
682
|
+
)
|
|
683
|
+
except Exception as exc: # noqa: BLE001 — diagnostic only
|
|
684
|
+
Log.debug("field coverage of %s failed: %s", variable, exc, layer=LAYER)
|
|
685
|
+
|
|
686
|
+
|
|
530
687
|
def _log_identical_content(db, variable: str, frame: pd.DataFrame, keys) -> None:
|
|
531
688
|
"""Warn when records of ``variable`` hold byte-identical payloads, naming
|
|
532
689
|
the schema keys that vary across each duplicate group.
|
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Saved plots: named, per-variable Plot Studio figures kept in the project
|
|
3
|
+
database.
|
|
4
|
+
|
|
5
|
+
A saved plot is **display intent**. It changes nothing a run computes, so it is
|
|
6
|
+
not an aspect of the GUI's intent store (docs/claude/intent-and-fact.md §1).
|
|
7
|
+
It lives here, in the layer that knows both the database and the spec, so a
|
|
8
|
+
script can reopen a figure the GUI saved::
|
|
9
|
+
|
|
10
|
+
from scistackplotdb import list_saved_plots, load_saved_plot
|
|
11
|
+
saved = load_saved_plot(db, list_saved_plots(db, "StepLength")[0].plot_id)
|
|
12
|
+
render(ScidbSource(db).get_table(saved.spec.variant_variables()), saved.spec)
|
|
13
|
+
|
|
14
|
+
**One table, append-only.** ``_saved_plot`` holds one row per *version*.
|
|
15
|
+
Saving under a name that is already showing appends a version to that plot;
|
|
16
|
+
the newest is what the list shows and what opens. Nothing is ever deleted
|
|
17
|
+
(feedback_never_delete_mark_hidden): "remove" sets ``hidden`` on every
|
|
18
|
+
version of the plot, and the rows stay.
|
|
19
|
+
|
|
20
|
+
``plot_id`` is the plot's identity, stable across renames. A name is a label.
|
|
21
|
+
Keying by name would make rename an update of an identity column, and would
|
|
22
|
+
make "a new plot reusing a removed plot's name" silently inherit its history.
|
|
23
|
+
|
|
24
|
+
**The stored spec is always the current format.** :func:`save_plot` reads the
|
|
25
|
+
incoming spec strictly, so what lands in the table is exactly what this
|
|
26
|
+
version writes. Old formats are only ever *read*, by
|
|
27
|
+
:func:`scistackplot.restore_spec`. The stored copy is never rewritten on
|
|
28
|
+
open; only an explicit save writes. See ``.claude/plan-saved-plots.md``.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from typing import Any, Callable
|
|
35
|
+
|
|
36
|
+
from scistacklog import Log
|
|
37
|
+
|
|
38
|
+
from ._versioned import MAX_NAME_LENGTH, VersionedStore # noqa: F401 (re-exported)
|
|
39
|
+
|
|
40
|
+
LAYER = "scistackplotdb"
|
|
41
|
+
|
|
42
|
+
TABLE = "_saved_plot"
|
|
43
|
+
|
|
44
|
+
#: The envelope's layout version. Written on every save and logged on every
|
|
45
|
+
#: load; nothing branches on it (salvage, never migrate).
|
|
46
|
+
ENVELOPE_FORMAT = 1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SavedPlotError(ValueError):
|
|
50
|
+
"""A save, rename or load that cannot be done as asked."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SavedPlotExists(SavedPlotError):
|
|
54
|
+
"""``save_plot(overwrite=False)`` found ANOTHER visible plot with that name.
|
|
55
|
+
|
|
56
|
+
``existing`` is that plot, so the caller can ask the user and retry with
|
|
57
|
+
``overwrite=True``. The name comparison stays here, where names are
|
|
58
|
+
normalised, instead of in each caller.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, existing: "SavedPlotInfo"):
|
|
62
|
+
super().__init__(
|
|
63
|
+
f"{existing.variable} already has a saved plot named "
|
|
64
|
+
f"{existing.name!r} (version {existing.version})."
|
|
65
|
+
)
|
|
66
|
+
self.existing = existing
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class SavedPlotInfo:
|
|
71
|
+
"""One version of a saved plot, without its contents: a list row."""
|
|
72
|
+
|
|
73
|
+
plot_id: str
|
|
74
|
+
variable: str
|
|
75
|
+
name: str
|
|
76
|
+
version: int
|
|
77
|
+
saved_at: str
|
|
78
|
+
hidden: bool = False
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> dict:
|
|
81
|
+
return {
|
|
82
|
+
"plot_id": self.plot_id,
|
|
83
|
+
"variable": self.variable,
|
|
84
|
+
"name": self.name,
|
|
85
|
+
"version": self.version,
|
|
86
|
+
"saved_at": self.saved_at,
|
|
87
|
+
"hidden": self.hidden,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class SavedPlot:
|
|
93
|
+
"""A saved plot, opened: the restored spec plus what did not survive."""
|
|
94
|
+
|
|
95
|
+
info: SavedPlotInfo
|
|
96
|
+
spec: Any # scistackplot.PlotSpec
|
|
97
|
+
#: The panel's own view settings (preview mode, aspect choice, figure
|
|
98
|
+
#: index), stored verbatim. The frontend owns their meaning and reads them
|
|
99
|
+
#: as leniently as the spec is read here.
|
|
100
|
+
view: dict = field(default_factory=dict)
|
|
101
|
+
#: ``scistackplot.RestoreNote`` s from restoring the spec and, when a table
|
|
102
|
+
#: was given, from reconciling it with today's data.
|
|
103
|
+
notes: list = field(default_factory=list)
|
|
104
|
+
#: The envelope format this version was written in.
|
|
105
|
+
stored_format: Any = None
|
|
106
|
+
#: Code versions in today's data NEWER than the ones this plot was saved
|
|
107
|
+
#: with: ``[{row, column, function, frozen, newer, newest, locations}]``
|
|
108
|
+
#: (:func:`thaw_code`). Non-empty is the GUI's cue to ask "switch to
|
|
109
|
+
#: latest?". Empty for a plot that follows latest or recorded no version.
|
|
110
|
+
newer: list = field(default_factory=list)
|
|
111
|
+
|
|
112
|
+
def to_dict(self) -> dict:
|
|
113
|
+
return {
|
|
114
|
+
**self.info.to_dict(),
|
|
115
|
+
"spec": self.spec.to_dict(),
|
|
116
|
+
"view": dict(self.view),
|
|
117
|
+
"notes": [note.to_dict() for note in self.notes],
|
|
118
|
+
"stored_format": self.stored_format,
|
|
119
|
+
"newer": [dict(entry) for entry in self.newer],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Table
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
#: The rules (names, versions, hiding, never deleting) live in `_versioned`,
|
|
128
|
+
#: shared with presets so the two stores cannot drift apart.
|
|
129
|
+
STORE = VersionedStore(
|
|
130
|
+
table=TABLE,
|
|
131
|
+
id_column="plot_id",
|
|
132
|
+
scope_column="variable",
|
|
133
|
+
noun="saved plot",
|
|
134
|
+
tag="saved_plot",
|
|
135
|
+
error=SavedPlotError,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def ensure_table(db) -> None:
|
|
140
|
+
"""Create ``_saved_plot`` if absent. Called by every write."""
|
|
141
|
+
STORE.ensure_table(db)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _table_exists(db) -> bool:
|
|
145
|
+
"""Reads never create the table: listing an untouched database must not
|
|
146
|
+
write to it."""
|
|
147
|
+
return STORE.table_exists(db)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# Writes
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def save_plot(
|
|
156
|
+
db,
|
|
157
|
+
variable: str,
|
|
158
|
+
name: str,
|
|
159
|
+
spec,
|
|
160
|
+
view: dict | None = None,
|
|
161
|
+
*,
|
|
162
|
+
overwrite: bool = True,
|
|
163
|
+
current_plot_id: str | None = None,
|
|
164
|
+
table=None,
|
|
165
|
+
table_for: Callable[[Any], Any] | None = None,
|
|
166
|
+
) -> SavedPlotInfo:
|
|
167
|
+
"""Save *spec* under *name* for *variable*, returning the new version.
|
|
168
|
+
|
|
169
|
+
**Freezes the code it drew.** Given the table the plot draws from
|
|
170
|
+
(*table*, or *table_for* to load it from the spec), every variant row not
|
|
171
|
+
following latest records the function hashes of its rows
|
|
172
|
+
(:func:`freeze_code`), so the plot reopens on that version after the code
|
|
173
|
+
changes. Without a table nothing is frozen and the plot reopens on current
|
|
174
|
+
code; that is logged.
|
|
175
|
+
|
|
176
|
+
A visible plot of that name gets a new version, so the previous one is
|
|
177
|
+
kept as history. Otherwise a new plot is started, even when a removed
|
|
178
|
+
(hidden) plot had that name, because a new plot does not inherit an old
|
|
179
|
+
plot's history.
|
|
180
|
+
|
|
181
|
+
With ``overwrite=False``, adding a version to an existing plot is allowed
|
|
182
|
+
only when that plot is *current_plot_id* (the one the user has open).
|
|
183
|
+
Saving onto any OTHER plot's name raises :class:`SavedPlotExists` so the
|
|
184
|
+
caller can ask first.
|
|
185
|
+
|
|
186
|
+
*spec* is a ``PlotSpec`` or its dict. A dict is read **strictly**: the
|
|
187
|
+
panel always sends the current format, and a spec that does not parse is
|
|
188
|
+
a bug to surface, not something to store.
|
|
189
|
+
"""
|
|
190
|
+
from scistackplot import PlotSpec, __version__ as plot_version
|
|
191
|
+
|
|
192
|
+
variable = _check_text(variable, "variable")
|
|
193
|
+
name = check_name(name)
|
|
194
|
+
if isinstance(spec, dict):
|
|
195
|
+
try:
|
|
196
|
+
spec = PlotSpec.from_dict(spec)
|
|
197
|
+
except Exception as exc:
|
|
198
|
+
raise SavedPlotError(f"The plot's settings could not be read: {exc}") from exc
|
|
199
|
+
if view is None:
|
|
200
|
+
view = {}
|
|
201
|
+
if not isinstance(view, dict):
|
|
202
|
+
raise SavedPlotError(f"view must be a table of settings, got {type(view).__name__}")
|
|
203
|
+
|
|
204
|
+
if table is None and table_for is not None:
|
|
205
|
+
try:
|
|
206
|
+
table = table_for(spec)
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
Log.warn(
|
|
209
|
+
"[saved_plot] %s / %r: the data could not be loaded to freeze its "
|
|
210
|
+
"code version (%s); saving unfrozen",
|
|
211
|
+
variable,
|
|
212
|
+
name,
|
|
213
|
+
exc,
|
|
214
|
+
layer=LAYER,
|
|
215
|
+
)
|
|
216
|
+
if table is not None:
|
|
217
|
+
frozen_code = freeze_code(db, spec, table)
|
|
218
|
+
else:
|
|
219
|
+
frozen_code = {}
|
|
220
|
+
Log.info(
|
|
221
|
+
"[saved_plot] %s / %r: no table given, so no code version is frozen "
|
|
222
|
+
"(the plot reopens on current code)",
|
|
223
|
+
variable,
|
|
224
|
+
name,
|
|
225
|
+
layer=LAYER,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
envelope = {
|
|
229
|
+
"format": ENVELOPE_FORMAT,
|
|
230
|
+
"spec": _strip_frozen(spec).to_dict(),
|
|
231
|
+
"view": view,
|
|
232
|
+
"saved_with": {"scistackplot": plot_version},
|
|
233
|
+
FROZEN_KEY: frozen_code,
|
|
234
|
+
}
|
|
235
|
+
row, _new = STORE.save(
|
|
236
|
+
db,
|
|
237
|
+
variable,
|
|
238
|
+
name,
|
|
239
|
+
envelope,
|
|
240
|
+
overwrite=overwrite,
|
|
241
|
+
current_id=current_plot_id,
|
|
242
|
+
on_clash=lambda clash: SavedPlotExists(_info(clash)),
|
|
243
|
+
)
|
|
244
|
+
return _info(row)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def rename_saved_plot(db, plot_id: str, new_name: str) -> SavedPlotInfo:
|
|
248
|
+
"""Rename a plot (every version). Refused when another visible plot of
|
|
249
|
+
the same variable already has that name."""
|
|
250
|
+
return _info(STORE.rename(db, plot_id, new_name))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def hide_saved_plot(db, plot_id: str, hidden: bool = True) -> SavedPlotInfo:
|
|
254
|
+
"""Remove a plot from the list, keeping every row. ``hidden=False``
|
|
255
|
+
brings it back."""
|
|
256
|
+
return _info(STORE.set_hidden(db, plot_id, hidden))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# Reads
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def list_saved_plots(db, variable: str, *, include_hidden: bool = False) -> list[SavedPlotInfo]:
|
|
265
|
+
"""The newest version of each saved plot of *variable*, newest first."""
|
|
266
|
+
return [_info(row) for row in STORE.list_rows(db, variable, include_hidden=include_hidden)]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def saved_plot_history(db, plot_id: str) -> list[SavedPlotInfo]:
|
|
270
|
+
"""Every version of one plot, newest first."""
|
|
271
|
+
return [_info(row) for row in STORE.history(db, plot_id)]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def load_saved_plot(
|
|
275
|
+
db,
|
|
276
|
+
plot_id: str,
|
|
277
|
+
*,
|
|
278
|
+
version: int | None = None,
|
|
279
|
+
table=None,
|
|
280
|
+
table_for: Callable[[Any], Any] | None = None,
|
|
281
|
+
) -> SavedPlot:
|
|
282
|
+
"""Open a saved plot (its newest version unless *version* is given).
|
|
283
|
+
|
|
284
|
+
The spec is read with :func:`scistackplot.restore_spec`, so a plot saved
|
|
285
|
+
by an older version of the plotting layer still opens. Settings that no
|
|
286
|
+
longer exist or no longer parse come back as notes.
|
|
287
|
+
|
|
288
|
+
To also reconcile the spec with today's data, pass either *table* (the
|
|
289
|
+
``LongTable`` the plot will draw from) or *table_for*, a callable that
|
|
290
|
+
loads that table from the RESTORED spec. The table depends on the spec
|
|
291
|
+
(its variant rows and factor variables decide what gets loaded), so a
|
|
292
|
+
caller that cannot know the spec in advance hands over the loader. The
|
|
293
|
+
GUI does this with its cached source. A loader that raises does not fail
|
|
294
|
+
the open: the plot opens unreconciled, with a note saying why.
|
|
295
|
+
|
|
296
|
+
Never rewrites the stored copy: a later build may salvage more of it.
|
|
297
|
+
"""
|
|
298
|
+
from scistackplot import NoteKind, RestoreNote, reconcile, restore_spec
|
|
299
|
+
|
|
300
|
+
row, text = STORE.load(db, plot_id, version)
|
|
301
|
+
info = _info(row)
|
|
302
|
+
envelope = STORE.parse_envelope(
|
|
303
|
+
text, f"{info.variable} / {info.name!r} version {info.version}"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
restored = restore_spec(envelope.get("spec"), fallback_measure=info.variable)
|
|
307
|
+
spec, notes = restored.spec, list(restored.notes)
|
|
308
|
+
if table is None and table_for is not None:
|
|
309
|
+
try:
|
|
310
|
+
table = table_for(spec)
|
|
311
|
+
except Exception as exc:
|
|
312
|
+
Log.warn(
|
|
313
|
+
"[saved_plot] %s / %r: today's data could not be loaded for "
|
|
314
|
+
"reconciling (%s); opening unreconciled",
|
|
315
|
+
info.variable,
|
|
316
|
+
info.name,
|
|
317
|
+
exc,
|
|
318
|
+
layer=LAYER,
|
|
319
|
+
)
|
|
320
|
+
notes.append(
|
|
321
|
+
RestoreNote(
|
|
322
|
+
"",
|
|
323
|
+
NoteKind.NOT_IN_DATA,
|
|
324
|
+
f"today's data could not be loaded, so the settings were "
|
|
325
|
+
f"not checked against it: {exc}",
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
newer: list = []
|
|
329
|
+
frozen_code = envelope.get(FROZEN_KEY)
|
|
330
|
+
if table is not None and isinstance(frozen_code, dict) and frozen_code:
|
|
331
|
+
# Before reconcile, so it checks the roles against the table the
|
|
332
|
+
# FROZEN rows derive (docs/claude/variant-row-selection.md).
|
|
333
|
+
spec, newer, thaw_notes = thaw_code(
|
|
334
|
+
db, spec, table, frozen_code, label=f"{info.variable} / {info.name!r}"
|
|
335
|
+
)
|
|
336
|
+
notes.extend(thaw_notes)
|
|
337
|
+
elif not frozen_code:
|
|
338
|
+
# Saved before freezing existed, or with no table: opens on current
|
|
339
|
+
# code with no prompt. Nothing is back-filled (no migrations).
|
|
340
|
+
Log.info(
|
|
341
|
+
"[saved_plot] %s / %r: no frozen code version stored; opening on "
|
|
342
|
+
"current code",
|
|
343
|
+
info.variable,
|
|
344
|
+
info.name,
|
|
345
|
+
layer=LAYER,
|
|
346
|
+
)
|
|
347
|
+
if table is not None:
|
|
348
|
+
reconciled = reconcile(spec, table)
|
|
349
|
+
spec, notes = reconciled.spec, notes + list(reconciled.notes)
|
|
350
|
+
|
|
351
|
+
view = envelope.get("view")
|
|
352
|
+
if not isinstance(view, dict):
|
|
353
|
+
Log.warn(
|
|
354
|
+
"[saved_plot] %s / %r: stored view settings are not a table (%r); "
|
|
355
|
+
"opening with the panel's defaults",
|
|
356
|
+
info.variable,
|
|
357
|
+
info.name,
|
|
358
|
+
view,
|
|
359
|
+
layer=LAYER,
|
|
360
|
+
)
|
|
361
|
+
view = {}
|
|
362
|
+
|
|
363
|
+
stored_format = envelope.get("format")
|
|
364
|
+
Log.info(
|
|
365
|
+
"[saved_plot] opened %s / %r version %d (envelope format %r, saved with %s): "
|
|
366
|
+
"%d note(s)",
|
|
367
|
+
info.variable,
|
|
368
|
+
info.name,
|
|
369
|
+
info.version,
|
|
370
|
+
stored_format,
|
|
371
|
+
envelope.get("saved_with"),
|
|
372
|
+
len(notes),
|
|
373
|
+
layer=LAYER,
|
|
374
|
+
)
|
|
375
|
+
return SavedPlot(
|
|
376
|
+
info=info,
|
|
377
|
+
spec=spec,
|
|
378
|
+
view=view,
|
|
379
|
+
notes=notes,
|
|
380
|
+
stored_format=stored_format,
|
|
381
|
+
newer=newer,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ---------------------------------------------------------------------------
|
|
386
|
+
# Helpers
|
|
387
|
+
# ---------------------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def check_name(name: Any) -> str:
|
|
391
|
+
"""A saved plot's name, trimmed, or :class:`SavedPlotError` saying why not.
|
|
392
|
+
|
|
393
|
+
The one statement of what a name may be (``VersionedStore.check_name``);
|
|
394
|
+
the GUI asks the backend rather than repeating the rule.
|
|
395
|
+
"""
|
|
396
|
+
return STORE.check_name(name)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _check_text(value: Any, what: str) -> str:
|
|
400
|
+
if not isinstance(value, str) or not value.strip():
|
|
401
|
+
raise SavedPlotError(f"A saved plot needs a {what}.")
|
|
402
|
+
return value.strip()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _info(row) -> SavedPlotInfo:
|
|
406
|
+
plot_id, variable, name, version, saved_at, hidden = row
|
|
407
|
+
return SavedPlotInfo(
|
|
408
|
+
plot_id=str(plot_id),
|
|
409
|
+
variable=str(variable),
|
|
410
|
+
name=str(name),
|
|
411
|
+
version=int(version),
|
|
412
|
+
saved_at=str(saved_at),
|
|
413
|
+
hidden=bool(hidden),
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# ---------------------------------------------------------------------------
|
|
418
|
+
# Frozen code versions (docs/claude/variant-row-selection.md)
|
|
419
|
+
# ---------------------------------------------------------------------------
|
|
420
|
+
#
|
|
421
|
+
# A saved plot remembers WHICH CODE drew it. At save time every variant row
|
|
422
|
+
# that does not follow latest records the function hashes of the rows it drew
|
|
423
|
+
# (``frozen_code`` in the envelope). On open, those hashes become today's
|
|
424
|
+
# ordinals in ``VariantSet.frozen`` — so the figure reopens on the version it
|
|
425
|
+
# was saved with — and anything newer is reported as ``SavedPlot.newer`` for
|
|
426
|
+
# the GUI's "switch to latest?" prompt. Hashes, not ordinals, are stored
|
|
427
|
+
# because ordinals renumber after a real Variants-popup delete.
|
|
428
|
+
|
|
429
|
+
#: Envelope key: ``{str(row_index): {function_name: [function_hash, ...]}}``.
|
|
430
|
+
FROZEN_KEY = "frozen_code"
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _row_masks(spec, table) -> list[tuple[int, Any]]:
|
|
434
|
+
"""``[(row_index, mask)]`` — the rows each variant row DRAWS, by the same
|
|
435
|
+
owner and first-match-wins order the figure uses
|
|
436
|
+
(``scistackplot.variants.row_mask`` / ``apply_variant_sets``).
|
|
437
|
+
|
|
438
|
+
With no row concrete, the figure draws an empty row's effective selection;
|
|
439
|
+
that is frozen as row 0 (synthesised on open if the spec has none).
|
|
440
|
+
"""
|
|
441
|
+
import pandas as pd
|
|
442
|
+
from scistackplot.spec import VariantSet
|
|
443
|
+
from scistackplot.variants import defined_sets, is_stated, row_mask
|
|
444
|
+
|
|
445
|
+
frame = table.frame
|
|
446
|
+
if not defined_sets(spec.variant_sets):
|
|
447
|
+
first = spec.variant_sets[0] if spec.variant_sets else VariantSet()
|
|
448
|
+
return [(0, row_mask(table, spec, first))]
|
|
449
|
+
claimed = pd.Series(False, index=frame.index)
|
|
450
|
+
out = []
|
|
451
|
+
for index, variant in enumerate(spec.variant_sets):
|
|
452
|
+
if not is_stated(variant, index):
|
|
453
|
+
continue
|
|
454
|
+
fresh = row_mask(table, spec, variant) & ~claimed
|
|
455
|
+
claimed |= fresh
|
|
456
|
+
out.append((index, fresh))
|
|
457
|
+
return out
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def freeze_code(db, spec, table) -> dict[str, dict[str, list[str]]]:
|
|
461
|
+
"""The envelope's ``frozen_code``: per variant row not following latest,
|
|
462
|
+
every function in the drawn records' chains and the hash(es) it ran.
|
|
463
|
+
|
|
464
|
+
Works with ONE version too (the usual state at save time): there is no
|
|
465
|
+
``Code:`` column then, but the chain still names the hash, and that is
|
|
466
|
+
what lets the plot reopen on it after a second version appears.
|
|
467
|
+
"""
|
|
468
|
+
from scidb.provenance_query import code_versions_batch
|
|
469
|
+
|
|
470
|
+
frame = table.frame
|
|
471
|
+
if "record_id" not in frame.columns:
|
|
472
|
+
Log.warn(
|
|
473
|
+
"[saved_plot] %r: the table has no record_id column, so no code "
|
|
474
|
+
"version can be frozen — the plot will reopen on current code",
|
|
475
|
+
getattr(table, "name", None),
|
|
476
|
+
layer=LAYER,
|
|
477
|
+
)
|
|
478
|
+
return {}
|
|
479
|
+
rows = _row_masks(spec, table)
|
|
480
|
+
follow = {
|
|
481
|
+
index for index, variant in enumerate(spec.variant_sets) if variant.follow_latest
|
|
482
|
+
}
|
|
483
|
+
wanted = {index: mask for index, mask in rows if index not in follow}
|
|
484
|
+
record_ids = sorted(
|
|
485
|
+
{
|
|
486
|
+
str(rid)
|
|
487
|
+
for mask in wanted.values()
|
|
488
|
+
for rid in frame.loc[mask, "record_id"].dropna().unique()
|
|
489
|
+
}
|
|
490
|
+
)
|
|
491
|
+
chains = code_versions_batch(db._duck, record_ids) if record_ids else {}
|
|
492
|
+
frozen: dict[str, dict[str, list[str]]] = {}
|
|
493
|
+
for index, mask in wanted.items():
|
|
494
|
+
by_fn: dict[str, set] = {}
|
|
495
|
+
for rid in frame.loc[mask, "record_id"].dropna().unique():
|
|
496
|
+
for fn_name, fn_hash in (chains.get(str(rid)) or {}).items():
|
|
497
|
+
by_fn.setdefault(fn_name, set()).add(fn_hash)
|
|
498
|
+
if by_fn:
|
|
499
|
+
frozen[str(index)] = {fn: sorted(hashes) for fn, hashes in sorted(by_fn.items())}
|
|
500
|
+
Log.info(
|
|
501
|
+
"[saved_plot] froze code for %d row(s) over %d record(s): %s%s",
|
|
502
|
+
len(frozen),
|
|
503
|
+
len(record_ids),
|
|
504
|
+
{
|
|
505
|
+
index: {fn: [h[:12] for h in hashes] for fn, hashes in fns.items()}
|
|
506
|
+
for index, fns in frozen.items()
|
|
507
|
+
},
|
|
508
|
+
f" (rows following latest: {sorted(follow)})" if follow else "",
|
|
509
|
+
layer=LAYER,
|
|
510
|
+
)
|
|
511
|
+
return frozen
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _strip_frozen(spec):
|
|
515
|
+
"""The spec as stored: ``frozen`` ordinals are derived on open from the
|
|
516
|
+
envelope's hashes, never stored (an ordinal can renumber)."""
|
|
517
|
+
from dataclasses import replace
|
|
518
|
+
|
|
519
|
+
return replace(
|
|
520
|
+
spec,
|
|
521
|
+
variant_sets=[replace(v, frozen={}) for v in spec.variant_sets],
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _code_columns(table) -> dict[str, str]:
|
|
526
|
+
"""``{function_name: column}`` for every code axis the table carries."""
|
|
527
|
+
out = {}
|
|
528
|
+
for factor in table.factors:
|
|
529
|
+
origin = factor.origin or {}
|
|
530
|
+
if origin.get("kind") == "code" and origin.get("function"):
|
|
531
|
+
out[origin["function"]] = factor.name
|
|
532
|
+
return out
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def thaw_code(db, spec, table, frozen_code: dict, *, label: str = ""):
|
|
536
|
+
"""``(spec, newer, notes)`` — apply a stored freeze to today's data.
|
|
537
|
+
|
|
538
|
+
For each frozen row and function that is CURRENTLY an axis (two or more
|
|
539
|
+
versions, so a ``Code:`` column), the stored hashes become today's
|
|
540
|
+
ordinals in ``VariantSet.frozen``. A single-version function needs
|
|
541
|
+
nothing. ``newer`` lists, per row and function, the versions present in
|
|
542
|
+
the data that are newer than every frozen one — what the GUI asks about.
|
|
543
|
+
|
|
544
|
+
A frozen hash that no longer exists (its version was deleted) is NOT
|
|
545
|
+
silently replaced: it gets a note, and the row falls back to the
|
|
546
|
+
remaining frozen versions or, with none left, to current code — which the
|
|
547
|
+
note says, and ``newer`` offers to make permanent.
|
|
548
|
+
"""
|
|
549
|
+
from dataclasses import replace
|
|
550
|
+
|
|
551
|
+
from scidb.provenance_query import function_versions
|
|
552
|
+
from scistackplot import NoteKind, RestoreNote
|
|
553
|
+
from scistackplot.spec import VariantSet
|
|
554
|
+
from scistackplot.variants import _ordinal_rank
|
|
555
|
+
|
|
556
|
+
if not frozen_code:
|
|
557
|
+
return spec, [], []
|
|
558
|
+
columns = _code_columns(table)
|
|
559
|
+
fn_names = sorted({fn for fns in frozen_code.values() for fn in fns})
|
|
560
|
+
versions = function_versions(db._duck, fn_names)
|
|
561
|
+
sets = list(spec.variant_sets)
|
|
562
|
+
newer: list[dict] = []
|
|
563
|
+
notes: list = []
|
|
564
|
+
frame = table.frame
|
|
565
|
+
levels = [key for key in table.schema_levels if key in frame.columns]
|
|
566
|
+
|
|
567
|
+
for raw_index, fns in sorted(frozen_code.items(), key=lambda kv: int(kv[0])):
|
|
568
|
+
index = int(raw_index)
|
|
569
|
+
if index >= len(sets):
|
|
570
|
+
if index == 0 and not sets:
|
|
571
|
+
sets.append(VariantSet())
|
|
572
|
+
else:
|
|
573
|
+
continue
|
|
574
|
+
variant = sets[index]
|
|
575
|
+
if variant.follow_latest:
|
|
576
|
+
continue
|
|
577
|
+
frozen: dict[str, list[str]] = {}
|
|
578
|
+
for fn_name, hashes in fns.items():
|
|
579
|
+
by_hash = {v["function_hash"]: v["version"] for v in versions.get(fn_name, [])}
|
|
580
|
+
ordinals = [by_hash[h] for h in hashes if h in by_hash]
|
|
581
|
+
missing = [h for h in hashes if h not in by_hash]
|
|
582
|
+
if missing:
|
|
583
|
+
notes.append(
|
|
584
|
+
RestoreNote(
|
|
585
|
+
f"variant_sets[{index}]",
|
|
586
|
+
NoteKind.NOT_IN_DATA,
|
|
587
|
+
f"the {fn_name} version(s) this plot was saved with "
|
|
588
|
+
f"({', '.join(h[:12] for h in missing)}) no longer exist"
|
|
589
|
+
+ ("" if ordinals else "; showing the current version instead"),
|
|
590
|
+
)
|
|
591
|
+
)
|
|
592
|
+
column = columns.get(fn_name)
|
|
593
|
+
if column is None:
|
|
594
|
+
continue # one version today: nothing to choose between
|
|
595
|
+
if ordinals:
|
|
596
|
+
frozen[column] = sorted(set(ordinals), key=_ordinal_rank)
|
|
597
|
+
present = frame[column].dropna().astype(str)
|
|
598
|
+
top = max((_ordinal_rank(o) for o in ordinals), default=0)
|
|
599
|
+
later = sorted(
|
|
600
|
+
{level for level in present.unique() if _ordinal_rank(level) > top},
|
|
601
|
+
key=_ordinal_rank,
|
|
602
|
+
)
|
|
603
|
+
if later:
|
|
604
|
+
at = frame[frame[column].astype(str).isin(later)]
|
|
605
|
+
newer.append(
|
|
606
|
+
{
|
|
607
|
+
"row": index,
|
|
608
|
+
"column": column,
|
|
609
|
+
"function": fn_name,
|
|
610
|
+
"frozen": list(frozen.get(column, [])),
|
|
611
|
+
"newer": later,
|
|
612
|
+
"newest": later[-1],
|
|
613
|
+
"locations": int(len(at[levels].drop_duplicates())) if levels else int(len(at)),
|
|
614
|
+
}
|
|
615
|
+
)
|
|
616
|
+
if frozen:
|
|
617
|
+
sets[index] = replace(variant, frozen=frozen)
|
|
618
|
+
|
|
619
|
+
Log.info(
|
|
620
|
+
"[saved_plot] %s: frozen code -> %s; newer: %s",
|
|
621
|
+
label,
|
|
622
|
+
{i: v.frozen for i, v in enumerate(sets) if v.frozen} or "nothing to pin",
|
|
623
|
+
[f"row {n['row']} {n['function']} {n['frozen']} < {n['newer']}" for n in newer]
|
|
624
|
+
or "none",
|
|
625
|
+
layer=LAYER,
|
|
626
|
+
)
|
|
627
|
+
return replace(spec, variant_sets=sets), newer, notes
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def follow_latest_saved_plot(db, plot_id: str, *, table=None) -> SavedPlotInfo:
|
|
631
|
+
"""Answer "switch to latest" for a saved plot: append a version whose rows
|
|
632
|
+
follow latest code from now on (never frozen again, never asked again).
|
|
633
|
+
|
|
634
|
+
Each row drops its frozen pins AND its own code choices (``Code:``/``Run:``
|
|
635
|
+
keys and the latest flag) — a row still saying ``Code:f = v1`` would keep
|
|
636
|
+
drawing v1. What remains (branch params, the variable) is kept, and the
|
|
637
|
+
row then draws current code by the effective-selection rule. Appends, as
|
|
638
|
+
any re-save does; older versions stay.
|
|
639
|
+
"""
|
|
640
|
+
from dataclasses import replace
|
|
641
|
+
|
|
642
|
+
from scistackplot import restore_spec
|
|
643
|
+
from scistackplot.spec import VariantSet
|
|
644
|
+
from scistackplot.variants import is_function_axis
|
|
645
|
+
|
|
646
|
+
from .load import LATEST_COLUMN
|
|
647
|
+
|
|
648
|
+
row, text = STORE.load(db, plot_id, None)
|
|
649
|
+
info = _info(row)
|
|
650
|
+
envelope = STORE.parse_envelope(text, f"{info.variable} / {info.name!r}")
|
|
651
|
+
spec = restore_spec(envelope.get("spec"), fallback_measure=info.variable).spec
|
|
652
|
+
sets = list(spec.variant_sets) or [VariantSet()]
|
|
653
|
+
sets = [
|
|
654
|
+
replace(
|
|
655
|
+
variant,
|
|
656
|
+
frozen={},
|
|
657
|
+
follow_latest=True,
|
|
658
|
+
selection={
|
|
659
|
+
key: value
|
|
660
|
+
for key, value in variant.selection.items()
|
|
661
|
+
if not is_function_axis(key) and key != LATEST_COLUMN
|
|
662
|
+
},
|
|
663
|
+
)
|
|
664
|
+
for variant in sets
|
|
665
|
+
]
|
|
666
|
+
spec = replace(spec, variant_sets=sets)
|
|
667
|
+
view = envelope.get("view") if isinstance(envelope.get("view"), dict) else {}
|
|
668
|
+
new_info = save_plot(
|
|
669
|
+
db,
|
|
670
|
+
info.variable,
|
|
671
|
+
info.name,
|
|
672
|
+
spec,
|
|
673
|
+
view,
|
|
674
|
+
overwrite=True,
|
|
675
|
+
current_plot_id=plot_id,
|
|
676
|
+
table=table,
|
|
677
|
+
)
|
|
678
|
+
Log.info(
|
|
679
|
+
"[saved_plot] %s / %r now follows latest code (version %d -> %d)",
|
|
680
|
+
info.variable,
|
|
681
|
+
info.name,
|
|
682
|
+
info.version,
|
|
683
|
+
new_info.version,
|
|
684
|
+
layer=LAYER,
|
|
685
|
+
)
|
|
686
|
+
return new_info
|
|
@@ -1,350 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Saved plots: named, per-variable Plot Studio figures kept in the project
|
|
3
|
-
database.
|
|
4
|
-
|
|
5
|
-
A saved plot is **display intent**. It changes nothing a run computes, so it is
|
|
6
|
-
not an aspect of the GUI's intent store (docs/claude/intent-and-fact.md §1).
|
|
7
|
-
It lives here, in the layer that knows both the database and the spec, so a
|
|
8
|
-
script can reopen a figure the GUI saved::
|
|
9
|
-
|
|
10
|
-
from scistackplotdb import list_saved_plots, load_saved_plot
|
|
11
|
-
saved = load_saved_plot(db, list_saved_plots(db, "StepLength")[0].plot_id)
|
|
12
|
-
render(ScidbSource(db).get_table(saved.spec.variant_variables()), saved.spec)
|
|
13
|
-
|
|
14
|
-
**One table, append-only.** ``_saved_plot`` holds one row per *version*.
|
|
15
|
-
Saving under a name that is already showing appends a version to that plot;
|
|
16
|
-
the newest is what the list shows and what opens. Nothing is ever deleted
|
|
17
|
-
(feedback_never_delete_mark_hidden): "remove" sets ``hidden`` on every
|
|
18
|
-
version of the plot, and the rows stay.
|
|
19
|
-
|
|
20
|
-
``plot_id`` is the plot's identity, stable across renames. A name is a label.
|
|
21
|
-
Keying by name would make rename an update of an identity column, and would
|
|
22
|
-
make "a new plot reusing a removed plot's name" silently inherit its history.
|
|
23
|
-
|
|
24
|
-
**The stored spec is always the current format.** :func:`save_plot` reads the
|
|
25
|
-
incoming spec strictly, so what lands in the table is exactly what this
|
|
26
|
-
version writes. Old formats are only ever *read*, by
|
|
27
|
-
:func:`scistackplot.restore_spec`. The stored copy is never rewritten on
|
|
28
|
-
open; only an explicit save writes. See ``.claude/plan-saved-plots.md``.
|
|
29
|
-
"""
|
|
30
|
-
|
|
31
|
-
from __future__ import annotations
|
|
32
|
-
|
|
33
|
-
from dataclasses import dataclass, field
|
|
34
|
-
from typing import Any, Callable
|
|
35
|
-
|
|
36
|
-
from scistacklog import Log
|
|
37
|
-
|
|
38
|
-
from ._versioned import MAX_NAME_LENGTH, VersionedStore # noqa: F401 (re-exported)
|
|
39
|
-
|
|
40
|
-
LAYER = "scistackplotdb"
|
|
41
|
-
|
|
42
|
-
TABLE = "_saved_plot"
|
|
43
|
-
|
|
44
|
-
#: The envelope's layout version. Written on every save and logged on every
|
|
45
|
-
#: load; nothing branches on it (salvage, never migrate).
|
|
46
|
-
ENVELOPE_FORMAT = 1
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
class SavedPlotError(ValueError):
|
|
50
|
-
"""A save, rename or load that cannot be done as asked."""
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
class SavedPlotExists(SavedPlotError):
|
|
54
|
-
"""``save_plot(overwrite=False)`` found ANOTHER visible plot with that name.
|
|
55
|
-
|
|
56
|
-
``existing`` is that plot, so the caller can ask the user and retry with
|
|
57
|
-
``overwrite=True``. The name comparison stays here, where names are
|
|
58
|
-
normalised, instead of in each caller.
|
|
59
|
-
"""
|
|
60
|
-
|
|
61
|
-
def __init__(self, existing: "SavedPlotInfo"):
|
|
62
|
-
super().__init__(
|
|
63
|
-
f"{existing.variable} already has a saved plot named "
|
|
64
|
-
f"{existing.name!r} (version {existing.version})."
|
|
65
|
-
)
|
|
66
|
-
self.existing = existing
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
@dataclass(frozen=True)
|
|
70
|
-
class SavedPlotInfo:
|
|
71
|
-
"""One version of a saved plot, without its contents: a list row."""
|
|
72
|
-
|
|
73
|
-
plot_id: str
|
|
74
|
-
variable: str
|
|
75
|
-
name: str
|
|
76
|
-
version: int
|
|
77
|
-
saved_at: str
|
|
78
|
-
hidden: bool = False
|
|
79
|
-
|
|
80
|
-
def to_dict(self) -> dict:
|
|
81
|
-
return {
|
|
82
|
-
"plot_id": self.plot_id,
|
|
83
|
-
"variable": self.variable,
|
|
84
|
-
"name": self.name,
|
|
85
|
-
"version": self.version,
|
|
86
|
-
"saved_at": self.saved_at,
|
|
87
|
-
"hidden": self.hidden,
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
@dataclass(frozen=True)
|
|
92
|
-
class SavedPlot:
|
|
93
|
-
"""A saved plot, opened: the restored spec plus what did not survive."""
|
|
94
|
-
|
|
95
|
-
info: SavedPlotInfo
|
|
96
|
-
spec: Any # scistackplot.PlotSpec
|
|
97
|
-
#: The panel's own view settings (preview mode, aspect choice, figure
|
|
98
|
-
#: index), stored verbatim. The frontend owns their meaning and reads them
|
|
99
|
-
#: as leniently as the spec is read here.
|
|
100
|
-
view: dict = field(default_factory=dict)
|
|
101
|
-
#: ``scistackplot.RestoreNote`` s from restoring the spec and, when a table
|
|
102
|
-
#: was given, from reconciling it with today's data.
|
|
103
|
-
notes: list = field(default_factory=list)
|
|
104
|
-
#: The envelope format this version was written in.
|
|
105
|
-
stored_format: Any = None
|
|
106
|
-
|
|
107
|
-
def to_dict(self) -> dict:
|
|
108
|
-
return {
|
|
109
|
-
**self.info.to_dict(),
|
|
110
|
-
"spec": self.spec.to_dict(),
|
|
111
|
-
"view": dict(self.view),
|
|
112
|
-
"notes": [note.to_dict() for note in self.notes],
|
|
113
|
-
"stored_format": self.stored_format,
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
# ---------------------------------------------------------------------------
|
|
118
|
-
# Table
|
|
119
|
-
# ---------------------------------------------------------------------------
|
|
120
|
-
|
|
121
|
-
#: The rules (names, versions, hiding, never deleting) live in `_versioned`,
|
|
122
|
-
#: shared with presets so the two stores cannot drift apart.
|
|
123
|
-
STORE = VersionedStore(
|
|
124
|
-
table=TABLE,
|
|
125
|
-
id_column="plot_id",
|
|
126
|
-
scope_column="variable",
|
|
127
|
-
noun="saved plot",
|
|
128
|
-
tag="saved_plot",
|
|
129
|
-
error=SavedPlotError,
|
|
130
|
-
)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def ensure_table(db) -> None:
|
|
134
|
-
"""Create ``_saved_plot`` if absent. Called by every write."""
|
|
135
|
-
STORE.ensure_table(db)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def _table_exists(db) -> bool:
|
|
139
|
-
"""Reads never create the table: listing an untouched database must not
|
|
140
|
-
write to it."""
|
|
141
|
-
return STORE.table_exists(db)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
# ---------------------------------------------------------------------------
|
|
145
|
-
# Writes
|
|
146
|
-
# ---------------------------------------------------------------------------
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def save_plot(
|
|
150
|
-
db,
|
|
151
|
-
variable: str,
|
|
152
|
-
name: str,
|
|
153
|
-
spec,
|
|
154
|
-
view: dict | None = None,
|
|
155
|
-
*,
|
|
156
|
-
overwrite: bool = True,
|
|
157
|
-
current_plot_id: str | None = None,
|
|
158
|
-
) -> SavedPlotInfo:
|
|
159
|
-
"""Save *spec* under *name* for *variable*, returning the new version.
|
|
160
|
-
|
|
161
|
-
A visible plot of that name gets a new version, so the previous one is
|
|
162
|
-
kept as history. Otherwise a new plot is started, even when a removed
|
|
163
|
-
(hidden) plot had that name, because a new plot does not inherit an old
|
|
164
|
-
plot's history.
|
|
165
|
-
|
|
166
|
-
With ``overwrite=False``, adding a version to an existing plot is allowed
|
|
167
|
-
only when that plot is *current_plot_id* (the one the user has open).
|
|
168
|
-
Saving onto any OTHER plot's name raises :class:`SavedPlotExists` so the
|
|
169
|
-
caller can ask first.
|
|
170
|
-
|
|
171
|
-
*spec* is a ``PlotSpec`` or its dict. A dict is read **strictly**: the
|
|
172
|
-
panel always sends the current format, and a spec that does not parse is
|
|
173
|
-
a bug to surface, not something to store.
|
|
174
|
-
"""
|
|
175
|
-
from scistackplot import PlotSpec, __version__ as plot_version
|
|
176
|
-
|
|
177
|
-
variable = _check_text(variable, "variable")
|
|
178
|
-
name = check_name(name)
|
|
179
|
-
if isinstance(spec, dict):
|
|
180
|
-
try:
|
|
181
|
-
spec = PlotSpec.from_dict(spec)
|
|
182
|
-
except Exception as exc:
|
|
183
|
-
raise SavedPlotError(f"The plot's settings could not be read: {exc}") from exc
|
|
184
|
-
if view is None:
|
|
185
|
-
view = {}
|
|
186
|
-
if not isinstance(view, dict):
|
|
187
|
-
raise SavedPlotError(f"view must be a table of settings, got {type(view).__name__}")
|
|
188
|
-
|
|
189
|
-
envelope = {
|
|
190
|
-
"format": ENVELOPE_FORMAT,
|
|
191
|
-
"spec": spec.to_dict(),
|
|
192
|
-
"view": view,
|
|
193
|
-
"saved_with": {"scistackplot": plot_version},
|
|
194
|
-
}
|
|
195
|
-
row, _new = STORE.save(
|
|
196
|
-
db,
|
|
197
|
-
variable,
|
|
198
|
-
name,
|
|
199
|
-
envelope,
|
|
200
|
-
overwrite=overwrite,
|
|
201
|
-
current_id=current_plot_id,
|
|
202
|
-
on_clash=lambda clash: SavedPlotExists(_info(clash)),
|
|
203
|
-
)
|
|
204
|
-
return _info(row)
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
def rename_saved_plot(db, plot_id: str, new_name: str) -> SavedPlotInfo:
|
|
208
|
-
"""Rename a plot (every version). Refused when another visible plot of
|
|
209
|
-
the same variable already has that name."""
|
|
210
|
-
return _info(STORE.rename(db, plot_id, new_name))
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
def hide_saved_plot(db, plot_id: str, hidden: bool = True) -> SavedPlotInfo:
|
|
214
|
-
"""Remove a plot from the list, keeping every row. ``hidden=False``
|
|
215
|
-
brings it back."""
|
|
216
|
-
return _info(STORE.set_hidden(db, plot_id, hidden))
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
# ---------------------------------------------------------------------------
|
|
220
|
-
# Reads
|
|
221
|
-
# ---------------------------------------------------------------------------
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
def list_saved_plots(db, variable: str, *, include_hidden: bool = False) -> list[SavedPlotInfo]:
|
|
225
|
-
"""The newest version of each saved plot of *variable*, newest first."""
|
|
226
|
-
return [_info(row) for row in STORE.list_rows(db, variable, include_hidden=include_hidden)]
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
def saved_plot_history(db, plot_id: str) -> list[SavedPlotInfo]:
|
|
230
|
-
"""Every version of one plot, newest first."""
|
|
231
|
-
return [_info(row) for row in STORE.history(db, plot_id)]
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
def load_saved_plot(
|
|
235
|
-
db,
|
|
236
|
-
plot_id: str,
|
|
237
|
-
*,
|
|
238
|
-
version: int | None = None,
|
|
239
|
-
table=None,
|
|
240
|
-
table_for: Callable[[Any], Any] | None = None,
|
|
241
|
-
) -> SavedPlot:
|
|
242
|
-
"""Open a saved plot (its newest version unless *version* is given).
|
|
243
|
-
|
|
244
|
-
The spec is read with :func:`scistackplot.restore_spec`, so a plot saved
|
|
245
|
-
by an older version of the plotting layer still opens. Settings that no
|
|
246
|
-
longer exist or no longer parse come back as notes.
|
|
247
|
-
|
|
248
|
-
To also reconcile the spec with today's data, pass either *table* (the
|
|
249
|
-
``LongTable`` the plot will draw from) or *table_for*, a callable that
|
|
250
|
-
loads that table from the RESTORED spec. The table depends on the spec
|
|
251
|
-
(its variant rows and factor variables decide what gets loaded), so a
|
|
252
|
-
caller that cannot know the spec in advance hands over the loader. The
|
|
253
|
-
GUI does this with its cached source. A loader that raises does not fail
|
|
254
|
-
the open: the plot opens unreconciled, with a note saying why.
|
|
255
|
-
|
|
256
|
-
Never rewrites the stored copy: a later build may salvage more of it.
|
|
257
|
-
"""
|
|
258
|
-
from scistackplot import NoteKind, RestoreNote, reconcile, restore_spec
|
|
259
|
-
|
|
260
|
-
row, text = STORE.load(db, plot_id, version)
|
|
261
|
-
info = _info(row)
|
|
262
|
-
envelope = STORE.parse_envelope(
|
|
263
|
-
text, f"{info.variable} / {info.name!r} version {info.version}"
|
|
264
|
-
)
|
|
265
|
-
|
|
266
|
-
restored = restore_spec(envelope.get("spec"), fallback_measure=info.variable)
|
|
267
|
-
spec, notes = restored.spec, list(restored.notes)
|
|
268
|
-
if table is None and table_for is not None:
|
|
269
|
-
try:
|
|
270
|
-
table = table_for(spec)
|
|
271
|
-
except Exception as exc:
|
|
272
|
-
Log.warn(
|
|
273
|
-
"[saved_plot] %s / %r: today's data could not be loaded for "
|
|
274
|
-
"reconciling (%s); opening unreconciled",
|
|
275
|
-
info.variable,
|
|
276
|
-
info.name,
|
|
277
|
-
exc,
|
|
278
|
-
layer=LAYER,
|
|
279
|
-
)
|
|
280
|
-
notes.append(
|
|
281
|
-
RestoreNote(
|
|
282
|
-
"",
|
|
283
|
-
NoteKind.NOT_IN_DATA,
|
|
284
|
-
f"today's data could not be loaded, so the settings were "
|
|
285
|
-
f"not checked against it: {exc}",
|
|
286
|
-
)
|
|
287
|
-
)
|
|
288
|
-
if table is not None:
|
|
289
|
-
reconciled = reconcile(spec, table)
|
|
290
|
-
spec, notes = reconciled.spec, notes + list(reconciled.notes)
|
|
291
|
-
|
|
292
|
-
view = envelope.get("view")
|
|
293
|
-
if not isinstance(view, dict):
|
|
294
|
-
Log.warn(
|
|
295
|
-
"[saved_plot] %s / %r: stored view settings are not a table (%r); "
|
|
296
|
-
"opening with the panel's defaults",
|
|
297
|
-
info.variable,
|
|
298
|
-
info.name,
|
|
299
|
-
view,
|
|
300
|
-
layer=LAYER,
|
|
301
|
-
)
|
|
302
|
-
view = {}
|
|
303
|
-
|
|
304
|
-
stored_format = envelope.get("format")
|
|
305
|
-
Log.info(
|
|
306
|
-
"[saved_plot] opened %s / %r version %d (envelope format %r, saved with %s): "
|
|
307
|
-
"%d note(s)",
|
|
308
|
-
info.variable,
|
|
309
|
-
info.name,
|
|
310
|
-
info.version,
|
|
311
|
-
stored_format,
|
|
312
|
-
envelope.get("saved_with"),
|
|
313
|
-
len(notes),
|
|
314
|
-
layer=LAYER,
|
|
315
|
-
)
|
|
316
|
-
return SavedPlot(
|
|
317
|
-
info=info, spec=spec, view=view, notes=notes, stored_format=stored_format
|
|
318
|
-
)
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
# ---------------------------------------------------------------------------
|
|
322
|
-
# Helpers
|
|
323
|
-
# ---------------------------------------------------------------------------
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
def check_name(name: Any) -> str:
|
|
327
|
-
"""A saved plot's name, trimmed, or :class:`SavedPlotError` saying why not.
|
|
328
|
-
|
|
329
|
-
The one statement of what a name may be (``VersionedStore.check_name``);
|
|
330
|
-
the GUI asks the backend rather than repeating the rule.
|
|
331
|
-
"""
|
|
332
|
-
return STORE.check_name(name)
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
def _check_text(value: Any, what: str) -> str:
|
|
336
|
-
if not isinstance(value, str) or not value.strip():
|
|
337
|
-
raise SavedPlotError(f"A saved plot needs a {what}.")
|
|
338
|
-
return value.strip()
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
def _info(row) -> SavedPlotInfo:
|
|
342
|
-
plot_id, variable, name, version, saved_at, hidden = row
|
|
343
|
-
return SavedPlotInfo(
|
|
344
|
-
plot_id=str(plot_id),
|
|
345
|
-
variable=str(variable),
|
|
346
|
-
name=str(name),
|
|
347
|
-
version=int(version),
|
|
348
|
-
saved_at=str(saved_at),
|
|
349
|
-
hidden=bool(hidden),
|
|
350
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|