scistackplotdb 0.1.29__tar.gz → 0.1.30__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.
@@ -31,3 +31,4 @@ scistack-gui/frontend/dist/
31
31
  /exports/
32
32
  *.log
33
33
  output.txt
34
+ scimatlab/tests/matlab/test-failures.txt
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scistackplotdb
3
- Version: 0.1.29
3
+ Version: 0.1.30
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.12; extra == 'dev'
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.X, "subject": Role.FREE, "trial": Role.FREE},
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.X})
87
- validate(spec, table)
88
- # RoleError: Variant factor(s) ['scale.factor'] would be pooled: their levels
89
- # are different pipeline variants, not replicates... Assign them
90
- # 'color'/'facet'/'iterate', select the variants you want with
91
- # PlotSpec.variant_sets, or — to pool them deliberately — set them to
92
- # 'aggregate' or 'free' yourself.
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
- See [`docs/claude/plotting-library-design.md`](../docs/claude/plotting-library-design.md).
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.X, "subject": Role.FREE, "trial": Role.FREE},
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.X})
59
- validate(spec, table)
60
- # RoleError: Variant factor(s) ['scale.factor'] would be pooled: their levels
61
- # are different pipeline variants, not replicates... Assign them
62
- # 'color'/'facet'/'iterate', select the variants you want with
63
- # PlotSpec.variant_sets, or — to pool them deliberately — set them to
64
- # 'aggregate' or 'free' yourself.
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
- See [`docs/claude/plotting-library-design.md`](../docs/claude/plotting-library-design.md).
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).
@@ -46,7 +46,7 @@ dev = [
46
46
  "pytest>=7.0",
47
47
  "pytest-cov>=4.0",
48
48
  "matplotlib>=3.6",
49
- "seaborn>=0.12",
49
+ "seaborn>=0.13",
50
50
  ]
51
51
 
52
52
  [tool.hatch.build.targets.sdist]
@@ -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.X, "subject": Role.FREE, "trial": Role.FREE},
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
- __version__ = "0.1.0"
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