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/paths.py ADDED
@@ -0,0 +1,88 @@
1
+ """Resolution of the local store's directory, [STORE_DIRNAME][dstrack.paths.STORE_DIRNAME].
2
+
3
+ Mirrors how git resolves `.git/`: absent an explicit override, it is found by
4
+ walking up from the current directory until a `STORE_DIRNAME/` directory turns up.
5
+ """
6
+
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Final
10
+
11
+ from dstrack.errors import StoreNotFoundError
12
+ from dstrack.utils import get_invocation_path
13
+
14
+ STORE_DIRNAME: Final = ".dstrack"
15
+ ROOT_PATH_ENV_VAR: Final = "DSTRACK_ROOT_PATH"
16
+
17
+
18
+ def _find_store_root(start: Path) -> Path:
19
+ """Walk upwards from `start` looking for a `STORE_DIRNAME/` directory.
20
+
21
+ Stops, like git, at the filesystem root or as soon as it would cross
22
+ onto a different filesystem/mount than `start`, rather than wandering
23
+ indefinitely upwards into unrelated system directories.
24
+
25
+ Args:
26
+ start: Directory to start the search from.
27
+
28
+ Returns:
29
+ The path to the `STORE_DIRNAME/` directory itself, the first one found
30
+ at or above `start`.
31
+
32
+ Raises:
33
+ StoreNotFoundError: If no directory at or above `start`, up to the
34
+ nearest filesystem boundary, contains a `STORE_DIRNAME/` directory.
35
+ """
36
+ current = start.resolve()
37
+ boundary_dev = current.stat().st_dev
38
+
39
+ while True:
40
+ candidate = current / STORE_DIRNAME
41
+ if candidate.is_dir():
42
+ return candidate
43
+
44
+ parent = current.parent
45
+ if parent == current or parent.stat().st_dev != boundary_dev:
46
+ break
47
+ current = parent
48
+
49
+ raise StoreNotFoundError(
50
+ f"Could not find a `{STORE_DIRNAME}/` directory in `{start}` or any "
51
+ "of its parent directories up to the filesystem boundary. Run "
52
+ "`dstrack init` to create one, or point at one explicitly via "
53
+ f"`--root` or `{ROOT_PATH_ENV_VAR}`."
54
+ )
55
+
56
+
57
+ def resolve_store_root(root: Path | str | None = None) -> Path:
58
+ """Resolve the path to the local store, [STORE_DIRNAME][dstrack.paths.STORE_DIRNAME].
59
+
60
+ Checked in order, the first one available wins:
61
+ 1. `root`, e.g. forwarded from a CLI `--root` option: the directory
62
+ `STORE_DIRNAME/` lives (or will be created) in.
63
+ 2. The `DSTRACK_ROOT_PATH` environment variable: same meaning as
64
+ `root`.
65
+ 3. Walking upwards from the current working directory until a
66
+ `STORE_DIRNAME/` directory is found, the same way git resolves `.git/`.
67
+
68
+ Args:
69
+ root: Explicit parent directory of `STORE_DIRNAME/`, typically supplied
70
+ by a CLI `--root` option. Taken as-is, without checking that
71
+ `STORE_DIRNAME/` actually exists inside it.
72
+
73
+ Returns:
74
+ The path to the `STORE_DIRNAME/` directory itself, as an absolute path.
75
+
76
+ Raises:
77
+ StoreNotFoundError: `root` is `None`, `DSTRACK_ROOT_PATH` is
78
+ unset, and no `STORE_DIRNAME/` directory is found by walking up from
79
+ the current directory.
80
+ """
81
+ if root is not None:
82
+ return Path(root).expanduser().resolve() / STORE_DIRNAME
83
+
84
+ env_root = os.environ.get(ROOT_PATH_ENV_VAR)
85
+ if env_root:
86
+ return Path(env_root).expanduser().resolve() / STORE_DIRNAME
87
+
88
+ return _find_store_root(get_invocation_path())
@@ -0,0 +1,69 @@
1
+ """Tabular data readers for dstrack.
2
+
3
+ Built-in readers
4
+ ----------------
5
+ [CsvReader][dstrack.readers._csv.CsvReader] - reads ``.csv`` files; no extra dependencies.
6
+
7
+ Extending
8
+ ---------
9
+ Implement [TabularReader][dstrack.readers._protocol.TabularReader] on any class to
10
+ create a custom reader. That alone is enough to build a snapshot from Python:
11
+
12
+ Examples:
13
+ ```python
14
+ >>> from dstrack.readers import ColumnInfo, TabularReader
15
+ >>> class MyParquetReader:
16
+ ... def columns(self):
17
+ ... return [ColumnInfo("x", "int64")]
18
+ ... def iter_batches(self, batch_size=1000):
19
+ ... return iter([[]])
20
+ ...
21
+ >>> isinstance(MyParquetReader(), TabularReader)
22
+ True
23
+
24
+ ```
25
+
26
+ To make a reader reachable *by name* as well - from the CLI, or by extension
27
+ inference - it must also satisfy
28
+ [ReaderFactory][dstrack.readers._protocol.ReaderFactory] (a ``from_path`` classmethod), and
29
+ be registered. There are two ways in, for two different situations:
30
+
31
+ - **Shipping a reader in a package** others install: declare an entry point in
32
+ the ``dstrack.readers`` group, and set ``EXTENSIONS`` on the class. It is then
33
+ picked up automatically, and ``dstrack track data.parquet`` just works with
34
+ nothing extra typed:
35
+
36
+ ```toml
37
+ [project.entry-points."dstrack.readers"]
38
+ parquet = "dstrack_parquet:ParquetReader"
39
+ ```
40
+
41
+ - **A reader in your own project**, not installed as a plugin: call
42
+ [register_reader][dstrack.readers._registry.register_reader] from Python, or name it on
43
+ the command line as ``--reader "mypackage.readers:ExcelReader"``.
44
+ """
45
+
46
+ from ._csv import CsvReader
47
+ from ._protocol import Cell, ColumnInfo, ReaderFactory, TabularReader
48
+ from ._registry import (
49
+ ENTRY_POINT_GROUP,
50
+ available_readers,
51
+ known_extensions,
52
+ register_reader,
53
+ )
54
+ from ._resolve import load_reader_class, resolve_reader, resolve_reader_class
55
+
56
+ __all__ = [
57
+ "ENTRY_POINT_GROUP",
58
+ "Cell",
59
+ "ColumnInfo",
60
+ "CsvReader",
61
+ "ReaderFactory",
62
+ "TabularReader",
63
+ "available_readers",
64
+ "known_extensions",
65
+ "load_reader_class",
66
+ "register_reader",
67
+ "resolve_reader",
68
+ "resolve_reader_class",
69
+ ]
@@ -0,0 +1,361 @@
1
+ import csv
2
+ import logging
3
+ import os
4
+ from collections import Counter
5
+ from collections.abc import Iterator
6
+ from pathlib import Path
7
+ from typing import Any, TextIO
8
+
9
+ from ._protocol import Cell, ColumnInfo
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ _NULL_PATTERNS: frozenset[str] = frozenset(
15
+ {"", "null", "NULL", "Null", "NaN", "nan", "NA", "N/A", "n/a", "None", "none"}
16
+ )
17
+ _BOOL_TRUE: frozenset[str] = frozenset({"true", "True", "TRUE"})
18
+ _BOOL_ALL: frozenset[str] = frozenset(
19
+ {"true", "True", "TRUE", "false", "False", "FALSE"}
20
+ )
21
+
22
+
23
+ def _deduplicate_fieldnames(fieldnames: list[str]) -> list[str]:
24
+ """Deduplicates strings in list by appending a counter.
25
+
26
+ If the list contains repeated values, an int counter is added
27
+ to the end of the value.
28
+
29
+ Names that appear exactly once are treated as reserved: generated suffixes
30
+ will never claim them. For example, ``["a", "a", "a_1"]`` becomes
31
+ ``["a", "a_2", "a_1"]`` rather than ``["a", "a_1", "a_1_1"]``.
32
+
33
+ Args:
34
+ fieldnames: list of strings to be deduplicated
35
+
36
+ Returns:
37
+ Deduplicated values
38
+ """
39
+ counts = Counter(fieldnames)
40
+ reserved = {name for name, count in counts.items() if count == 1}
41
+ seen: dict[str, int] = {}
42
+ used: set[str] = set()
43
+ result = []
44
+ for name in fieldnames:
45
+ candidate = name
46
+ suffix = seen.get(name, 1)
47
+ while candidate in used or (candidate != name and candidate in reserved):
48
+ candidate = f"{name}_{suffix}"
49
+ suffix += 1
50
+ seen[name] = suffix
51
+ used.add(candidate)
52
+ result.append(candidate)
53
+ return result
54
+
55
+
56
+ def _looks_like_datetime(value: str) -> bool:
57
+ """Check for a leading ISO-8601 date (YYYY-MM-DD) without importing re.
58
+
59
+ Args:
60
+ value: Raw string value from a CSV cell.
61
+
62
+ Returns:
63
+ ``True`` if the value starts with a ``YYYY-MM-DD`` pattern.
64
+ """
65
+ return (
66
+ len(value) >= 10
67
+ and value[4] == "-"
68
+ and value[7] == "-"
69
+ and value[:4].isdigit()
70
+ and value[5:7].isdigit()
71
+ and value[8:10].isdigit()
72
+ )
73
+
74
+
75
+ def _infer_dtype(samples: list[str]) -> str:
76
+ """Return the narrowest dtype that fits all non-null sample values.
77
+
78
+ Probes in order: ``int64``, ``float64``, ``bool``, ``datetime64``,
79
+ falling back to ``string``.
80
+
81
+ Args:
82
+ samples: Raw string values from a single column, including empty
83
+ strings for missing values.
84
+
85
+ Returns:
86
+ One of ``"int64"``, ``"float64"``, ``"bool"``, ``"datetime64"``,
87
+ or ``"string"``.
88
+ """
89
+ non_null = [v for v in samples if v not in _NULL_PATTERNS]
90
+ if not non_null:
91
+ return "string"
92
+ if all(v.lstrip("-").isdigit() for v in non_null):
93
+ return "int64"
94
+ try:
95
+ for v in non_null:
96
+ float(v)
97
+ return "float64"
98
+ except ValueError:
99
+ pass
100
+ if all(v in _BOOL_ALL for v in non_null):
101
+ return "bool"
102
+ if all(_looks_like_datetime(v) for v in non_null):
103
+ return "datetime64"
104
+ return "string"
105
+
106
+
107
+ def _coerce(raw: str | None, dtype: str) -> Cell:
108
+ """Cast a raw CSV string to the column's inferred Python type.
109
+
110
+ Args:
111
+ raw: Raw string from the CSV cell, or ``None`` if the field is absent.
112
+ dtype: Target dtype string (e.g. ``"int64"``, ``"float64"``).
113
+
114
+ Returns:
115
+ The coerced value, or ``None`` for empty or missing inputs.
116
+ """
117
+ if raw is None or raw in _NULL_PATTERNS:
118
+ return None
119
+ if dtype == "int64":
120
+ return int(raw)
121
+ if dtype == "float64":
122
+ return float(raw)
123
+ if dtype == "bool":
124
+ if raw not in _BOOL_ALL:
125
+ raise ValueError(f"Invalid boolean value for bool column: {raw!r}")
126
+ return raw in _BOOL_TRUE
127
+ # string, datetime64, bytes - keep as str
128
+ return raw
129
+
130
+
131
+ class CsvReader:
132
+ """Reads a CSV file using the standard-library ``csv`` module.
133
+
134
+ Satisfies [TabularReader][dstrack.readers._protocol.TabularReader] without
135
+ inheriting from it. Column dtypes are inferred from the first
136
+ ``sample_rows`` data rows; every subsequent call to
137
+ [columns()][dstrack.readers._csv.CsvReader.columns] returns the cached
138
+ result.
139
+
140
+ The file's modification time and size are recorded when schema inference
141
+ runs. [iter_batches()][dstrack.readers._csv.CsvReader.iter_batches] checks
142
+ them again before reading and raises [RuntimeError][] if the file has
143
+ changed, preventing silent schema/data mismatches.
144
+
145
+ Note:
146
+ Change detection relies on ``mtime_ns`` and file size reported by the
147
+ OS. On filesystems with coarse modification-time resolution (FAT32,
148
+ some network or CI mounts), two writes that happen within the same
149
+ clock tick will share the same ``mtime_ns``, so a modification that
150
+ also preserves the file size may go undetected. If you need
151
+ guaranteed detection in such environments, ensure at least one clock
152
+ tick (≥ 10 ms on most systems) elapses between calling
153
+ [columns()][dstrack.readers._csv.CsvReader.columns] and overwriting the
154
+ file.
155
+
156
+ Args:
157
+ path: Path to the CSV file.
158
+ sample_rows: Number of rows to read for dtype inference.
159
+ The values are read and data types for each column are inferred
160
+ from them.
161
+ encoding: File encoding passed to [open][]. Defaults to
162
+ ``"utf-8"``; use ``"cp1252"`` or ``"latin-1"`` for Excel exports.
163
+ rename_duplicates: When ``True``, duplicate header names are made unique
164
+ by appending a counter suffix (e.g. ``col``, ``col_1``, ``col_2``).
165
+ Headers that already appear exactly once in the file are treated as
166
+ reserved: generated suffixes will never overwrite them (e.g.
167
+ ``["a", "a", "a_1"]`` → ``["a", "a_2", "a_1"]``).
168
+ When ``False`` (default), a [ValueError][] is raised instead.
169
+ column_dtypes: Optional mapping of column name to dtype string that
170
+ overrides the inferred dtype for those columns. Only the listed
171
+ columns are affected; all others are still inferred automatically.
172
+ ``"bytes"`` is not a valid override (see ADR-0002); passing it
173
+ raises [ValueError][].
174
+ **csv_kwargs: Forwarded verbatim to [DictReader][csv.DictReader]
175
+ (e.g. ``delimiter=";"``, ``quotechar="'"``).
176
+ """
177
+
178
+ EXTENSIONS = (".csv",)
179
+
180
+ @classmethod
181
+ def from_path(cls, path: str | Path) -> "CsvReader":
182
+ """Build a reader for ``path`` with default options.
183
+
184
+ Satisfies [ReaderFactory][dstrack.readers._protocol.ReaderFactory], which is how
185
+ the registry and ``--reader`` construct a reader they only know by name.
186
+ Options other than the path are not reachable this way; construct the
187
+ reader directly to set them.
188
+
189
+ Args:
190
+ path: Path to the CSV file.
191
+
192
+ Returns:
193
+ A [CsvReader][dstrack.readers._csv.CsvReader] bound to ``path``.
194
+ """
195
+ return cls(path)
196
+
197
+ def __init__(
198
+ self,
199
+ path: str | Path,
200
+ *,
201
+ sample_rows: int = 200,
202
+ encoding: str = "utf-8",
203
+ rename_duplicates: bool = False,
204
+ column_dtypes: dict[str, str] | None = None,
205
+ **csv_kwargs: Any,
206
+ ) -> None:
207
+ if column_dtypes:
208
+ bad = [name for name, dt in column_dtypes.items() if dt == "bytes"]
209
+ if bad:
210
+ raise ValueError(
211
+ f"'bytes' dtype is not supported by CsvReader (column(s): {bad}). "
212
+ "Keep the column as 'string' and decode in application code. "
213
+ "See ADR-0002."
214
+ )
215
+ self._path = Path(path)
216
+ self._sample_rows = sample_rows
217
+ self._encoding = encoding
218
+ self._rename_duplicates = rename_duplicates
219
+ self._column_dtypes = column_dtypes or {}
220
+ self._csv_kwargs = csv_kwargs
221
+ self._columns: list[ColumnInfo] | None = None
222
+ self._file_stat: tuple[int, int] | None = None
223
+
224
+ @property
225
+ def path(self) -> Path:
226
+ """Path to the CSV file this reader is bound to."""
227
+ return self._path
228
+
229
+ def columns(self) -> list[ColumnInfo]:
230
+ """Return column descriptors, inferring dtypes on the first call.
231
+
232
+ Returns:
233
+ Ordered list of [ColumnInfo][dstrack.readers._protocol.ColumnInfo]
234
+ objects, one per CSV field.
235
+ """
236
+ if self._columns is None:
237
+ self._columns = self._detect_columns()
238
+ return list(self._columns)
239
+
240
+ def _stat_signature(self, fh: TextIO) -> tuple[int, int]:
241
+ """Return the change signature ``(mtime_ns, size)`` for an open file.
242
+
243
+ Args:
244
+ fh: An open handle to the CSV file.
245
+
246
+ Returns:
247
+ The file's ``mtime_ns`` and size, used to detect whether the file
248
+ was modified between schema inference and iteration.
249
+ """
250
+ stat = os.fstat(fh.fileno())
251
+ return (stat.st_mtime_ns, stat.st_size)
252
+
253
+ def _raise_if_file_changed(self, fh: TextIO) -> None:
254
+ """Raise if the file changed since schema inference recorded it.
255
+
256
+ Compares the current signature of ``fh`` against the one captured in
257
+ [_detect_columns()][dstrack.readers._csv.CsvReader._detect_columns].
258
+ Does nothing if no signature has been recorded yet.
259
+
260
+ Args:
261
+ fh: An open handle to the CSV file.
262
+
263
+ Raises:
264
+ RuntimeError: If the file's signature differs from the recorded one.
265
+ """
266
+ if self._file_stat is not None and self._stat_signature(fh) != self._file_stat:
267
+ raise RuntimeError(
268
+ f"File changed between schema inference and iteration: {self._path}"
269
+ )
270
+
271
+ def _detect_columns(self) -> list[ColumnInfo]:
272
+ """Read up to ``sample_rows`` rows and infer a ColumnInfo per field.
273
+
274
+ Records ``(mtime_ns, size)`` of the open file handle in
275
+ ``_file_stat`` for later comparison by
276
+ [iter_batches()][dstrack.readers._csv.CsvReader.iter_batches].
277
+
278
+ Returns:
279
+ Ordered list of [ColumnInfo][dstrack.readers._protocol.ColumnInfo]
280
+ objects, one per CSV field.
281
+ """
282
+ logger.debug(
283
+ f"Inferring column dtypes for {self._path} from up to {self._sample_rows} sample rows",
284
+ )
285
+ samples: dict[str, list[str]] = {}
286
+ with self._path.open(newline="", encoding=self._encoding) as fh:
287
+ self._file_stat = self._stat_signature(fh)
288
+ reader = csv.DictReader(fh, **self._csv_kwargs)
289
+ fieldnames = reader.fieldnames
290
+ if not fieldnames:
291
+ return []
292
+ if len(set(fieldnames)) != len(fieldnames):
293
+ if not self._rename_duplicates:
294
+ raise ValueError(
295
+ "Duplicate CSV headers detected. Pass rename_duplicates=True"
296
+ " to automatically rename them (e.g. 'col', 'col_1', 'col_2')."
297
+ )
298
+ fieldnames = _deduplicate_fieldnames(list(fieldnames))
299
+ reader.fieldnames = fieldnames
300
+ for name in fieldnames:
301
+ samples[name] = []
302
+ for i, row in enumerate(reader):
303
+ if i >= self._sample_rows:
304
+ break
305
+ for name in fieldnames:
306
+ raw = row.get(name)
307
+ samples[name].append("" if raw is None else raw)
308
+
309
+ columns = [
310
+ ColumnInfo(
311
+ name=name,
312
+ dtype=self._column_dtypes.get(name) or _infer_dtype(vals),
313
+ nullable=any(v in _NULL_PATTERNS for v in vals),
314
+ )
315
+ for name, vals in samples.items()
316
+ ]
317
+ logger.debug(f"Inferred {len(columns)} columns for {self._path}")
318
+ return columns
319
+
320
+ def iter_batches(self, batch_size: int = 1000) -> Iterator[list[list[Cell]]]:
321
+ """Yield batches of coerced rows.
322
+
323
+ Opens the file once per call. Before reading, compares the file's
324
+ current ``mtime_ns`` and size against the values recorded during
325
+ schema inference and raises if they differ.
326
+
327
+ Args:
328
+ batch_size: Maximum number of rows per batch.
329
+
330
+ Yields:
331
+ A list of rows; each row is a list of
332
+ [Cell][dstrack.readers._protocol.Cell] values aligned with
333
+ [columns()][dstrack.readers._csv.CsvReader.columns].
334
+
335
+ Raises:
336
+ RuntimeError: If the file was modified since
337
+ [columns()][dstrack.readers._csv.CsvReader.columns] was last
338
+ called.
339
+ """
340
+ cols = self.columns()
341
+ names = [c.name for c in cols]
342
+ dtypes = [c.dtype for c in cols]
343
+ batch: list[list[Cell]] = []
344
+
345
+ with self._path.open(newline="", encoding=self._encoding) as fh:
346
+ self._raise_if_file_changed(fh)
347
+ reader = csv.DictReader(fh, **self._csv_kwargs)
348
+ _ = reader.fieldnames # consume header row
349
+ reader.fieldnames = names
350
+ for row in reader:
351
+ batch.append(
352
+ [
353
+ _coerce(row.get(name), dtype)
354
+ for name, dtype in zip(names, dtypes, strict=True)
355
+ ]
356
+ )
357
+ if len(batch) >= batch_size:
358
+ yield batch
359
+ batch = []
360
+ if batch:
361
+ yield batch
@@ -0,0 +1,120 @@
1
+ from collections.abc import Iterator
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import Protocol, TypeAlias, runtime_checkable
5
+
6
+ # A single cell value after dtype coercion; None represents a missing value.
7
+ Cell: TypeAlias = int | float | str | bool | None
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class ColumnInfo:
12
+ """Metadata for one column of a tabular dataset.
13
+
14
+ Attributes:
15
+ name: Column name as it appears in the source.
16
+ dtype: Storage type using snapshot-schema vocabulary: ``int64``,
17
+ ``float64``, ``string``, ``bool``, ``datetime64``, or ``bytes``.
18
+ nullable: ``True`` if any value in this column may be ``None``.
19
+ """
20
+
21
+ name: str
22
+ dtype: str
23
+ nullable: bool = True
24
+
25
+
26
+ @runtime_checkable
27
+ class TabularReader(Protocol):
28
+ """Structural protocol for tabular data sources.
29
+
30
+ Any class that exposes
31
+ [columns()][dstrack.readers._protocol.TabularReader.columns] and
32
+ [iter_batches()][dstrack.readers._protocol.TabularReader.iter_batches]
33
+ satisfies this protocol, no inheritance required. Third-party readers for
34
+ Parquet, SQL, HuggingFace datasets, etc. only need to implement these two
35
+ methods.
36
+
37
+ Examples:
38
+ >>> class MyParquetReader:
39
+ ... def columns(self):
40
+ ... return [ColumnInfo("x", "int64")]
41
+ ...
42
+ ... def iter_batches(self, batch_size=1000):
43
+ ... return iter([[]])
44
+ >>> isinstance(MyParquetReader(), TabularReader)
45
+ True
46
+ """
47
+
48
+ def columns(self) -> list[ColumnInfo]:
49
+ """Return column descriptors.
50
+
51
+ May open or inspect the source on the first call; subsequent calls
52
+ should return a cached result.
53
+
54
+ Returns:
55
+ Ordered list of [ColumnInfo][dstrack.readers._protocol.ColumnInfo]
56
+ objects, one per column.
57
+ """
58
+ ...
59
+
60
+ def iter_batches(self, batch_size: int = 1000) -> Iterator[list[list[Cell]]]:
61
+ """Yield non-empty batches of rows.
62
+
63
+ Each row is a list of coerced values aligned with
64
+ [columns()][dstrack.readers._protocol.TabularReader.columns].
65
+ Missing values are represented as ``None``.
66
+
67
+ Args:
68
+ batch_size: Maximum number of rows per batch.
69
+
70
+ Yields:
71
+ A list of rows, each row being a list of
72
+ [Cell][dstrack.readers._protocol.Cell] values.
73
+ """
74
+ ...
75
+
76
+
77
+ @runtime_checkable
78
+ class ReaderFactory(Protocol):
79
+ """Construction contract for readers reached *by name* rather than by instance.
80
+
81
+ [TabularReader][dstrack.readers._protocol.TabularReader] describes how a
82
+ reader is *read*, and says nothing about how one is *built*: code that
83
+ already holds an instance never needs to know. But the registry and the
84
+ ``"package.module:ClassName"`` spec only ever yield a class, so they need a
85
+ uniform way to turn that class into an instance given a source path.
86
+ [from_path()][dstrack.readers._protocol.ReaderFactory.from_path] is that way,
87
+ and it is checked against this protocol before the class is ever called.
88
+
89
+ This is deliberately a second, separate protocol: a reader used only from
90
+ Python (constructed by the caller, handed straight to
91
+ [SnapshotBuilder][dstrack.snapshot._builder.SnapshotBuilder]) still needs
92
+ nothing beyond [TabularReader][dstrack.readers._protocol.TabularReader].
93
+ Only readers that are registered, or named on the command line, must also
94
+ satisfy this one.
95
+
96
+ Examples:
97
+ ```python
98
+ >>> from pathlib import Path
99
+ >>> from dstrack.readers import CsvReader, ReaderFactory
100
+ >>> isinstance(CsvReader, ReaderFactory) # the class, not an instance
101
+ True
102
+
103
+ ```
104
+ """
105
+
106
+ def from_path(self, path: Path) -> TabularReader:
107
+ """Build a reader for ``path`` using default options.
108
+
109
+ Implemented as a ``classmethod`` on the reader class; the protocol is
110
+ therefore checked against the class object itself
111
+ (``isinstance(MyReader, ReaderFactory)``).
112
+
113
+ Args:
114
+ path: Path to the dataset source the reader will read.
115
+
116
+ Returns:
117
+ An instance satisfying
118
+ [TabularReader][dstrack.readers._protocol.TabularReader].
119
+ """
120
+ ...