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/_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