dstrack 0.1.0__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.
dstrack/_track.py ADDED
@@ -0,0 +1,144 @@
1
+ """The ``dstrack track`` command: snapshot a dataset into the local store."""
2
+
3
+ import getpass
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from dstrack import console
11
+ from dstrack.errors import (
12
+ DatasetNotFoundError,
13
+ InputTooLargeError,
14
+ StoreCorruptionError,
15
+ StoreNotFoundError,
16
+ )
17
+ from dstrack.paths import resolve_store_root
18
+ from dstrack.readers import resolve_reader
19
+ from dstrack.snapshot import SnapshotBuilder, StatsComputer
20
+ from dstrack.store import write_snapshot
21
+
22
+
23
+ def _default_created_by() -> str:
24
+ """Best-effort identifier for the user creating the snapshot."""
25
+ try:
26
+ return getpass.getuser()
27
+ except Exception: # pragma: no cover - platform dependent
28
+ return "unknown"
29
+
30
+
31
+ def track(
32
+ path: Annotated[
33
+ Path,
34
+ typer.Argument(
35
+ exists=True,
36
+ dir_okay=False,
37
+ help="Path to the dataset file to snapshot.",
38
+ ),
39
+ ],
40
+ name: Annotated[
41
+ str | None,
42
+ typer.Option(
43
+ "--name",
44
+ help="Human-readable dataset name. Defaults to the file's stem.",
45
+ ),
46
+ ] = None,
47
+ reader: Annotated[
48
+ str | None,
49
+ typer.Option(
50
+ "--reader",
51
+ help="Reader to use: a registered name (e.g. 'csv'), or "
52
+ "'package.module:ClassName' for a reader that is not installed as a "
53
+ "plugin. Inferred from the file extension when omitted.",
54
+ ),
55
+ ] = None,
56
+ root: Annotated[
57
+ Path | None,
58
+ typer.Option(
59
+ "--root",
60
+ help="Path root the stored dataset_path is made relative to. "
61
+ "Defaults to the store root (the directory containing .dstrack/).",
62
+ ),
63
+ ] = None,
64
+ dataset_id: Annotated[
65
+ str | None,
66
+ typer.Option(
67
+ "--dataset-id",
68
+ help="Continue this dataset's lineage explicitly, instead of "
69
+ "matching by path. Needed after a file is renamed or moved.",
70
+ ),
71
+ ] = None,
72
+ created_by: Annotated[
73
+ str | None,
74
+ typer.Option("--created-by", help="Override the recorded author."),
75
+ ] = None,
76
+ max_rows: Annotated[
77
+ int | None,
78
+ typer.Option(
79
+ "--max-rows",
80
+ help="Maximum number of rows to process. Statistics are computed "
81
+ "in memory, so this bounds memory use; datasets larger than the "
82
+ "limit are rejected. Omit to use the default limit.",
83
+ ),
84
+ ] = None,
85
+ ) -> None:
86
+ """Compute a snapshot of a dataset and store it in the local store."""
87
+ # Locate the .dstrack/ store; without one there is nowhere to write.
88
+ try:
89
+ store_root = resolve_store_root()
90
+ except StoreNotFoundError as e:
91
+ console.error(str(e))
92
+ raise typer.Exit(code=1) from e
93
+
94
+ # Record the dataset location relative to the path root, so the store stays
95
+ # portable across machines and checkouts.
96
+ path_root = root if root is not None else store_root.parent
97
+ dataset_path = Path(
98
+ os.path.relpath(path.resolve(), Path(path_root).resolve())
99
+ ).as_posix()
100
+
101
+ # Pick the reader: explicit --reader wins, otherwise infer from the extension.
102
+ try:
103
+ tabular_reader = resolve_reader(path, reader=reader)
104
+ except (ValueError, TypeError) as e:
105
+ hint = "" if reader is not None else " Use --reader to name one explicitly."
106
+ console.error(f"{e}{hint}")
107
+ raise typer.Exit(code=1) from e
108
+
109
+ # Read the file and compute the snapshot's statistics and metadata. The
110
+ # portable relative path is recorded; the real file on disk is what gets read
111
+ # and hashed.
112
+ console.info(f"Reading {path} and computing snapshot...")
113
+ # An explicit --max-rows overrides the StatsComputer default; omitting it
114
+ # keeps the built-in limit that guards against unbounded memory use.
115
+ stats_computer = (
116
+ StatsComputer() if max_rows is None else StatsComputer(max_rows=max_rows)
117
+ )
118
+ try:
119
+ snapshot = SnapshotBuilder(stats_computer=stats_computer).build(
120
+ tabular_reader,
121
+ dataset_name=name or path.stem,
122
+ dataset_path=dataset_path,
123
+ source=path,
124
+ source_type="file", # TODO: Add a way to manage other sources like folders
125
+ created_by=created_by or _default_created_by(),
126
+ )
127
+ except InputTooLargeError as e:
128
+ console.error(str(e))
129
+ raise typer.Exit(code=1) from e
130
+
131
+ # Persist the snapshot, attaching it to an existing lineage when one matches.
132
+ try:
133
+ result = write_snapshot(snapshot, store_root=store_root, dataset_id=dataset_id)
134
+ except (DatasetNotFoundError, StoreCorruptionError) as e:
135
+ console.error(str(e))
136
+ raise typer.Exit(code=1) from e
137
+
138
+ # Report what was written and whether it started or extended a lineage.
139
+ lineage = "new dataset" if result.is_new_dataset else "continued lineage"
140
+ console.success(
141
+ f"Snapshot {result.snapshot_id} written ({lineage}, "
142
+ f"dataset {result.dataset_id})."
143
+ )
144
+ console.info(f"Stored at {result.snapshot_path}")
dstrack/cache.py ADDED
@@ -0,0 +1,448 @@
1
+ """A SQLite index over the store's dataset logs.
2
+
3
+ The index at ``.dstrack/.cache/index.db`` answers "what is this dataset's
4
+ history?" and "which dataset was recorded at this path?" without reparsing
5
+ every dataset's ``log.jsonl`` on every invocation.
6
+
7
+ It is a cache, never a second source of truth. ``log.jsonl`` is committed to
8
+ git and is authoritative; the index is gitignored, derived from it,
9
+ and safe to delete at any moment. Two consequences shape this module:
10
+
11
+ - **It can be stale, so every read resyncs first.** The index does not travel
12
+ with the repository, and `dstrack track` does not write to it, so a clone, a
13
+ pull, or a snapshot taken on another machine all leave it behind. Before any
14
+ query, [sync][dstrack.cache.sync] fingerprints each dataset's log and
15
+ reimports the ones that changed. When nothing changed, that costs one
16
+ ``stat`` and one tiny ``HEAD`` read per dataset.
17
+ - **It can be discarded, so damage is repaired rather than reported.** An
18
+ unreadable file or a schema from another version is rebuilt in place; there
19
+ is nothing to lose by doing so.
20
+
21
+ ``HEAD`` is deliberately *not* cached. It is a few bytes, it is the store's
22
+ commit pointer, and reading it fresh keeps
23
+ [write_snapshot][dstrack.store.write_snapshot]'s central invariant intact: the
24
+ index caches parsed lines, while what those lines *mean* is still decided by
25
+ the ``HEAD`` on disk.
26
+ """
27
+
28
+ import contextlib
29
+ import logging
30
+ import sqlite3
31
+ from collections.abc import Iterator
32
+ from pathlib import Path
33
+ from typing import Final
34
+
35
+ from dstrack import store
36
+ from dstrack.errors import IndexUnusable
37
+ from dstrack.store import DatasetSummary, LogEntry
38
+
39
+ _log = logging.getLogger(__name__)
40
+
41
+ CACHE_DIRNAME: Final = ".cache"
42
+ INDEX_FILENAME: Final = "index.db"
43
+
44
+ # Bump whenever the schema below changes: a mismatch rebuilds the index rather
45
+ # than querying it with the wrong shape.
46
+ _SCHEMA_VERSION: Final = "1"
47
+
48
+ _SCHEMA: Final = """
49
+ CREATE TABLE meta (
50
+ key TEXT PRIMARY KEY,
51
+ value TEXT NOT NULL
52
+ );
53
+
54
+ CREATE TABLE datasets (
55
+ dataset_id TEXT PRIMARY KEY,
56
+ log_size INTEGER NOT NULL,
57
+ log_mtime_ns INTEGER NOT NULL,
58
+ head_snapshot_id TEXT
59
+ );
60
+
61
+ CREATE TABLE snapshots (
62
+ snapshot_id TEXT PRIMARY KEY,
63
+ dataset_id TEXT NOT NULL REFERENCES datasets(dataset_id) ON DELETE CASCADE,
64
+ parent_snapshot_id TEXT,
65
+ created_at TEXT,
66
+ created_by TEXT,
67
+ dataset_name TEXT,
68
+ dataset_path TEXT,
69
+ num_rows INTEGER,
70
+ num_columns INTEGER
71
+ );
72
+
73
+ CREATE INDEX idx_snapshots_dataset ON snapshots(dataset_id);
74
+ """
75
+
76
+ _SNAPSHOT_COLUMNS: Final = (
77
+ "snapshot_id",
78
+ "dataset_id",
79
+ "parent_snapshot_id",
80
+ "created_at",
81
+ "created_by",
82
+ "dataset_name",
83
+ "dataset_path",
84
+ "num_rows",
85
+ "num_columns",
86
+ )
87
+
88
+
89
+ def index_path(store_root: Path) -> Path:
90
+ """Return the path of the store's index. Need not exist.
91
+
92
+ Args:
93
+ store_root: Path to the ``.dstrack/`` directory.
94
+
95
+ Returns:
96
+ The index's path, inside the store's gitignored cache directory.
97
+ """
98
+ return store_root / CACHE_DIRNAME / INDEX_FILENAME
99
+
100
+
101
+ def index_exists(store_root: Path) -> bool:
102
+ """Return whether the store's index has been built yet.
103
+
104
+ Args:
105
+ store_root: Path to the ``.dstrack/`` directory.
106
+
107
+ Returns:
108
+ ``True`` if an index file is present. It may still turn out to be
109
+ unusable, in which case [sync][dstrack.cache.sync] rebuilds it.
110
+ """
111
+ return index_path(store_root).is_file()
112
+
113
+
114
+ def sync(store_root: Path) -> str | None:
115
+ """Bring the index in line with the store's logs.
116
+
117
+ Reimports each dataset whose ``log.jsonl`` changed since it was last
118
+ indexed, and forgets datasets that are no longer on disk. Builds the index
119
+ if it is absent, and rebuilds it if it cannot be used as-is.
120
+
121
+ Args:
122
+ store_root: Path to the ``.dstrack/`` directory, e.g. from
123
+ [resolve_store_root][dstrack.paths.resolve_store_root].
124
+
125
+ Returns:
126
+ ``None`` if the index was built from scratch or updated in place, or a
127
+ human-readable reason if an existing index had to be discarded and
128
+ rebuilt, for the caller to report. Discarding is recovery, not an
129
+ error: the index holds nothing that is not derived from the logs.
130
+
131
+ Raises:
132
+ StoreCorruptionError: If a dataset's log holds a malformed entry.
133
+ OSError: If the store cannot be read, or the index cannot be written.
134
+ """
135
+ reason: str | None = None
136
+ if index_exists(store_root):
137
+ try:
138
+ with _connect(store_root) as conn:
139
+ _check_schema_version(conn)
140
+ _sync(conn, store_root)
141
+ return None
142
+ except (sqlite3.DatabaseError, IndexUnusable) as e:
143
+ reason = str(e)
144
+ _log.warning(f"Rebuilding the snapshot index: {e}")
145
+
146
+ _create_empty(store_root)
147
+ with _connect(store_root) as conn:
148
+ _sync(conn, store_root)
149
+ return reason
150
+
151
+
152
+ def query_history(dataset_id: str, *, store_root: Path) -> list[LogEntry]:
153
+ """Return a dataset's committed history, newest first.
154
+
155
+ Walks back from the ``HEAD`` recorded by the last
156
+ [sync][dstrack.cache.sync], rather than re-reading ``HEAD`` from disk.
157
+ That is what keeps the answer self-consistent: a sync reads each dataset's
158
+ ``HEAD`` immediately before importing its log, so every snapshot that head
159
+ names is guaranteed to be indexed. Reading ``HEAD`` again afterwards could
160
+ pick up a snapshot a concurrent `dstrack track` committed since, whose log
161
+ line this index has not seen, which is indistinguishable from a log that
162
+ has lost an entry.
163
+
164
+ Call [sync][dstrack.cache.sync] first, or the answer is as old as the last
165
+ one.
166
+
167
+ Args:
168
+ dataset_id: Dataset whose history to read.
169
+ store_root: Path to the ``.dstrack/`` directory.
170
+
171
+ Returns:
172
+ The dataset's snapshots, ordered newest first and ending at the one
173
+ with no parent. Empty if the dataset is not indexed, or has no
174
+ committed snapshot yet.
175
+
176
+ Raises:
177
+ StoreCorruptionError: If the indexed lineage is missing an entry or
178
+ loops back on itself.
179
+ """
180
+ with _connect(store_root) as conn:
181
+ head_row = conn.execute(
182
+ "SELECT head_snapshot_id FROM datasets WHERE dataset_id = ?",
183
+ (dataset_id,),
184
+ ).fetchone()
185
+ if head_row is None or head_row["head_snapshot_id"] is None:
186
+ return []
187
+ head: str = head_row["head_snapshot_id"]
188
+ rows = conn.execute(
189
+ "SELECT * FROM snapshots WHERE dataset_id = ?", (dataset_id,)
190
+ ).fetchall()
191
+ index = {row["snapshot_id"]: _entry_from_row(row) for row in rows}
192
+ return store.walk_lineage(index, head)
193
+
194
+
195
+ def find_dataset_by_path(dataset_path: str, *, store_root: Path) -> str | None:
196
+ """Return the id of the dataset whose latest snapshot has this path.
197
+
198
+ Matches the same way [write_snapshot][dstrack.store.write_snapshot] does:
199
+ against each dataset's ``HEAD`` snapshot only, never its older ones. A
200
+ dataset whose file has since been renamed therefore does not match the new
201
+ path, and must be named by its id instead.
202
+
203
+ Args:
204
+ dataset_path: Relative POSIX path to match, computed against the same
205
+ path root the snapshot was recorded with.
206
+ store_root: Path to the ``.dstrack/`` directory.
207
+
208
+ Returns:
209
+ The matching ``dataset_id``, or ``None`` if no dataset's latest
210
+ snapshot recorded this path.
211
+ """
212
+ with _connect(store_root) as conn:
213
+ row = conn.execute(
214
+ "SELECT d.dataset_id FROM datasets d "
215
+ "JOIN snapshots s ON s.snapshot_id = d.head_snapshot_id "
216
+ "WHERE s.dataset_path = ? ORDER BY d.dataset_id LIMIT 1",
217
+ (dataset_path,),
218
+ ).fetchone()
219
+ if row is None:
220
+ return None
221
+ dataset_id: str = row["dataset_id"]
222
+ return dataset_id
223
+
224
+
225
+ def list_datasets(*, store_root: Path) -> list[DatasetSummary]:
226
+ """Return every indexed dataset, described by its latest snapshot.
227
+
228
+ Args:
229
+ store_root: Path to the ``.dstrack/`` directory.
230
+
231
+ Returns:
232
+ One summary per dataset, sorted by id. A dataset with no committed
233
+ snapshot yet is included, with a ``head`` of ``None``.
234
+ """
235
+ with _connect(store_root) as conn:
236
+ rows = conn.execute(
237
+ "SELECT d.dataset_id AS id, s.* FROM datasets d "
238
+ "LEFT JOIN snapshots s ON s.snapshot_id = d.head_snapshot_id "
239
+ "ORDER BY d.dataset_id"
240
+ ).fetchall()
241
+ return [
242
+ DatasetSummary(
243
+ dataset_id=row["id"],
244
+ head=_entry_from_row(row) if row["snapshot_id"] is not None else None,
245
+ )
246
+ for row in rows
247
+ ]
248
+
249
+
250
+ @contextlib.contextmanager
251
+ def _connect(store_root: Path) -> Iterator[sqlite3.Connection]:
252
+ """Open the index, creating its parent directory if needed.
253
+
254
+ Args:
255
+ store_root: Path to the ``.dstrack/`` directory.
256
+
257
+ Yields:
258
+ A connection with row access by name and foreign keys enforced, so a
259
+ dataset's rows are cleared with it. Uncommitted work is rolled back if
260
+ the block raises.
261
+ """
262
+ path = index_path(store_root)
263
+ path.parent.mkdir(parents=True, exist_ok=True)
264
+ conn = sqlite3.connect(path)
265
+ try:
266
+ conn.row_factory = sqlite3.Row
267
+ conn.execute("PRAGMA foreign_keys = ON")
268
+ yield conn
269
+ finally:
270
+ conn.close()
271
+
272
+
273
+ def _create_empty(store_root: Path) -> None:
274
+ """Replace the index with an empty one at the current schema version.
275
+
276
+ Args:
277
+ store_root: Path to the ``.dstrack/`` directory.
278
+
279
+ Raises:
280
+ OSError: If the old index cannot be removed or the new one written.
281
+ """
282
+ index_path(store_root).unlink(missing_ok=True)
283
+ with _connect(store_root) as conn:
284
+ conn.executescript(_SCHEMA)
285
+ conn.execute(
286
+ "INSERT INTO meta (key, value) VALUES ('schema_version', ?)",
287
+ (_SCHEMA_VERSION,),
288
+ )
289
+ conn.commit()
290
+
291
+
292
+ def _check_schema_version(conn: sqlite3.Connection) -> None:
293
+ """Raise unless the index was built by this version of the schema.
294
+
295
+ Args:
296
+ conn: A connection to the index.
297
+
298
+ Raises:
299
+ _IndexUnusable: If the index records a different schema version, or no
300
+ version at all.
301
+ """
302
+ try:
303
+ row = conn.execute(
304
+ "SELECT value FROM meta WHERE key = 'schema_version'"
305
+ ).fetchone()
306
+ except sqlite3.DatabaseError as e:
307
+ raise IndexUnusable(f"its schema could not be read ({e})") from e
308
+ if row is None:
309
+ raise IndexUnusable("it records no schema version")
310
+ if row["value"] != _SCHEMA_VERSION:
311
+ raise IndexUnusable(
312
+ f"it was built for schema version {row['value']}, "
313
+ f"but this dstrack expects {_SCHEMA_VERSION}"
314
+ )
315
+
316
+
317
+ def _sync(conn: sqlite3.Connection, store_root: Path) -> None:
318
+ """Reimport every dataset whose log changed, and forget deleted ones.
319
+
320
+ Runs as a single transaction, so an interrupted sync leaves the index at
321
+ its previous state rather than partly reimported.
322
+
323
+ Args:
324
+ conn: A connection to the index.
325
+ store_root: Path to the ``.dstrack/`` directory.
326
+
327
+ Raises:
328
+ StoreCorruptionError: If a dataset's log holds a malformed entry.
329
+ OSError: If the store cannot be read.
330
+ """
331
+ fingerprints = {
332
+ row["dataset_id"]: (row["log_size"], row["log_mtime_ns"])
333
+ for row in conn.execute(
334
+ "SELECT dataset_id, log_size, log_mtime_ns FROM datasets"
335
+ )
336
+ }
337
+
338
+ on_disk = store.list_dataset_ids(store_root=store_root)
339
+ for dataset_id in on_disk:
340
+ # HEAD before the log, so a concurrent `track` cannot move HEAD onto a
341
+ # snapshot whose line this sync has already read past.
342
+ head = store.read_head(dataset_id, store_root=store_root)
343
+ fingerprint = _fingerprint(dataset_id, store_root=store_root)
344
+
345
+ conn.execute(
346
+ "INSERT INTO datasets (dataset_id, log_size, log_mtime_ns, head_snapshot_id) "
347
+ "VALUES (?, ?, ?, ?) ON CONFLICT(dataset_id) DO UPDATE SET "
348
+ "log_size = excluded.log_size, "
349
+ "log_mtime_ns = excluded.log_mtime_ns, "
350
+ "head_snapshot_id = excluded.head_snapshot_id",
351
+ (dataset_id, fingerprint[0], fingerprint[1], head),
352
+ )
353
+
354
+ if fingerprints.get(dataset_id) == fingerprint:
355
+ continue
356
+ _reimport(conn, dataset_id, store_root=store_root)
357
+
358
+ for dataset_id in fingerprints.keys() - set(on_disk):
359
+ conn.execute("DELETE FROM datasets WHERE dataset_id = ?", (dataset_id,))
360
+
361
+ conn.commit()
362
+
363
+
364
+ def _reimport(conn: sqlite3.Connection, dataset_id: str, *, store_root: Path) -> None:
365
+ """Replace a dataset's indexed rows with what its log currently says.
366
+
367
+ The whole log is reparsed rather than appended from a recorded offset.
368
+ ``log.jsonl`` is append-only in normal operation, but the store's own
369
+ corruption messages instruct users to delete an offending line by hand, so
370
+ an offset cannot be trusted to still point at the same entry. The logs are
371
+ small by design, which is what makes reparsing the cheaper bet.
372
+
373
+ Args:
374
+ conn: A connection to the index, inside a transaction.
375
+ dataset_id: Dataset to reimport.
376
+ store_root: Path to the ``.dstrack/`` directory.
377
+
378
+ Raises:
379
+ StoreCorruptionError: If the log holds a malformed entry.
380
+ OSError: If the log cannot be read.
381
+ """
382
+ conn.execute("DELETE FROM snapshots WHERE dataset_id = ?", (dataset_id,))
383
+ entries = store.read_log_entries(dataset_id, store_root=store_root)
384
+ placeholders = ", ".join("?" * len(_SNAPSHOT_COLUMNS))
385
+ # A duplicated line is ignored rather than fatal: the first occurrence
386
+ # wins, matching how the lineage walk would have reached it.
387
+ conn.executemany(
388
+ f"INSERT OR IGNORE INTO snapshots ({', '.join(_SNAPSHOT_COLUMNS)}) "
389
+ f"VALUES ({placeholders})",
390
+ [
391
+ (
392
+ entry.snapshot_id,
393
+ dataset_id,
394
+ entry.parent_snapshot_id,
395
+ entry.created_at,
396
+ entry.created_by,
397
+ entry.dataset_name,
398
+ entry.dataset_path,
399
+ entry.num_rows,
400
+ entry.num_columns,
401
+ )
402
+ for entry in entries
403
+ ],
404
+ )
405
+
406
+
407
+ def _fingerprint(dataset_id: str, *, store_root: Path) -> tuple[int, int]:
408
+ """Return a cheap signature of a dataset's log, to detect changes.
409
+
410
+ Size and modification time, the same trade-off git's index makes: it
411
+ misses an edit that preserves both, which a rebuild is the answer to, and
412
+ costs one ``stat`` rather than a reparse when nothing changed.
413
+
414
+ Args:
415
+ dataset_id: Dataset whose log to fingerprint.
416
+ store_root: Path to the ``.dstrack/`` directory.
417
+
418
+ Returns:
419
+ The log's size and modification time in nanoseconds, or zeroes if the
420
+ dataset has no log yet.
421
+ """
422
+ path = store.dataset_log_path(dataset_id, store_root=store_root)
423
+ try:
424
+ stat = path.stat()
425
+ except FileNotFoundError:
426
+ return (0, 0)
427
+ return (stat.st_size, stat.st_mtime_ns)
428
+
429
+
430
+ def _entry_from_row(row: sqlite3.Row) -> LogEntry:
431
+ """Build a [LogEntry][dstrack.store.LogEntry] from an indexed row.
432
+
433
+ Args:
434
+ row: A ``snapshots`` row, or a join that selects its columns.
435
+
436
+ Returns:
437
+ The row as a [LogEntry][dstrack.store.LogEntry].
438
+ """
439
+ return LogEntry(
440
+ snapshot_id=row["snapshot_id"],
441
+ parent_snapshot_id=row["parent_snapshot_id"],
442
+ created_at=row["created_at"],
443
+ created_by=row["created_by"],
444
+ dataset_name=row["dataset_name"],
445
+ dataset_path=row["dataset_path"],
446
+ num_rows=row["num_rows"],
447
+ num_columns=row["num_columns"],
448
+ )
dstrack/console.py ADDED
@@ -0,0 +1,47 @@
1
+ """Rich-based console output helpers for dstrack's CLI.
2
+
3
+ Gives every command a consistent way to report results to the user,
4
+ independent of the logging configuration.
5
+
6
+ Messages are plain text, not rich markup. What they report is routinely
7
+ user-derived -- a dataset name from `dstrack track --name`, a path, an
8
+ exception carrying either -- and square brackets are legal in all of them.
9
+ Rendering such a message as markup would swallow ``[bold]`` as styling and
10
+ raise outright on ``[/]``, so each helper escapes its message and applies
11
+ styling only to the icon it puts in front.
12
+ """
13
+
14
+ from rich.console import Console, RenderableType
15
+ from rich.markup import escape
16
+
17
+ console = Console()
18
+
19
+
20
+ def display(renderable: RenderableType) -> None:
21
+ """Print a rich renderable, e.g. a table or a timeline.
22
+
23
+ For command output that is built rather than phrased: unlike the message
24
+ helpers below, what is passed is rendered as-is, so a caller assembling
25
+ text from user-derived values should build it with `rich.text.Text`.
26
+ """
27
+ console.print(renderable)
28
+
29
+
30
+ def success(message: str) -> None:
31
+ """Print a success message prefixed with a check mark."""
32
+ console.print(f"[bold green]\N{HEAVY CHECK MARK}[/bold green] {escape(message)}")
33
+
34
+
35
+ def warning(message: str) -> None:
36
+ """Print a warning message prefixed with a lightning bolt."""
37
+ console.print(f"[bold yellow]\N{HIGH VOLTAGE SIGN}[/bold yellow] {escape(message)}")
38
+
39
+
40
+ def error(message: str) -> None:
41
+ """Print an error message prefixed with a bug icon."""
42
+ console.print(f"[bold red]\N{BUG}[/bold red] {escape(message)}")
43
+
44
+
45
+ def info(message: str) -> None:
46
+ """Print general information with an info icon."""
47
+ console.print(f"[bold blue]\N{INFORMATION SOURCE}[/bold blue] {escape(message)}")
dstrack/errors.py ADDED
@@ -0,0 +1,22 @@
1
+ class StoreInitError(Exception):
2
+ """Raised when local store initialization fails partway through."""
3
+
4
+
5
+ class StoreNotFoundError(Exception):
6
+ """Raised when no local store root can be resolved."""
7
+
8
+
9
+ class StoreCorruptionError(Exception):
10
+ """Raised when a file in the local store cannot be read back as written."""
11
+
12
+
13
+ class DatasetNotFoundError(Exception):
14
+ """Raised when a named dataset does not exist in the local store."""
15
+
16
+
17
+ class InputTooLargeError(Exception):
18
+ """Raised when a dataset exceeds the configured snapshot row limit."""
19
+
20
+
21
+ class IndexUnusable(Exception):
22
+ """The index exists but cannot be queried, and must be rebuilt."""