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/_log.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""The ``dstrack log`` command: show a tracked dataset's history."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sqlite3
|
|
5
|
+
import sys
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from itertools import pairwise
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from dstrack import cache, console, store
|
|
15
|
+
from dstrack._log_render import render_history
|
|
16
|
+
from dstrack.errors import (
|
|
17
|
+
DatasetNotFoundError,
|
|
18
|
+
StoreCorruptionError,
|
|
19
|
+
StoreNotFoundError,
|
|
20
|
+
)
|
|
21
|
+
from dstrack.paths import resolve_store_root
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def log(
|
|
25
|
+
target: Annotated[
|
|
26
|
+
str,
|
|
27
|
+
typer.Argument(
|
|
28
|
+
metavar="TARGET",
|
|
29
|
+
help="Dataset to show the history of: either a dataset id, or the "
|
|
30
|
+
"path to a tracked dataset file.",
|
|
31
|
+
),
|
|
32
|
+
],
|
|
33
|
+
limit: Annotated[
|
|
34
|
+
int | None,
|
|
35
|
+
typer.Option(
|
|
36
|
+
"-n",
|
|
37
|
+
"--limit",
|
|
38
|
+
min=1,
|
|
39
|
+
help="Show at most this many snapshots, counting back from the latest.",
|
|
40
|
+
),
|
|
41
|
+
] = None,
|
|
42
|
+
reverse: Annotated[
|
|
43
|
+
bool,
|
|
44
|
+
typer.Option("--reverse", help="Show oldest first instead of newest first."),
|
|
45
|
+
] = False,
|
|
46
|
+
oneline: Annotated[
|
|
47
|
+
bool,
|
|
48
|
+
typer.Option("--oneline", help="Condense each snapshot to a single line."),
|
|
49
|
+
] = False,
|
|
50
|
+
yes: Annotated[
|
|
51
|
+
bool,
|
|
52
|
+
typer.Option(
|
|
53
|
+
"-y",
|
|
54
|
+
"--yes",
|
|
55
|
+
help="Build the snapshot index without asking, if it does not exist yet.",
|
|
56
|
+
),
|
|
57
|
+
] = False,
|
|
58
|
+
rebuild: Annotated[
|
|
59
|
+
bool,
|
|
60
|
+
typer.Option(
|
|
61
|
+
"--rebuild",
|
|
62
|
+
help="Discard the snapshot index and rebuild it from the dataset logs.",
|
|
63
|
+
),
|
|
64
|
+
] = False,
|
|
65
|
+
root: Annotated[
|
|
66
|
+
Path | None,
|
|
67
|
+
typer.Option(
|
|
68
|
+
"--root",
|
|
69
|
+
help="Path root a TARGET path is made relative to, matching the one "
|
|
70
|
+
"`dstrack track` recorded it with. Defaults to the store root (the "
|
|
71
|
+
"directory containing .dstrack/). Ignored when TARGET is a dataset id.",
|
|
72
|
+
),
|
|
73
|
+
] = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Show the snapshot history of a tracked dataset."""
|
|
76
|
+
# Locate the .dstrack/ store; without one there is no history to show.
|
|
77
|
+
try:
|
|
78
|
+
store_root = resolve_store_root()
|
|
79
|
+
except StoreNotFoundError as e:
|
|
80
|
+
console.error(str(e))
|
|
81
|
+
raise typer.Exit(code=1) from e
|
|
82
|
+
|
|
83
|
+
_ensure_index(store_root, yes=yes, rebuild=rebuild)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
# Sync before resolving: the index does not travel with the repository,
|
|
87
|
+
# so a dataset tracked elsewhere is only visible once its log is read.
|
|
88
|
+
reason = cache.sync(store_root)
|
|
89
|
+
if reason is not None:
|
|
90
|
+
console.warning(f"Rebuilt the snapshot index: {reason}.")
|
|
91
|
+
|
|
92
|
+
dataset_id = _resolve_target(target, store_root=store_root, root=root)
|
|
93
|
+
history = cache.query_history(dataset_id, store_root=store_root)
|
|
94
|
+
except (
|
|
95
|
+
DatasetNotFoundError,
|
|
96
|
+
StoreCorruptionError,
|
|
97
|
+
ValueError,
|
|
98
|
+
OSError,
|
|
99
|
+
sqlite3.DatabaseError,
|
|
100
|
+
) as e:
|
|
101
|
+
console.error(str(e))
|
|
102
|
+
raise typer.Exit(code=1) from e
|
|
103
|
+
|
|
104
|
+
if not history:
|
|
105
|
+
console.warning(f"Dataset {dataset_id} has no snapshots yet.")
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
# Deltas are keyed by lineage, not by display position, so that slicing and
|
|
109
|
+
# reversing below cannot change what a snapshot is compared against.
|
|
110
|
+
parents = {entry.snapshot_id: parent for entry, parent in pairwise(history)}
|
|
111
|
+
head = history[0].snapshot_id
|
|
112
|
+
|
|
113
|
+
# Limit counts back from HEAD, then --reverse flips what is shown; the two
|
|
114
|
+
# compose the way `git log -n ... --reverse` does.
|
|
115
|
+
shown = history if limit is None else history[:limit]
|
|
116
|
+
if reverse:
|
|
117
|
+
shown = list(reversed(shown))
|
|
118
|
+
|
|
119
|
+
console.display(
|
|
120
|
+
render_history(
|
|
121
|
+
shown,
|
|
122
|
+
parents=parents,
|
|
123
|
+
head_id=head,
|
|
124
|
+
now=datetime.now(UTC),
|
|
125
|
+
oneline=oneline,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _ensure_index(store_root: Path, *, yes: bool, rebuild: bool) -> None:
|
|
131
|
+
"""Make sure the snapshot index may be built, asking first if need be.
|
|
132
|
+
|
|
133
|
+
Building parses every dataset's log, so it is offered rather than done
|
|
134
|
+
silently. Consent is only about the first build: an index that exists but
|
|
135
|
+
cannot be used is rebuilt without asking, since it holds nothing that the
|
|
136
|
+
logs do not.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
store_root: Path to the ``.dstrack/`` directory.
|
|
140
|
+
yes: Consent given up front, so nothing is asked.
|
|
141
|
+
rebuild: Discard any existing index, which implies consent.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
typer.Exit: Code 0 if the user declines to build it; code 1 if there
|
|
145
|
+
is no index and no terminal to ask at.
|
|
146
|
+
"""
|
|
147
|
+
path = cache.index_path(store_root)
|
|
148
|
+
if rebuild:
|
|
149
|
+
path.unlink(missing_ok=True)
|
|
150
|
+
console.info("Rebuilding the snapshot index from the dataset logs...")
|
|
151
|
+
return
|
|
152
|
+
if cache.index_exists(store_root):
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
if not yes:
|
|
156
|
+
if not sys.stdin.isatty():
|
|
157
|
+
console.error(
|
|
158
|
+
f"No snapshot index at `{path}`, and there is no terminal to "
|
|
159
|
+
"ask whether to build one. Re-run with `--yes` to build it."
|
|
160
|
+
)
|
|
161
|
+
raise typer.Exit(code=1)
|
|
162
|
+
console.info(f"No snapshot index at `{path}` yet.")
|
|
163
|
+
if not typer.confirm("Build it now from the dataset logs?"):
|
|
164
|
+
console.warning(
|
|
165
|
+
"Not building the index; there is nothing to show without it."
|
|
166
|
+
)
|
|
167
|
+
raise typer.Exit(code=0)
|
|
168
|
+
|
|
169
|
+
console.info("Building the snapshot index from the dataset logs...")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _resolve_target(target: str, *, store_root: Path, root: Path | None) -> str:
|
|
173
|
+
"""Resolve a user-supplied dataset id or path to a dataset id.
|
|
174
|
+
|
|
175
|
+
A ``dataset_id`` is a random UUID4, so nothing about it can be
|
|
176
|
+
derived from a path: a path is resolved by matching it against what each
|
|
177
|
+
dataset last recorded. `target` is read as an id first and as a path
|
|
178
|
+
second, but only when the id names a dataset that exists. Reading it as a
|
|
179
|
+
path first would let any file in the current directory shadow a real id,
|
|
180
|
+
and would send a mistyped path into the id lookup, which can only report
|
|
181
|
+
that no dataset goes by that name.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
target: A dataset id, or a path to a tracked dataset file.
|
|
185
|
+
store_root: Path to the ``.dstrack/`` directory.
|
|
186
|
+
root: Path root a `target` path is made relative to, or ``None`` to
|
|
187
|
+
use the store root.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
The id of the dataset `target` names.
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
DatasetNotFoundError: If `target` is an id no dataset goes by, or a
|
|
194
|
+
path no dataset last recorded.
|
|
195
|
+
ValueError: If `target` and the path root are on different drives, so
|
|
196
|
+
no relative path exists between them.
|
|
197
|
+
"""
|
|
198
|
+
canonical = _canonical_uuid(target)
|
|
199
|
+
if canonical is not None and store.dataset_exists(canonical, store_root=store_root):
|
|
200
|
+
return canonical
|
|
201
|
+
|
|
202
|
+
path_root = root if root is not None else store_root.parent
|
|
203
|
+
dataset_path = Path(
|
|
204
|
+
os.path.relpath(Path(target).resolve(), Path(path_root).resolve())
|
|
205
|
+
).as_posix()
|
|
206
|
+
|
|
207
|
+
matched = cache.find_dataset_by_path(dataset_path, store_root=store_root)
|
|
208
|
+
if matched is not None:
|
|
209
|
+
return matched
|
|
210
|
+
|
|
211
|
+
if canonical is not None:
|
|
212
|
+
raise DatasetNotFoundError(
|
|
213
|
+
f"No dataset {target!r} in the store.\n{_known_datasets(store_root)}"
|
|
214
|
+
)
|
|
215
|
+
raise DatasetNotFoundError(
|
|
216
|
+
f"No tracked dataset at {target!r} (recorded as {dataset_path!r} "
|
|
217
|
+
f"relative to {path_root}).\n"
|
|
218
|
+
"If the file was renamed or moved, its recorded path no longer "
|
|
219
|
+
"matches; pass the dataset id instead. Use `--root` to change the "
|
|
220
|
+
"path root the recorded path is computed against.\n"
|
|
221
|
+
f"{_known_datasets(store_root)}"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _canonical_uuid(value: str) -> str | None:
|
|
226
|
+
"""Return `value` as a canonical UUID string, or ``None`` if it is not one.
|
|
227
|
+
|
|
228
|
+
Datasets are named by ``str(uuid.uuid4())``, but a user may paste an id
|
|
229
|
+
uppercased, braced, or without its dashes, all of which name the same
|
|
230
|
+
dataset. Canonicalizing accepts those spellings, and makes the result
|
|
231
|
+
provably free of path separators before it is joined onto the store.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
value: The string to interpret.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
The canonical form of `value` if it is a UUID in any spelling, else
|
|
238
|
+
``None``.
|
|
239
|
+
"""
|
|
240
|
+
try:
|
|
241
|
+
return str(uuid.UUID(value))
|
|
242
|
+
except (ValueError, AttributeError, TypeError):
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _known_datasets(store_root: Path) -> str:
|
|
247
|
+
"""List the store's datasets, for an error message to end on.
|
|
248
|
+
|
|
249
|
+
A dataset id says nothing on its own, so each is shown with the name and
|
|
250
|
+
path its latest snapshot recorded: that is what lets a reader recognize
|
|
251
|
+
the dataset they meant and copy its id.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
store_root: Path to the ``.dstrack/`` directory.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
An indented listing of every dataset, or a note that there are none.
|
|
258
|
+
"""
|
|
259
|
+
datasets = cache.list_datasets(store_root=store_root)
|
|
260
|
+
lines = [
|
|
261
|
+
f" {d.dataset_id} {(d.head.dataset_name if d.head else None) or '-'} "
|
|
262
|
+
f"{(d.head.dataset_path if d.head else None) or '-'}"
|
|
263
|
+
for d in datasets
|
|
264
|
+
]
|
|
265
|
+
if not lines:
|
|
266
|
+
return "Known datasets:\n (none)\nRun `dstrack track <path>` to record one."
|
|
267
|
+
listing = "\n".join(lines)
|
|
268
|
+
return f"Known datasets:\n{listing}"
|
dstrack/_log_render.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Rendering of a dataset's history as a timeline.
|
|
2
|
+
|
|
3
|
+
This is the only layer that knows what a dataset's history looks like on
|
|
4
|
+
screen. It builds renderables and returns them; printing is the caller's job.
|
|
5
|
+
|
|
6
|
+
The timeline is a two-column ``Table.grid``: a rail on the left, one glyph per
|
|
7
|
+
line, and the snapshot's facts on the right. Keeping one grid row per rendered
|
|
8
|
+
line is what holds the rail in step with the text beside it, and it is why the
|
|
9
|
+
content column must keep rich's default overflow: a column told not to wrap is
|
|
10
|
+
dropped entirely when the grid has to shrink, taking the rail's glyphs with it
|
|
11
|
+
and leaving a plausible-looking but railless timeline on narrow terminals.
|
|
12
|
+
|
|
13
|
+
Every value that reaches a cell comes from the store, which means it comes from
|
|
14
|
+
whatever the user passed to `dstrack track --name` or from a path on disk.
|
|
15
|
+
Cells are therefore assembled as `Text`, never interpolated into rich's markup:
|
|
16
|
+
a dataset named ``sales[/]report`` is not markup to be parsed, and treating it
|
|
17
|
+
as such raises rather than prints.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from collections.abc import Mapping, Sequence
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from typing import Final
|
|
23
|
+
|
|
24
|
+
from rich.console import Group, RenderableType
|
|
25
|
+
from rich.table import Table
|
|
26
|
+
from rich.text import Text
|
|
27
|
+
|
|
28
|
+
from dstrack.store import LogEntry
|
|
29
|
+
|
|
30
|
+
_MISSING: Final = "\N{EM DASH}"
|
|
31
|
+
_NODE: Final = "\N{BLACK CIRCLE}"
|
|
32
|
+
_RAIL: Final = "\N{BOX DRAWINGS LIGHT VERTICAL}"
|
|
33
|
+
_SHORT_ID_LEN: Final = 8
|
|
34
|
+
|
|
35
|
+
# Largest unit first; each is the number of seconds in one of that unit.
|
|
36
|
+
_UNITS: Final[tuple[tuple[str, int], ...]] = (
|
|
37
|
+
("year", 365 * 24 * 3600),
|
|
38
|
+
("month", 30 * 24 * 3600),
|
|
39
|
+
("week", 7 * 24 * 3600),
|
|
40
|
+
("day", 24 * 3600),
|
|
41
|
+
("hour", 3600),
|
|
42
|
+
("minute", 60),
|
|
43
|
+
("second", 1),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def render_history(
|
|
48
|
+
entries: Sequence[LogEntry],
|
|
49
|
+
*,
|
|
50
|
+
parents: Mapping[str, LogEntry],
|
|
51
|
+
head_id: str,
|
|
52
|
+
now: datetime,
|
|
53
|
+
oneline: bool = False,
|
|
54
|
+
) -> RenderableType:
|
|
55
|
+
"""Render a dataset's snapshots as a timeline.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
entries: The snapshots to show, in display order. May be a slice of
|
|
59
|
+
the dataset's full history.
|
|
60
|
+
parents: Each snapshot's predecessor, keyed by ``snapshot_id``, built
|
|
61
|
+
from the *full* history rather than `entries`. Passed separately
|
|
62
|
+
so that a snapshot whose parent was sliced off or reordered still
|
|
63
|
+
shows how it differed from it.
|
|
64
|
+
head_id: Id of the dataset's latest snapshot, marked in the output.
|
|
65
|
+
now: The moment to measure each snapshot's age against.
|
|
66
|
+
oneline: Condense each snapshot to a single line.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
A renderable timeline, newest or oldest first according to the order
|
|
70
|
+
of `entries`.
|
|
71
|
+
"""
|
|
72
|
+
grid = Table.grid(padding=(0, 1))
|
|
73
|
+
grid.add_column(no_wrap=True)
|
|
74
|
+
# No overflow or wrap settings: rich's defaults ellipsize each cell to one
|
|
75
|
+
# line, which is what keeps every content line paired with one rail glyph.
|
|
76
|
+
grid.add_column()
|
|
77
|
+
|
|
78
|
+
for position, entry in enumerate(entries):
|
|
79
|
+
is_last = position == len(entries) - 1
|
|
80
|
+
parent = parents.get(entry.snapshot_id)
|
|
81
|
+
lines = (
|
|
82
|
+
[_oneline(entry, parent=parent, head_id=head_id, now=now)]
|
|
83
|
+
if oneline
|
|
84
|
+
else _detail_lines(entry, parent=parent, head_id=head_id, now=now)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
for offset, line in enumerate(lines):
|
|
88
|
+
grid.add_row(
|
|
89
|
+
_glyph(entry, head_id=head_id, first=offset == 0, last=is_last), line
|
|
90
|
+
)
|
|
91
|
+
if not oneline and not is_last:
|
|
92
|
+
grid.add_row(Text(_RAIL, style="dim cyan"), "")
|
|
93
|
+
|
|
94
|
+
return Group(grid)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _glyph(entry: LogEntry, *, head_id: str, first: bool, last: bool) -> Text:
|
|
98
|
+
"""Return the rail character for one rendered line.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
entry: The snapshot the line belongs to.
|
|
102
|
+
head_id: Id of the dataset's latest snapshot.
|
|
103
|
+
first: Whether this is the snapshot's first line, which carries the
|
|
104
|
+
node rather than the rail.
|
|
105
|
+
last: Whether this is the oldest snapshot shown. Its continuation lines
|
|
106
|
+
carry no rail, so the timeline ends at the node instead of trailing
|
|
107
|
+
off below it.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The styled glyph to place in the rail column.
|
|
111
|
+
"""
|
|
112
|
+
if first:
|
|
113
|
+
style = "bold cyan" if entry.snapshot_id == head_id else "cyan"
|
|
114
|
+
return Text(_NODE, style=style)
|
|
115
|
+
return Text(" " if last else _RAIL, style="dim cyan")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _detail_lines(
|
|
119
|
+
entry: LogEntry, *, parent: LogEntry | None, head_id: str, now: datetime
|
|
120
|
+
) -> list[Text]:
|
|
121
|
+
"""Render one snapshot as its multi-line timeline entry.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
entry: The snapshot to render.
|
|
125
|
+
parent: The snapshot it succeeds, for computing deltas, or ``None``.
|
|
126
|
+
head_id: Id of the dataset's latest snapshot.
|
|
127
|
+
now: The moment to measure the snapshot's age against.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
One `Text` per line, each rendered beside its own rail glyph.
|
|
131
|
+
"""
|
|
132
|
+
title = Text()
|
|
133
|
+
title.append(_short_id(entry.snapshot_id), style="bold yellow")
|
|
134
|
+
title.append(" ")
|
|
135
|
+
title.append(entry.dataset_name or _MISSING, style="bold")
|
|
136
|
+
if entry.snapshot_id == head_id:
|
|
137
|
+
title.append(" HEAD", style="bold cyan")
|
|
138
|
+
|
|
139
|
+
when = Text()
|
|
140
|
+
when.append(_humanize(entry.created_at, now))
|
|
141
|
+
when.append(" by ", style="dim")
|
|
142
|
+
when.append(entry.created_by or _MISSING)
|
|
143
|
+
|
|
144
|
+
counts = Text()
|
|
145
|
+
counts.append_text(
|
|
146
|
+
_count(entry.num_rows, "row", parent.num_rows if parent else None)
|
|
147
|
+
)
|
|
148
|
+
counts.append(" ")
|
|
149
|
+
counts.append_text(
|
|
150
|
+
_count(entry.num_columns, "col", parent.num_columns if parent else None)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return [title, when, counts, Text(entry.dataset_path or _MISSING, style="dim")]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _oneline(
|
|
157
|
+
entry: LogEntry, *, parent: LogEntry | None, head_id: str, now: datetime
|
|
158
|
+
) -> Text:
|
|
159
|
+
"""Render one snapshot as a single timeline line.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
entry: The snapshot to render.
|
|
163
|
+
parent: The snapshot it succeeds, for computing deltas, or ``None``.
|
|
164
|
+
head_id: Id of the dataset's latest snapshot.
|
|
165
|
+
now: The moment to measure the snapshot's age against.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
The snapshot's identity, age, name and row count on one line.
|
|
169
|
+
"""
|
|
170
|
+
line = Text()
|
|
171
|
+
line.append(_short_id(entry.snapshot_id), style="bold yellow")
|
|
172
|
+
line.append(" ")
|
|
173
|
+
line.append(_humanize(entry.created_at, now), style="dim")
|
|
174
|
+
line.append(" ")
|
|
175
|
+
line.append(entry.dataset_name or _MISSING, style="bold")
|
|
176
|
+
line.append(" ")
|
|
177
|
+
line.append_text(_count(entry.num_rows, "row", parent.num_rows if parent else None))
|
|
178
|
+
if entry.snapshot_id == head_id:
|
|
179
|
+
line.append(" HEAD", style="bold cyan")
|
|
180
|
+
return line
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _count(value: int | None, unit: str, parent: int | None) -> Text:
|
|
184
|
+
"""Render a count and how it changed from its parent.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
value: The count, or ``None`` if the snapshot did not record one.
|
|
188
|
+
unit: Singular name of what is being counted, e.g. ``"row"``.
|
|
189
|
+
parent: The parent snapshot's count, or ``None`` if there is no parent
|
|
190
|
+
or it recorded none.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
The count, or an em-dash if it is missing, followed by its signed
|
|
194
|
+
change when one can be computed.
|
|
195
|
+
"""
|
|
196
|
+
text = Text()
|
|
197
|
+
text.append(_MISSING if value is None else f"{value:,}", style="bold")
|
|
198
|
+
text.append(f" {unit}" if value == 1 else f" {unit}s", style="dim")
|
|
199
|
+
|
|
200
|
+
delta = _delta(value, parent)
|
|
201
|
+
if delta is not None:
|
|
202
|
+
text.append(" ")
|
|
203
|
+
text.append(delta[0], style=delta[1])
|
|
204
|
+
return text
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _delta(value: int | None, parent: int | None) -> tuple[str, str] | None:
|
|
208
|
+
"""Return a count's signed change and the style to render it in.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
value: The snapshot's count.
|
|
212
|
+
parent: The parent snapshot's count.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
The change and its style, or ``None`` when there is nothing to say:
|
|
216
|
+
no parent, an unrecorded count on either side, or no change at all. An
|
|
217
|
+
em-dash marks a missing *value*; a delta that cannot be computed is
|
|
218
|
+
simply not shown, rather than shown as unknown.
|
|
219
|
+
"""
|
|
220
|
+
if value is None or parent is None or value == parent:
|
|
221
|
+
return None
|
|
222
|
+
change = value - parent
|
|
223
|
+
return f"{change:+,}", "green" if change > 0 else "red"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _short_id(snapshot_id: str) -> str:
|
|
227
|
+
"""Return the leading characters of a snapshot id, as git shows a commit."""
|
|
228
|
+
return snapshot_id[:_SHORT_ID_LEN]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _humanize(created_at: str | None, now: datetime) -> str:
|
|
232
|
+
"""Render when a snapshot was taken, relative to `now`.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
created_at: The recorded timestamp, as it appears in the log.
|
|
236
|
+
now: The moment to measure against.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
An approximate age such as ``"3 days ago"``, or an em-dash if the
|
|
240
|
+
timestamp is missing or unreadable. A snapshot dated in the future
|
|
241
|
+
reads as ``"in 3 days"``: the store travels between machines by git,
|
|
242
|
+
so a teammate's clock can legitimately be ahead of this one.
|
|
243
|
+
"""
|
|
244
|
+
when = _parse_created_at(created_at)
|
|
245
|
+
if when is None:
|
|
246
|
+
return _MISSING
|
|
247
|
+
|
|
248
|
+
seconds = int((now - when).total_seconds())
|
|
249
|
+
magnitude = abs(seconds)
|
|
250
|
+
if magnitude < 1:
|
|
251
|
+
return "just now"
|
|
252
|
+
|
|
253
|
+
for unit, size in _UNITS:
|
|
254
|
+
if magnitude >= size:
|
|
255
|
+
count = magnitude // size
|
|
256
|
+
plural = "" if count == 1 else "s"
|
|
257
|
+
return (
|
|
258
|
+
f"{count} {unit}{plural} ago"
|
|
259
|
+
if seconds > 0
|
|
260
|
+
else f"in {count} {unit}{plural}"
|
|
261
|
+
)
|
|
262
|
+
return "just now"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _parse_created_at(created_at: str | None) -> datetime | None:
|
|
266
|
+
"""Parse a recorded timestamp into an aware datetime.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
created_at: The recorded timestamp, as it appears in the log.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
The timestamp, or ``None`` if it is missing or unparsable. A timestamp
|
|
273
|
+
without an offset is read as UTC, which is what the store writes; it
|
|
274
|
+
cannot be left naive, since subtracting it from an aware `now` raises.
|
|
275
|
+
"""
|
|
276
|
+
if created_at is None:
|
|
277
|
+
return None
|
|
278
|
+
try:
|
|
279
|
+
parsed = datetime.fromisoformat(created_at)
|
|
280
|
+
except ValueError:
|
|
281
|
+
return None
|
|
282
|
+
return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed
|
dstrack/_logging.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Logging setup for dstrack.
|
|
2
|
+
|
|
3
|
+
Every module gets its own logger via ``logging.getLogger(__name__)``, all of
|
|
4
|
+
which are children of, and propagate to, the top-level ``"dstrack"`` logger
|
|
5
|
+
configured here. Applications embedding dstrack are free to configure that
|
|
6
|
+
hierarchy however they like: ``logging.basicConfig()``, attaching handlers to
|
|
7
|
+
the root logger, or attaching handlers directly to ``logging.getLogger("dstrack")``.
|
|
8
|
+
|
|
9
|
+
If the application does none of that,
|
|
10
|
+
[_DefaultHandler][dstrack._logging._DefaultHandler] prints
|
|
11
|
+
``WARNING``-and-above records to stderr with a sensible default format so
|
|
12
|
+
messages are never silently dropped. It steps aside automatically the moment
|
|
13
|
+
it detects that the application has configured logging itself.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from typing import TextIO
|
|
18
|
+
|
|
19
|
+
_FORMAT = "%(asctime)s %(levelname)-8s %(name)s: %(message)s"
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("dstrack")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _DefaultHandler(logging.StreamHandler[TextIO]):
|
|
25
|
+
"""Stderr handler used only until the application configures logging.
|
|
26
|
+
|
|
27
|
+
Yields to any handler the application attaches to the root logger or to
|
|
28
|
+
the ``dstrack`` logger, so it never produces duplicate output.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
32
|
+
if logging.root.handlers:
|
|
33
|
+
return
|
|
34
|
+
if any(h is not self for h in logger.handlers):
|
|
35
|
+
return
|
|
36
|
+
super().emit(record)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _configure_default_logging() -> None:
|
|
40
|
+
"""Attach the fallback stderr handler to the ``dstrack`` logger.
|
|
41
|
+
|
|
42
|
+
Called once, at import time. The handler installed here checks, on every
|
|
43
|
+
record, whether the application has since configured logging itself and
|
|
44
|
+
stays silent if so, see
|
|
45
|
+
[_DefaultHandler.emit()][dstrack._logging._DefaultHandler.emit].
|
|
46
|
+
|
|
47
|
+
A no-op if a [_DefaultHandler][dstrack._logging._DefaultHandler] is already
|
|
48
|
+
attached: since two of them would each treat the other as an
|
|
49
|
+
application-supplied handler and both step aside, re-running this (e.g. via
|
|
50
|
+
``importlib.reload`` under Jupyter's autoreload) would otherwise silence
|
|
51
|
+
dstrack's logging entirely.
|
|
52
|
+
"""
|
|
53
|
+
if any(isinstance(h, _DefaultHandler) for h in logger.handlers):
|
|
54
|
+
return
|
|
55
|
+
handler = _DefaultHandler()
|
|
56
|
+
handler.setFormatter(logging.Formatter(_FORMAT))
|
|
57
|
+
logger.addHandler(handler)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_configure_default_logging()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Declarative description of the local store's directory structure."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
from dstrack.paths import STORE_DIRNAME
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class TemplateFile:
|
|
12
|
+
"""A file to create, with its literal content."""
|
|
13
|
+
|
|
14
|
+
name: str
|
|
15
|
+
content: str = ""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class TemplateDir:
|
|
20
|
+
"""A directory to create, optionally containing files and subdirectories."""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
entries: "tuple[TemplateDir | TemplateFile, ...]" = field(default_factory=tuple)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
STORE_TEMPLATE: Final[TemplateDir] = TemplateDir(
|
|
27
|
+
name=STORE_DIRNAME,
|
|
28
|
+
entries=(
|
|
29
|
+
TemplateDir(name="datasets"),
|
|
30
|
+
TemplateFile(name=".gitignore", content=".cache/"),
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def materialize(template: TemplateDir, parent: Path) -> Path:
|
|
36
|
+
"""Recursively create `template`'s directory structure under `parent`.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
template: Directory template to materialize, including the
|
|
40
|
+
top-level directory itself.
|
|
41
|
+
parent: Existing directory that `template`'s directory is created in.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The path created for `template`.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
FileExistsError: If any file or directory in the template already
|
|
48
|
+
exists under `parent`.
|
|
49
|
+
"""
|
|
50
|
+
path = parent / template.name
|
|
51
|
+
path.mkdir()
|
|
52
|
+
for entry in template.entries:
|
|
53
|
+
if isinstance(entry, TemplateDir):
|
|
54
|
+
materialize(entry, path)
|
|
55
|
+
else:
|
|
56
|
+
with open(path / entry.name, "x") as f:
|
|
57
|
+
f.write(entry.content)
|
|
58
|
+
return path
|