trendify2 2.0.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trendify/__about__.py +14 -0
- trendify/__init__.py +250 -0
- trendify/base/__init__.py +36 -0
- trendify/base/helpers.py +76 -0
- trendify/base/pen.py +161 -0
- trendify/base/record.py +148 -0
- trendify/cli.py +403 -0
- trendify/color.py +260 -0
- trendify/examples.py +474 -0
- trendify/formats/__init__.py +28 -0
- trendify/formats/format2d.py +128 -0
- trendify/formats/table.py +55 -0
- trendify/generator/__init__.py +38 -0
- trendify/generator/generate.py +167 -0
- trendify/generator/histogrammer.py +62 -0
- trendify/generator/render.py +195 -0
- trendify/generator/table_builder.py +194 -0
- trendify/generator/xy_data_plotter.py +195 -0
- trendify/log.py +195 -0
- trendify/pipeline.py +136 -0
- trendify/plotting/__init__.py +48 -0
- trendify/plotting/axline.py +122 -0
- trendify/plotting/figure.py +389 -0
- trendify/plotting/histogram.py +282 -0
- trendify/plotting/point.py +89 -0
- trendify/plotting/scatter.py +100 -0
- trendify/plotting/trace.py +129 -0
- trendify/progress.py +43 -0
- trendify/store/__init__.py +30 -0
- trendify/store/db.py +119 -0
- trendify/store/record_store.py +448 -0
- trendify/store/tags.py +65 -0
- trendify/styling/__init__.py +30 -0
- trendify/styling/grid.py +125 -0
- trendify/styling/legend.py +217 -0
- trendify/styling/marker.py +168 -0
- trendify/typing.py +26 -0
- trendify/viewer/__init__.py +41 -0
- trendify/viewer/app.py +208 -0
- trendify/viewer/hydration.py +181 -0
- trendify/viewer/plot_config.py +102 -0
- trendify/viewer/routes/__init__.py +6 -0
- trendify/viewer/routes/api.py +360 -0
- trendify/viewer/routes/pages.py +39 -0
- trendify/viewer/tag_tree.py +148 -0
- trendify/viewer/templates/base.html +63 -0
- trendify/viewer/templates/index.html +131 -0
- trendify/viewer/templates/partials/__init__.py +1 -0
- trendify/viewer/templates/partials/footer.html +15 -0
- trendify/viewer/templates/partials/nav.html +181 -0
- trendify/viewer/templates/partials/plot_view.html +221 -0
- trendify/viewer/templates/partials/sidebar.html +163 -0
- trendify/viewer/templates/partials/table_view.html +43 -0
- trendify/viewer/templates/static/__init__.py +1 -0
- trendify/viewer/templates/static/assets/__init__.py +1 -0
- trendify/viewer/templates/static/assets/main-logo-white-bg.svg +75 -0
- trendify/viewer/templates/static/assets/main-logo.svg +66 -0
- trendify/viewer/templates/static/css/app.css +75 -0
- trendify/viewer/templates/static/js/main.js +2 -0
- trendify/viewer/templates/static/vendored/__init__.py +1 -0
- trendify/viewer/templates/static/vendored/alpine-3.15.12.min.js +5 -0
- trendify/viewer/templates/static/vendored/alpine-LICENSE.md +21 -0
- trendify/viewer/templates/static/vendored/dataTables-LICENSE.txt +3 -0
- trendify/viewer/templates/static/vendored/dataTables.dataTables.min.css +1 -0
- trendify/viewer/templates/static/vendored/dataTables.min.js +4 -0
- trendify/viewer/templates/static/vendored/jquery-3.7.1.min.js +2 -0
- trendify/viewer/templates/static/vendored/jquery-LICENSE.txt +3 -0
- trendify/viewer/templates/static/vendored/plotly-3.6.0.min.js +3882 -0
- trendify/viewer/templates/static/vendored/plotly-LICENSE.txt +21 -0
- trendify/viewer/templates/static/vendored/tailwind-4.3.2.global.js +947 -0
- trendify/viewer/templates/static/vendored/tailwind-LICENSE +21 -0
- trendify2-2.0.4.dist-info/METADATA +107 -0
- trendify2-2.0.4.dist-info/RECORD +76 -0
- trendify2-2.0.4.dist-info/WHEEL +4 -0
- trendify2-2.0.4.dist-info/entry_points.txt +2 -0
- trendify2-2.0.4.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`RecordStore`: the SQLite-backed store for tagged `Record`s.
|
|
3
|
+
|
|
4
|
+
Pydantic objects are what flow in and out everywhere; storage and querying happen entirely
|
|
5
|
+
underneath that interface. Every record's full JSON payload is stored opaquely, so new
|
|
6
|
+
`Record` subclasses are storable and queryable the moment they're registered, with no
|
|
7
|
+
schema migration required. Tags are normalized into an indexed join table, so a record with N
|
|
8
|
+
tags costs one payload row plus N small index rows rather than N duplicated payloads. `TableEntry`
|
|
9
|
+
is one exception: it gets real SQL columns (row/col/value/unit) instead of an opaque payload,
|
|
10
|
+
since pivoting and computing per-column statistics is naturally SQL-shaped. `Format2D` is the
|
|
11
|
+
other exception: it's a singleton per tag, so `write_run` deletes any existing `Format2D` row(s)
|
|
12
|
+
sharing a tag (from any run) before inserting a new one, rather than accumulating duplicates.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import datetime
|
|
18
|
+
import logging
|
|
19
|
+
import sqlite3
|
|
20
|
+
from collections.abc import Iterable, Iterator
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import cast
|
|
24
|
+
|
|
25
|
+
import polars as pl
|
|
26
|
+
|
|
27
|
+
from trendify.base.helpers import R, Tag
|
|
28
|
+
from trendify.base.record import Record
|
|
29
|
+
from trendify.formats.format2d import Format2D
|
|
30
|
+
from trendify.formats.table import TableEntry
|
|
31
|
+
from trendify.store import db
|
|
32
|
+
from trendify.store.tags import decode_tag, encode_tag
|
|
33
|
+
|
|
34
|
+
__all__ = ["RecordStore"]
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _leaf_type_names(object_type: type[Record]) -> list[str]:
|
|
40
|
+
"""
|
|
41
|
+
Expands a (possibly non-leaf) `Record` type into the list of registered leaf
|
|
42
|
+
`record_type` names that are instances of it: `PlottableData2D`, for example, expands to
|
|
43
|
+
`["Point2D", "Trace2D", "AxLine", "HistogramEntry"]`. This lets tag/type filtering happen
|
|
44
|
+
as a plain SQL `record_type IN (...)` clause instead of deserializing every tag-matched
|
|
45
|
+
row just to run an `isinstance` check in Python.
|
|
46
|
+
"""
|
|
47
|
+
return [
|
|
48
|
+
name for name, cls in Record.registry().items() if issubclass(cls, object_type)
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _tag_sort_key(tag: Tag) -> tuple[int, tuple[tuple[bool, str | int], ...]]:
|
|
53
|
+
# `Tag` elements may be `str` or `int`, which aren't comparable to each other; sorting
|
|
54
|
+
# each element on `(is_int, value)` keeps same-type elements ordered by value while
|
|
55
|
+
# elements of different types compare on the `is_int` flag instead of colliding.
|
|
56
|
+
as_tuple = tag if isinstance(tag, tuple) else (tag,)
|
|
57
|
+
return (len(as_tuple), tuple((isinstance(x, int), x) for x in as_tuple))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class RecordStore:
|
|
62
|
+
"""
|
|
63
|
+
SQLite-backed store for `Record`s, grouped by run and queryable by tag/type.
|
|
64
|
+
|
|
65
|
+
Usage:
|
|
66
|
+
with RecordStore.open(db_path) as store:
|
|
67
|
+
store.write_run(run_path, records)
|
|
68
|
+
traces = store.get_records_of_type(Trace2D, tag="my_tag")
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
_conn: sqlite3.Connection
|
|
72
|
+
_format2d_warned: set[str] = field(default_factory=set, init=False, repr=False)
|
|
73
|
+
"""Tag keys already warned about by `_warn_if_format2d_differs`, so each warns once."""
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def open(cls, db_path: Path, *, readonly: bool = False) -> RecordStore:
|
|
77
|
+
return cls(db.connect(Path(db_path), readonly=readonly))
|
|
78
|
+
|
|
79
|
+
def __enter__(self) -> RecordStore:
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def __exit__(self, *exc_info) -> None:
|
|
83
|
+
self.close()
|
|
84
|
+
|
|
85
|
+
def close(self) -> None:
|
|
86
|
+
logger.debug("Closing RecordStore connection")
|
|
87
|
+
self._conn.close()
|
|
88
|
+
|
|
89
|
+
def write_run(self, run_path: Path, records: Iterable[Record]) -> int:
|
|
90
|
+
"""
|
|
91
|
+
Replaces all records for `run_path` in a single transaction. Idempotent: calling this
|
|
92
|
+
again for the same `run_path` (e.g. re-running a generator on the same input directory)
|
|
93
|
+
deletes and replaces that run's rows rather than accumulating duplicates.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
run_path (Path): the raw-data run directory these records were generated from
|
|
97
|
+
records (Iterable[Record]): records returned by the user's `RecordGenerator`
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
(int): number of records written
|
|
101
|
+
|
|
102
|
+
"""
|
|
103
|
+
records = list(records)
|
|
104
|
+
run_path_str = str(Path(run_path).resolve())
|
|
105
|
+
now = datetime.datetime.now(datetime.UTC).isoformat()
|
|
106
|
+
logger.debug(f"Writing {len(records)} record(s) for run {run_path_str}")
|
|
107
|
+
|
|
108
|
+
conn = self._conn
|
|
109
|
+
with conn: # transaction: commits on success, rolls back on exception
|
|
110
|
+
row = conn.execute(
|
|
111
|
+
"SELECT id FROM runs WHERE path = ?", (run_path_str,)
|
|
112
|
+
).fetchone()
|
|
113
|
+
if row is None:
|
|
114
|
+
run_id = conn.execute(
|
|
115
|
+
"INSERT INTO runs(path, generated_at) VALUES (?, ?)",
|
|
116
|
+
(run_path_str, now),
|
|
117
|
+
).lastrowid
|
|
118
|
+
else:
|
|
119
|
+
run_id = row["id"]
|
|
120
|
+
conn.execute(
|
|
121
|
+
"UPDATE runs SET generated_at = ? WHERE id = ?", (now, run_id)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# ON DELETE CASCADE (record_tags, table_entries) requires PRAGMA foreign_keys=ON,
|
|
125
|
+
# set in db.connect().
|
|
126
|
+
conn.execute("DELETE FROM records WHERE run_id = ?", (run_id,))
|
|
127
|
+
|
|
128
|
+
# `records.id` is a plain `INTEGER PRIMARY KEY` (rowid alias, no AUTOINCREMENT),
|
|
129
|
+
# so SQLite's own rule for an auto-assigned rowid is just "one more than the
|
|
130
|
+
# current max" -- reading that once and assigning a contiguous block of ids
|
|
131
|
+
# ourselves is equivalent to what N individual inserts would each get via
|
|
132
|
+
# `lastrowid`, and it's what lets everything below be batched into a handful of
|
|
133
|
+
# `executemany` calls instead of one `execute` round trip per record (this is
|
|
134
|
+
# where the bulk of write_run's time was going).
|
|
135
|
+
next_id = conn.execute(
|
|
136
|
+
"SELECT COALESCE(MAX(id), 0) + 1 FROM records"
|
|
137
|
+
).fetchone()[0]
|
|
138
|
+
|
|
139
|
+
pending_record_rows: list[tuple] = []
|
|
140
|
+
pending_tag_rows: list[tuple] = []
|
|
141
|
+
table_entry_rows: list[tuple] = []
|
|
142
|
+
|
|
143
|
+
for record_id, record in zip(
|
|
144
|
+
range(next_id, next_id + len(records)), records
|
|
145
|
+
):
|
|
146
|
+
tag_keys = [encode_tag(t) for t in record.tags]
|
|
147
|
+
|
|
148
|
+
if isinstance(record, Format2D):
|
|
149
|
+
# Format2D is a singleton per tag: delete any existing Format2D row(s)
|
|
150
|
+
# sharing any of these tags (from any run, not just this one) before
|
|
151
|
+
# inserting the new one, so re-emitting the same tag-level styling from
|
|
152
|
+
# every run (unavoidable given `RecordGenerator` is called once per run
|
|
153
|
+
# with no cross-run coordination) never accumulates duplicate rows. This
|
|
154
|
+
# stays row-by-row (unlike the batching below) because a later Format2D
|
|
155
|
+
# record's dedup delete must see an earlier same-tag Format2D record
|
|
156
|
+
# *from this same call* as already inserted, which only holds if that
|
|
157
|
+
# earlier row's insert has actually happened by the time this check runs.
|
|
158
|
+
payload = record.model_dump_json()
|
|
159
|
+
for tk in tag_keys:
|
|
160
|
+
self._warn_if_format2d_differs(tk, payload, run_path_str)
|
|
161
|
+
# The inner `record_type` filter lets SQLite seek straight to this tag's
|
|
162
|
+
# Format2D row through (tag_key, record_type). Without it the subquery
|
|
163
|
+
# returns every record under the tag, so cost grows with the database.
|
|
164
|
+
conn.execute(
|
|
165
|
+
"DELETE FROM records WHERE record_type = 'Format2D' AND id IN "
|
|
166
|
+
"(SELECT record_id FROM record_tags "
|
|
167
|
+
"WHERE tag_key = ? AND record_type = 'Format2D')",
|
|
168
|
+
(tk,),
|
|
169
|
+
)
|
|
170
|
+
conn.execute(
|
|
171
|
+
"INSERT INTO records(id, run_id, record_type, payload, created_at) "
|
|
172
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
173
|
+
(record_id, run_id, record.record_type, payload, now),
|
|
174
|
+
)
|
|
175
|
+
conn.executemany(
|
|
176
|
+
"INSERT INTO record_tags(record_id, tag_key, record_type) "
|
|
177
|
+
"VALUES (?, ?, ?)",
|
|
178
|
+
[(record_id, tk, record.record_type) for tk in tag_keys],
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
pending_record_rows.append(
|
|
182
|
+
(
|
|
183
|
+
record_id,
|
|
184
|
+
run_id,
|
|
185
|
+
record.record_type,
|
|
186
|
+
record.model_dump_json(),
|
|
187
|
+
now,
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
pending_tag_rows.extend(
|
|
191
|
+
(record_id, tk, record.record_type) for tk in tag_keys
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if isinstance(record, TableEntry):
|
|
195
|
+
value_num = value_text = value_bool = None
|
|
196
|
+
if isinstance(record.value, bool):
|
|
197
|
+
value_bool = int(record.value)
|
|
198
|
+
elif isinstance(record.value, (int, float)):
|
|
199
|
+
value_num = float(record.value)
|
|
200
|
+
else:
|
|
201
|
+
value_text = record.value
|
|
202
|
+
|
|
203
|
+
table_entry_rows.extend(
|
|
204
|
+
(
|
|
205
|
+
record_id,
|
|
206
|
+
tk,
|
|
207
|
+
str(record.row),
|
|
208
|
+
str(record.col),
|
|
209
|
+
value_num,
|
|
210
|
+
value_text,
|
|
211
|
+
value_bool,
|
|
212
|
+
record.unit,
|
|
213
|
+
)
|
|
214
|
+
for tk in tag_keys
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if pending_record_rows:
|
|
218
|
+
conn.executemany(
|
|
219
|
+
"INSERT INTO records(id, run_id, record_type, payload, created_at) "
|
|
220
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
221
|
+
pending_record_rows,
|
|
222
|
+
)
|
|
223
|
+
if pending_tag_rows:
|
|
224
|
+
conn.executemany(
|
|
225
|
+
"INSERT INTO record_tags(record_id, tag_key, record_type) "
|
|
226
|
+
"VALUES (?, ?, ?)",
|
|
227
|
+
pending_tag_rows,
|
|
228
|
+
)
|
|
229
|
+
if table_entry_rows:
|
|
230
|
+
conn.executemany(
|
|
231
|
+
"INSERT INTO table_entries("
|
|
232
|
+
"record_id, tag_key, row_key, col_key, "
|
|
233
|
+
"value_num, value_text, value_bool, unit) "
|
|
234
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
235
|
+
table_entry_rows,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
logger.debug(
|
|
239
|
+
f"Wrote {len(records)} record(s) for run {run_path_str} (run_id={run_id})"
|
|
240
|
+
)
|
|
241
|
+
return len(records)
|
|
242
|
+
|
|
243
|
+
def _warn_if_format2d_differs(
|
|
244
|
+
self, tag_key: str, payload: str, run_path: str
|
|
245
|
+
) -> None:
|
|
246
|
+
"""
|
|
247
|
+
Warns (once per tag) when the `Format2D` about to replace this tag's stored one has
|
|
248
|
+
different settings. Every run normally re-emits an identical `Format2D` for its tags,
|
|
249
|
+
which is expected and silent. A difference means runs disagree about how one tag should
|
|
250
|
+
be formatted, and only the last one written is kept.
|
|
251
|
+
"""
|
|
252
|
+
if tag_key in self._format2d_warned:
|
|
253
|
+
return
|
|
254
|
+
stored = self._conn.execute(
|
|
255
|
+
"SELECT r.payload FROM record_tags t JOIN records r ON r.id = t.record_id "
|
|
256
|
+
"WHERE t.tag_key = ? AND t.record_type = 'Format2D'",
|
|
257
|
+
(tag_key,),
|
|
258
|
+
).fetchall()
|
|
259
|
+
if any(row["payload"] != payload for row in stored):
|
|
260
|
+
self._format2d_warned.add(tag_key)
|
|
261
|
+
logger.warning(
|
|
262
|
+
f"Format2D for tag {decode_tag(tag_key)!r} differs between runs. Only one "
|
|
263
|
+
f"Format2D is kept per tag, so the one from run '{run_path}' replaces the "
|
|
264
|
+
f"earlier one. If runs should share formatting, check the record generator."
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def get_tags(self, object_type: type[Record] | None = None) -> set[Tag]:
|
|
268
|
+
"""
|
|
269
|
+
Args:
|
|
270
|
+
object_type (type[Record] | None): restrict to tags used by records of this
|
|
271
|
+
type (or any registered subclass of it); `None` matches every record type.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
(set[Tag]): the set of tags in use
|
|
275
|
+
|
|
276
|
+
"""
|
|
277
|
+
if object_type is None:
|
|
278
|
+
rows = self._conn.execute("SELECT DISTINCT tag_key FROM record_tags")
|
|
279
|
+
else:
|
|
280
|
+
names = _leaf_type_names(object_type)
|
|
281
|
+
if not names:
|
|
282
|
+
return set()
|
|
283
|
+
placeholders = ",".join("?" * len(names))
|
|
284
|
+
rows = self._conn.execute(
|
|
285
|
+
f"SELECT DISTINCT tag_key FROM record_tags "
|
|
286
|
+
f"WHERE record_type IN ({placeholders})",
|
|
287
|
+
names,
|
|
288
|
+
)
|
|
289
|
+
return {decode_tag(r["tag_key"]) for r in rows}
|
|
290
|
+
|
|
291
|
+
def get_tag_byte_sizes(self) -> dict[Tag, int]:
|
|
292
|
+
"""
|
|
293
|
+
Total payload size in bytes for each tag, summed across every record carrying that
|
|
294
|
+
tag. A record with N tags counts its full payload size toward all N (matches how
|
|
295
|
+
`write_run` stores one payload row referenced by N `record_tags` rows, not divided
|
|
296
|
+
N ways). Backs the viewer's background-hydration prioritization (`viewer.tag_tree`),
|
|
297
|
+
which starts prefetching the largest tag at the level the user is browsing.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
(dict[Tag, int]): total payload bytes per tag
|
|
301
|
+
|
|
302
|
+
"""
|
|
303
|
+
rows = self._conn.execute(
|
|
304
|
+
"SELECT rt.tag_key AS tag_key, SUM(LENGTH(r.payload)) AS size "
|
|
305
|
+
"FROM record_tags rt JOIN records r ON r.id = rt.record_id "
|
|
306
|
+
"GROUP BY rt.tag_key"
|
|
307
|
+
).fetchall()
|
|
308
|
+
return {decode_tag(r["tag_key"]): r["size"] for r in rows}
|
|
309
|
+
|
|
310
|
+
def tag_tree(self, object_type: type[Record] | None = None) -> list[Tag]:
|
|
311
|
+
"""
|
|
312
|
+
Returns:
|
|
313
|
+
(list[Tag]): tags sorted for display in a nested sidebar or tree view: shallow
|
|
314
|
+
tags first, then lexicographically within each depth.
|
|
315
|
+
|
|
316
|
+
"""
|
|
317
|
+
return sorted(self.get_tags(object_type=object_type), key=_tag_sort_key)
|
|
318
|
+
|
|
319
|
+
def get_records(
|
|
320
|
+
self,
|
|
321
|
+
tag: Tag | None = None,
|
|
322
|
+
object_type: type[R] | None = None,
|
|
323
|
+
) -> Iterator[R]:
|
|
324
|
+
"""
|
|
325
|
+
Streams matching records, deserialized to their concrete pydantic type. Filtering by
|
|
326
|
+
`tag` and/or `object_type` happens in SQL (indexed tag lookup, `record_type IN (...)`
|
|
327
|
+
for the type hierarchy) before anything is deserialized, so this only ever parses JSON
|
|
328
|
+
for rows that actually match.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
tag (Tag | None): tag to filter by; `None` matches every tag
|
|
332
|
+
object_type (type[R] | None): type (or base type) to filter by; `None` matches
|
|
333
|
+
every type
|
|
334
|
+
|
|
335
|
+
Yields:
|
|
336
|
+
(R): matching records, in insertion order
|
|
337
|
+
|
|
338
|
+
"""
|
|
339
|
+
logger.debug(f"Querying records ({tag = }, {object_type = })")
|
|
340
|
+
select = "SELECT p.record_type, p.payload FROM records p"
|
|
341
|
+
joins = []
|
|
342
|
+
where = []
|
|
343
|
+
params: list[object] = []
|
|
344
|
+
|
|
345
|
+
if tag is not None:
|
|
346
|
+
joins.append("JOIN record_tags pt ON pt.record_id = p.id")
|
|
347
|
+
where.append("pt.tag_key = ?")
|
|
348
|
+
params.append(encode_tag(tag))
|
|
349
|
+
|
|
350
|
+
if object_type is not None:
|
|
351
|
+
names = _leaf_type_names(object_type)
|
|
352
|
+
if not names:
|
|
353
|
+
return
|
|
354
|
+
where.append(f"p.record_type IN ({','.join('?' * len(names))})")
|
|
355
|
+
params.extend(names)
|
|
356
|
+
|
|
357
|
+
query = " ".join([select, *joins])
|
|
358
|
+
if where:
|
|
359
|
+
query += " WHERE " + " AND ".join(where)
|
|
360
|
+
|
|
361
|
+
cursor = self._conn.execute(query, params)
|
|
362
|
+
for row in cursor:
|
|
363
|
+
yield cast(R, Record.deserialize(row["record_type"], row["payload"]))
|
|
364
|
+
|
|
365
|
+
def get_records_of_type(
|
|
366
|
+
self, object_type: type[R], tag: Tag | None = None
|
|
367
|
+
) -> list[R]:
|
|
368
|
+
"""
|
|
369
|
+
Same as `get_records`, but eager (a `list`), for callers that don't need streaming.
|
|
370
|
+
"""
|
|
371
|
+
return list(self.get_records(tag=tag, object_type=object_type))
|
|
372
|
+
|
|
373
|
+
def has_records(
|
|
374
|
+
self, tag: Tag | None = None, object_type: type[Record] | None = None
|
|
375
|
+
) -> bool:
|
|
376
|
+
"""
|
|
377
|
+
Cheap existence check for `get_records`'s same `(tag, object_type)` filter: stops at
|
|
378
|
+
the first matching row instead of deserializing every one, for callers (like the
|
|
379
|
+
viewer's tag tree, `viewer.tag_tree._record_kinds`) that only need to know whether
|
|
380
|
+
*any* record matches, not what it is.
|
|
381
|
+
"""
|
|
382
|
+
return (
|
|
383
|
+
next(self.get_records(tag=tag, object_type=object_type), None) is not None
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
def get_table_entries(self, tag: Tag) -> pl.DataFrame:
|
|
387
|
+
"""
|
|
388
|
+
Fetches `TableEntry` rows for `tag` directly from the `table_entries` table, skipping
|
|
389
|
+
full `Record` payload deserialization entirely since row/col/value/unit are already
|
|
390
|
+
real columns.
|
|
391
|
+
|
|
392
|
+
Note: `value` is resolved to a single Python value per row (whichever of
|
|
393
|
+
`value_num`/`value_text`/`value_bool` is populated) before handing rows to Polars,
|
|
394
|
+
rather than asking Polars/SQLite to reconcile a mixed-type SQL column. Polars expects
|
|
395
|
+
one dtype per column, so resolving the union type in Python first is the well-defined
|
|
396
|
+
choice.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
tag (Tag): tag to filter by
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
(pl.DataFrame): a "melted" table with columns `row`, `col`, `value`, `unit`, one
|
|
403
|
+
row per `TableEntry` record matching `tag`.
|
|
404
|
+
|
|
405
|
+
"""
|
|
406
|
+
rows = self._conn.execute(
|
|
407
|
+
"SELECT row_key, col_key, value_num, value_text, value_bool, unit "
|
|
408
|
+
"FROM table_entries WHERE tag_key = ?",
|
|
409
|
+
(encode_tag(tag),),
|
|
410
|
+
).fetchall()
|
|
411
|
+
logger.debug(f"Fetched {len(rows)} table_entries row(s) for {tag = }")
|
|
412
|
+
|
|
413
|
+
records = []
|
|
414
|
+
for r in rows:
|
|
415
|
+
if r["value_bool"] is not None:
|
|
416
|
+
value = bool(r["value_bool"])
|
|
417
|
+
elif r["value_num"] is not None:
|
|
418
|
+
value = r["value_num"]
|
|
419
|
+
else:
|
|
420
|
+
value = r["value_text"]
|
|
421
|
+
records.append(
|
|
422
|
+
{
|
|
423
|
+
"row": r["row_key"],
|
|
424
|
+
"col": r["col_key"],
|
|
425
|
+
"value": value,
|
|
426
|
+
"unit": r["unit"],
|
|
427
|
+
}
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
return pl.DataFrame(
|
|
431
|
+
records,
|
|
432
|
+
schema={
|
|
433
|
+
"row": pl.Utf8,
|
|
434
|
+
"col": pl.Utf8,
|
|
435
|
+
"value": pl.Object,
|
|
436
|
+
"unit": pl.Utf8,
|
|
437
|
+
},
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
def has_table_entries(self, tag: Tag) -> bool:
|
|
441
|
+
"""
|
|
442
|
+
Cheap existence check for `get_table_entries`'s same `tag` filter, without building a
|
|
443
|
+
`pl.DataFrame` (or resolving each row's value union) just to check whether it's empty.
|
|
444
|
+
"""
|
|
445
|
+
row = self._conn.execute(
|
|
446
|
+
"SELECT 1 FROM table_entries WHERE tag_key = ? LIMIT 1", (encode_tag(tag),)
|
|
447
|
+
).fetchone()
|
|
448
|
+
return row is not None
|
trendify/store/tags.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Canonical encoding between the pydantic-facing `Tag` type and the `tag_key` string
|
|
3
|
+
stored/indexed in the `record_tags` and `table_entries` SQL tables.
|
|
4
|
+
|
|
5
|
+
The same `encode_tag` function must be used at write time and query time so lookups are
|
|
6
|
+
exact-match index seeks.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
from trendify.base.helpers import Tag
|
|
14
|
+
|
|
15
|
+
__all__ = ["decode_tag", "encode_tag", "tag_to_path_parts"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def encode_tag(tag: Tag) -> str:
|
|
19
|
+
"""
|
|
20
|
+
Canonicalizes a `Tag` (`str`, `int`, or `tuple[str | int, ...]`) into the `tag_key` string
|
|
21
|
+
used for indexed storage/lookup. Scalars and tuples produce distinguishable encodings
|
|
22
|
+
(`json.dumps` renders a tuple as a JSON array), so `"foo"` and `("foo",)` never collide.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
tag (Tag): tag to encode
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
(str): canonical, indexable string key
|
|
29
|
+
|
|
30
|
+
"""
|
|
31
|
+
return json.dumps(tag, separators=(",", ":"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def decode_tag(tag_key: str) -> Tag:
|
|
35
|
+
"""
|
|
36
|
+
Inverse of `encode_tag`. JSON arrays decode back to `tuple`s (the `Tag` type never uses
|
|
37
|
+
plain `list`s) so round-tripping a tag through the store reproduces the original shape.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
tag_key (str): canonical string key, as produced by `encode_tag`
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
(Tag): decoded tag
|
|
44
|
+
|
|
45
|
+
"""
|
|
46
|
+
decoded = json.loads(tag_key)
|
|
47
|
+
if isinstance(decoded, list):
|
|
48
|
+
return tuple(decoded)
|
|
49
|
+
return decoded
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def tag_to_path_parts(tag: Tag) -> tuple[str, ...]:
|
|
53
|
+
"""
|
|
54
|
+
Converts a `Tag` into output-path parts: a tuple tag `("a", "b")` becomes nested path
|
|
55
|
+
segments `("a", "b")`, and a scalar tag `"a"` becomes a single segment `("a",)`.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
tag (Tag): tag to convert
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
(tuple[str, ...]): path parts, last one being the file stem
|
|
62
|
+
|
|
63
|
+
"""
|
|
64
|
+
parts = tag if isinstance(tag, tuple) else (tag,)
|
|
65
|
+
return tuple(str(p) for p in parts)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Styling objects for plots: markers, grids, and legends."""
|
|
2
|
+
|
|
3
|
+
from trendify.styling import grid
|
|
4
|
+
from trendify.styling import legend
|
|
5
|
+
from trendify.styling import marker
|
|
6
|
+
|
|
7
|
+
from trendify.styling.grid import (
|
|
8
|
+
Grid,
|
|
9
|
+
GridAxis,
|
|
10
|
+
GridTheme,
|
|
11
|
+
)
|
|
12
|
+
from trendify.styling.legend import (
|
|
13
|
+
Legend,
|
|
14
|
+
LegendLocation,
|
|
15
|
+
)
|
|
16
|
+
from trendify.styling.marker import (
|
|
17
|
+
Marker,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Grid",
|
|
22
|
+
"GridAxis",
|
|
23
|
+
"GridTheme",
|
|
24
|
+
"Legend",
|
|
25
|
+
"LegendLocation",
|
|
26
|
+
"Marker",
|
|
27
|
+
"grid",
|
|
28
|
+
"legend",
|
|
29
|
+
"marker",
|
|
30
|
+
]
|
trendify/styling/grid.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`Grid` (major/minor gridline styling for a plot axes) and its `GridTheme` presets, selectable
|
|
3
|
+
via `Grid.from_theme`, plus the per-axis `GridAxis` settings a `Grid` is built from.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
|
|
10
|
+
from pydantic import ConfigDict
|
|
11
|
+
|
|
12
|
+
from trendify.base.helpers import HashableBase
|
|
13
|
+
from trendify.base.pen import Pen
|
|
14
|
+
|
|
15
|
+
__all__ = ["Grid", "GridAxis", "GridTheme"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GridTheme(StrEnum):
|
|
19
|
+
MATLAB = "matlab"
|
|
20
|
+
LIGHT = "light"
|
|
21
|
+
DARK = "dark"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GridAxis(HashableBase):
|
|
25
|
+
"""
|
|
26
|
+
Controls styling and visibility for one type of grid (major or minor).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
show: bool = False
|
|
30
|
+
"""Whether to display this grid axis."""
|
|
31
|
+
|
|
32
|
+
pen: Pen = Pen(
|
|
33
|
+
color="gray",
|
|
34
|
+
alpha=1.0,
|
|
35
|
+
size=0.75,
|
|
36
|
+
linestyle="-",
|
|
37
|
+
label=None,
|
|
38
|
+
)
|
|
39
|
+
"""Style and label information for drawing to matplotlib axes."""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(extra="forbid")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Grid(HashableBase):
|
|
45
|
+
"""
|
|
46
|
+
Container for major and minor grid line configuration.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
major: GridAxis = GridAxis(show=False)
|
|
50
|
+
"""Configuration for major grid lines."""
|
|
51
|
+
|
|
52
|
+
minor: GridAxis = GridAxis(show=False)
|
|
53
|
+
"""Configuration for minor grid lines."""
|
|
54
|
+
|
|
55
|
+
enable_minor_ticks: bool = False
|
|
56
|
+
"""Whether to enable minor ticks on the axes."""
|
|
57
|
+
|
|
58
|
+
zorder: float = -1
|
|
59
|
+
"""Prioritization of the grid lines relative to plotted data."""
|
|
60
|
+
|
|
61
|
+
model_config = ConfigDict(extra="forbid")
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_theme(cls, name: GridTheme) -> Grid:
|
|
65
|
+
"""
|
|
66
|
+
Predefined themes for common grid styles.
|
|
67
|
+
"""
|
|
68
|
+
themes = {
|
|
69
|
+
GridTheme.MATLAB: cls(
|
|
70
|
+
major=GridAxis(
|
|
71
|
+
show=True,
|
|
72
|
+
pen=Pen(
|
|
73
|
+
color="#b0b0b0",
|
|
74
|
+
linestyle="-",
|
|
75
|
+
size=0.8,
|
|
76
|
+
alpha=0.35,
|
|
77
|
+
label=None,
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
minor=GridAxis(
|
|
81
|
+
show=True,
|
|
82
|
+
pen=Pen(
|
|
83
|
+
color="#b0b0b0",
|
|
84
|
+
linestyle=(0, (3, 1, 1, 1)),
|
|
85
|
+
size=0.6,
|
|
86
|
+
alpha=0.25,
|
|
87
|
+
label=None,
|
|
88
|
+
),
|
|
89
|
+
),
|
|
90
|
+
enable_minor_ticks=True,
|
|
91
|
+
),
|
|
92
|
+
GridTheme.LIGHT: cls(
|
|
93
|
+
major=GridAxis(
|
|
94
|
+
show=True,
|
|
95
|
+
pen=Pen(
|
|
96
|
+
color="#E0E0E0",
|
|
97
|
+
linestyle="--",
|
|
98
|
+
size=0.7,
|
|
99
|
+
alpha=0.9,
|
|
100
|
+
label=None,
|
|
101
|
+
),
|
|
102
|
+
),
|
|
103
|
+
minor=GridAxis(show=False),
|
|
104
|
+
enable_minor_ticks=False,
|
|
105
|
+
),
|
|
106
|
+
GridTheme.DARK: cls(
|
|
107
|
+
major=GridAxis(
|
|
108
|
+
show=True,
|
|
109
|
+
pen=Pen(
|
|
110
|
+
color="#444444",
|
|
111
|
+
linestyle="--",
|
|
112
|
+
size=0.7,
|
|
113
|
+
alpha=0.5,
|
|
114
|
+
label=None,
|
|
115
|
+
),
|
|
116
|
+
),
|
|
117
|
+
minor=GridAxis(show=False),
|
|
118
|
+
enable_minor_ticks=False,
|
|
119
|
+
),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
return themes[name]
|
|
124
|
+
except KeyError:
|
|
125
|
+
raise ValueError(f"Unknown grid theme: {name!r}")
|