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/__init__.py +8 -0
- dstrack/_benchmark/__init__.py +54 -0
- dstrack/_benchmark/_cli.py +114 -0
- dstrack/_benchmark/_environment.py +144 -0
- dstrack/_benchmark/_profiling.py +207 -0
- dstrack/_benchmark/_report.py +152 -0
- dstrack/_benchmark/_runner.py +196 -0
- dstrack/_benchmark/_synthetic.py +158 -0
- dstrack/_cli.py +105 -0
- dstrack/_log.py +268 -0
- dstrack/_log_render.py +282 -0
- dstrack/_logging.py +60 -0
- dstrack/_store_template.py +58 -0
- dstrack/_track.py +144 -0
- dstrack/cache.py +448 -0
- dstrack/console.py +47 -0
- dstrack/errors.py +22 -0
- dstrack/paths.py +88 -0
- dstrack/readers/__init__.py +69 -0
- dstrack/readers/_csv.py +361 -0
- dstrack/readers/_protocol.py +120 -0
- dstrack/readers/_registry.py +241 -0
- dstrack/readers/_resolve.py +140 -0
- dstrack/schemas/snapshot_v1.json +299 -0
- dstrack/snapshot/__init__.py +46 -0
- dstrack/snapshot/_builder.py +114 -0
- dstrack/snapshot/_metadata.py +154 -0
- dstrack/snapshot/_stats.py +487 -0
- dstrack/snapshot/_version.py +13 -0
- dstrack/store.py +765 -0
- dstrack/utils.py +6 -0
- dstrack-0.1.0.dist-info/METADATA +123 -0
- dstrack-0.1.0.dist-info/RECORD +36 -0
- dstrack-0.1.0.dist-info/WHEEL +4 -0
- dstrack-0.1.0.dist-info/entry_points.txt +4 -0
- dstrack-0.1.0.dist-info/licenses/LICENSE +21 -0
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."""
|