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/store.py ADDED
@@ -0,0 +1,765 @@
1
+ """Persistence of snapshots into the local store, and reads back out of it.
2
+
3
+ A snapshot is written under ``datasets/<dataset_id>/`` as its full JSON
4
+ payload, a one-line append to ``log.jsonl``, and an updated ``HEAD``. The
5
+ three are written in that order so a crash never leaves ``HEAD``
6
+ pointing at a snapshot that was not fully written. Re-tracking a source whose
7
+ recorded path matches an existing dataset's latest snapshot continues that
8
+ dataset's lineage rather than creating a new one.
9
+
10
+ The three writes are not a single atomic transaction, so two safeguards keep
11
+ readers consistent. A store-wide advisory lock serializes
12
+ the whole resolve-parent-then-write sequence, so concurrent writers cannot read
13
+ the same ``HEAD`` as their parent nor mint two datasets for one path. And
14
+ ``HEAD`` -- written last -- is the single source of truth for what is committed:
15
+ recovery and path matching read the log entry that ``HEAD`` names rather than
16
+ trusting the final line, so a ``log.jsonl`` left one entry ahead by a crash
17
+ between the append and the ``HEAD`` write is ignored.
18
+
19
+ Reading history back is therefore *reachability-based*, not positional.
20
+ [read_log_entries][dstrack.store.read_log_entries] returns every line a dataset's log holds, including
21
+ entries no ``HEAD`` ever named; [walk_lineage][dstrack.store.walk_lineage] then follows
22
+ ``parent_snapshot_id`` links back from ``HEAD`` to select the ones that are
23
+ actually committed. Neither "read every line" nor "read every line up to
24
+ ``HEAD``" is correct: a crash between the append and the ``HEAD`` write leaves
25
+ an uncommitted entry behind, and the *next* successful write appends after it,
26
+ stranding it in the middle of the file rather than at the end. Only
27
+ reachability from ``HEAD`` tells the two apart.
28
+ """
29
+
30
+ import contextlib
31
+ import json
32
+ import os
33
+ import sys
34
+ import tempfile
35
+ import uuid
36
+ from collections.abc import Iterator, Mapping
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+ from typing import Any, Final
40
+
41
+ from dstrack.errors import DatasetNotFoundError, StoreCorruptionError
42
+
43
+ # Lightweight identity fields copied from a snapshot into its `log.jsonl` line.
44
+ # A subset of ADR-0001's fields: only those the current builders produce.
45
+ _LOG_FIELDS: Final[tuple[str, ...]] = (
46
+ "snapshot_id",
47
+ "parent_snapshot_id",
48
+ "created_at",
49
+ "created_by",
50
+ "dataset_name",
51
+ "dataset_path",
52
+ "num_rows",
53
+ "num_columns",
54
+ )
55
+
56
+ _DATASETS_DIRNAME: Final = "datasets"
57
+ _LOG_FILENAME: Final = "log.jsonl"
58
+ _HEAD_FILENAME: Final = "HEAD"
59
+ _SNAPSHOTS_DIRNAME: Final = "snapshots"
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class LogEntry:
64
+ """One snapshot as recorded in a dataset's ``log.jsonl``.
65
+
66
+ The lightweight per-snapshot record, mirroring
67
+ log fields rather than the full
68
+ ``snapshots/<snapshot_id>.json`` payload.
69
+
70
+ Every field except ``snapshot_id`` is optional. Log lines are written with
71
+ ``payload.get(key)`` (see [write_snapshot][dstrack.store.write_snapshot]),
72
+ so a snapshot built without a field records it as JSON null, and a line
73
+ written by a different version of dstrack may omit it entirely.
74
+
75
+ Attributes:
76
+ snapshot_id: Identifier of the snapshot. Always present: an entry is
77
+ only ever reached via ``HEAD`` or a ``parent_snapshot_id`` link,
78
+ both of which name it.
79
+ parent_snapshot_id: Snapshot this one succeeds, or ``None`` for the
80
+ dataset's first snapshot.
81
+ created_at: ISO-8601 UTC timestamp, kept as the raw recorded string.
82
+ Deliberately not parsed here: an unparsable value is a display
83
+ concern, not a reason to fail a read.
84
+ created_by: Recorded author, or ``None`` if not recorded.
85
+ dataset_name: Human-readable name at snapshot time, or ``None``.
86
+ dataset_path: POSIX path relative to the path root in force at
87
+ snapshot time, or ``None``.
88
+ num_rows: Row count, or ``None`` if not recorded.
89
+ num_columns: Column count, or ``None`` if not recorded.
90
+ """
91
+
92
+ snapshot_id: str
93
+ parent_snapshot_id: str | None
94
+ created_at: str | None
95
+ created_by: str | None
96
+ dataset_name: str | None
97
+ dataset_path: str | None
98
+ num_rows: int | None
99
+ num_columns: int | None
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class DatasetSummary:
104
+ """A dataset in the store, described by its latest committed snapshot.
105
+
106
+ Attributes:
107
+ dataset_id: The dataset's directory name under ``datasets/``.
108
+ head: The log entry the dataset's ``HEAD`` names, or ``None`` if the
109
+ dataset has no committed snapshot yet.
110
+ """
111
+
112
+ dataset_id: str
113
+ head: LogEntry | None
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class SnapshotWriteResult:
118
+ """Outcome of writing one snapshot into the store.
119
+
120
+ Attributes:
121
+ dataset_id: Dataset whose lineage the snapshot joined, whether it was
122
+ passed in, matched by path, or minted by this write.
123
+ snapshot_id: Identifier of the snapshot that was written, copied from
124
+ the snapshot payload.
125
+ snapshot_path: Path of the written ``snapshots/<snapshot_id>.json``.
126
+ parent_snapshot_id: Snapshot this one succeeds, i.e. the dataset's
127
+ ``HEAD`` before this write, or ``None`` for a dataset's first
128
+ snapshot.
129
+ is_new_dataset: Whether this write minted a new ``dataset_id``, as
130
+ opposed to continuing an existing dataset's lineage.
131
+ """
132
+
133
+ dataset_id: str
134
+ snapshot_id: str
135
+ snapshot_path: Path
136
+ parent_snapshot_id: str | None
137
+ is_new_dataset: bool
138
+
139
+
140
+ def write_snapshot(
141
+ snapshot: dict[str, Any],
142
+ *,
143
+ store_root: Path,
144
+ dataset_id: str | None = None,
145
+ ) -> SnapshotWriteResult:
146
+ """Write ``snapshot`` into the store and update the dataset's history.
147
+
148
+ Resolves which dataset the snapshot belongs to, then writes the full
149
+ payload, appends the lightweight ``log.jsonl`` line, and moves ``HEAD``
150
+ onto the new snapshot.
151
+
152
+ Args:
153
+ snapshot: A snapshot dict as produced by
154
+ [SnapshotBuilder][dstrack.snapshot._builder.SnapshotBuilder]. Must contain
155
+ ``snapshot_id`` and ``dataset_path``; ``parent_snapshot_id`` is
156
+ filled in here and overwritten if already present.
157
+ store_root: Path to the ``.dstrack/`` directory, e.g. from
158
+ [resolve_store_root][dstrack.paths.resolve_store_root].
159
+ dataset_id: Continue this dataset's lineage explicitly. The dataset must
160
+ already exist. When ``None``, the dataset is matched by
161
+ ``dataset_path`` against each existing dataset's latest snapshot; a
162
+ new ``dataset_id`` is minted if none matches.
163
+
164
+ Returns:
165
+ A [SnapshotWriteResult][dstrack.store.SnapshotWriteResult] describing
166
+ where the snapshot landed and which lineage it joined.
167
+
168
+ Raises:
169
+ KeyError: If ``snapshot`` lacks ``snapshot_id`` or ``dataset_path``.
170
+ ValueError: If ``snapshot_id`` or an explicit ``dataset_id`` resolves to
171
+ a path outside the store.
172
+ DatasetNotFoundError: If ``dataset_id`` is given but names no dataset in
173
+ the store.
174
+ StoreCorruptionError: If an existing dataset's ``log.jsonl`` ends in a
175
+ line that is not valid JSON.
176
+ OSError: If the store cannot be written to.
177
+ """
178
+ datasets_dir = _datasets_dir(store_root)
179
+ dataset_path = snapshot["dataset_path"]
180
+ snapshot_id = snapshot["snapshot_id"]
181
+
182
+ # Lock the store to avoid cross-process contamination
183
+ with _store_lock(store_root):
184
+ if dataset_id is not None:
185
+ # An explicit id continues a lineage; it must name a dataset
186
+ _ensure_direct_child(
187
+ base=datasets_dir,
188
+ candidate=datasets_dir / dataset_id,
189
+ kind="dataset_id",
190
+ value=dataset_id,
191
+ )
192
+ _check_dataset_exists(datasets_dir, dataset_id)
193
+ is_new_dataset = False
194
+ else:
195
+ dataset_id = _match_dataset_by_path(datasets_dir, dataset_path)
196
+ is_new_dataset = dataset_id is None
197
+ if dataset_id is None:
198
+ dataset_id = str(uuid.uuid4())
199
+
200
+ dataset_dir = datasets_dir / dataset_id
201
+ snapshots_dir = dataset_dir / _SNAPSHOTS_DIRNAME
202
+ snapshot_path = snapshots_dir / f"{snapshot_id}.json"
203
+ # snapshot_id arrives in the payload from outside, so confirm the file
204
+ # lands inside the dataset's snapshots/ before creating any directory.
205
+ _ensure_direct_child(
206
+ base=snapshots_dir,
207
+ candidate=snapshot_path,
208
+ kind="snapshot_id",
209
+ value=snapshot_id,
210
+ )
211
+ snapshots_dir.mkdir(parents=True, exist_ok=True)
212
+
213
+ # HEAD is the dataset's latest snapshot, so it is this snapshot's parent
214
+ parent_snapshot_id = _read_head(dataset_dir)
215
+ payload = {**snapshot, "parent_snapshot_id": parent_snapshot_id}
216
+
217
+ _atomic_write(snapshot_path, json.dumps(payload, indent=2) + "\n")
218
+
219
+ log_line = {key: payload.get(key) for key in _LOG_FIELDS}
220
+ with (dataset_dir / _LOG_FILENAME).open("a", encoding="utf-8") as fh:
221
+ fh.write(json.dumps(log_line) + "\n")
222
+
223
+ _atomic_write(dataset_dir / _HEAD_FILENAME, snapshot_id + "\n")
224
+
225
+ return SnapshotWriteResult(
226
+ dataset_id=dataset_id,
227
+ snapshot_id=snapshot_id,
228
+ snapshot_path=snapshot_path,
229
+ parent_snapshot_id=parent_snapshot_id,
230
+ is_new_dataset=is_new_dataset,
231
+ )
232
+
233
+
234
+ def list_dataset_ids(*, store_root: Path) -> list[str]:
235
+ """Return the id of every dataset in the store, sorted.
236
+
237
+ Args:
238
+ store_root: Path to the ``.dstrack/`` directory, e.g. from
239
+ [resolve_store_root][dstrack.paths.resolve_store_root].
240
+
241
+ Returns:
242
+ Each dataset's id, i.e. its directory name under ``datasets/``. Empty
243
+ if the store holds no datasets yet.
244
+ """
245
+ return [d.name for d in _iter_dataset_dirs(_datasets_dir(store_root))]
246
+
247
+
248
+ def dataset_exists(dataset_id: str, *, store_root: Path) -> bool:
249
+ """Return whether the store holds a dataset with this id.
250
+
251
+ Args:
252
+ dataset_id: The dataset id, which may come from the user.
253
+ store_root: Path to the ``.dstrack/`` directory.
254
+
255
+ Returns:
256
+ ``True`` if the dataset has a directory in the store, whether or not
257
+ it has been snapshotted yet.
258
+
259
+ Raises:
260
+ ValueError: If ``dataset_id`` resolves to a path outside the store.
261
+ """
262
+ return _resolve_dataset_dir(dataset_id, store_root).is_dir()
263
+
264
+
265
+ def read_head(dataset_id: str, *, store_root: Path) -> str | None:
266
+ """Return the id of the snapshot a dataset's ``HEAD`` names.
267
+
268
+ ``HEAD`` is the single source of truth for what a dataset has committed,
269
+ so callers that derive history from it should read it *before* reading
270
+ ``log.jsonl``: a snapshot's log line is appended before the ``HEAD`` write
271
+ that names it, so any ``HEAD`` observed at a given moment is guaranteed to
272
+ have its whole ancestry already on disk. Reading them the other way round
273
+ can observe a ``HEAD`` naming a snapshot whose line was appended after the
274
+ log was read, which looks indistinguishable from corruption.
275
+
276
+ Args:
277
+ dataset_id: Dataset whose ``HEAD`` to read.
278
+ store_root: Path to the ``.dstrack/`` directory.
279
+
280
+ Returns:
281
+ The latest committed snapshot's id, or ``None`` if the dataset does
282
+ not exist or has never been snapshotted.
283
+
284
+ Raises:
285
+ ValueError: If ``dataset_id`` resolves to a path outside the store.
286
+ """
287
+ return _read_head(_resolve_dataset_dir(dataset_id, store_root))
288
+
289
+
290
+ def dataset_log_path(dataset_id: str, *, store_root: Path) -> Path:
291
+ """Return the path of a dataset's ``log.jsonl``. Need not exist.
292
+
293
+ Args:
294
+ dataset_id: Dataset whose log to locate.
295
+ store_root: Path to the ``.dstrack/`` directory.
296
+
297
+ Returns:
298
+ The path of the dataset's append-only log.
299
+
300
+ Raises:
301
+ ValueError: If ``dataset_id`` resolves to a path outside the store.
302
+ """
303
+ return _resolve_dataset_dir(dataset_id, store_root) / _LOG_FILENAME
304
+
305
+
306
+ def read_log_entries(dataset_id: str, *, store_root: Path) -> list[LogEntry]:
307
+ """Return every entry in a dataset's ``log.jsonl``, in file order.
308
+
309
+ Includes entries that no ``HEAD`` ever named, so the result is what the
310
+ log *says*, not what the dataset has *committed*. Pass the result through
311
+ [walk_lineage][dstrack.store.walk_lineage] to select the committed
312
+ lineage.
313
+
314
+ A trailing line that is not valid JSON and is not newline-terminated is
315
+ treated as an interrupted append and dropped, not reported as corruption:
316
+ the log is appended to before ``HEAD`` moves, so a torn final line is a
317
+ snapshot that was never committed and is expected after a crash. A
318
+ malformed line anywhere else was written completely and is corruption.
319
+
320
+ Args:
321
+ dataset_id: Dataset whose log to read.
322
+ store_root: Path to the ``.dstrack/`` directory.
323
+
324
+ Returns:
325
+ One entry per log line, oldest first. Empty if the dataset does not
326
+ exist or has no log yet.
327
+
328
+ Raises:
329
+ ValueError: If ``dataset_id`` resolves to a path outside the store.
330
+ StoreCorruptionError: If a complete log line is not a valid JSON
331
+ object, or records a ``snapshot_id`` or ``parent_snapshot_id``
332
+ that is not a string.
333
+ OSError: If the log cannot be read.
334
+ """
335
+ path = dataset_log_path(dataset_id, store_root=store_root)
336
+ if not path.is_file():
337
+ return []
338
+
339
+ lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
340
+ entries: list[LogEntry] = []
341
+ for index, line in enumerate(lines):
342
+ if not line.strip():
343
+ continue
344
+ try:
345
+ data = json.loads(line)
346
+ except json.JSONDecodeError as e:
347
+ is_torn_append = index == len(lines) - 1 and not line.endswith("\n")
348
+ if is_torn_append:
349
+ break
350
+ raise StoreCorruptionError(
351
+ f"An entry of `{path}` is not valid JSON. The file may have "
352
+ "been truncated by an interrupted write; restore it from git "
353
+ f"or delete the offending line. Error found in line loc {index}."
354
+ ) from e
355
+ if not isinstance(data, dict):
356
+ raise StoreCorruptionError(
357
+ f"An entry of `{path}` is not a JSON object. Restore the file "
358
+ "from git or delete the offending line."
359
+ )
360
+ entries.append(_log_entry_from_line(data, path=path))
361
+ return entries
362
+
363
+
364
+ def walk_lineage(index: Mapping[str, LogEntry], head: str) -> list[LogEntry]:
365
+ """Select the snapshots reachable from ``head``, newest first.
366
+
367
+ Follows ``parent_snapshot_id`` links back from ``head``, which is what
368
+ makes a history *committed* rather than merely *recorded*. Entries the
369
+ walk does not reach are ignored: a crash between a log append and the
370
+ ``HEAD`` write (see [write_snapshot][dstrack.store.write_snapshot]) leaves
371
+ an entry the store never committed, and a later successful write appends
372
+ after it, stranding it mid-file rather than at the end. Reachability is
373
+ the only thing that tells such an entry apart from a real snapshot.
374
+
375
+ Args:
376
+ index: Every entry the dataset's log holds, keyed by ``snapshot_id``,
377
+ e.g. built from [read_log_entries][dstrack.store.read_log_entries].
378
+ head: Id of the snapshot to walk back from, i.e. the dataset's
379
+ ``HEAD``.
380
+
381
+ Returns:
382
+ The lineage ``head`` names, ordered newest first and ending at the
383
+ snapshot with no parent.
384
+
385
+ Raises:
386
+ StoreCorruptionError: If the lineage names a snapshot ``index`` has no
387
+ entry for, or if the ``parent_snapshot_id`` links form a cycle.
388
+ """
389
+ lineage: list[LogEntry] = []
390
+ seen: set[str] = set()
391
+ current: str | None = head
392
+ child: str | None = None
393
+
394
+ while current is not None:
395
+ if current in seen:
396
+ raise StoreCorruptionError(
397
+ f"The history of snapshot {head!r} loops back on itself at "
398
+ f"{current!r}. A snapshot cannot be its own ancestor; the log "
399
+ "has been edited into a cycle. Restore it from git."
400
+ )
401
+ seen.add(current)
402
+
403
+ entry = index.get(current)
404
+ if entry is None:
405
+ missing = (
406
+ f"The log has no entry for snapshot {current!r}"
407
+ if child is None
408
+ else f"The log has no entry for snapshot {current!r}, named as "
409
+ f"the parent of {child!r}"
410
+ )
411
+ raise StoreCorruptionError(
412
+ f"{missing}. Part of the dataset's lineage is missing; restore "
413
+ "the log from git."
414
+ )
415
+
416
+ lineage.append(entry)
417
+ child = current
418
+ current = entry.parent_snapshot_id
419
+
420
+ return lineage
421
+
422
+
423
+ def _log_entry_from_line(line: dict[str, Any], *, path: Path) -> LogEntry:
424
+ """Build a [LogEntry][dstrack.store.LogEntry] from a parsed log line.
425
+
426
+ Reads each field by name and ignores any it does not know, rather than
427
+ unpacking the line. A store written by a newer dstrack would make ``LogEntry(**line)`` raise, and
428
+ ``.dstrack/`` is committed to git, so such a store legitimately reaches an
429
+ older client by way of a clone or a pull.
430
+
431
+ Fields are validated by the job they do. ``snapshot_id`` and
432
+ ``parent_snapshot_id`` are structural: they drive
433
+ [walk_lineage][dstrack.store.walk_lineage], so a wrong type there is
434
+ corruption. The rest are cosmetic, and a wrong type is coerced to ``None``
435
+ for the caller to render as missing: the store's own corruption messages
436
+ tell users to hand-edit the log, so hand-edited damage is expected, and a
437
+ display command must not die on a field it only prints.
438
+
439
+ Args:
440
+ line: A parsed ``log.jsonl`` line.
441
+ path: The log the line came from, used in error messages.
442
+
443
+ Returns:
444
+ The line as a [LogEntry][dstrack.store.LogEntry].
445
+
446
+ Raises:
447
+ StoreCorruptionError: If ``snapshot_id`` is missing, empty, or not a
448
+ string, or if ``parent_snapshot_id`` is present but not a string.
449
+ """
450
+ snapshot_id = line.get("snapshot_id")
451
+ if not isinstance(snapshot_id, str) or not snapshot_id:
452
+ raise StoreCorruptionError(
453
+ f"An entry of `{path}` has no usable `snapshot_id`. Every entry "
454
+ "must identify its snapshot; restore the file from git or delete "
455
+ "the offending line."
456
+ )
457
+
458
+ parent_snapshot_id = line.get("parent_snapshot_id")
459
+ if parent_snapshot_id is not None and not isinstance(parent_snapshot_id, str):
460
+ raise StoreCorruptionError(
461
+ f"Entry {snapshot_id!r} of `{path}` records a "
462
+ "`parent_snapshot_id` that is not a snapshot id. Restore the file "
463
+ "from git or delete the offending line."
464
+ )
465
+
466
+ return LogEntry(
467
+ snapshot_id=snapshot_id,
468
+ parent_snapshot_id=parent_snapshot_id,
469
+ created_at=_optional_str(line.get("created_at")),
470
+ created_by=_optional_str(line.get("created_by")),
471
+ dataset_name=_optional_str(line.get("dataset_name")),
472
+ dataset_path=_optional_str(line.get("dataset_path")),
473
+ num_rows=_optional_int(line.get("num_rows")),
474
+ num_columns=_optional_int(line.get("num_columns")),
475
+ )
476
+
477
+
478
+ def _optional_str(value: Any) -> str | None:
479
+ """Return `value` if it is a string, else ``None``."""
480
+ return value if isinstance(value, str) else None
481
+
482
+
483
+ def _optional_int(value: Any) -> int | None:
484
+ """Return `value` if it is an integer, else ``None``.
485
+
486
+ ``bool`` is a subclass of ``int``, so it is rejected explicitly: a count
487
+ recorded as ``true`` is damage, not the number one.
488
+ """
489
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
490
+
491
+
492
+ def _datasets_dir(store_root: Path) -> Path:
493
+ """Return the store's ``datasets/`` directory. Need not exist.
494
+
495
+ Args:
496
+ store_root: Path to the ``.dstrack/`` directory.
497
+
498
+ Returns:
499
+ The directory every dataset lives directly inside.
500
+ """
501
+ return store_root / _DATASETS_DIRNAME
502
+
503
+
504
+ def _iter_dataset_dirs(datasets_dir: Path) -> Iterator[Path]:
505
+ """Yield each dataset's directory, in a stable order.
506
+
507
+ Args:
508
+ datasets_dir: The store's ``datasets/`` directory. Need not exist.
509
+
510
+ Yields:
511
+ Every directory directly inside ``datasets_dir``, sorted by name.
512
+ Non-directory entries are skipped, so a stray file in the store does
513
+ not read as a dataset.
514
+ """
515
+ if not datasets_dir.is_dir():
516
+ return
517
+ for dataset_dir in sorted(datasets_dir.iterdir()):
518
+ if dataset_dir.is_dir():
519
+ yield dataset_dir
520
+
521
+
522
+ def _resolve_dataset_dir(dataset_id: str, store_root: Path) -> Path:
523
+ """Return a dataset's directory, rejecting an id that escapes the store.
524
+
525
+ Args:
526
+ dataset_id: The dataset id, which may come from the user.
527
+ store_root: Path to the ``.dstrack/`` directory.
528
+
529
+ Returns:
530
+ The ``datasets/<dataset_id>/`` directory. Need not exist.
531
+
532
+ Raises:
533
+ ValueError: If ``dataset_id`` does not name a direct child of
534
+ ``datasets/``.
535
+ """
536
+ datasets_dir = _datasets_dir(store_root)
537
+ dataset_dir = datasets_dir / dataset_id
538
+ _ensure_direct_child(
539
+ base=datasets_dir,
540
+ candidate=dataset_dir,
541
+ kind="dataset_id",
542
+ value=dataset_id,
543
+ )
544
+ return dataset_dir
545
+
546
+
547
+ def _ensure_direct_child(base: Path, candidate: Path, kind: str, value: str) -> None:
548
+ """Raise unless ``candidate`` resolves to a direct child of ``base``.
549
+
550
+ ``dataset_id`` and ``snapshot_id`` are joined onto the store to form a single
551
+ directory or file name directly under ``base``. A value holding a ``..``
552
+ segment, a path separator, or an absolute path can make ``candidate`` land
553
+ outside ``base`` (and clobber files elsewhere) or nest under a subdirectory
554
+ that does not exist. Requiring the resolved parent to equal ``base`` rejects
555
+ both, regardless of how the escape is spelled, before any file is written.
556
+
557
+ Args:
558
+ base: Directory the candidate must sit directly under, e.g. the store's
559
+ ``datasets/`` directory.
560
+ candidate: The joined path derived from ``value``.
561
+ kind: Name of the identifier, used in the error message.
562
+ value: The identifier value, used in the error message.
563
+
564
+ Raises:
565
+ ValueError: If ``candidate`` does not resolve to a direct child of
566
+ ``base``.
567
+ """
568
+ if candidate.resolve().parent != base.resolve():
569
+ raise ValueError(
570
+ f"Invalid {kind} {value!r}: it must name a single entry directly "
571
+ "inside the store, not a nested or outside path."
572
+ )
573
+
574
+
575
+ def _check_dataset_exists(datasets_dir: Path, dataset_id: str) -> None:
576
+ """Raise unless ``dataset_id`` names a dataset already in the store.
577
+
578
+ Args:
579
+ datasets_dir: The store's ``datasets/`` directory. Need not exist.
580
+ dataset_id: The dataset id the caller asked to continue.
581
+
582
+ Raises:
583
+ DatasetNotFoundError: If the store holds no such dataset.
584
+ """
585
+ if (datasets_dir / dataset_id).is_dir():
586
+ return
587
+ known: list[str] = []
588
+ if datasets_dir.is_dir():
589
+ known = sorted(d.name for d in datasets_dir.iterdir() if d.is_dir())
590
+ listing = "\n".join(f" {d}" for d in known) or " (none)"
591
+ raise DatasetNotFoundError(
592
+ f"No dataset {dataset_id!r} in the store. Omit --dataset-id to match "
593
+ f"the dataset by path, or start a new lineage.\nKnown datasets:\n{listing}"
594
+ )
595
+
596
+
597
+ def _match_dataset_by_path(datasets_dir: Path, dataset_path: str) -> str | None:
598
+ """Return the id of the dataset whose latest snapshot has this path.
599
+
600
+ Compares ``dataset_path`` against the last ``log.jsonl`` entry of every
601
+ existing dataset, the cheap lightweight record rather than the full
602
+ snapshot JSON.
603
+
604
+ Args:
605
+ datasets_dir: The store's ``datasets/`` directory. Need not exist.
606
+ dataset_path: Relative POSIX path recorded for the snapshot being
607
+ written, as computed against the path root.
608
+
609
+ Returns:
610
+ The ``dataset_id`` of the first dataset whose latest snapshot recorded
611
+ the same path, or ``None`` if the store holds no dataset that matches.
612
+
613
+ Raises:
614
+ StoreCorruptionError: If a dataset's ``log.jsonl`` ends in a line that
615
+ is not valid JSON.
616
+ """
617
+ for dataset_dir in _iter_dataset_dirs(datasets_dir):
618
+ entry = _read_committed_log_entry(dataset_dir)
619
+ if entry is not None and entry.get("dataset_path") == dataset_path:
620
+ return dataset_dir.name
621
+ return None
622
+
623
+
624
+ def _read_committed_log_entry(dataset_dir: Path) -> dict[str, Any] | None:
625
+ """Return the ``log.jsonl`` entry for the snapshot ``HEAD`` names.
626
+
627
+ ``HEAD`` is written last, so it is the single source of truth for what is
628
+ committed. The final log line is not: a crash between the ``log.jsonl``
629
+ append and the ``HEAD`` write can leave the log one entry ahead of ``HEAD``.
630
+ So the committed entry is the one whose ``snapshot_id`` equals ``HEAD``,
631
+ which is guaranteed to be present because its append precedes the ``HEAD``
632
+ write that names it.
633
+
634
+ Args:
635
+ dataset_dir: A ``datasets/<dataset_id>/`` directory.
636
+
637
+ Returns:
638
+ The parsed log entry the dataset's ``HEAD`` points at, or ``None`` if
639
+ the dataset has no ``HEAD`` or no ``log.jsonl`` yet.
640
+
641
+ Raises:
642
+ StoreCorruptionError: If a log line is not valid JSON, or no entry
643
+ matches ``HEAD`` (the log lost the committed snapshot's line).
644
+ """
645
+ head = _read_head(dataset_dir)
646
+ if head is None:
647
+ return None
648
+ log_path = dataset_dir / _LOG_FILENAME
649
+ if not log_path.is_file():
650
+ return None
651
+ with log_path.open(encoding="utf-8") as fh:
652
+ for line in fh:
653
+ if not line.strip():
654
+ continue
655
+ try:
656
+ entry: dict[str, Any] = json.loads(line)
657
+ except json.JSONDecodeError as e:
658
+ raise StoreCorruptionError(
659
+ f"An entry of `{log_path}` is not valid JSON. The file may "
660
+ "have been truncated by an interrupted write; restore it "
661
+ "from git or delete the offending line."
662
+ ) from e
663
+ if entry.get("snapshot_id") == head:
664
+ return entry
665
+ raise StoreCorruptionError(
666
+ f"`{log_path}` has no entry for HEAD {head!r}. The log is missing the "
667
+ "committed snapshot's line; restore it from git."
668
+ )
669
+
670
+
671
+ def _read_head(dataset_dir: Path) -> str | None:
672
+ """Return the snapshot_id recorded in a dataset's ``HEAD``.
673
+
674
+ Args:
675
+ dataset_dir: A ``datasets/<dataset_id>/`` directory.
676
+
677
+ Returns:
678
+ The latest snapshot's id, or ``None`` if the dataset has no ``HEAD``
679
+ yet, i.e. it has never been snapshotted.
680
+ """
681
+ head_path = dataset_dir / _HEAD_FILENAME
682
+ if not head_path.is_file():
683
+ return None
684
+ head = head_path.read_text(encoding="utf-8").strip()
685
+ return head or None
686
+
687
+
688
+ def _atomic_write(path: Path, text: str) -> None:
689
+ """Write ``text`` to ``path`` atomically via a temp file and ``os.replace``.
690
+
691
+ The temp file is created in the destination directory so the final rename
692
+ stays on the same filesystem and is therefore atomic. Readers only ever see
693
+ the old content or the new one, never a partial write.
694
+
695
+ Args:
696
+ path: File to create or replace. Its parent directory must exist.
697
+ text: Full content to write, encoded as UTF-8.
698
+
699
+ Raises:
700
+ OSError: If the temp file cannot be written or renamed into place. The
701
+ temp file is removed and ``path`` is left untouched.
702
+ """
703
+ fd, tmp_name = tempfile.mkstemp(
704
+ dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
705
+ )
706
+ try:
707
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
708
+ fh.write(text)
709
+ os.replace(tmp_name, path)
710
+ except BaseException:
711
+ Path(tmp_name).unlink(missing_ok=True)
712
+ raise
713
+
714
+
715
+ @contextlib.contextmanager
716
+ def _store_lock(store_root: Path) -> Iterator[None]:
717
+ """Hold an exclusive, cross-process lock on the store for the block's body.
718
+
719
+ Serializes writers across processes so the resolve-parent-then-write
720
+ sequence in [write_snapshot][dstrack.store.write_snapshot] commits as a
721
+ unit: no two writers observe the same ``HEAD`` as a parent, and no two
722
+ path-matched writers mint separate datasets for one path. The lock is a
723
+ single ``.lock`` file at the store root, held for every dataset rather than
724
+ per-dataset, which also covers the cross-dataset scan that path matching
725
+ performs. It is an OS advisory lock, so it is released automatically if the
726
+ holding process dies, leaving no stale lock to clear by hand.
727
+
728
+ Args:
729
+ store_root: Path to the ``.dstrack/`` directory. Created if absent so
730
+ the lock file has somewhere to live.
731
+
732
+ Yields:
733
+ Nothing; the caller runs its critical section inside the ``with``.
734
+ """
735
+ store_root.mkdir(parents=True, exist_ok=True)
736
+ # Open without truncating: the file is a lock handle, its contents unused.
737
+ fh = (store_root / ".lock").open("a", encoding="utf-8")
738
+ try:
739
+ _acquire(fh)
740
+ try:
741
+ yield
742
+ finally:
743
+ _release(fh)
744
+ finally:
745
+ fh.close()
746
+
747
+
748
+ if sys.platform == "win32": # pragma: no cover - platform specific
749
+ import msvcrt
750
+
751
+ def _acquire(fh: Any) -> None:
752
+ msvcrt.locking(fh.fileno(), msvcrt.LK_LOCK, 1)
753
+
754
+ def _release(fh: Any) -> None:
755
+ fh.seek(0)
756
+ msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
757
+
758
+ else:
759
+ import fcntl
760
+
761
+ def _acquire(fh: Any) -> None:
762
+ fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
763
+
764
+ def _release(fh: Any) -> None:
765
+ fcntl.flock(fh.fileno(), fcntl.LOCK_UN)