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.
@@ -0,0 +1,196 @@
1
+ """Runs the snapshot pipeline against a synthetic CSV and times each phase.
2
+
3
+ The runner performs no output of its own: progress is announced through a
4
+ [BenchmarkObserver][dstrack._benchmark._runner.BenchmarkObserver] and everything
5
+ measured is returned as a
6
+ [BenchmarkRun][dstrack._benchmark._runner.BenchmarkRun], so the same run can be
7
+ driven from a test with no console attached.
8
+ """
9
+
10
+ import cProfile
11
+ import time
12
+ from collections.abc import Iterator
13
+ from contextlib import contextmanager
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Protocol
17
+
18
+ from dstrack._benchmark._environment import EnvironmentInfo, collect_environment_info
19
+ from dstrack._benchmark._profiling import CallGraph, dstrack_scope
20
+ from dstrack._benchmark._synthetic import SyntheticCsvSpec, write_synthetic_csv
21
+ from dstrack.readers import CsvReader
22
+ from dstrack.snapshot import MetadataBuilder, StatsComputer
23
+
24
+ _DATASET_NAME = "benchmark-dataset"
25
+ _CREATED_BY = "dstrack-benchmark"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class BenchmarkResult:
30
+ """Timings and sizing for one synthetic-CSV snapshot benchmark run.
31
+
32
+ Attributes:
33
+ num_rows: Rows written to the synthetic CSV.
34
+ num_rows_read: Rows the statistics pass actually read back.
35
+ num_columns: Columns the reader inferred, including ``id``.
36
+ csv_size_bytes: Size of the generated CSV on disk.
37
+ generation_seconds: Time to write the synthetic CSV.
38
+ metadata_seconds: Time to build snapshot metadata from the reader.
39
+ stats_seconds: Time for the statistics data pass.
40
+ schema_hash: Schema hash of the snapshot that was built.
41
+ """
42
+
43
+ num_rows: int
44
+ num_rows_read: int
45
+ num_columns: int
46
+ csv_size_bytes: int
47
+ generation_seconds: float
48
+ metadata_seconds: float
49
+ stats_seconds: float
50
+ schema_hash: str
51
+
52
+ @property
53
+ def total_snapshot_seconds(self) -> float:
54
+ """Time to build the snapshot: metadata plus statistics."""
55
+ return self.metadata_seconds + self.stats_seconds
56
+
57
+ @property
58
+ def rows_per_second(self) -> float:
59
+ """Rows processed per second of snapshot time."""
60
+ return self.num_rows_read / self.total_snapshot_seconds
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class BenchmarkRun:
65
+ """Everything one benchmark run produced, ready to be reported."""
66
+
67
+ result: BenchmarkResult
68
+ environment: EnvironmentInfo
69
+ call_graph: CallGraph | None
70
+
71
+
72
+ class BenchmarkObserver(Protocol):
73
+ """Receives a benchmark's progress as each phase starts."""
74
+
75
+ def generating_csv(self, path: Path, num_rows: int) -> None:
76
+ """Called before the synthetic CSV is written."""
77
+ ...
78
+
79
+ def building_metadata(self) -> None:
80
+ """Called before snapshot metadata is built."""
81
+ ...
82
+
83
+ def computing_stats(self) -> None:
84
+ """Called before the statistics data pass."""
85
+ ...
86
+
87
+
88
+ class SilentObserver:
89
+ """Observer that reports nothing; the default for programmatic runs."""
90
+
91
+ def generating_csv(self, path: Path, num_rows: int) -> None:
92
+ return None
93
+
94
+ def building_metadata(self) -> None:
95
+ return None
96
+
97
+ def computing_stats(self) -> None:
98
+ return None
99
+
100
+
101
+ @dataclass
102
+ class _Elapsed:
103
+ """Wall-clock seconds a phase took, filled in when its block exits."""
104
+
105
+ seconds: float = 0.0
106
+
107
+
108
+ class BenchmarkRunner:
109
+ """Generates a synthetic CSV and times a snapshot being built from it.
110
+
111
+ Metadata and statistics are timed separately, and share one profiler so the
112
+ call tree covers the whole snapshot pipeline. CSV generation is never
113
+ profiled: it measures the harness, not dstrack.
114
+
115
+ Args:
116
+ spec: The synthetic dataset to benchmark against.
117
+ observer: Notified as each phase starts. Defaults to reporting nothing.
118
+ profile: Whether to profile dstrack's own methods while building the
119
+ snapshot. Adds cProfile overhead to the reported timings.
120
+ """
121
+
122
+ def __init__(
123
+ self,
124
+ spec: SyntheticCsvSpec,
125
+ *,
126
+ observer: BenchmarkObserver | None = None,
127
+ profile: bool = True,
128
+ ) -> None:
129
+ self._spec = spec
130
+ self._observer = observer or SilentObserver()
131
+ self._profiler = cProfile.Profile() if profile else None
132
+
133
+ def run(self, csv_path: Path) -> BenchmarkRun:
134
+ """Generate the dataset at ``csv_path`` and benchmark a snapshot of it."""
135
+ # clean profiler if already created to avoid misleading profiles on re-runs.
136
+ if self._profiler is not None:
137
+ self._profiler.clear()
138
+
139
+ # generate data for benchmark
140
+ self._observer.generating_csv(csv_path, self._spec.num_rows)
141
+ with self._timed() as generation:
142
+ write_synthetic_csv(csv_path, self._spec)
143
+ csv_size_bytes = csv_path.stat().st_size
144
+
145
+ reader = CsvReader(csv_path)
146
+
147
+ self._observer.building_metadata()
148
+ with self._timed(profiled=True) as metadata_phase:
149
+ metadata = MetadataBuilder().build(
150
+ reader,
151
+ dataset_name=_DATASET_NAME,
152
+ dataset_path=csv_path,
153
+ source_type="file",
154
+ created_by=_CREATED_BY,
155
+ )
156
+
157
+ self._observer.computing_stats()
158
+ with self._timed(profiled=True) as stats_phase:
159
+ stats = StatsComputer().compute(reader)
160
+
161
+ result = BenchmarkResult(
162
+ num_rows=self._spec.num_rows,
163
+ num_rows_read=stats.num_rows,
164
+ num_columns=metadata.num_columns,
165
+ csv_size_bytes=csv_size_bytes,
166
+ generation_seconds=generation.seconds,
167
+ metadata_seconds=metadata_phase.seconds,
168
+ stats_seconds=stats_phase.seconds,
169
+ schema_hash=metadata.schema_hash,
170
+ )
171
+ return BenchmarkRun(
172
+ result=result,
173
+ environment=collect_environment_info(),
174
+ call_graph=self._call_graph(),
175
+ )
176
+
177
+ @contextmanager
178
+ def _timed(self, *, profiled: bool = False) -> Iterator[_Elapsed]:
179
+ """Time the block, optionally recording it into the shared profiler."""
180
+ profiler = self._profiler if profiled else None
181
+ elapsed = _Elapsed()
182
+ if profiler is not None:
183
+ profiler.enable()
184
+ start = time.perf_counter()
185
+ try:
186
+ yield elapsed
187
+ finally:
188
+ elapsed.seconds = time.perf_counter() - start
189
+ if profiler is not None:
190
+ profiler.disable()
191
+
192
+ def _call_graph(self) -> CallGraph | None:
193
+ """Get a call graph of the snapshot phases, or None if profiling was disabled."""
194
+ if self._profiler is None:
195
+ return None
196
+ return CallGraph.from_profile(self._profiler, dstrack_scope())
@@ -0,0 +1,158 @@
1
+ """Synthetic CSV generation for the benchmark.
2
+
3
+ The dataset is described by a
4
+ [SyntheticCsvSpec][dstrack._benchmark._synthetic.SyntheticCsvSpec] value object
5
+ and written by
6
+ [write_synthetic_csv()][dstrack._benchmark._synthetic.write_synthetic_csv]. Each
7
+ generated dtype is one
8
+ [SyntheticColumn][dstrack._benchmark._synthetic.SyntheticColumn] whose
9
+ [generate][dstrack._benchmark._synthetic.SyntheticColumn.generate] callable
10
+ renders a single cell, so supporting a new dtype means adding one cell factory
11
+ and one entry to ``_COLUMN_KINDS`` rather than threading another count through
12
+ every layer.
13
+ """
14
+
15
+ import csv
16
+ import math
17
+ import random
18
+ from collections.abc import Callable
19
+ from dataclasses import dataclass
20
+ from datetime import UTC, datetime, timedelta
21
+ from pathlib import Path
22
+ from typing import TypeAlias
23
+
24
+ # Renders one cell as the text that lands in the CSV. Cells are always
25
+ # generated from the spec's seeded RNG, so a spec plus a seed is reproducible.
26
+ CellFactory: TypeAlias = Callable[[random.Random], str]
27
+
28
+ _WORD_POOL: tuple[str, ...] = (
29
+ "alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel",
30
+ "india", "juliet", "kilo", "lima", "mike", "november", "oscar", "papa",
31
+ "quebec", "romeo", "sierra", "tango", "uniform", "victor", "whiskey",
32
+ ) # fmt: skip
33
+
34
+ _DATETIME_START = datetime(2020, 1, 1, tzinfo=UTC)
35
+ _DATETIME_END = datetime(2026, 1, 1, tzinfo=UTC)
36
+
37
+
38
+ def _numeric_cell(rng: random.Random) -> str:
39
+ return f"{rng.gauss(100, 25):.4f}"
40
+
41
+
42
+ def _string_cell(rng: random.Random) -> str:
43
+ return " ".join(rng.choices(_WORD_POOL, k=rng.randint(1, 4)))
44
+
45
+
46
+ def _datetime_cell(rng: random.Random) -> str:
47
+ span = (_DATETIME_END - _DATETIME_START).total_seconds()
48
+ return (_DATETIME_START + timedelta(seconds=rng.uniform(0, span))).isoformat()
49
+
50
+
51
+ def _bool_cell(rng: random.Random) -> str:
52
+ return rng.choice(["True", "False"])
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class SyntheticColumn:
57
+ """One generated column: its header name and how to render a cell.
58
+
59
+ Attributes:
60
+ name: Column header written to the CSV.
61
+ generate: Renders a single non-null cell from the seeded RNG.
62
+ """
63
+
64
+ name: str
65
+ generate: CellFactory
66
+
67
+
68
+ # (name prefix, cell factory) per dtype dstrack's CSV reader infers, in the
69
+ # order the columns appear in the file.
70
+ _COLUMN_KINDS: tuple[tuple[str, CellFactory], ...] = (
71
+ ("numeric", _numeric_cell),
72
+ ("string", _string_cell),
73
+ ("datetime", _datetime_cell),
74
+ ("bool", _bool_cell),
75
+ )
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class SyntheticCsvSpec:
80
+ """Describes the synthetic dataset a benchmark run should be measured on.
81
+
82
+ Attributes:
83
+ num_rows: Number of data rows to generate.
84
+ num_numeric_cols: Number of ``float64`` columns.
85
+ num_string_cols: Number of ``string`` columns.
86
+ num_datetime_cols: Number of ``datetime64`` columns.
87
+ num_bool_cols: Number of ``bool`` columns.
88
+ null_rate: Fraction of non-id cells left empty (null).
89
+ seed: Seed for the random generator, for reproducible datasets.
90
+ """
91
+
92
+ num_rows: int = 200_000
93
+ num_numeric_cols: int = 5
94
+ num_string_cols: int = 5
95
+ num_datetime_cols: int = 2
96
+ num_bool_cols: int = 2
97
+ null_rate: float = 0.02
98
+ seed: int = 42
99
+
100
+ def __post_init__(self) -> None:
101
+ """Reject counts and a null rate that would silently corrupt the dataset.
102
+
103
+ Negative counts and an out-of-range ``null_rate`` have no meaningful
104
+ dataset to generate, so they are refused here rather than producing a
105
+ subtly wrong CSV further downstream.
106
+ """
107
+ for name in (
108
+ "num_rows",
109
+ "num_numeric_cols",
110
+ "num_string_cols",
111
+ "num_datetime_cols",
112
+ "num_bool_cols",
113
+ ):
114
+ value: int = getattr(self, name)
115
+ if value < 0:
116
+ raise ValueError(f"{name} must be >= 0, got {value}")
117
+ if not (math.isfinite(self.null_rate) and 0.0 <= self.null_rate <= 1.0):
118
+ raise ValueError(f"null_rate must be in [0, 1], got {self.null_rate!r}")
119
+
120
+ def columns(self) -> list[SyntheticColumn]:
121
+ """Return the nullable, randomly generated columns, in file order.
122
+
123
+ The leading ``id`` column is not included: it is written from the row
124
+ index and is never null.
125
+ """
126
+ counts = (
127
+ self.num_numeric_cols,
128
+ self.num_string_cols,
129
+ self.num_datetime_cols,
130
+ self.num_bool_cols,
131
+ )
132
+ return [
133
+ SyntheticColumn(f"{prefix}_{i}", generate)
134
+ for (prefix, generate), count in zip(_COLUMN_KINDS, counts, strict=True)
135
+ for i in range(count)
136
+ ]
137
+
138
+
139
+ def write_synthetic_csv(path: Path, spec: SyntheticCsvSpec) -> None:
140
+ """Write the dataset described by ``spec`` to ``path``, overwriting it.
141
+
142
+ Args:
143
+ path: Destination CSV path; overwritten if it already exists.
144
+ spec: The dataset to generate.
145
+ """
146
+ rng = random.Random(spec.seed)
147
+ columns = spec.columns()
148
+
149
+ with path.open("w", newline="", encoding="utf-8") as fh:
150
+ writer = csv.writer(fh)
151
+ writer.writerow(["id", *(column.name for column in columns)])
152
+ for row_id in range(spec.num_rows):
153
+ row = [str(row_id)]
154
+ row.extend(
155
+ "" if rng.random() < spec.null_rate else column.generate(rng)
156
+ for column in columns
157
+ )
158
+ writer.writerow(row)
dstrack/_cli.py ADDED
@@ -0,0 +1,105 @@
1
+ import logging
2
+ import tempfile
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from dstrack import __version__, console
9
+ from dstrack._log import log
10
+ from dstrack._store_template import STORE_TEMPLATE, materialize
11
+ from dstrack._track import track
12
+ from dstrack.errors import StoreInitError
13
+ from dstrack.utils import get_invocation_path
14
+
15
+ _log = logging.getLogger(__name__)
16
+
17
+ app = typer.Typer(
18
+ no_args_is_help=True,
19
+ epilog="Made with :heart:",
20
+ suggest_commands=True,
21
+ pretty_exceptions_show_locals=False,
22
+ help="Main CLI interface to `dstrack`'s capabilities.",
23
+ )
24
+
25
+
26
+ def init_local_store() -> Path:
27
+ """Initializes local store structure.
28
+
29
+ Builds [STORE_TEMPLATE][dstrack._store_template.STORE_TEMPLATE] in a
30
+ temporary directory next to the destination, and only moves it into place
31
+ once every file and directory in the template has been created successfully.
32
+ The destination is therefore never touched by a failed or partial build.
33
+
34
+ Returns:
35
+ Path where the local store was created.
36
+
37
+ Raises:
38
+ FileExistsError: If the local store path already exists.
39
+ StoreInitError: If building the store from its template fails. The
40
+ temporary build directory is cleaned up before raising.
41
+ """
42
+ invocation_path = get_invocation_path()
43
+ store_path = invocation_path / STORE_TEMPLATE.name
44
+
45
+ if store_path.is_dir():
46
+ raise FileExistsError(f"Local store path already exists: {store_path}")
47
+
48
+ with tempfile.TemporaryDirectory(
49
+ prefix=f"{STORE_TEMPLATE.name}-tmp-", dir=invocation_path
50
+ ) as tmp_dir:
51
+ try:
52
+ built_path = materialize(STORE_TEMPLATE, Path(tmp_dir))
53
+ except Exception as e:
54
+ raise StoreInitError(
55
+ f"Failed to initialize local store at {store_path}"
56
+ ) from e
57
+
58
+ console.info(f"Generating local store structure at {store_path}.")
59
+ built_path.rename(store_path)
60
+
61
+ return store_path
62
+
63
+
64
+ @app.command(
65
+ help="Initialize local store before starting to track any dataset. "
66
+ "The local store `.dstrack/` is created at the location where this "
67
+ "command is called."
68
+ )
69
+ def init(
70
+ allow_exists: Annotated[
71
+ bool, typer.Option(help="Do not fail if `.dstrack/` already exists.")
72
+ ] = False,
73
+ ) -> None:
74
+ try:
75
+ store_path = init_local_store()
76
+ console.success(f"Finished creating local store: {store_path}")
77
+ except FileExistsError as e:
78
+ if not allow_exists:
79
+ console.error(str(e))
80
+ raise
81
+ _log.warning(f"{e}")
82
+ console.warning(f"{e}")
83
+ except StoreInitError as e:
84
+ _log.error(f"Failed to create local store. {e}")
85
+ console.error(str(e))
86
+ raise
87
+
88
+
89
+ app.command(help="Compute a snapshot of a dataset and store it in the local store.")(
90
+ track
91
+ )
92
+
93
+ app.command(
94
+ help="Show the snapshot history of a tracked dataset, given its dataset id "
95
+ "or its path."
96
+ )(log)
97
+
98
+
99
+ @app.command(help="Print package version.")
100
+ def version() -> None:
101
+ print(__version__)
102
+
103
+
104
+ if __name__ == "__main__":
105
+ app()