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/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
|
+
]
|
dstrack/readers/_csv.py
ADDED
|
@@ -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
|
+
...
|