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.
Files changed (76) hide show
  1. trendify/__about__.py +14 -0
  2. trendify/__init__.py +250 -0
  3. trendify/base/__init__.py +36 -0
  4. trendify/base/helpers.py +76 -0
  5. trendify/base/pen.py +161 -0
  6. trendify/base/record.py +148 -0
  7. trendify/cli.py +403 -0
  8. trendify/color.py +260 -0
  9. trendify/examples.py +474 -0
  10. trendify/formats/__init__.py +28 -0
  11. trendify/formats/format2d.py +128 -0
  12. trendify/formats/table.py +55 -0
  13. trendify/generator/__init__.py +38 -0
  14. trendify/generator/generate.py +167 -0
  15. trendify/generator/histogrammer.py +62 -0
  16. trendify/generator/render.py +195 -0
  17. trendify/generator/table_builder.py +194 -0
  18. trendify/generator/xy_data_plotter.py +195 -0
  19. trendify/log.py +195 -0
  20. trendify/pipeline.py +136 -0
  21. trendify/plotting/__init__.py +48 -0
  22. trendify/plotting/axline.py +122 -0
  23. trendify/plotting/figure.py +389 -0
  24. trendify/plotting/histogram.py +282 -0
  25. trendify/plotting/point.py +89 -0
  26. trendify/plotting/scatter.py +100 -0
  27. trendify/plotting/trace.py +129 -0
  28. trendify/progress.py +43 -0
  29. trendify/store/__init__.py +30 -0
  30. trendify/store/db.py +119 -0
  31. trendify/store/record_store.py +448 -0
  32. trendify/store/tags.py +65 -0
  33. trendify/styling/__init__.py +30 -0
  34. trendify/styling/grid.py +125 -0
  35. trendify/styling/legend.py +217 -0
  36. trendify/styling/marker.py +168 -0
  37. trendify/typing.py +26 -0
  38. trendify/viewer/__init__.py +41 -0
  39. trendify/viewer/app.py +208 -0
  40. trendify/viewer/hydration.py +181 -0
  41. trendify/viewer/plot_config.py +102 -0
  42. trendify/viewer/routes/__init__.py +6 -0
  43. trendify/viewer/routes/api.py +360 -0
  44. trendify/viewer/routes/pages.py +39 -0
  45. trendify/viewer/tag_tree.py +148 -0
  46. trendify/viewer/templates/base.html +63 -0
  47. trendify/viewer/templates/index.html +131 -0
  48. trendify/viewer/templates/partials/__init__.py +1 -0
  49. trendify/viewer/templates/partials/footer.html +15 -0
  50. trendify/viewer/templates/partials/nav.html +181 -0
  51. trendify/viewer/templates/partials/plot_view.html +221 -0
  52. trendify/viewer/templates/partials/sidebar.html +163 -0
  53. trendify/viewer/templates/partials/table_view.html +43 -0
  54. trendify/viewer/templates/static/__init__.py +1 -0
  55. trendify/viewer/templates/static/assets/__init__.py +1 -0
  56. trendify/viewer/templates/static/assets/main-logo-white-bg.svg +75 -0
  57. trendify/viewer/templates/static/assets/main-logo.svg +66 -0
  58. trendify/viewer/templates/static/css/app.css +75 -0
  59. trendify/viewer/templates/static/js/main.js +2 -0
  60. trendify/viewer/templates/static/vendored/__init__.py +1 -0
  61. trendify/viewer/templates/static/vendored/alpine-3.15.12.min.js +5 -0
  62. trendify/viewer/templates/static/vendored/alpine-LICENSE.md +21 -0
  63. trendify/viewer/templates/static/vendored/dataTables-LICENSE.txt +3 -0
  64. trendify/viewer/templates/static/vendored/dataTables.dataTables.min.css +1 -0
  65. trendify/viewer/templates/static/vendored/dataTables.min.js +4 -0
  66. trendify/viewer/templates/static/vendored/jquery-3.7.1.min.js +2 -0
  67. trendify/viewer/templates/static/vendored/jquery-LICENSE.txt +3 -0
  68. trendify/viewer/templates/static/vendored/plotly-3.6.0.min.js +3882 -0
  69. trendify/viewer/templates/static/vendored/plotly-LICENSE.txt +21 -0
  70. trendify/viewer/templates/static/vendored/tailwind-4.3.2.global.js +947 -0
  71. trendify/viewer/templates/static/vendored/tailwind-LICENSE +21 -0
  72. trendify2-2.0.4.dist-info/METADATA +107 -0
  73. trendify2-2.0.4.dist-info/RECORD +76 -0
  74. trendify2-2.0.4.dist-info/WHEEL +4 -0
  75. trendify2-2.0.4.dist-info/entry_points.txt +2 -0
  76. 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
+ ]
@@ -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}")