scistackplotdb 0.1.29__tar.gz → 0.1.31__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.29 → scistackplotdb-0.1.31}/.gitignore +1 -0
- {scistackplotdb-0.1.29 → scistackplotdb-0.1.31}/PKG-INFO +50 -12
- {scistackplotdb-0.1.29 → scistackplotdb-0.1.31}/README.md +48 -10
- {scistackplotdb-0.1.29 → scistackplotdb-0.1.31}/pyproject.toml +1 -1
- {scistackplotdb-0.1.29 → scistackplotdb-0.1.31}/src/scistackplotdb/__init__.py +69 -2
- scistackplotdb-0.1.31/src/scistackplotdb/_versioned.py +354 -0
- {scistackplotdb-0.1.29 → scistackplotdb-0.1.31}/src/scistackplotdb/endpoint.py +46 -5
- {scistackplotdb-0.1.29 → scistackplotdb-0.1.31}/src/scistackplotdb/load.py +340 -15
- scistackplotdb-0.1.31/src/scistackplotdb/presets.py +315 -0
- scistackplotdb-0.1.31/src/scistackplotdb/saved.py +350 -0
- scistackplotdb-0.1.31/src/scistackplotdb/source.py +1839 -0
- {scistackplotdb-0.1.29 → scistackplotdb-0.1.31}/src/scistackplotdb/variants.py +102 -32
- scistackplotdb-0.1.29/src/scistackplotdb/source.py +0 -936
- {scistackplotdb-0.1.29 → scistackplotdb-0.1.31}/src/scistackplotdb/hierarchy.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.31
|
|
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
|
|
@@ -23,7 +23,7 @@ Provides-Extra: dev
|
|
|
23
23
|
Requires-Dist: matplotlib>=3.6; extra == 'dev'
|
|
24
24
|
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
25
25
|
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
26
|
-
Requires-Dist: seaborn>=0.
|
|
26
|
+
Requires-Dist: seaborn>=0.13; extra == 'dev'
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
|
|
29
29
|
# scistackplotdb
|
|
@@ -49,8 +49,8 @@ source = ScidbSource(db)
|
|
|
49
49
|
table = source.get_table(["StepLength"])
|
|
50
50
|
spec = PlotSpec(
|
|
51
51
|
measures=["StepLength"],
|
|
52
|
-
roles={"session": Role.
|
|
53
|
-
kind=PlotKind.BOX,
|
|
52
|
+
roles={"session": Role.GROUP, "subject": Role.COLLAPSE, "trial": Role.COLLAPSE},
|
|
53
|
+
kind=PlotKind.BOX, # trial averaged within subject; the box is over subjects
|
|
54
54
|
)
|
|
55
55
|
figure = render(table, spec)
|
|
56
56
|
```
|
|
@@ -83,13 +83,13 @@ branch params as ordinary columns silently plots two pipelines' results as if
|
|
|
83
83
|
they were replicates of one:
|
|
84
84
|
|
|
85
85
|
```python
|
|
86
|
-
spec = PlotSpec(measures=["Scaled"], roles={"session": Role.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
86
|
+
spec = PlotSpec(measures=["Scaled"], roles={"session": Role.GROUP})
|
|
87
|
+
complete_roles(spec, table)["scale.factor"] # Role.ITERATE — one figure per variant, never pooled
|
|
88
|
+
validate(spec.with_roles(**{"scale.factor": Role.COLLAPSE}), table)
|
|
89
|
+
# RoleError: Variant factor(s) ['scale.factor'] cannot be collapsed: their
|
|
90
|
+
# levels are different pipeline variants, not replicates... Give them a
|
|
91
|
+
# grouping layer, 'facet' or 'iterate', or select the variant you want with
|
|
92
|
+
# PlotSpec.variant_sets.
|
|
93
93
|
```
|
|
94
94
|
|
|
95
95
|
**A transport budget.** 1-D data across hundreds of trials is megabytes.
|
|
@@ -134,6 +134,34 @@ Everything about *recording* the figure — `finalized`, artifact stamping,
|
|
|
134
134
|
`skip_computed`, `scidb report` — is SciDB's existing endpoint machinery and is
|
|
135
135
|
untouched.
|
|
136
136
|
|
|
137
|
+
## Saved plots
|
|
138
|
+
|
|
139
|
+
Plot Studio's **Saved plots** are stored in the project database, so a
|
|
140
|
+
script can reopen them too:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from scistackplotdb import list_saved_plots, load_saved_plot, save_plot
|
|
144
|
+
|
|
145
|
+
info = save_plot(db, "StepLength", "Figure 3", spec) # version 1
|
|
146
|
+
save_plot(db, "StepLength", "Figure 3", edited_spec) # version 2; v1 kept
|
|
147
|
+
for plot in list_saved_plots(db, "StepLength"):
|
|
148
|
+
print(plot.name, plot.version, plot.saved_at)
|
|
149
|
+
|
|
150
|
+
saved = load_saved_plot(db, info.plot_id) # newest version
|
|
151
|
+
old = load_saved_plot(db, info.plot_id, version=1)
|
|
152
|
+
figure = render(ScidbSource(db).get_table(saved.spec.variant_variables()), saved.spec)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
- **Append-only.** Re-saving a name adds a version.
|
|
156
|
+
`hide_saved_plot` removes a plot from the list and deletes nothing.
|
|
157
|
+
- **Always written in the current format, read leniently.** A plot saved by
|
|
158
|
+
an older `scistackplot` still opens; `saved.notes` lists any setting that
|
|
159
|
+
no longer applies (`scistackplot.restore_spec`).
|
|
160
|
+
- Pass `table=` or `table_for=` to also check the spec against today's data.
|
|
161
|
+
|
|
162
|
+
The table, the drift policy and why this is not in the GUI's intent store:
|
|
163
|
+
`docs/claude/saved-plots.md`.
|
|
164
|
+
|
|
137
165
|
## Ordering
|
|
138
166
|
|
|
139
167
|
Factor levels are ordered by SciDB's declared `schema_key_types`, not by
|
|
@@ -141,4 +169,14 @@ pandas' default: a key declared `numeric` sorts numerically, and everything
|
|
|
141
169
|
else goes through a natural sort so zero-padded IDs land as
|
|
142
170
|
`01, 02, … 10` instead of `01, 10, 02`.
|
|
143
171
|
|
|
144
|
-
|
|
172
|
+
A project that declares its levels (`[schema_keys]` in scistack.toml, read by
|
|
173
|
+
`scidb.schema_order`) wins over both: declared levels first, in that order,
|
|
174
|
+
the rest after them by the rule above. The declaration is read live, and a
|
|
175
|
+
change drops the source's built tables, so an edit shows on the next plot
|
|
176
|
+
request. Exported seaborn code states every order it relies on (`order=`,
|
|
177
|
+
`hue_order=`, `col_order=`, `row_order=`) rather than leaving seaborn to use
|
|
178
|
+
the frame's row order.
|
|
179
|
+
|
|
180
|
+
See [`docs/claude/plotting-library-design.md`](../docs/claude/plotting-library-design.md)
|
|
181
|
+
and the `[schema_keys]` section of
|
|
182
|
+
[`docs/claude/config-file-formats.md`](../docs/claude/config-file-formats.md).
|
|
@@ -21,8 +21,8 @@ source = ScidbSource(db)
|
|
|
21
21
|
table = source.get_table(["StepLength"])
|
|
22
22
|
spec = PlotSpec(
|
|
23
23
|
measures=["StepLength"],
|
|
24
|
-
roles={"session": Role.
|
|
25
|
-
kind=PlotKind.BOX,
|
|
24
|
+
roles={"session": Role.GROUP, "subject": Role.COLLAPSE, "trial": Role.COLLAPSE},
|
|
25
|
+
kind=PlotKind.BOX, # trial averaged within subject; the box is over subjects
|
|
26
26
|
)
|
|
27
27
|
figure = render(table, spec)
|
|
28
28
|
```
|
|
@@ -55,13 +55,13 @@ branch params as ordinary columns silently plots two pipelines' results as if
|
|
|
55
55
|
they were replicates of one:
|
|
56
56
|
|
|
57
57
|
```python
|
|
58
|
-
spec = PlotSpec(measures=["Scaled"], roles={"session": Role.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
58
|
+
spec = PlotSpec(measures=["Scaled"], roles={"session": Role.GROUP})
|
|
59
|
+
complete_roles(spec, table)["scale.factor"] # Role.ITERATE — one figure per variant, never pooled
|
|
60
|
+
validate(spec.with_roles(**{"scale.factor": Role.COLLAPSE}), table)
|
|
61
|
+
# RoleError: Variant factor(s) ['scale.factor'] cannot be collapsed: their
|
|
62
|
+
# levels are different pipeline variants, not replicates... Give them a
|
|
63
|
+
# grouping layer, 'facet' or 'iterate', or select the variant you want with
|
|
64
|
+
# PlotSpec.variant_sets.
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
**A transport budget.** 1-D data across hundreds of trials is megabytes.
|
|
@@ -106,6 +106,34 @@ Everything about *recording* the figure — `finalized`, artifact stamping,
|
|
|
106
106
|
`skip_computed`, `scidb report` — is SciDB's existing endpoint machinery and is
|
|
107
107
|
untouched.
|
|
108
108
|
|
|
109
|
+
## Saved plots
|
|
110
|
+
|
|
111
|
+
Plot Studio's **Saved plots** are stored in the project database, so a
|
|
112
|
+
script can reopen them too:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from scistackplotdb import list_saved_plots, load_saved_plot, save_plot
|
|
116
|
+
|
|
117
|
+
info = save_plot(db, "StepLength", "Figure 3", spec) # version 1
|
|
118
|
+
save_plot(db, "StepLength", "Figure 3", edited_spec) # version 2; v1 kept
|
|
119
|
+
for plot in list_saved_plots(db, "StepLength"):
|
|
120
|
+
print(plot.name, plot.version, plot.saved_at)
|
|
121
|
+
|
|
122
|
+
saved = load_saved_plot(db, info.plot_id) # newest version
|
|
123
|
+
old = load_saved_plot(db, info.plot_id, version=1)
|
|
124
|
+
figure = render(ScidbSource(db).get_table(saved.spec.variant_variables()), saved.spec)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
- **Append-only.** Re-saving a name adds a version.
|
|
128
|
+
`hide_saved_plot` removes a plot from the list and deletes nothing.
|
|
129
|
+
- **Always written in the current format, read leniently.** A plot saved by
|
|
130
|
+
an older `scistackplot` still opens; `saved.notes` lists any setting that
|
|
131
|
+
no longer applies (`scistackplot.restore_spec`).
|
|
132
|
+
- Pass `table=` or `table_for=` to also check the spec against today's data.
|
|
133
|
+
|
|
134
|
+
The table, the drift policy and why this is not in the GUI's intent store:
|
|
135
|
+
`docs/claude/saved-plots.md`.
|
|
136
|
+
|
|
109
137
|
## Ordering
|
|
110
138
|
|
|
111
139
|
Factor levels are ordered by SciDB's declared `schema_key_types`, not by
|
|
@@ -113,4 +141,14 @@ pandas' default: a key declared `numeric` sorts numerically, and everything
|
|
|
113
141
|
else goes through a natural sort so zero-padded IDs land as
|
|
114
142
|
`01, 02, … 10` instead of `01, 10, 02`.
|
|
115
143
|
|
|
116
|
-
|
|
144
|
+
A project that declares its levels (`[schema_keys]` in scistack.toml, read by
|
|
145
|
+
`scidb.schema_order`) wins over both: declared levels first, in that order,
|
|
146
|
+
the rest after them by the rule above. The declaration is read live, and a
|
|
147
|
+
change drops the source's built tables, so an edit shows on the next plot
|
|
148
|
+
request. Exported seaborn code states every order it relies on (`order=`,
|
|
149
|
+
`hue_order=`, `col_order=`, `row_order=`) rather than leaving seaborn to use
|
|
150
|
+
the frame's row order.
|
|
151
|
+
|
|
152
|
+
See [`docs/claude/plotting-library-design.md`](../docs/claude/plotting-library-design.md)
|
|
153
|
+
and the `[schema_keys]` section of
|
|
154
|
+
[`docs/claude/config-file-formats.md`](../docs/claude/config-file-formats.md).
|
|
@@ -18,7 +18,7 @@ from a finished spec.
|
|
|
18
18
|
table = source.get_table(["StepLength"])
|
|
19
19
|
spec = PlotSpec(
|
|
20
20
|
measures=["StepLength"],
|
|
21
|
-
roles={"session": Role.
|
|
21
|
+
roles={"session": Role.GROUP, "subject": Role.COLLAPSE, "trial": Role.COLLAPSE},
|
|
22
22
|
kind=PlotKind.BOX,
|
|
23
23
|
)
|
|
24
24
|
figure = render(table, spec)
|
|
@@ -43,12 +43,43 @@ from .load import (
|
|
|
43
43
|
VERSION_FACTOR_PREFIX,
|
|
44
44
|
VariableFrame,
|
|
45
45
|
attach_variants,
|
|
46
|
+
data_column_types_for,
|
|
46
47
|
data_columns_for,
|
|
48
|
+
is_container_type,
|
|
47
49
|
load_variable,
|
|
48
50
|
registered_variables,
|
|
49
51
|
sample_value,
|
|
50
52
|
schema_keys,
|
|
51
53
|
)
|
|
54
|
+
from .saved import (
|
|
55
|
+
SavedPlot,
|
|
56
|
+
SavedPlotError,
|
|
57
|
+
SavedPlotExists,
|
|
58
|
+
SavedPlotInfo,
|
|
59
|
+
check_name,
|
|
60
|
+
hide_saved_plot,
|
|
61
|
+
list_saved_plots,
|
|
62
|
+
load_saved_plot,
|
|
63
|
+
rename_saved_plot,
|
|
64
|
+
save_plot,
|
|
65
|
+
saved_plot_history,
|
|
66
|
+
)
|
|
67
|
+
from .presets import (
|
|
68
|
+
AppliedPreset,
|
|
69
|
+
Preset,
|
|
70
|
+
PresetError,
|
|
71
|
+
PresetExists,
|
|
72
|
+
PresetInfo,
|
|
73
|
+
apply_preset_to,
|
|
74
|
+
check_preset_name,
|
|
75
|
+
find_preset,
|
|
76
|
+
hide_preset,
|
|
77
|
+
list_presets,
|
|
78
|
+
load_preset,
|
|
79
|
+
preset_history,
|
|
80
|
+
rename_preset,
|
|
81
|
+
save_preset,
|
|
82
|
+
)
|
|
52
83
|
from .source import ScidbSource
|
|
53
84
|
from .variants import (
|
|
54
85
|
branch_params_for,
|
|
@@ -59,6 +90,33 @@ from .variants import (
|
|
|
59
90
|
|
|
60
91
|
__all__ = [
|
|
61
92
|
"ScidbSource",
|
|
93
|
+
# saved plots
|
|
94
|
+
"save_plot",
|
|
95
|
+
"list_saved_plots",
|
|
96
|
+
"load_saved_plot",
|
|
97
|
+
"rename_saved_plot",
|
|
98
|
+
"hide_saved_plot",
|
|
99
|
+
"saved_plot_history",
|
|
100
|
+
"check_name",
|
|
101
|
+
"SavedPlot",
|
|
102
|
+
"SavedPlotInfo",
|
|
103
|
+
"SavedPlotError",
|
|
104
|
+
"SavedPlotExists",
|
|
105
|
+
# plot presets (docs/claude/plot-presets.md)
|
|
106
|
+
"save_preset",
|
|
107
|
+
"list_presets",
|
|
108
|
+
"load_preset",
|
|
109
|
+
"apply_preset_to",
|
|
110
|
+
"rename_preset",
|
|
111
|
+
"hide_preset",
|
|
112
|
+
"preset_history",
|
|
113
|
+
"find_preset",
|
|
114
|
+
"check_preset_name",
|
|
115
|
+
"Preset",
|
|
116
|
+
"PresetInfo",
|
|
117
|
+
"AppliedPreset",
|
|
118
|
+
"PresetError",
|
|
119
|
+
"PresetExists",
|
|
62
120
|
"variant_set",
|
|
63
121
|
"variant_graph",
|
|
64
122
|
"selection_for",
|
|
@@ -72,6 +130,8 @@ __all__ = [
|
|
|
72
130
|
"registered_variables",
|
|
73
131
|
"schema_keys",
|
|
74
132
|
"data_columns_for",
|
|
133
|
+
"data_column_types_for",
|
|
134
|
+
"is_container_type",
|
|
75
135
|
"sample_value",
|
|
76
136
|
"join_kind",
|
|
77
137
|
"joinable",
|
|
@@ -83,4 +143,11 @@ __all__ = [
|
|
|
83
143
|
"default_path_template",
|
|
84
144
|
]
|
|
85
145
|
|
|
86
|
-
|
|
146
|
+
# The release tag owns the version (hatch-vcs writes it into the installed
|
|
147
|
+
# metadata); "0.0.0" marks a source tree that was never installed.
|
|
148
|
+
from importlib import metadata as _metadata # noqa: E402
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
__version__ = _metadata.version("scistackplotdb")
|
|
152
|
+
except _metadata.PackageNotFoundError:
|
|
153
|
+
__version__ = "0.0.0"
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Named, versioned, hideable rows: the store behind saved plots and presets.
|
|
3
|
+
|
|
4
|
+
Both are "a JSON envelope the user names, re-saves and removes", with the same
|
|
5
|
+
rules (docs/claude/saved-plots.md, docs/claude/plot-presets.md):
|
|
6
|
+
|
|
7
|
+
* **The id is the identity; the name is a label.** Rename is an UPDATE of
|
|
8
|
+
``name``. A new item reusing a removed item's name gets a new id.
|
|
9
|
+
* **Re-saving a visible name appends a version.** The newest version is what
|
|
10
|
+
lists and opens. The version number is computed inside the INSERT, so two
|
|
11
|
+
racing saves cannot claim the same one.
|
|
12
|
+
* **Nothing is ever deleted** (feedback_never_delete_mark_hidden): "remove"
|
|
13
|
+
sets ``hidden`` on every version.
|
|
14
|
+
* **Reads never create the table.** Writes call :meth:`ensure_table`.
|
|
15
|
+
* All reads go through ``_fetchall``/``_fetchone`` (the fetch-locking guard).
|
|
16
|
+
|
|
17
|
+
This module owns those rules once, so the two stores cannot drift apart
|
|
18
|
+
(CLAUDE.md NOTE 4). A store with a *scope column* (saved plots: ``variable``)
|
|
19
|
+
keeps names unique per scope; a store without one (presets) keeps them unique
|
|
20
|
+
across the project. Callers turn the raw rows this returns into their own
|
|
21
|
+
info objects.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import uuid
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from typing import Any, Callable
|
|
31
|
+
|
|
32
|
+
from scistacklog import Log
|
|
33
|
+
|
|
34
|
+
LAYER = "scistackplotdb"
|
|
35
|
+
|
|
36
|
+
#: Longest name accepted. A name is a list label, not a description.
|
|
37
|
+
MAX_NAME_LENGTH = 120
|
|
38
|
+
|
|
39
|
+
#: ``(item_id, scope, name, version, saved_at, hidden)``; scope is None for an
|
|
40
|
+
#: unscoped store.
|
|
41
|
+
Row = tuple
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class VersionedStore:
|
|
46
|
+
"""One append-only table of named, versioned envelopes."""
|
|
47
|
+
|
|
48
|
+
table: str
|
|
49
|
+
#: Column holding the item's identity (``plot_id``, ``preset_id``).
|
|
50
|
+
id_column: str
|
|
51
|
+
#: Column names are unique within, or None for project-wide names.
|
|
52
|
+
scope_column: str | None
|
|
53
|
+
#: What an item is called in messages ("saved plot", "preset").
|
|
54
|
+
noun: str
|
|
55
|
+
#: Log tag, without brackets.
|
|
56
|
+
tag: str
|
|
57
|
+
#: The error every refusal raises.
|
|
58
|
+
error: type[Exception]
|
|
59
|
+
|
|
60
|
+
# ---- table ----------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def ensure_table(self, db) -> None:
|
|
63
|
+
scope = f"{self.scope_column} VARCHAR NOT NULL," if self.scope_column else ""
|
|
64
|
+
db._duck._execute(f"""
|
|
65
|
+
CREATE TABLE IF NOT EXISTS {self.table} (
|
|
66
|
+
{self.id_column} VARCHAR NOT NULL,
|
|
67
|
+
{scope}
|
|
68
|
+
name VARCHAR NOT NULL,
|
|
69
|
+
version INTEGER NOT NULL,
|
|
70
|
+
saved_at VARCHAR NOT NULL,
|
|
71
|
+
hidden BOOLEAN NOT NULL DEFAULT FALSE,
|
|
72
|
+
envelope_json VARCHAR NOT NULL,
|
|
73
|
+
PRIMARY KEY ({self.id_column}, version)
|
|
74
|
+
)
|
|
75
|
+
""")
|
|
76
|
+
|
|
77
|
+
def table_exists(self, db) -> bool:
|
|
78
|
+
row = db._duck._fetchone(
|
|
79
|
+
"SELECT count(*) FROM information_schema.tables WHERE table_name = ?",
|
|
80
|
+
[self.table],
|
|
81
|
+
)
|
|
82
|
+
return bool(row and row[0])
|
|
83
|
+
|
|
84
|
+
# ---- names ----------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def check_name(self, name: Any) -> str:
|
|
87
|
+
"""*name* trimmed, or the store's error saying why it is not one."""
|
|
88
|
+
if not isinstance(name, str):
|
|
89
|
+
raise self.error(f"A {self.noun} needs a name.")
|
|
90
|
+
name = " ".join(name.split()) # trim, and no tabs/newlines in a list label
|
|
91
|
+
if not name:
|
|
92
|
+
raise self.error(f"A {self.noun} needs a name.")
|
|
93
|
+
if len(name) > MAX_NAME_LENGTH:
|
|
94
|
+
raise self.error(
|
|
95
|
+
f"A {self.noun}'s name is at most {MAX_NAME_LENGTH} characters "
|
|
96
|
+
f"(this one is {len(name)})."
|
|
97
|
+
)
|
|
98
|
+
return name
|
|
99
|
+
|
|
100
|
+
def clash_message(self, scope: str | None, name: str) -> str:
|
|
101
|
+
if self.scope_column:
|
|
102
|
+
return f"{scope} already has a {self.noun} named {name!r}."
|
|
103
|
+
return f"There is already a {self.noun} named {name!r}."
|
|
104
|
+
|
|
105
|
+
def describe(self, scope: str | None, name: str) -> str:
|
|
106
|
+
"""``StepLength / 'Fig 3'`` or ``'Session box'``, for logs."""
|
|
107
|
+
return f"{scope} / {name!r}" if self.scope_column else repr(name)
|
|
108
|
+
|
|
109
|
+
# ---- writes ---------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def save(
|
|
112
|
+
self,
|
|
113
|
+
db,
|
|
114
|
+
scope: str | None,
|
|
115
|
+
name: str,
|
|
116
|
+
envelope: dict,
|
|
117
|
+
*,
|
|
118
|
+
overwrite: bool,
|
|
119
|
+
current_id: str | None,
|
|
120
|
+
on_clash: Callable[[Row], Exception],
|
|
121
|
+
) -> tuple[Row, bool]:
|
|
122
|
+
"""Append *envelope* as a new version, returning ``(row, is_new)``.
|
|
123
|
+
|
|
124
|
+
A visible item called *name* (in *scope*) gets a new version.
|
|
125
|
+
Otherwise a new item is started. With ``overwrite=False``, appending
|
|
126
|
+
to an item other than *current_id* raises ``on_clash(that row)`` so
|
|
127
|
+
the caller can ask first.
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
text = json.dumps(envelope, sort_keys=True)
|
|
131
|
+
except (TypeError, ValueError) as exc:
|
|
132
|
+
raise self.error(f"The settings are not storable as JSON: {exc}") from exc
|
|
133
|
+
|
|
134
|
+
self.ensure_table(db)
|
|
135
|
+
current = self.visible_by_name(db, scope, name)
|
|
136
|
+
if current is not None and not overwrite and current[0] != current_id:
|
|
137
|
+
Log.info(
|
|
138
|
+
"[%s] save of %s refused: another %s (%s) has that name and "
|
|
139
|
+
"overwrite was not confirmed",
|
|
140
|
+
self.tag,
|
|
141
|
+
self.describe(scope, name),
|
|
142
|
+
self.noun,
|
|
143
|
+
current[0][:8],
|
|
144
|
+
layer=LAYER,
|
|
145
|
+
)
|
|
146
|
+
raise on_clash(current)
|
|
147
|
+
item_id = current[0] if current else uuid.uuid4().hex
|
|
148
|
+
saved_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
149
|
+
scope_col = f"{self.scope_column}, " if self.scope_column else ""
|
|
150
|
+
scope_val = [scope] if self.scope_column else []
|
|
151
|
+
# One statement: the next version number is decided inside the insert,
|
|
152
|
+
# so two saves racing on one item cannot both claim the same version.
|
|
153
|
+
db._duck._execute(
|
|
154
|
+
f"""
|
|
155
|
+
INSERT INTO {self.table}
|
|
156
|
+
({self.id_column}, {scope_col}name, version, saved_at, hidden, envelope_json)
|
|
157
|
+
SELECT ?, {"?, " if self.scope_column else ""}?,
|
|
158
|
+
COALESCE(MAX(version), 0) + 1, ?, FALSE, ?
|
|
159
|
+
FROM {self.table} WHERE {self.id_column} = ?
|
|
160
|
+
""",
|
|
161
|
+
[item_id, *scope_val, name, saved_at, text, item_id],
|
|
162
|
+
)
|
|
163
|
+
row = self.latest(db, item_id)
|
|
164
|
+
Log.info(
|
|
165
|
+
"[%s] saved %s as version %d (%s, %d bytes, envelope format %r)",
|
|
166
|
+
self.tag,
|
|
167
|
+
self.describe(scope, name),
|
|
168
|
+
row[3],
|
|
169
|
+
f"new {self.noun}" if current is None else f"{self.noun} {item_id[:8]}",
|
|
170
|
+
len(text),
|
|
171
|
+
envelope.get("format"),
|
|
172
|
+
layer=LAYER,
|
|
173
|
+
)
|
|
174
|
+
return row, current is None
|
|
175
|
+
|
|
176
|
+
def rename(self, db, item_id: str, new_name: str) -> Row:
|
|
177
|
+
new_name = self.check_name(new_name)
|
|
178
|
+
row = self.require(db, item_id)
|
|
179
|
+
clash = self.visible_by_name(db, row[1], new_name)
|
|
180
|
+
if clash is not None and clash[0] != item_id:
|
|
181
|
+
raise self.error(self.clash_message(row[1], new_name))
|
|
182
|
+
db._duck._execute(
|
|
183
|
+
f"UPDATE {self.table} SET name = ? WHERE {self.id_column} = ?",
|
|
184
|
+
[new_name, item_id],
|
|
185
|
+
)
|
|
186
|
+
Log.info(
|
|
187
|
+
"[%s] renamed %s -> %r (%s)",
|
|
188
|
+
self.tag,
|
|
189
|
+
self.describe(row[1], row[2]),
|
|
190
|
+
new_name,
|
|
191
|
+
item_id[:8],
|
|
192
|
+
layer=LAYER,
|
|
193
|
+
)
|
|
194
|
+
return self.latest(db, item_id)
|
|
195
|
+
|
|
196
|
+
def set_hidden(self, db, item_id: str, hidden: bool) -> Row:
|
|
197
|
+
row = self.require(db, item_id)
|
|
198
|
+
if not hidden:
|
|
199
|
+
clash = self.visible_by_name(db, row[1], row[2])
|
|
200
|
+
if clash is not None and clash[0] != item_id:
|
|
201
|
+
raise self.error(
|
|
202
|
+
f"{self.clash_message(row[1], row[2])[:-1]}; rename one of them first."
|
|
203
|
+
)
|
|
204
|
+
db._duck._execute(
|
|
205
|
+
f"UPDATE {self.table} SET hidden = ? WHERE {self.id_column} = ?",
|
|
206
|
+
[bool(hidden), item_id],
|
|
207
|
+
)
|
|
208
|
+
Log.info(
|
|
209
|
+
"[%s] %s %s (%s)",
|
|
210
|
+
self.tag,
|
|
211
|
+
"hid" if hidden else "unhid",
|
|
212
|
+
self.describe(row[1], row[2]),
|
|
213
|
+
item_id[:8],
|
|
214
|
+
layer=LAYER,
|
|
215
|
+
)
|
|
216
|
+
return self.latest(db, item_id)
|
|
217
|
+
|
|
218
|
+
# ---- reads ----------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
def _columns(self) -> str:
|
|
221
|
+
scope = self.scope_column or "NULL"
|
|
222
|
+
return f"{self.id_column}, {scope}, name, version, saved_at, hidden"
|
|
223
|
+
|
|
224
|
+
def list_rows(
|
|
225
|
+
self,
|
|
226
|
+
db,
|
|
227
|
+
scope: str | None = None,
|
|
228
|
+
*,
|
|
229
|
+
include_hidden: bool = False,
|
|
230
|
+
with_envelope: bool = False,
|
|
231
|
+
) -> list[Row]:
|
|
232
|
+
"""The newest version of each item (in *scope*), newest first.
|
|
233
|
+
|
|
234
|
+
With *with_envelope*, each row carries its envelope JSON as a 7th
|
|
235
|
+
element, for a list that shows something stored inside it.
|
|
236
|
+
"""
|
|
237
|
+
envelope = ", envelope_json" if with_envelope else ""
|
|
238
|
+
if not self.table_exists(db):
|
|
239
|
+
return []
|
|
240
|
+
where = f"{self.scope_column} = ? AND " if self.scope_column else ""
|
|
241
|
+
rows = db._duck._fetchall(
|
|
242
|
+
f"""
|
|
243
|
+
SELECT {self._columns()}{envelope}
|
|
244
|
+
FROM {self.table} AS t
|
|
245
|
+
WHERE {where}version = (
|
|
246
|
+
SELECT MAX(version) FROM {self.table}
|
|
247
|
+
WHERE {self.id_column} = t.{self.id_column}
|
|
248
|
+
)
|
|
249
|
+
{"" if include_hidden else "AND NOT hidden"}
|
|
250
|
+
ORDER BY saved_at DESC, name
|
|
251
|
+
""",
|
|
252
|
+
[scope] if self.scope_column else [],
|
|
253
|
+
)
|
|
254
|
+
Log.debug(
|
|
255
|
+
"[%s] %s: %d %s(s) listed%s",
|
|
256
|
+
self.tag,
|
|
257
|
+
scope if self.scope_column else "project",
|
|
258
|
+
len(rows),
|
|
259
|
+
self.noun,
|
|
260
|
+
" (hidden included)" if include_hidden else "",
|
|
261
|
+
layer=LAYER,
|
|
262
|
+
)
|
|
263
|
+
return list(rows)
|
|
264
|
+
|
|
265
|
+
def history(self, db, item_id: str, *, with_envelope: bool = False) -> list[Row]:
|
|
266
|
+
if not self.table_exists(db):
|
|
267
|
+
return []
|
|
268
|
+
envelope = ", envelope_json" if with_envelope else ""
|
|
269
|
+
return list(db._duck._fetchall(
|
|
270
|
+
f"""
|
|
271
|
+
SELECT {self._columns()}{envelope}
|
|
272
|
+
FROM {self.table} WHERE {self.id_column} = ? ORDER BY version DESC
|
|
273
|
+
""",
|
|
274
|
+
[item_id],
|
|
275
|
+
))
|
|
276
|
+
|
|
277
|
+
def load(self, db, item_id: str, version: int | None = None) -> tuple[Row, str]:
|
|
278
|
+
"""``(row, envelope_json)`` of one version (the newest by default)."""
|
|
279
|
+
if not self.table_exists(db):
|
|
280
|
+
raise self.error(f"No {self.noun} {item_id!r}: nothing has been saved yet.")
|
|
281
|
+
if version is None:
|
|
282
|
+
row = db._duck._fetchone(
|
|
283
|
+
f"""
|
|
284
|
+
SELECT {self._columns()}, envelope_json
|
|
285
|
+
FROM {self.table} WHERE {self.id_column} = ?
|
|
286
|
+
ORDER BY version DESC LIMIT 1
|
|
287
|
+
""",
|
|
288
|
+
[item_id],
|
|
289
|
+
)
|
|
290
|
+
else:
|
|
291
|
+
row = db._duck._fetchone(
|
|
292
|
+
f"""
|
|
293
|
+
SELECT {self._columns()}, envelope_json
|
|
294
|
+
FROM {self.table} WHERE {self.id_column} = ? AND version = ?
|
|
295
|
+
""",
|
|
296
|
+
[item_id, int(version)],
|
|
297
|
+
)
|
|
298
|
+
if row is None:
|
|
299
|
+
which = f"version {version} of " if version is not None else ""
|
|
300
|
+
raise self.error(f"No {self.noun} {which}{item_id!r}.")
|
|
301
|
+
return tuple(row[:6]), row[6]
|
|
302
|
+
|
|
303
|
+
def visible_by_name(self, db, scope: str | None, name: str) -> Row | None:
|
|
304
|
+
if not self.table_exists(db):
|
|
305
|
+
return None
|
|
306
|
+
where = f"{self.scope_column} = ? AND " if self.scope_column else ""
|
|
307
|
+
row = db._duck._fetchone(
|
|
308
|
+
f"""
|
|
309
|
+
SELECT {self._columns()}
|
|
310
|
+
FROM {self.table}
|
|
311
|
+
WHERE {where}name = ? AND NOT hidden
|
|
312
|
+
ORDER BY version DESC LIMIT 1
|
|
313
|
+
""",
|
|
314
|
+
[*([scope] if self.scope_column else []), name],
|
|
315
|
+
)
|
|
316
|
+
return tuple(row) if row else None
|
|
317
|
+
|
|
318
|
+
def latest(self, db, item_id: str) -> Row:
|
|
319
|
+
row = db._duck._fetchone(
|
|
320
|
+
f"""
|
|
321
|
+
SELECT {self._columns()}
|
|
322
|
+
FROM {self.table} WHERE {self.id_column} = ?
|
|
323
|
+
ORDER BY version DESC LIMIT 1
|
|
324
|
+
""",
|
|
325
|
+
[item_id],
|
|
326
|
+
)
|
|
327
|
+
if row is None:
|
|
328
|
+
raise self.error(f"No {self.noun} {item_id!r}.")
|
|
329
|
+
return tuple(row)
|
|
330
|
+
|
|
331
|
+
def require(self, db, item_id: str) -> Row:
|
|
332
|
+
if not self.table_exists(db):
|
|
333
|
+
raise self.error(f"No {self.noun} {item_id!r}: nothing has been saved yet.")
|
|
334
|
+
return self.latest(db, item_id)
|
|
335
|
+
|
|
336
|
+
def parse_envelope(self, text: str, label: str) -> dict:
|
|
337
|
+
"""The stored envelope as a dict. A row that is not one opens on
|
|
338
|
+
defaults rather than failing the open."""
|
|
339
|
+
try:
|
|
340
|
+
envelope = json.loads(text)
|
|
341
|
+
except (TypeError, ValueError) as exc:
|
|
342
|
+
Log.warn("[%s] %s: stored envelope is not JSON (%s)", self.tag, label, exc,
|
|
343
|
+
layer=LAYER)
|
|
344
|
+
return {}
|
|
345
|
+
if not isinstance(envelope, dict):
|
|
346
|
+
Log.warn(
|
|
347
|
+
"[%s] %s: stored envelope is %s, not a table",
|
|
348
|
+
self.tag,
|
|
349
|
+
label,
|
|
350
|
+
type(envelope).__name__,
|
|
351
|
+
layer=LAYER,
|
|
352
|
+
)
|
|
353
|
+
return {}
|
|
354
|
+
return envelope
|